Files
zero/internal/sandbox/runner_linux_integration_test.go
55fa05f0ab fix(sandbox): deny reads of Zero credential stores (#681)
* fix(sandbox): deny reads of zero credential stores

* fix(sandbox): address PR review comments

- Deny reads of mcp-oauth-tokens.json.secret in the default Zero
  config candidates: file-backed oauth stores keep their encryption
  secret in a sibling .secret file, so the default MCP token store
  needs the same protection as the override paths (CodeRabbit).
- Clarify that explicit credential-file overrides are still filtered
  by on-disk existence like every other candidate (Copilot).
- Align the zeroUserConfigDir doc comment with config.UserConfigDir:
  macOS honors XDG_CONFIG_HOME before falling back to ~/.config
  (Copilot).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(sandbox): deny the whole Zero config dir, not itemized filenames

Address code review on PR #681:

- Deny credentialDenyReadPaths' Zero-config candidate as the containing
  directory instead of an itemized filename list. The token/credential/
  config stores each publish through a randomly-named .tmp-<pid>-<nanos>
  sibling before their atomic rename, and the legacy MCP token store
  leaves a mcp-oauth-tokens.json.migrated backup after importing it —
  none of those names were covered by the old itemized list, so a
  sandboxed command could read them under the read-all posture.

- Stop dropping default candidates that don't exist yet at profile-build
  time. A store created later in a long-lived sandboxed session (e.g. a
  concurrent ) previously got no deny rule at all; every
  backend already treats a deny rule over a not-yet-existing path as a
  harmless no-op that still takes effect once the path appears.

- Resolve ZERO_OAUTH_TOKENS_PATH / ZERO_MCP_OAUTH_TOKENS_PATH overrides
  the same way the token stores resolve them (relative-to-cwd, no ~
  expansion) instead of through normalizeProfilePath, which tilde-expands
  and so could derive a deny path different from where the store actually
  writes.

Adds regression coverage for the directory-wide deny (including the
migrated backup and synthetic temp-file siblings), for building the
profile before the store directory exists, and for the tilde-override
resolution mismatch.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(sandbox): address macOS deny-path test and config parity

Recompute the expected Zero config deny path after MkdirAll and resolve
the temp base with EvalSymlinks so macOS /var -> /private/var does not
flake TestPermissionProfileDeniesZeroCredentialFiles. Add a parity test
that sandbox.zeroUserConfigDir stays aligned with config.UserConfigDir.

Co-authored-by: PierrunoYT <[email protected]>

* fix(sandbox): harden credential deny paths

* test(cli): normalize credential deny baseline

* fix(sandbox): close credential path gaps

* test(sandbox): normalize command credential path

* fix(sandbox): handle absent credential paths safely

* fix(sandbox): protect MCP OAuth siblings

* fix(sandbox): protect OAuth lock siblings

* docs(sandbox): clarify Linux future-path limits

* test(sandbox): probe credential store reads in the Linux smoke test

The smoke test wired HOME/XDG_CONFIG_HOME at credential directories but never
attempted a read through the sandbox, so it could not catch a real-bwrap
regression for the paths this change protects. Write a secret into ~/.aws and
$XDG_CONFIG_HOME/zero and add both to the deny-read table.

* fix(sandbox): keep the credential deny usable, current, and unguessable

Four problems with the credential deny baseline, all in the same mechanism.

The whole-directory deny over $XDG_CONFIG_HOME/zero also hid the supported
user plugin, specialist, and command roots, whose commands and scripts are
executed through this sandbox, so an installed user plugin could no longer
start. Narrowing the deny to itemized files was not an option: the stores
publish through temporary names and a concurrent login can add a store that
did not exist when the profile was built, and only a directory rule covers
both. The directory deny therefore stays, and the non-secret subtrees are
carved back out through a new DenyReadCarveouts field: seatbelt re-allows
them after the deny (SBPL is last-match-wins) plus ancestor metadata so path
resolution can reach them, and bubblewrap masks the directory with --perms
111 instead of 000 -- unlistable but traversable -- and re-binds the carveout
read-only before the tmpfs is remounted read-only.

A relative ZERO_OAUTH_TOKENS_PATH, ZERO_MCP_OAUTH_TOKENS_PATH, or
XDG_CONFIG_HOME was resolved against the command directory only, while
oauth.ResolveStorePath and mcp.ResolveTokenStorePath call filepath.Abs, i.e.
resolve against the Zero process working directory. A command run from a
subdirectory therefore had the wrong path denied and the real store stayed
readable under the read-all posture. Relative candidates now resolve against
both directories, since the process that resolves the value is not
necessarily the one that writes the store. The regression test runs the
command from a different directory than the process, which the previous
tests did not.

bubblewrap cannot mount over a path that is absent when the namespace is
assembled, so a store created mid-session stayed readable through the live
read-only host-root bind for the rest of a long-lived sandbox session. The
directories Zero owns -- its config directory and the per-store publication
directories -- are now reported as EnsureDenyReadDirs and created (0700)
before the namespace is assembled, so their mask always exists. Third-party
stores such as ~/.aws are still skipped when absent: Zero must not create
them, and that limitation stays documented on the field.

The fixed <store>.tmp publication sibling made the plaintext token, and via
the same path its encryption key, observable to a same-user sandboxed process
with write access to an override parent: it could wait for the known name and
rename the opened inode into an allowed path. Both stores now publish through
a per-store <store>.publish directory with a randomly-named file inside. The
directory name is derived from the store path, so the profile can deny it up
front, while the random name inside is what cannot be waited for or renamed
away. A test keeps the duplicated suffix in step with oauth.PublicationDirSuffix.

Also adds the seatbelt render coverage that was missing: dropping
DenyReadIfExists from denyReadRules made every credential store readable
again on macOS without failing a single test.

* fix(sandbox): close credential deny review gaps

Preserve process credential roots alongside command overrides while limiting host precreation and read carveouts to trusted process-derived paths.

Canonicalize future credential roots through existing path aliases, reject symlink carveouts, and collapse automatic masks already covered by user or automatic directory denies. Normalize the policy golden and add regression coverage across profile, Seatbelt, and bubblewrap rendering.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): normalize credential carveout paths

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): canonicalize backend credential masks

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* test(sandbox): normalize backend mask expectations

Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-2f41-7398-8692-75327064c6f0

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): fail closed for replaceable token stores

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): allow keyring token path overrides

