Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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,10 @@ 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
)

// TODO: remove before publishing.
replace github.com/Use-Tusk/fence => ../fence
Comment thread
This conversation was marked as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
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
19 changes: 16 additions & 3 deletions internal/runner/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,20 @@ type MockNotFoundEvent struct {
ReplaySpan *core.Span `json:"replaySpan"` // The outbound span that failed to find a mock
}

func isDockerCommand(cmd string) bool {
// serviceDelegatesToHostDaemon reports whether the configured service start
// command delegates port binding / process execution to an external daemon
// whose network listener lives on the host (outside any sandbox netns that
// fence might set up). Today this covers the docker / docker-compose family;
// extending to podman, nerdctl, or systemctl is a one-liner.
//
// This predicate is consulted in two places:
// - determineCommunicationType: daemon-delegated services cannot reach a
// Unix socket on the host filesystem from inside a container, so we must
// use TCP for the mock server ↔ SDK channel.
// - StartService: daemon-delegated services bind the host port via the
// daemon's own bind/iptables, so fence's reverse bridge would collide;
// fence is told ServiceBindsOnHost and skips it.
func serviceDelegatesToHostDaemon(cmd string) bool {
cmd = strings.ToLower(cmd)
cmd = strings.Join(strings.Fields(cmd), " ")

Expand All @@ -134,8 +147,8 @@ func determineCommunicationType(cfg *config.ServiceConfig) CommunicationType {

// Auto-detect based on start command
if commType == "auto" {
if isDockerCommand(cfg.Start.Command) {
log.Debug("Auto-detected Docker command, using TCP communication")
if serviceDelegatesToHostDaemon(cfg.Start.Command) {
log.Debug("Auto-detected host-daemon-delegated service, using TCP communication")
return CommunicationTCP
}
return CommunicationUnix
Expand Down
13 changes: 7 additions & 6 deletions internal/runner/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,32 +302,33 @@ func TestDetermineCommunicationType(t *testing.T) {
}
}

func TestIsDockerCommand(t *testing.T) {
func TestServiceDelegatesToHostDaemon(t *testing.T) {
tests := []struct {
command string
expected bool
}{
// Docker commands
// Docker commands (host-daemon-delegated)
{"docker", true},
{"docker-compose", true},
{"docker compose up", true},
{"docker-compose up", true},
{"docker run myimage", true},
{"ENV=test docker compose up", true},

// Non-Docker commands
// Directly-executed services (bind in-process)
{"npm run start", false},
{"node server.js", false},
{"python app.py", false},

// This is likely a docker-related script, but we don't make further assumptions.
// Users can explicitly set the communication type in the config.
// Likely a docker-related script, but we don't make further assumptions.
// Users can explicitly set the communication type / execution model
// in the config.
{"./start-docker.sh", false},
}

for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := isDockerCommand(tt.command)
result := serviceDelegatesToHostDaemon(tt.command)
assert.Equal(t, tt.expected, result, "Command: %s", tt.command)
})
}
Expand Down
62 changes: 49 additions & 13 deletions internal/runner/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,32 +87,68 @@ func (e *Executor) StartService() error {
log.ServiceLog(fmt.Sprintf("🔧 Merged custom Fence config into replay sandbox: %s", utils.ResolveTuskPath(sandboxConfigPath)))
}
e.fenceManager = fence.NewManager(fenceCfg, e.debug, false)
e.fenceManager.SetExposedPorts([]int{cfg.Service.Port})

if err := e.fenceManager.Initialize(); err != nil {
// Tell fence how the sandboxed service binds its port. For
// docker / docker-compose / podman commands, the daemon binds
Comment thread
This conversation was marked as resolved.
Outdated
// the host port outside the sandbox netns, so fence must NOT
// set up a reverse bridge (it would collide with the daemon's
// bind). For everything else, fence proxies inbound traffic
// into the sandbox netns as usual.
executionModel := fence.ServiceBindsInSandbox
if serviceDelegatesToHostDaemon(cfg.Service.Start.Command) {
executionModel = fence.ServiceBindsOnHost
}
e.fenceManager.SetService(fence.ServiceOptions{
ExposedPorts: []int{cfg.Service.Port},
ExecutionModel: executionModel,
})

// Hand any caller-generated host files the sandboxed process
// needs to see (e.g. the replay compose env-override YAML)
// to fence. Without this, a file created via
// os.CreateTemp("", ...) lives under /tmp, which fence
// tmpfs-overmounts — invisible to the sandboxed docker client.
exposeErr := error(nil)
if replayOverridePath != "" {
if err := e.fenceManager.ExposeHostPath(replayOverridePath, false); err != nil {
exposeErr = err
}
}
if exposeErr != nil {
if requireSandbox {
e.fenceManager = nil
return fmt.Errorf("strict replay sandbox unavailable: %s", friendlySandboxError(err))
return fmt.Errorf("strict replay sandbox unavailable: failed to expose replay override file to sandbox: %w", exposeErr)
}
log.UserWarn(fmt.Sprintf("⚠️ Sandbox unavailable: %s", friendlySandboxError(err)))
log.UserWarn(" Tests will run without network isolation (real connections allowed)\n")
log.UserWarn(fmt.Sprintf("⚠️ Sandbox: failed to expose replay override file (%v); proceeding without sandbox", exposeErr))
e.fenceManager = nil
} else {
wrappedCmd, err := e.fenceManager.WrapCommand(command)
if err != nil {
}

if e.fenceManager != nil {
if err := e.fenceManager.Initialize(); err != nil {
if requireSandbox {
e.fenceManager.Cleanup()
e.fenceManager = nil
return fmt.Errorf("strict replay sandbox unavailable: %s", friendlySandboxError(err))
}
log.UserWarn(fmt.Sprintf("⚠️ Sandbox unavailable: %s", friendlySandboxError(err)))
log.UserWarn(" Tests will run without network isolation (real connections allowed)\n")
e.fenceManager.Cleanup()
e.fenceManager = nil
} else {
command = wrappedCmd
e.lastServiceSandboxed = true
log.ServiceLog("🔒 Service sandboxed (localhost outbound blocked for replay isolation)")
wrappedCmd, err := e.fenceManager.WrapCommand(command)
if err != nil {
if requireSandbox {
e.fenceManager.Cleanup()
e.fenceManager = nil
return fmt.Errorf("strict replay sandbox unavailable: %s", friendlySandboxError(err))
}
log.UserWarn(fmt.Sprintf("⚠️ Sandbox unavailable: %s", friendlySandboxError(err)))
log.UserWarn(" Tests will run without network isolation (real connections allowed)\n")
e.fenceManager.Cleanup()
e.fenceManager = nil
} else {
command = wrappedCmd
e.lastServiceSandboxed = true
log.ServiceLog("🔒 Service sandboxed (localhost outbound blocked for replay isolation)")
}
}
}
}
Expand Down
Loading