Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 additions & 0 deletions internal/runner/sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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()
}

// sandboxConfigError marks errors that stem from invalid user sandbox config
// (bad JSON, denied localhost, missing file). These are always fatal
// regardless of sandbox mode — a user who supplied a broken config asked for
// sandboxing and shouldn't silently get unisolated execution. Distinct from
// runtime-availability errors (missing bwrap/socat, Initialize failure), which
// auto mode treats as "fall back to no sandbox".
type sandboxConfigError struct{ err error }

func (e *sandboxConfigError) Error() string { return e.err.Error() }
func (e *sandboxConfigError) Unwrap() error { return e.err }

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
199 changes: 199 additions & 0 deletions internal/runner/sandbox_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//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 any error after fence.NewManager succeeds, the manager's Cleanup is
// invoked before returning so no fence-allocated resources leak.
func newReplaySandboxManager(opts replaySandboxOptions) (sandboxManager, error) {
Comment thread
jy-tan marked this conversation as resolved.
fenceCfg, err := createReplayFenceConfig(opts.UserConfigPath)
if err != nil {
// No prefix here: service.go adds the user-facing
// "failed to prepare replay sandbox config:" framing.
return nil, &sandboxConfigError{err: err}
}

mgr := fence.NewManager(fenceCfg, opts.Debug, false)
// Defensive: Cleanup is idempotent and fence's Initialize already
// unwinds its own partial state on failure, but this guards against
// future fence changes that add allocating steps between NewManager
// and Initialize (or between error returns inside Initialize).
success := false
defer func() {
if !success {
mgr.Cleanup()
}
}()

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)
}

success = true
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{
"/",
}
}
Loading
Loading