Amp-Thread-ID: https://ampcode.com/threads/T-019fbdfa-8469-731e-87c0-b26b7641cf59
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): harden command credential roots

Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-c723-74d9-a323-026040b94436
Co-authored-by: Pierre Bruno <[email protected]>

* fix(sandbox): close credential deny review gaps

Reapply credential denies after Seatbelt extension carveouts and model the legacy MCP token path as a migration input rather than an atomic writer.

Retain Bubblewrap fail-closed markers for OAuth outputs exposed through readable carveouts, and add regressions for both platform policies.

Amp-Thread-ID: https://ampcode.com/threads/T-019fbf55-f6b0-71fc-b384-652ba32b9645
Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-08-10 07:33:29 +08:00

521 lines
19 KiB
Go

//go:build linux
package sandbox
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git", "hooks"), 0o755); err != nil {
t.Fatalf("Mkdir .git/hooks: %v", err)
}
if err := os.WriteFile(filepath.Join(root, ".git", "config"), []byte("[core]\n"), 0o644); err != nil {
t.Fatalf("WriteFile .git/config: %v", err)
}
credentialHome := t.TempDir()
configHome := filepath.Join(credentialHome, "config")
for _, path := range []string{
filepath.Join(credentialHome, ".aws"),
filepath.Join(credentialHome, ".config", "gcloud"),
filepath.Join(credentialHome, ".azure"),
filepath.Join(configHome, "zero"),
} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatalf("Mkdir credential path: %v", err)
}
}
// Secrets inside those stores, so the deny-read table below probes a real read
// rather than only the directory's presence.
awsCredentials := filepath.Join(credentialHome, ".aws", "credentials")
if err := os.WriteFile(awsCredentials, []byte("[default]\naws_secret_access_key = leaked\n"), 0o600); err != nil {
t.Fatalf("WriteFile aws credentials: %v", err)
}
zeroTokens := filepath.Join(configHome, "zero", "oauth-tokens.json")
if err := os.WriteFile(zeroTokens, []byte(`{"access_token":"leaked"}`+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile zero tokens: %v", err)
}
t.Setenv("HOME", credentialHome)
t.Setenv("XDG_CONFIG_HOME", configHome)
secretDir := t.TempDir()
if err := os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("hidden\n"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
blockedDir := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedDir, 0o755); err != nil {
t.Fatalf("Mkdir blocked: %v", err)
}
policy := DefaultPolicy()
policy.DenyRead = []string{secretDir}
policy.DenyWrite = []string{blockedDir}
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: backend})
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", strings.Join([]string{
"set -eu",
"test \"$HOME\" != \"$PWD\"",
"test ! -e .zero && test ! -e .agents",
"echo cache > \"$npm_config_cache/zero-runtime-probe\"",
"test ! -e .npm && test ! -e .cache",
"rm -f \"$npm_config_cache/zero-runtime-probe\"",
"echo ok > write-ok.txt",
"test \"$(cat write-ok.txt)\" = ok",
"echo tmp > /tmp/zero-sandbox-smoke",
"test \"$(cat /tmp/zero-sandbox-smoke)\" = tmp",
"cat .git/config >/dev/null",
}, "\n")},
Dir: root,
})
if runErr != nil {
if linuxSandboxLaunchUnsupported(string(output)) {
t.Skipf("Linux sandbox launch is unsupported in this environment: %v\n%s", runErr, output)
}
t.Fatalf("allowed smoke command failed: %v\n%s", runErr, output)
}
t.Run("fresh home and non-git workspace launch", func(t *testing.T) {
freshRoot := t.TempDir()
freshHome := t.TempDir()
freshEngine := NewEngine(EngineOptions{WorkspaceRoot: freshRoot, Policy: DefaultPolicy(), Backend: backend})
output, runErr := runLinuxSandboxSmokeCommand(t, freshEngine, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "echo ok > launched"},
Dir: freshRoot,
Env: []string{"HOME=" + freshHome, "XDG_CONFIG_HOME=" + filepath.Join(freshHome, ".config")},
})
if runErr != nil {
t.Fatalf("fresh environment failed to launch: %v\n%s", runErr, output)
}
})
t.Run("missing command credential root fails closed without host mutation", func(t *testing.T) {
commandRoot := filepath.Join(tempDirOutsideDefaultTemp(t), "missing-command-home")
commandConfig := filepath.Join(commandRoot, "config")
launched := filepath.Join(root, "command-credential-root-launched")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
_, err := engine.BuildCommandPlan(CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "echo launched > " + shellQuote(launched)},
Dir: root,
Env: []string{"HOME=" + commandRoot, "XDG_CONFIG_HOME=" + commandConfig},
})
if err == nil || !strings.Contains(err.Error(), "created after launch") {
t.Fatalf("BuildCommandPlan error = %v, want future credential-directory failure", err)
}
for _, path := range []string{commandRoot, commandConfig, launched} {
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("failed-closed planning materialized command-controlled path %q: %v", path, statErr)
}
}
})
// The mid-session race the EnsureDenyReadDirs contract exists for: nothing
// under $XDG_CONFIG_HOME exists when the plan is built, so bubblewrap would
// have had no mount destination to mask. The sandbox creates Zero's own
// directory first, and the token written by this test WHILE the sandboxed
// command is already running must stay invisible to it.
t.Run("credential store created during the session stays hidden", func(t *testing.T) {
raceHome := t.TempDir()
raceConfig := filepath.Join(raceHome, "config")
raceStore := filepath.Join(raceConfig, "zero")
tokenPath := filepath.Join(raceStore, "oauth-tokens.json")
raceRoot := t.TempDir()
started := filepath.Join(raceRoot, "started")
ready := filepath.Join(raceRoot, "ready")
done := make(chan struct{})
go func() {
defer close(done)
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(started); err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
// The namespace is assembled by now, so this write is the concurrent
// login the sandbox must not be able to read.
if err := os.MkdirAll(raceStore, 0o700); err == nil {
_ = os.WriteFile(tokenPath, []byte("racyleak\n"), 0o600)
}
_ = os.WriteFile(ready, []byte("1\n"), 0o600)
}()
raceEngine := NewEngine(EngineOptions{WorkspaceRoot: raceRoot, Policy: DefaultPolicy(), Backend: backend})
output, _ := runLinuxSandboxSmokeCommand(t, raceEngine, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", strings.Join([]string{
"echo 1 > " + shellQuote(started),
"i=0",
"while [ ! -e " + shellQuote(ready) + " ] && [ \"$i\" -lt 2000 ]; do i=$((i+1)); sleep 0.01; done",
"if cat " + shellQuote(tokenPath) + " 2>/dev/null | grep -q racyleak; then echo MIDSESSION_CREDENTIAL_READ_SUCCEEDED; fi",
}, "\n")},
Dir: raceRoot,
Env: []string{"HOME=" + raceHome, "XDG_CONFIG_HOME=" + raceConfig},
})
<-done
if strings.Contains(string(output), "MIDSESSION_CREDENTIAL_READ_SUCCEEDED") {
t.Fatalf("token created during the session was readable: %s", output)
}
if _, err := os.Stat(tokenPath); err != nil {
t.Fatalf("test did not create the token it probes for: %v", err)
}
})
// The user plugin root shares the denied credential directory, and its
// commands are executed through this sandbox, so it must stay readable.
t.Run("user plugin root inside the denied config dir stays readable", func(t *testing.T) {
pluginHome := t.TempDir()
pluginConfig := filepath.Join(pluginHome, "config")
pluginRoot := filepath.Join(pluginConfig, "zero", "plugins", "demo")
if err := os.MkdirAll(pluginRoot, 0o700); err != nil {
t.Fatalf("MkdirAll plugin root: %v", err)
}
manifest := filepath.Join(pluginRoot, "plugin.json")
if err := os.WriteFile(manifest, []byte(`{"name":"demo"}`+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile plugin manifest: %v", err)
}
secret := filepath.Join(pluginConfig, "zero", "oauth-tokens.json")
if err := os.WriteFile(secret, []byte(`{"access_token":"leaked"}`+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile zero tokens: %v", err)
}
pluginEngine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
output, runErr := runLinuxSandboxSmokeCommand(t, pluginEngine, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", strings.Join([]string{
"set -eu",
"grep -q demo " + shellQuote(manifest),
"if cat " + shellQuote(secret) + " 2>/dev/null | grep -q leaked; then echo CARVEOUT_LEAKED_SIBLING; exit 42; fi",
}, "\n")},
Dir: root,
Env: []string{"HOME=" + pluginHome, "XDG_CONFIG_HOME=" + pluginConfig},
})
if runErr != nil {
t.Fatalf("plugin root inside the denied config dir was not readable: %v\n%s", runErr, output)
}
if strings.Contains(string(output), "CARVEOUT_LEAKED_SIBLING") {
t.Fatalf("carveout exposed a credential sibling: %s", output)
}
})
t.Run("credential store created after launch stays hidden on the next run", func(t *testing.T) {
lateHome := t.TempDir()
lateConfig := filepath.Join(lateHome, "config")
lateStore := filepath.Join(lateConfig, "zero")
if err := os.MkdirAll(lateStore, 0o700); err != nil {
t.Fatalf("MkdirAll late credential store: %v", err)
}
if err := os.WriteFile(filepath.Join(lateStore, "oauth-tokens.json"), []byte("token\n"), 0o600); err != nil {
t.Fatalf("WriteFile late token: %v", err)
}
lateEngine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
output, runErr := runLinuxSandboxSmokeCommand(t, lateEngine, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "cat " + shellQuote(filepath.Join(lateStore, "oauth-tokens.json"))},
Dir: root,
Env: []string{"HOME=" + lateHome, "XDG_CONFIG_HOME=" + lateConfig},
})
if runErr == nil || strings.Contains(string(output), "token") {
t.Fatalf("credential store created before this run stayed readable: err=%v output=%s", runErr, output)
}
})
for _, tc := range []struct {
name string
script string
marker string
}{
{
name: "outside write",
script: "if echo leak > /etc/zero_sandbox_smoke 2>/dev/null; then echo OUTSIDE_WRITE_SUCCEEDED; exit 42; fi",
marker: "OUTSIDE_WRITE_SUCCEEDED",
},
{
name: "deny read",
script: "if cat " + shellQuote(filepath.Join(secretDir, "secret.txt")) + " >/dev/null 2>&1; then echo DENY_READ_SUCCEEDED; exit 42; fi",
marker: "DENY_READ_SUCCEEDED",
},
{
name: "deny write",
script: "if echo leak > blocked/file 2>/dev/null; then echo DENY_WRITE_SUCCEEDED; exit 42; fi",
marker: "DENY_WRITE_SUCCEEDED",
},
{
name: "metadata write",
script: "if echo leak > .git/config 2>/dev/null; then echo METADATA_WRITE_SUCCEEDED; exit 42; fi",
marker: "METADATA_WRITE_SUCCEEDED",
},
{
name: "cloud credential store read",
script: "if cat " + shellQuote(awsCredentials) + " 2>/dev/null | grep -q leaked; then echo CLOUD_CREDENTIAL_READ_SUCCEEDED; exit 42; fi",
marker: "CLOUD_CREDENTIAL_READ_SUCCEEDED",
},
{
name: "zero credential store read",
script: "if cat " + shellQuote(zeroTokens) + " 2>/dev/null | grep -q leaked; then echo ZERO_CREDENTIAL_READ_SUCCEEDED; exit 42; fi",
marker: "ZERO_CREDENTIAL_READ_SUCCEEDED",
},
} {
t.Run(tc.name, func(t *testing.T) {
output, _ := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", tc.script},
Dir: root,
})
if strings.Contains(string(output), tc.marker) {
t.Fatalf("sandbox allowed %s; output=%s", tc.name, output)
}
})
}
if python, err := exec.LookPath("python3"); err == nil && python != "" {
t.Run("network deny", func(t *testing.T) {
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 2).close()"},
Dir: root,
})
if runErr == nil {
t.Fatalf("sandbox allowed outbound network; output=%s", output)
}
})
} else {
t.Log("python3 not found; skipping real network deny probe")
}
}
func TestLinuxLandlockRealSandboxSmoke(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
helper, err := linuxSandboxHelperCommand()
if err != nil {
t.Skipf("Linux sandbox helper unavailable: %v", err)
}
root := t.TempDir()
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "blocked.txt")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: root}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
output, runErr := runLinuxLandlockSmokeCommand(t, helper, profile, root, []string{"/bin/sh", "-c", strings.Join([]string{
"set -eu",
"echo ok > " + shellQuote(filepath.Join(root, "write-ok.txt")),
"test \"$(cat " + shellQuote(filepath.Join(root, "write-ok.txt")) + ")\" = ok",
"if echo leak > " + shellQuote(outsideFile) + " 2>/dev/null; then echo LANDLOCK_OUTSIDE_WRITE_SUCCEEDED; exit 42; fi",
}, "\n")})
if runErr != nil {
if landlockLaunchUnsupported(string(output)) {
t.Skipf("Landlock is unsupported in this environment: %v\n%s", runErr, output)
}
t.Fatalf("Landlock smoke command failed: %v\n%s", runErr, output)
}
if strings.Contains(string(output), "LANDLOCK_OUTSIDE_WRITE_SUCCEEDED") {
t.Fatalf("Landlock allowed write outside approved roots: %s", output)
}
if _, err := os.Stat(outsideFile); err == nil {
t.Fatalf("Landlock wrote host file outside approved roots: %s", outsideFile)
}
if python, err := exec.LookPath("python3"); err == nil && python != "" {
output, runErr = runLinuxLandlockSmokeCommand(t, helper, profile, root, []string{
python,
"-c",
"import socket; socket.create_connection(('1.1.1.1', 80), 2).close()",
})
if runErr == nil {
t.Fatalf("Landlock mode allowed outbound network; output=%s", output)
}
} else {
t.Log("python3 not found; skipping Landlock network deny probe")
}
}
func TestLinuxHelperAllowsIsolatedLoopbackWithoutExternalEgress(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
python, err := exec.LookPath("python3")
if err != nil || python == "" {
t.Skip("python3 not found; skipping isolated loopback probe")
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
loopbackScript := strings.Join([]string{
"import socket",
"server = socket.socket()",
"server.bind(('127.0.0.1', 0))",
"server.listen()",
"client = socket.socket()",
"client.connect(('127.0.0.1', server.getsockname()[1]))",
"accepted, _ = server.accept()",
"client.send(b'ok')",
"assert accepted.recv(2) == b'ok'",
}, "; ")
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", loopbackScript},
Dir: root,
})
if runErr != nil {
t.Fatalf("isolated loopback failed: %v\n%s", runErr, output)
}
output, runErr = runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 1).close()"},
Dir: root,
})
if runErr == nil {
t.Fatalf("sandbox allowed external egress while isolated loopback was enabled: %s", output)
}
}
func TestLinuxHelperPreservesAbsentProtectedMetadata(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "set -eu; test ! -e .zero; mkdir .zero"},
Dir: root,
})
if err != nil {
t.Fatalf("CommandContext: %v", err)
}
defer plan.Cleanup()
output, runErr := command.CombinedOutput()
if runErr == nil {
t.Fatalf("sandbox reported success after protected metadata creation; output=%s", output)
}
if !strings.Contains(string(output), "blocked creation of protected workspace metadata path") {
t.Fatalf("sandbox did not explain protected metadata denial: %v\n%s", runErr, output)
}
if _, err := os.Lstat(filepath.Join(root, ".zero")); !os.IsNotExist(err) {
t.Fatalf("protected metadata path remained after execution: %v", err)
}
report, err := plan.ExecutionReport()
if err != nil {
t.Fatalf("ExecutionReport: %v", err)
}
if report.Denial == nil || report.Denial.Capability.Kind != execution.CapabilityProtectedMetadata {
t.Fatalf("execution report denial = %#v, want protected metadata", report.Denial)
}
if report.Denial.Capability.Scope != filepath.Join(root, ".zero") {
t.Fatalf("denial scope = %q, want exact protected path", report.Denial.Capability.Scope)
}
}
func runLinuxSandboxSmokeCommand(t *testing.T, engine *Engine, spec CommandSpec) ([]byte, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command, plan, err := engine.CommandContext(ctx, spec)
if err != nil {
t.Fatalf("CommandContext: %v", err)
}
output, runErr := command.CombinedOutput()
if strings.Contains(string(output), "OUTSIDE_WRITE_SUCCEEDED") {
t.Fatalf("sandbox allowed write outside workspace; plan=%#v output=%s", plan, output)
}
return output, runErr
}
func runLinuxLandlockSmokeCommand(t *testing.T, helper LinuxSandboxHelperCommand, profile PermissionProfile, root string, command []string) ([]byte, error) {
t.Helper()
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: root,
CommandCWD: root,
PermissionProfile: profile,
UseLandlock: true,
Command: command,
})
if err != nil {
t.Fatalf("BuildLinuxSandboxCommandArgs: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, helper.Name, append(append([]string{}, helper.ArgsPrefix...), args...)...)
if helper.Dir != "" {
cmd.Dir = helper.Dir
} else {
cmd.Dir = root
}
cmd.Env = os.Environ()
return cmd.CombinedOutput()
}
func linuxSandboxLaunchUnsupported(output string) bool {
for _, marker := range []string{
"Operation not permitted",
"Permission denied",
"Invalid argument",
"No permissions to create new namespace",
"creating new namespace failed",
"bubblewrap is not available",
"Can't mount proc on",
} {
if strings.Contains(output, marker) {
return true
}
}
return false
}
func landlockLaunchUnsupported(output string) bool {
for _, marker := range []string{
"apply Landlock: query ABI",
"operation not supported",
"Operation not supported",
"invalid argument",
"Invalid argument",
"function not implemented",
"Function not implemented",
} {
if strings.Contains(output, marker) {
return true
}
}
return false
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}