Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/Use-Tusk/tusk-cli
go 1.25.0

require (
github.com/Use-Tusk/fence v0.1.36
github.com/Use-Tusk/fence v0.1.51
github.com/Use-Tusk/tusk-drift-schemas v0.1.36
github.com/agnivade/levenshtein v1.0.3
github.com/aymanbagabas/go-osc52/v2 v2.0.1
Expand All @@ -28,7 +28,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/zricethezav/gitleaks/v8 v8.30.1
golang.org/x/mod v0.29.0
golang.org/x/term v0.41.0
golang.org/x/term v0.42.0
google.golang.org/protobuf v1.36.9
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.12.0
Expand Down Expand Up @@ -119,7 +119,7 @@ require (
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/Use-Tusk/fence v0.1.36 h1:8S15y8cp3X+xXukx6AN0Ky/aX9/dZyW3fLw5XOQ8YtE=
github.com/Use-Tusk/fence v0.1.36/go.mod h1:YkowBDzXioVKJE16vg9z3gSVC6vhzkIZZw2dFf7MW/o=
github.com/Use-Tusk/fence v0.1.51 h1:GGr4bx/eFYYA3WNNIIE7RAkJJu5zlW6nsTdrAqEzTQc=
github.com/Use-Tusk/fence v0.1.51/go.mod h1:ADX3cEerqZumoA+RXDtLC1p+8vUqcNaaaXEK33vHnVs=
github.com/Use-Tusk/tusk-drift-schemas v0.1.36 h1:baojaWiEFEdRU61CLYAbFievXxDLlWTFW/ijL4IpdiE=
github.com/Use-Tusk/tusk-drift-schemas v0.1.36/go.mod h1:pa3EvTj9kKxl9f904RVFkj9YK1zB75QogboKi70zalM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
Expand Down Expand Up @@ -437,13 +437,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
6 changes: 6 additions & 0 deletions internal/runner/compose_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ func createReplayComposeOverrideFile(envVars map[string]string, groupName string
if safeGroup == "" {
safeGroup = "default"
}
// The override file lives in the OS temp dir (/tmp on Linux). Fence
// tmpfs-overmounts /tmp inside its Linux sandbox, so a naive `docker
// compose -f /tmp/...` inside the sandbox can't see this file. Callers
// that pass this path into a sandboxed command must register it via
// fence.Manager.ExposeHostPath before launching the sandbox — see
// StartService in service.go, which does this automatically.
tempFile, err := os.CreateTemp("", fmt.Sprintf("tusk-replay-env-override-%s-*.yml", safeGroup))
if err != nil {
return "", fmt.Errorf("failed to create temporary replay compose override file: %w", err)
Expand Down
5 changes: 2 additions & 3 deletions internal/runner/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"sync"
"time"

"github.com/Use-Tusk/fence/pkg/fence"
"github.com/Use-Tusk/tusk-cli/internal/config"
"github.com/Use-Tusk/tusk-cli/internal/log"
"github.com/Use-Tusk/tusk-cli/internal/utils"
Expand Down Expand Up @@ -87,7 +86,7 @@ type Executor struct {
sandboxMode string
lastServiceSandboxed bool
debug bool
fenceManager *fence.Manager
sandbox sandboxManager
requireInboundReplay bool
replayComposeOverride string
replayEnvVars map[string]string
Expand Down Expand Up @@ -142,7 +141,7 @@ func (e *Executor) GetEffectiveSandboxMode() string {
if e.sandboxMode != "" {
return e.sandboxMode
}
if fence.IsSupported() {
if isSandboxSupported() {
return SandboxModeStrict
}
return SandboxModeAuto
Expand Down
27 changes: 27 additions & 0 deletions internal/runner/sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package runner

// Platform-split sandbox adapter. Real implementation lives in sandbox_unix.go
// (fence-backed); Windows gets a no-op stub in sandbox_windows.go because
// fence doesn't cross-compile there.

// sandboxManager wraps whatever sandbox backs replay isolation on the current
// platform. Nil means no sandbox configured.
type sandboxManager interface {
WrapCommand(command string) (string, error)
Cleanup()
}

type replaySandboxOptions struct {
UserConfigPath string // optional fence config override (e.g. .tusk/replay.fence.json)
Debug bool
ExposedPort int
// BindsOnHost signals that an external daemon (docker, podman) binds
// ExposedPort outside the sandbox netns; skips the reverse bridge.
BindsOnHost bool
ExposedHostPaths []exposedHostPath
}

type exposedHostPath struct {
Path string
Writable bool
}
4 changes: 1 addition & 3 deletions internal/runner/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package runner

import (
"testing"

"github.com/Use-Tusk/fence/pkg/fence"
)

// newExecutorForServiceLifecycleTests keeps generic lifecycle tests focused on
Expand All @@ -16,7 +14,7 @@ func newExecutorForServiceLifecycleTests() *Executor {

func TestGetEffectiveSandboxMode(t *testing.T) {
e := NewExecutor()
if fence.IsSupported() {
if isSandboxSupported() {
if got := e.GetEffectiveSandboxMode(); got != SandboxModeStrict {
t.Fatalf("expected default sandbox mode %q on supported platform, got %q", SandboxModeStrict, got)
}
Expand Down
185 changes: 185 additions & 0 deletions internal/runner/sandbox_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//go:build darwin || linux || freebsd

package runner

import (
"fmt"
"strings"

"github.com/Use-Tusk/fence/pkg/fence"
"github.com/Use-Tusk/tusk-cli/internal/utils"
)

// isSandboxSupported reports whether the current platform can actually
// isolate replay service startup (i.e. fence is available).
func isSandboxSupported() bool {
return fence.IsSupported()
}

// fenceSandbox is the Unix-platform implementation of sandboxManager,
// backed by github.com/Use-Tusk/fence.
type fenceSandbox struct {
mgr *fence.Manager
}

// WrapCommand delegates to the underlying fence.Manager.
func (s *fenceSandbox) WrapCommand(command string) (string, error) {
return s.mgr.WrapCommand(command)
}

// Cleanup releases fence's socat bridges, proxies, and temp sockets.
func (s *fenceSandbox) Cleanup() {
if s.mgr != nil {
s.mgr.Cleanup()
}
}

// newReplaySandboxManager builds the effective fence config for replay
// mode, creates the fence.Manager, applies the requested service
// execution model + exposed host paths, and initializes the manager.
// On error, any partial state is cleaned up before returning.
func newReplaySandboxManager(opts replaySandboxOptions) (sandboxManager, error) {
Comment thread
jy-tan marked this conversation as resolved.
fenceCfg, err := createReplayFenceConfig(opts.UserConfigPath)
if err != nil {
return nil, fmt.Errorf("prepare replay sandbox config: %w", err)
}

mgr := fence.NewManager(fenceCfg, opts.Debug, false)

executionModel := fence.ServiceBindsInSandbox
if opts.BindsOnHost {
executionModel = fence.ServiceBindsOnHost
}
mgr.SetService(fence.ServiceOptions{
ExposedPorts: []int{opts.ExposedPort},
ExecutionModel: executionModel,
})

for _, ehp := range opts.ExposedHostPaths {
if err := mgr.ExposeHostPath(ehp.Path, ehp.Writable); err != nil {
return nil, fmt.Errorf("expose host path %q to sandbox: %w", ehp.Path, err)
}
}

if err := mgr.Initialize(); err != nil {
return nil, fmt.Errorf("initialize replay sandbox: %w", err)
}

return &fenceSandbox{mgr: mgr}, nil
}

// createReplayFenceConfig creates the effective fence config for replay mode.
// This blocks localhost outbound connections to force the service to use SDK
// mocks.
//
// Exposed (lowercase) for the Unix-only service_test.go tests that verify
// user-config merging behavior. Not part of the package's cross-platform
// surface.
func createReplayFenceConfig(userConfigPath string) (*fence.Config, error) {
cfg := baseReplayFenceConfig()
if userConfigPath == "" {
return cfg, nil
}

resolvedPath := utils.ResolveTuskPath(userConfigPath)
userCfg, err := fence.LoadConfigResolved(resolvedPath)
if err != nil {
return nil, fmt.Errorf("load custom fence config %q: %w", resolvedPath, err)
}
if userCfg == nil {
return nil, fmt.Errorf("custom fence config not found: %s", resolvedPath)
}
if err := validateReplayFenceConfig(userCfg); err != nil {
return nil, err
}

merged := fence.MergeConfigs(cfg, userCfg)
applyReplayFenceInvariants(merged)
return merged, nil
}

func baseReplayFenceConfig() *fence.Config {
f := false
return &fence.Config{
Network: fence.NetworkConfig{
AllowedDomains: []string{
// Allow localhost for the service's own health checks
"localhost",
"127.0.0.1",
},
AllowLocalBinding: true, // Allow service to bind to its port
AllowLocalOutbound: &f, // Block outbound to localhost (Postgres, Redis, etc.)
AllowAllUnixSockets: true, // Allow SDK to connect to mock server via Unix socket
},
Filesystem: fence.FilesystemConfig{
AllowWrite: getAllowedWriteDirs(),
},
}
}

func validateReplayFenceConfig(cfg *fence.Config) error {
if cfg == nil {
return nil
}

requiredDomains := []string{"localhost", "127.0.0.1"}
for _, deniedDomain := range cfg.Network.DeniedDomains {
for _, requiredDomain := range requiredDomains {
if strings.EqualFold(deniedDomain, requiredDomain) {
return fmt.Errorf("custom replay fence config cannot deny %q because replay health checks require it", requiredDomain)
}
}
}

return nil
}

func applyReplayFenceInvariants(cfg *fence.Config) {
if cfg == nil {
return
}

f := false
cfg.Network.AllowedDomains = mergeUniqueStrings(
cfg.Network.AllowedDomains,
[]string{"localhost", "127.0.0.1"},
)
cfg.Network.AllowLocalBinding = true
cfg.Network.AllowLocalOutbound = &f
cfg.Network.AllowAllUnixSockets = true
cfg.Filesystem.AllowWrite = mergeUniqueStrings(cfg.Filesystem.AllowWrite, getAllowedWriteDirs())
}

func mergeUniqueStrings(existing, required []string) []string {
if len(required) == 0 {
return existing
}

seen := make(map[string]struct{}, len(existing)+len(required))
merged := make([]string, 0, len(existing)+len(required))
for _, value := range existing {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
merged = append(merged, value)
}
for _, value := range required {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
merged = append(merged, value)
}
return merged
}

// getAllowedWriteDirs returns the default writable paths for replay mode.
// We allow broad local writes by default. Note that Fence still enforces
// mandatory dangerous-path protections (see
// https://github.com/Use-Tusk/fence/blob/main/internal/sandbox/dangerous.go).
func getAllowedWriteDirs() []string {
return []string{
"/",
}
}
52 changes: 52 additions & 0 deletions internal/runner/sandbox_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//go:build darwin || linux || freebsd

package runner

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCreateReplayFenceConfigMergesCustomConfig(t *testing.T) {
customConfigPath := filepath.Join(t.TempDir(), "replay.fence.json")
err := os.WriteFile(customConfigPath, []byte(`{
"network": {
"allowedDomains": ["api.example.com"]
},
"filesystem": {
"allowWrite": ["custom-cache"]
}
}`), 0o600)
require.NoError(t, err)

cfg, err := createReplayFenceConfig(customConfigPath)
require.NoError(t, err)
require.NotNil(t, cfg)
require.NotNil(t, cfg.Network.AllowLocalOutbound)

assert.Contains(t, cfg.Network.AllowedDomains, "localhost")
assert.Contains(t, cfg.Network.AllowedDomains, "127.0.0.1")
assert.Contains(t, cfg.Network.AllowedDomains, "api.example.com")
assert.True(t, cfg.Network.AllowLocalBinding)
assert.False(t, *cfg.Network.AllowLocalOutbound)
assert.True(t, cfg.Network.AllowAllUnixSockets)
assert.Contains(t, cfg.Filesystem.AllowWrite, "custom-cache")
assert.Contains(t, cfg.Filesystem.AllowWrite, "/")
}

func TestCreateReplayFenceConfigRejectsDeniedLocalhost(t *testing.T) {
customConfigPath := filepath.Join(t.TempDir(), "replay.fence.json")
err := os.WriteFile(customConfigPath, []byte(`{
"network": {
"deniedDomains": ["localhost"]
}
}`), 0o600)
require.NoError(t, err)

_, err = createReplayFenceConfig(customConfigPath)
require.ErrorContains(t, err, `cannot deny "localhost"`)
}
18 changes: 18 additions & 0 deletions internal/runner/sandbox_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build windows

package runner

import "errors"

// Fence only supports Linux and macOS; on Windows the replay sandbox is a
// no-op. Callers treat the error the same as "sandbox not available on this
// platform" on an unsupported Unix.
var errSandboxUnsupportedOnWindows = errors.New("replay sandbox not supported on Windows")

func isSandboxSupported() bool {
return false
}

func newReplaySandboxManager(_ replaySandboxOptions) (sandboxManager, error) {
return nil, errSandboxUnsupportedOnWindows
}
Loading
Loading