diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index 61d9c5a..9c81207 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -452,6 +452,9 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "auth regenerate-api-key", Description: "Regenerate your API key."}, {Name: "config get ", Description: "Get a config value. Keys: api-host, project, agent."}, {Name: "config set ", Description: "Set a config value. Keys: api-host, project, agent."}, + {Name: "sandbox audit", Description: "Red-team the enforce-mode sandbox: attempt every known egress bypass and report which the OS actually stops. Exits non-zero if any hole is found.", Args: []ArgInfo{ + {Name: "", Description: "Agent to audit (codex, cursor, claude, ...). Defaults to the OneCLI-owned sandbox."}, + }}, {Name: "migrate", Description: "Migrate data to OneCLI Cloud.", Args: []ArgInfo{ {Name: "--cloud-key", Required: true, Description: "OneCLI Cloud API key."}, }}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index 6c08b55..c0dea40 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -35,6 +35,7 @@ type CLI struct { Counts CountsCmd `cmd:"" help:"Show the project's resource counts."` Auth AuthCmd `cmd:"" help:"Manage authentication."` Config ConfigCmd `cmd:"" help:"Manage configuration settings."` + Sandbox SandboxCmd `cmd:"" help:"Inspect and audit the enforce-mode sandbox."` Migrate MigrateCmd `cmd:"" help:"Migrate data to OneCLI Cloud."` } diff --git a/cmd/onecli/run.go b/cmd/onecli/run.go index 3b7609a..53b0ca1 100644 --- a/cmd/onecli/run.go +++ b/cmd/onecli/run.go @@ -45,7 +45,7 @@ type RunCmd struct { Agent string `optional:"" name:"agent" help:"OneCLI agent identifier (default: ONECLI_AGENT env, then 'onecli config set agent', then the project's default agent)."` Gateway string `optional:"" name:"gateway" help:"Gateway host:port override (default: derived from API host)."` NoCA bool `optional:"" name:"no-ca" help:"Skip writing the CA cert and CA trust env injection."` - Enforce bool `optional:"" name:"enforce" help:"OS-enforced governance: route the agent's sandboxed egress through the gateway so it cannot be bypassed (Claude Code only)."` + Enforce bool `optional:"" name:"enforce" help:"OS-enforced governance: sandbox the agent so all egress is routed through the gateway and cannot be bypassed."` DryRun bool `optional:"" name:"dry-run" help:"Print resolved env and command without executing."` Args []string `arg:"" optional:"" name:"command" help:"Command and arguments to execute (after --)."` } @@ -136,11 +136,48 @@ func (c *RunCmd) Run(out *output.Writer) error { } } + // Enforce mode routes one of two ways. Agents with a cooperating + // OS sandbox (Claude Code) keep the native integration: their own + // sandbox becomes the enforcement layer via --settings. Everything + // else gets the OneCLI-owned sandbox wrap (run_enforce_wrap.go). + // The wrap decision must happen HERE — before the child env and any + // agent config injections are derived from cfg.Env — because the + // wrapped process can only dial loopback, so every proxy URL has to + // be repointed at the forwarder first. Both paths fail closed. + enforceNative := false + wrapProfilePath := "" + var wrapPort uint16 + if c.Enforce { + spec, known := agentSkillDir(c.Args[0]) + switch { + case known && enforceSupportedAgents[spec.agentName]: + enforceNative = true + case known && spec.dockerSandbox: + // Tools run in a Docker container OUTSIDE any host sandbox + // (and the wrap denies docker.sock as an egress bypass), so + // the wrap cannot govern this agent. Fail closed. + return fmt.Errorf("--enforce is not supported for %s: its tools run in a Docker sandbox the OS sandbox cannot govern", spec.agentName) + default: + wrapProfilePath, wrapPort, err = resolveEnforceWrap(cfg.Env) + if err != nil { + return fmt.Errorf("enforce mode unavailable: %w", err) + } + } + } + // Build child environment. env := buildChildEnv(os.Environ(), cfg.Env, caPath) env = append(env, "ONECLI_GATEWAY=true") + // When the gateway routes Node's own egress via NODE_USE_ENV_PROXY, Node + // prints a one-time "[UNDICI-EHPA] EnvHttpProxyAgent is experimental" + // warning to stderr on startup — the mechanism announcing itself, not an + // error. Mute just that warning code (Node 22+ --disable-warning) so + // gateway plumbing doesn't leak noise into the agent's output. Scoped to + // the flag's presence so we never alter Node behavior otherwise. + env = suppressUndiciProxyWarning(env) + // For known agents, fetch the agent-specific skill variant and install // to the agent's skill directory. Also optionally register a hook. agentFramework := strings.ToLower(filepath.Base(c.Args[0])) @@ -207,16 +244,12 @@ func (c *RunCmd) Run(out *output.Writer) error { out.Stderr(fmt.Sprintf("onecli: warning: %s", w)) } - // Enforce mode: fork the loopback auth forwarder, write the sandbox - // settings, and extend the agent argv. Fails closed — a broken - // forwarder would leave the sandbox with no route to the gateway, - // which is worse than an explicit error. + // Native enforce path: fork the loopback auth forwarder, write the + // sandbox settings, and extend the agent argv. Fails closed — a + // broken forwarder would leave the sandbox with no route to the + // gateway, which is worse than an explicit error. args := c.Args - if c.Enforce { - a, ok := agentSkillDir(c.Args[0]) - if !ok || !enforceSupportedAgents[a.agentName] { - return fmt.Errorf("--enforce is not supported for %q yet (Claude Code only)", filepath.Base(c.Args[0])) - } + if enforceNative { port, err := spawnEnforceForwarder(firstProxyURL(cfg.Env)) if err != nil { return fmt.Errorf("enforce mode unavailable: %w", err) @@ -229,9 +262,24 @@ func (c *RunCmd) Run(out *output.Writer) error { out.Stderr(fmt.Sprintf("onecli: enforce mode active — sandboxed egress locked to the gateway (forwarder :%d).", port)) } + // Wrap enforce path: exec sandbox-exec around the agent so the OS + // confines the whole process tree to loopback-only egress. + execBinary := binary + if wrapProfilePath != "" { + execBinary, err = enforceWrapLauncher() + if err != nil { + return fmt.Errorf("enforce mode unavailable: %w", err) + } + args = enforceWrapArgv(wrapProfilePath, binary, c.Args[1:], agentFramework) + if notice := enforceWrapNotice(agentFramework); notice != "" { + out.Stderr(notice) + } + out.Stderr(fmt.Sprintf("onecli: enforce mode active — all process egress locked to the gateway (forwarder :%d).", wrapPort)) + } + // Exec — replaces this process so the agent gets direct terminal control. out.Stderr(fmt.Sprintf("onecli: gateway connected. Starting %s...", c.Args[0])) - if err := syscall.Exec(binary, args, env); err != nil { + if err := syscall.Exec(execBinary, args, env); err != nil { return fmt.Errorf("could not start %s: %w", c.Args[0], err) } return nil @@ -363,6 +411,46 @@ func buildChildEnv(current []string, serverEnv map[string]string, caPath string) return out } +// undiciWarningFlag mutes Node's experimental EnvHttpProxyAgent warning by its +// stable code (Node 22+). Scoped to a single code so real warnings still show. +const undiciWarningFlag = "--disable-warning=UNDICI-EHPA" + +// suppressUndiciProxyWarning appends undiciWarningFlag to NODE_OPTIONS, but only +// when the gateway has enabled Node's env-proxy support (NODE_USE_ENV_PROXY) — +// the setting that triggers the warning. It edits an existing NODE_OPTIONS in +// place (preserving the user's flags) or appends a new one, and is a no-op if +// the flag is already present. POSIX getenv returns the first match, so editing +// in place rather than appending a second NODE_OPTIONS matters. +func suppressUndiciProxyWarning(env []string) []string { + hasProxyFlag := false + for _, kv := range env { + if strings.HasPrefix(kv, "NODE_USE_ENV_PROXY=") { + hasProxyFlag = true + break + } + } + if !hasProxyFlag { + return env + } + const key = "NODE_OPTIONS=" + for i, kv := range env { + if !strings.HasPrefix(kv, key) { + continue + } + existing := kv[len(key):] + if strings.Contains(existing, undiciWarningFlag) { + return env + } + if existing == "" { + env[i] = key + undiciWarningFlag + } else { + env[i] = key + existing + " " + undiciWarningFlag + } + return env + } + return append(env, key+undiciWarningFlag) +} + // proxyEnvKeys are the proxy URL env vars (both casings) the gateway sets. var proxyEnvKeys = []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} diff --git a/cmd/onecli/run_enforce.go b/cmd/onecli/run_enforce.go index f23c970..132cdda 100644 --- a/cmd/onecli/run_enforce.go +++ b/cmd/onecli/run_enforce.go @@ -9,7 +9,9 @@ package main // one network path, a loopback forwarder that injects the gateway // credentials (see run_enforce_forwarder.go). Direct dials fail at the OS. // -// Currently Claude Code only. Its sandbox honors --settings for the +// This file is the NATIVE enforce path, used for agents whose runtime has +// an OS-level sandbox with a custom-proxy setting (currently Claude Code). +// Its sandbox honors --settings for the // sandbox.* keys, denies writes to every settings.json scope from inside // the sandbox, and (with allowUnsandboxedCommands=false) ignores the // dangerouslyDisableSandbox escape hatch — the exact three properties diff --git a/cmd/onecli/run_enforce_wrap.go b/cmd/onecli/run_enforce_wrap.go new file mode 100644 index 0000000..b12af98 --- /dev/null +++ b/cmd/onecli/run_enforce_wrap.go @@ -0,0 +1,113 @@ +package main + +// Enforce-wrap mode: the agent-agnostic half of `onecli run --enforce`. +// +// The native path (run_enforce.go) borrows Claude Code's own sandbox and +// therefore only ever covers Claude Code — and only its Bash tool. The +// wrap path inverts ownership: onecli brings its own OS sandbox and puts +// the ENTIRE agent process tree inside it, so any agent (Codex, Cursor, +// Gemini, a bare shell) gets the same guarantee, including its own model +// API calls and every subprocess it spawns. +// +// The OS mechanism lives in internal/sandbox, selected by build tag +// (Seatbelt today; Linux planned). This file holds only what is NOT +// OS-specific: repointing the proxy env at the loopback forwarder, and +// the per-agent quirks table. The profile itself deliberately has ONE +// definition, in that package: two copies of a security policy is how the +// docker.sock rule stayed broken while a text assertion kept passing. + +import ( + "net" + "net/url" + "strconv" + + "github.com/onecli/onecli-cli/internal/sandbox" +) + +// rewriteProxyEnvToLoopback repoints every proxy URL in env at the +// loopback forwarder, preserving scheme and credentials. Under the wrap +// the sandbox blocks a direct dial to the gateway host, so the proxy env +// (and everything derived from it: Codex's proxy_url TOML, Electron +// settings) must route through the forwarder. Credentials are kept +// intact — the forwarder overrides them anyway, and Codex's config +// refresh logic keys on the embedded aoc_ marker. +func rewriteProxyEnvToLoopback(env map[string]string, port uint16) { + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port))) + for _, k := range proxyEnvKeys { + v := env[k] + if v == "" { + continue + } + u, err := url.Parse(v) + if err != nil { + continue + } + u.Host = addr + env[k] = u.String() + } +} + +// enforceWrapQuirkArgs returns extra argv appended for agents whose own +// runtime needs adjusting to live inside the wrap. Appended last so it +// wins over defaults (and is visible in ps for auditability). +func enforceWrapQuirkArgs(framework string) []string { + if framework == "codex" { + // Codex's internal Seatbelt (sandbox_apply) gets EPERM inside an + // outer Seatbelt profile, which breaks every shell command it + // runs. Disable its inner sandbox: network governance moves to + // the OneCLI sandbox (strictly stronger — whole process tree), + // filesystem confinement falls back to Codex's approval flow + // plus the deferred-egress denies in the profile. + return []string{"-c", "sandbox_mode=danger-full-access"} + } + return nil +} + +// enforceWrapNotice returns the agent-specific stderr notice for quirks +// that change the agent's own behavior, or "". +func enforceWrapNotice(framework string) string { + if framework == "codex" { + return "onecli: Codex's internal sandbox is disabled under enforce — the OneCLI sandbox governs all egress instead; filesystem safety falls back to Codex approvals." + } + return "" +} + +// enforceWrapArgv builds the argv that runs the agent confined: the +// launcher's argv with per-agent quirk flags appended last. +func enforceWrapArgv(profilePath, binary string, agentArgs []string, framework string) []string { + args := append(append([]string{}, agentArgs...), enforceWrapQuirkArgs(framework)...) + return sandbox.WrapArgv(profilePath, binary, args) +} + +// enforceWrapLauncher resolves the binary to exec for wrap mode. +func enforceWrapLauncher() (string, error) { + if err := sandbox.Available(); err != nil { + return "", err + } + return sandbox.LauncherPath(), nil +} + +// resolveEnforceWrap prepares wrap mode: verifies platform support, +// spawns the loopback forwarder, repoints the proxy env at it, and +// materializes the sandbox profile. Returns the profile handle and the +// forwarder port. Must run BEFORE the child env is built — everything +// derived from cfg.Env has to carry the loopback URL. Fails closed, like +// the native path. +func resolveEnforceWrap(env map[string]string) (profilePath string, port uint16, err error) { + if err := sandbox.Available(); err != nil { + return "", 0, err + } + port, err = spawnEnforceForwarder(firstProxyURL(env)) + if err != nil { + return "", 0, err + } + rewriteProxyEnvToLoopback(env, port) + // The profile is rendered AFTER the forwarder exists: its network allow + // is scoped to that exact port, which is what keeps the host's LAN + // interfaces out of reach. + profilePath, err = sandbox.Materialize(port) + if err != nil { + return "", 0, err + } + return profilePath, port, nil +} diff --git a/cmd/onecli/run_enforce_wrap_bypass_live_test.go b/cmd/onecli/run_enforce_wrap_bypass_live_test.go new file mode 100644 index 0000000..4f7f0d7 --- /dev/null +++ b/cmd/onecli/run_enforce_wrap_bypass_live_test.go @@ -0,0 +1,103 @@ +//go:build darwin + +package main + +// Live bypass suite for the enforce-wrap sandbox. +// +// The base live test (run_enforce_wrap_live_test.go) proves the happy +// path: loopback works, a direct dial doesn't. This file attacks the +// guarantee instead, by running the same escape matrix that +// `onecli sandbox audit` exposes to users (sandboxProbes in +// sandbox_audit.go). One table, two consumers: a vector added for users +// is automatically enforced in CI, so the shipped audit and the test +// suite can never drift apart. +// +// Every mustBlock vector here is an escape that env-var-only +// "enforcement" would miss — none of them read HTTPS_PROXY. + +import ( + "os" + "strings" + "testing" + + "github.com/onecli/onecli-cli/internal/sandbox" +) + +func TestLiveEnforceWrapBypasses(t *testing.T) { + if err := sandbox.Available(); err != nil { + t.Skipf("sandbox unavailable: %v", err) + } + // Port 1: nothing listens there, so every probe destination is outside + // the allow. The forwarder-reachable probe is skipped by sandboxProbes() + // when no real port is supplied. + profile, err := sandbox.Materialize(1) + if err != nil { + t.Fatalf("materializing profile: %v", err) + } + + for _, p := range sandboxProbes() { + t.Run(p.name, func(t *testing.T) { + res := runSandboxProbe(profile, p) + if res.skipped { + t.Skip(res.detail) + } + if !res.held { + if p.want == mustBlock { + t.Fatalf("SANDBOX HOLE: %s\nwhy it matters: %s", res.detail, p.why) + } + t.Fatalf("profile breaks a required capability: %s\nwhy it matters: %s", res.detail, p.why) + } + }) + } +} + +// TestLiveSandboxAuditDetectsAHole is the audit's own regression test: +// it re-introduces the exact bug we shipped — denying the docker socket +// by its well-known path instead of its resolved one — and asserts the +// audit reports a hole. +// +// Without this, the audit could silently degrade into a test that always +// passes (a mis-typed rule name, a probe whose binary moved), and a +// green "no bypasses found" would be indistinguishable from a broken +// harness. A safety check nobody has ever seen fail is not evidence. +func TestLiveSandboxAuditDetectsAHole(t *testing.T) { + if err := sandbox.Available(); err != nil { + t.Skipf("sandbox unavailable: %v", err) + } + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket not present — this host cannot exercise the vector") + } + + // The pre-fix profile: path-literal denies that never match, because + // Seatbelt resolves the symlink first. + base := sandbox.Profile(1) + if base == "" { + t.Skip("no profile available on this host") + } + holed := strings.Replace(base, + `(deny network-outbound (regex #"docker\.sock$"))`, + `(deny network-outbound (remote unix-socket (path-literal "/var/run/docker.sock")))`, + 1) + if holed == base { + t.Fatal("profile no longer contains the docker regex deny — update this test to match the current rule") + } + + path := t.TempDir() + "/holed.sb" + if err := os.WriteFile(path, []byte(holed), 0o600); err != nil { + t.Fatal(err) + } + + var docker sandboxProbe + for _, p := range sandboxProbes() { + if p.name == "docker-socket" { + docker = p + } + } + if docker.name == "" { + t.Fatal("docker-socket probe missing from the escape matrix") + } + + if res := runSandboxProbe(path, docker); res.held { + t.Fatalf("audit did NOT detect a known hole — the harness is not actually testing anything: %s", res.detail) + } +} diff --git a/cmd/onecli/run_enforce_wrap_test.go b/cmd/onecli/run_enforce_wrap_test.go new file mode 100644 index 0000000..62e9fbd --- /dev/null +++ b/cmd/onecli/run_enforce_wrap_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "strings" + "testing" + + "github.com/onecli/onecli-cli/internal/sandbox" +) + +// Platform support is the sandbox package's concern now; this asserts the +// CLI-side contract that matters here: the launcher is only handed back +// when the sandbox is actually available, so wrap mode can never exec an +// unconfined binary while claiming enforcement. +func TestEnforceWrapLauncherGatesOnAvailability(t *testing.T) { + got, err := enforceWrapLauncher() + if err := sandbox.Available(); err != nil { + return // unsupported host: nothing to assert beyond the error path + } + if err != nil { + t.Fatalf("launcher unavailable on a supported host: %v", err) + } + if got == "" { + t.Error("launcher path is empty on a supported host") + } +} + +func TestRewriteProxyEnvToLoopback(t *testing.T) { + env := map[string]string{ + "HTTPS_PROXY": "http://x:aoc_tok@gateway.example.com:8443", + "HTTP_PROXY": "http://x:aoc_tok@gateway.example.com:8443", + "NO_PROXY": "keep-me", + "OTHER": "untouched", + } + rewriteProxyEnvToLoopback(env, 45678) + + want := "http://x:aoc_tok@127.0.0.1:45678" + if env["HTTPS_PROXY"] != want { + t.Errorf("HTTPS_PROXY = %q, want %q", env["HTTPS_PROXY"], want) + } + if env["HTTP_PROXY"] != want { + t.Errorf("HTTP_PROXY = %q, want %q", env["HTTP_PROXY"], want) + } + if env["NO_PROXY"] != "keep-me" || env["OTHER"] != "untouched" { + t.Error("non-proxy keys must not be rewritten") + } +} + +func TestRewriteProxyEnvToLoopbackPreservesAocMarker(t *testing.T) { + // Codex's config.toml refresh keys on the aoc_ marker in proxy_url; + // losing the credentials would orphan stale injected values. + env := map[string]string{"HTTPS_PROXY": "http://x:aoc_tok@gw:8443"} + rewriteProxyEnvToLoopback(env, 1) + if !strings.Contains(env["HTTPS_PROXY"], "aoc_") { + t.Errorf("rewrite dropped the aoc_ credential marker: %q", env["HTTPS_PROXY"]) + } +} + +func TestEnforceWrapArgvAppendsQuirks(t *testing.T) { + // enforceWrapArgv assembles agent args + per-agent quirk flags and + // hands them to the platform sandbox's WrapArgv. The OS-launcher shape + // (e.g. sandbox-exec on macOS) is the sandbox package's concern and is + // asserted there; here we assert only the platform-neutral contract: + // the agent's own args are present and quirk flags are appended last. + got := strings.Join(enforceWrapArgv("/p/wrap.sb", "/bin/codex", []string{"exec", "task"}, "codex"), " ") + if !strings.Contains(got, "exec task") { + t.Errorf("agent args missing from argv: %q", got) + } + if !strings.HasSuffix(got, "-c sandbox_mode=danger-full-access") { + t.Errorf("codex quirk args missing or not last: %q", got) + } +} + +func TestEnforceWrapNoticeAndQuirks(t *testing.T) { + // An agent without quirks gets neither extra args nor a notice. + if len(enforceWrapQuirkArgs("somecli")) != 0 { + t.Error("no quirk args expected for an agent without quirks") + } + if enforceWrapNotice("codex") == "" { + t.Error("codex quirk must surface a user-facing notice") + } + if enforceWrapNotice("somecli") != "" { + t.Error("no notice expected for agents without quirks") + } +} diff --git a/cmd/onecli/run_test.go b/cmd/onecli/run_test.go index 5f66329..99280a6 100644 --- a/cmd/onecli/run_test.go +++ b/cmd/onecli/run_test.go @@ -213,6 +213,57 @@ func TestCodexSkipsGatewayHook(t *testing.T) { } } +func TestSuppressUndiciProxyWarning(t *testing.T) { + t.Run("no-op without NODE_USE_ENV_PROXY", func(t *testing.T) { + in := []string{"HOME=/h", "NODE_OPTIONS=--foo"} + out := suppressUndiciProxyWarning(in) + if v, _ := envValue(out, "NODE_OPTIONS"); v != "--foo" { + t.Errorf("NODE_OPTIONS = %q, want untouched --foo", v) + } + }) + + t.Run("adds NODE_OPTIONS when proxy flag present and none set", func(t *testing.T) { + out := suppressUndiciProxyWarning([]string{"NODE_USE_ENV_PROXY=1"}) + if v, ok := envValue(out, "NODE_OPTIONS"); !ok || v != undiciWarningFlag { + t.Errorf("NODE_OPTIONS = %q (present=%v), want %q", v, ok, undiciWarningFlag) + } + }) + + t.Run("appends to existing NODE_OPTIONS in place", func(t *testing.T) { + out := suppressUndiciProxyWarning([]string{"NODE_USE_ENV_PROXY=1", "NODE_OPTIONS=--max-old-space-size=4096"}) + v, _ := envValue(out, "NODE_OPTIONS") + if v != "--max-old-space-size=4096 "+undiciWarningFlag { + t.Errorf("NODE_OPTIONS = %q, want existing flag preserved + suppression", v) + } + // Must not create a second NODE_OPTIONS (POSIX first-match ambiguity). + n := 0 + for _, kv := range out { + if strings.HasPrefix(kv, "NODE_OPTIONS=") { + n++ + } + } + if n != 1 { + t.Errorf("NODE_OPTIONS entries = %d, want 1", n) + } + }) + + t.Run("idempotent when already present", func(t *testing.T) { + in := []string{"NODE_USE_ENV_PROXY=1", "NODE_OPTIONS=" + undiciWarningFlag} + out := suppressUndiciProxyWarning(in) + v, _ := envValue(out, "NODE_OPTIONS") + if v != undiciWarningFlag { + t.Errorf("NODE_OPTIONS = %q, want unchanged (no double-add)", v) + } + }) + + t.Run("fills an empty NODE_OPTIONS", func(t *testing.T) { + out := suppressUndiciProxyWarning([]string{"NODE_USE_ENV_PROXY=1", "NODE_OPTIONS="}) + if v, _ := envValue(out, "NODE_OPTIONS"); v != undiciWarningFlag { + t.Errorf("NODE_OPTIONS = %q, want %q", v, undiciWarningFlag) + } + }) +} + func TestProxyURLWithHost(t *testing.T) { tests := []struct{ name, raw, host, want string }{ {"rewrites host keeping port+creds", "http://aoc_tok:x@127.0.0.1:10255", "host.docker.internal", "http://aoc_tok:x@host.docker.internal:10255"}, diff --git a/cmd/onecli/sandbox_audit.go b/cmd/onecli/sandbox_audit.go new file mode 100644 index 0000000..8c55e44 --- /dev/null +++ b/cmd/onecli/sandbox_audit.go @@ -0,0 +1,645 @@ +package main + +// `onecli sandbox audit` — a one-command red-team of the enforce-mode +// sandbox. +// +// Enforce mode's whole promise is "egress cannot bypass the gateway". +// That promise is made of OS rules, and OS rules fail in ways that are +// invisible by inspection: a deny rule can be syntactically valid, load +// without error, and still never match. We shipped exactly that bug — +// `(path-literal "/var/run/docker.sock")` looked correct but never fired, +// because Seatbelt matches the RESOLVED path and Docker Desktop makes +// that path a symlink into ~/.docker/run. The profile "looked right" for +// as long as nobody tried the bypass. +// +// So the guarantee gets an executable test, runnable by anyone, on the +// machine that matters: this command runs each known escape technique +// inside the real profile and reports whether the OS actually stopped it. +// +// The probe table is the single source of truth: the live test +// (run_enforce_wrap_bypass_live_test.go) iterates the same table, so a +// vector added here is automatically covered in CI and by users. + +import ( + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "time" + + "github.com/onecli/onecli-cli/internal/sandbox" + "github.com/onecli/onecli-cli/pkg/output" +) + +// probeOutcome is what a probe is expected to do under a correct profile. +type probeOutcome int + +const ( + // mustBlock: the OS must refuse this. A success is a sandbox hole. + mustBlock probeOutcome = iota + // mustAllow: legitimate local work that must keep functioning. A + // failure means the profile is too tight and will break real usage — + // which matters because an unusable sandbox gets turned off. + mustAllow +) + +// sandboxProbe is one escape technique (or one required capability). +type sandboxProbe struct { + name string + // why explains what the vector buys an attacker, so a failure report + // tells the reader the stakes rather than just a rule name. + why string + want probeOutcome + argv []string + // needs, when set, is a path that must exist for the probe to be + // meaningful (e.g. the Docker socket). Absent → skipped, not failed. + needs string + // precondition, when set, reports whether the environment can exercise + // this vector at all. It exists for the same reason as needs, but for + // conditions that aren't a file: an IPv6 probe on an IPv4-only network + // "fails" for lack of a route and would otherwise report a confident + // ok while testing nothing. Skipping is honest; a false pass is not. + precondition func() (ok bool, reason string) +} + +// canSendAppleEvents reports whether Apple Events are usable at all here. +// macOS gates them behind TCC automation consent independently of any +// sandbox, so on a machine that has never granted it, the probe fails for +// a reason that has nothing to do with our deny rule — a false pass. +// TestProbeNotVacuous is what exposed this. +func canSendAppleEvents() (bool, string) { + out, err := exec.Command("/usr/bin/osascript", "-e", + `tell application "System Events" to get name of every process`).CombinedOutput() + if err != nil && strings.Contains(string(out), "Not authorized") { + return false, "Apple Events are TCC-denied on this host (the OS already blocks this vector)" + } + return true, "" +} + +// hasIPv6Route reports whether this host can reach the IPv6 internet. +func hasIPv6Route() (bool, string) { + // A UDP "connect" performs no handshake — it just resolves a route — + // so this is instant and sends nothing. + c, err := net.DialTimeout("udp6", "[2606:4700:4700::1111]:53", 2*time.Second) + if err != nil { + return false, "no IPv6 route on this network" + } + _ = c.Close() + return true, "" +} + +// lanRelayTarget starts a listener bound to ALL interfaces and returns the +// host's own LAN address for it, standing in for the unsandboxed relay a +// real bypass would use (a Docker published port, a dev server on +// 0.0.0.0). The audit owns the listener so the probe never depends on +// something happening to be running: a probe whose target is absent +// "passes" for the wrong reason, which is the failure TestProbesCanDetectHoles +// exists to reject. Returns "" when the host has no LAN interface. +func lanRelayTarget() (url string, stop func()) { + out, err := exec.Command("/bin/bash", "-c", + "ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1").Output() + ip := strings.TrimSpace(string(out)) + if err != nil || ip == "" { + return "", func() {} + } + // 0.0.0.0 so the socket is reachable via the LAN address, exactly like + // the container that demonstrated the original exfil. + ln, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + return "", func() {} + } + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + _ = c.Close() + } + }() + port := ln.Addr().(*net.TCPAddr).Port + return fmt.Sprintf("http://%s:%d/exfil", ip, port), func() { _ = ln.Close() } +} + +// sandboxProbes is the escape matrix. Every entry is a way to reach the +// network WITHOUT the loopback forwarder — none of them read HTTPS_PROXY, +// which is precisely why env-var "enforcement" is not enforcement. +// +// 1.1.1.1 is used as a raw IP so DNS is never involved: connect() itself +// must be refused, which also makes a denial instant and distinguishable +// from a network timeout. +// sandboxProbes uses port 0, meaning "no stand-in forwarder": callers that +// only need the escape matrix (tests) get every vector except the two that +// require a live allowed port. +// The relay listener is deliberately NOT closed here: the probes are run +// by the caller, after this returns, so closing it would leave the LAN +// probe pointing at a dead port — it would then "pass" because nothing +// was listening rather than because the sandbox blocked it. The listener +// is bound to an ephemeral port and dies with the process. +func sandboxProbes() []sandboxProbe { + url, _ := lanRelayTarget() + return sandboxProbesFor(0, url) +} + +// sandboxProbesFor builds the matrix against a known forwarder port. +func sandboxProbesFor(forwarderPort uint16, lanRelayURL string) []sandboxProbe { + direct := []sandboxProbe{ + { + name: "direct-dial", + why: "plain curl to a raw IP — the baseline bypass", + want: mustBlock, + argv: []string{"/usr/bin/curl", "-sS", "--max-time", "5", "http://1.1.1.1/"}, + }, + { + name: "proxy-env-stripped", + why: "agent unsets HTTPS_PROXY — enforcement must not depend on env it can edit", + want: mustBlock, + argv: []string{"/bin/bash", "-c", + "unset HTTPS_PROXY HTTP_PROXY ALL_PROXY https_proxy http_proxy all_proxy; " + + "/usr/bin/curl -sS --max-time 5 http://1.1.1.1/"}, + }, + { + name: "raw-socket", + why: "a language runtime socket ignores proxy config entirely", + want: mustBlock, + argv: []string{"/usr/bin/python3", "-c", + "import socket;socket.create_connection(('1.1.1.1',80),5)"}, + }, + { + name: "child-process", + why: "a grandchild must inherit the boundary, or any wrapper script escapes", + want: mustBlock, + argv: []string{"/bin/bash", "-c", "/usr/bin/curl -sS --max-time 5 http://1.1.1.1/"}, + }, + { + // THE vector a "localhost is safe" assumption misses. Seatbelt's + // (remote ip "localhost:*") means any address LOCAL TO THIS + // MACHINE, including the host's LAN interfaces — so any listener + // bound to 0.0.0.0 (a Docker published port, a dev server on + // your own laptop) is reachable, and being unsandboxed it + // relays straight to the internet. Proven end-to-end against a + // container before the allow was scoped to the forwarder port: + // a secret left the machine while direct egress was blocked. + name: "lan-interface-relay", + why: "a listener on the host's LAN IP is unsandboxed and relays to the internet", + want: mustBlock, + precondition: func() (bool, string) { + if lanRelayURL == "" { + return false, "no LAN interface to exercise the relay vector" + } + return true, "" + }, + argv: []string{"/usr/bin/curl", "-sS", "--max-time", "4", "-o", "/dev/null", lanRelayURL}, + }, + { + // Only meaningful where IPv6 actually routes. On an IPv4-only + // network this fails for lack of a route and would report a + // confident "ok" while proving nothing, so it is gated on a + // working v6 path (see needsIPv6) and skipped otherwise. + // TestProbeNotVacuous is what surfaced this. + name: "ipv6-literal", + why: "an IPv4-only rule is sidestepped by dialing the v6 address", + want: mustBlock, + precondition: hasIPv6Route, + argv: []string{"/usr/bin/curl", "-sS", "--max-time", "5", "-g", "http://[2606:4700:4700::1111]/"}, + }, + { + name: "udp-dns-exfil", + why: "DNS to an off-box resolver is a covert channel, not name lookup", + want: mustBlock, + argv: []string{"/usr/bin/python3", "-c", + "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);" + + "s.settimeout(5);s.sendto(b'x',('8.8.8.8',53))"}, + }, + { + name: "launchservices-open", + why: "`open URL` launches a browser OUTSIDE the sandbox — fetch-and-exfil", + want: mustBlock, + argv: []string{"/usr/bin/open", "https://example.com"}, + }, + { + // Uses System Events rather than Safari: driving Safari trips + // the macOS TCC automation prompt and fails for that reason + // even with no sandbox at all, which made the old probe + // vacuous. This form exercises the appleevent-send deny itself. + name: "appleevent-fetch", + why: "Apple Events drive apps OUTSIDE the sandbox, which can fetch URLs for you", + want: mustBlock, + precondition: canSendAppleEvents, + argv: []string{"/usr/bin/osascript", "-e", + `tell application "System Events" to get name of every process`}, + }, + { + name: "docker-socket", + why: "a container runs OUTSIDE the sandbox: one line to unrestricted egress", + want: mustBlock, + needs: "/var/run/docker.sock", + argv: []string{"/usr/bin/curl", "-sS", "--max-time", "5", + "--unix-socket", "/var/run/docker.sock", "http://localhost/version"}, + }, + } + + home, err := os.UserHomeDir() + if err != nil { + // Without a home dir the deferred-egress paths can't be + // materialized. Return the direct matrix rather than silently + // grading fewer vectors than the caller believes were tried — a + // short audit that still prints PASS is worse than an error. + return direct + } + probes := make([]sandboxProbe, 0, len(direct)+8) + probes = append(probes, direct...) + probes = append(probes, deferredEgressProbes(home)...) + probes = append(probes, requiredCapabilities(home)...) + if forwarderPort != 0 { + probes = append(probes, sandboxProbe{ + // The counterpart to lan-interface-relay: scoping the allow to + // one port must not sever the one path that has to work. + name: "forwarder-reachable", + why: "the loopback forwarder is the ONLY sanctioned egress path; severing it breaks everything", + want: mustAllow, + argv: []string{"/usr/bin/curl", "-sS", "--max-time", "4", "-o", "/dev/null", + fmt.Sprintf("http://127.0.0.1:%d/", forwarderPort)}, + }) + } + return probes +} + +// deferredEgressProbes are the write-now, run-later vectors: the +// sandboxed process cannot dial out, so instead it plants something an +// UNSANDBOXED process executes later — and that process has unrestricted +// egress. They are network bypasses with a delay, which is why a +// network-only profile never actually delivered a network guarantee. +// Every one of these succeeded before the filesystem rules existed. +func deferredEgressProbes(home string) []sandboxProbe { + // plant tests whether path is WRITABLE, via a zero-byte append. + // + // The obvious form (`echo probe > path`) is unacceptable here: these + // paths include the user's real ~/.zshrc, and a truncating redirect + // would destroy it on exactly the machines where the sandbox has the + // hole being tested for. A security audit must not be able to damage + // the system it audits. + // + // `>>` opens for append and writes nothing: the open() is the entire + // test — EPERM under a correct profile, success under a holed one — + // while existing content is untouched and any file the probe creates + // is empty and inert. + plant := func(name, why, path string) sandboxProbe { + return sandboxProbe{ + name: name, why: why, want: mustBlock, + argv: []string{"/bin/bash", "-c", fmt.Sprintf("printf '' >> %q", path)}, + } + } + return []sandboxProbe{ + plant("plant-launchagent", + "launchd runs a LaunchAgent at every login, entirely outside the sandbox", + home+"/Library/LaunchAgents/onecli-audit-probe.plist"), + plant("write-shell-rc", + "the user's next terminal sources this and runs it unsandboxed", + home+"/.zshrc"), + plant("plant-path-binary", + "a binary on $PATH gets executed later by an unsandboxed shell", + "/usr/local/bin/onecli-audit-probe"), + // Targets the REAL artifact, not a made-up filename under the + // directory. The earlier version probed a path the product never + // writes, so it passed against a broad subpath deny that was in + // fact breaking `onecli config set` — a probe can only defend the + // exact path it names. + plant("rewrite-sandbox-profile", + "rewriting the profile disables enforcement on the very next run", + home+"/.onecli/enforce-wrap.sb"), + plant("rewrite-enforce-settings", + "rewriting the Claude enforce settings disables the native enforce path", + home+"/.onecli/enforce-sandbox-settings.json"), + { + name: "read-ssh-keys", + why: "private keys are the payload any exfil attempt is after", + want: mustBlock, + needs: home + "/.ssh", + argv: []string{"/bin/bash", "-c", fmt.Sprintf("ls %q > /dev/null", home+"/.ssh")}, + }, + // Git probes build a THROWAWAY repo in the temp dir first, then + // write into its .git. An earlier version targeted `.git/hooks` in + // the process's cwd, which didn't exist under the test harness — so + // the write failed for the wrong reason and the probe reported + // "blocked" while the rule was entirely absent. A probe that can + // pass without exercising the rule is worse than no probe, so each + // one creates the target it needs. + { + name: "plant-git-hook", + why: "a pre-commit hook runs UNSANDBOXED on the user's very next commit", + want: mustBlock, + argv: []string{"/bin/bash", "-c", + "d=$(mktemp -d) && /usr/bin/git init -q \"$d\" && " + + "printf '' >> \"$d/.git/hooks/pre-commit\""}, + }, + { + // The .sample carve-out is the load-bearing exception in the + // hooks rule (without it `git clone` fails). Promoting a sample + // to a live hook name is the obvious way to abuse it, so the + // carve-out gets its own probe rather than being trusted. + name: "promote-sample-hook", + why: "copying pre-commit.sample to pre-commit would turn the clone carve-out into a hook", + want: mustBlock, + argv: []string{"/bin/bash", "-c", + "d=$(mktemp -d) && /usr/bin/git init -q \"$d\" && " + + "cp \"$d/.git/hooks/pre-commit.sample\" \"$d/.git/hooks/pre-commit\""}, + }, + { + name: "global-git-hookspath", + why: "core.hooksPath in the GLOBAL gitconfig redirects hooks for every repo at once", + want: mustBlock, + argv: []string{"/bin/bash", "-c", + "/usr/bin/git config --global core.hooksPath /tmp/onecli-audit-probe"}, + }, + } +} + +// requiredCapabilities are legitimate operations the profile must NOT +// break. They belong in a security audit because an unusable sandbox gets +// switched off, and a sandbox that is off enforces nothing: "too tight" +// is a real failure mode, not a nitpick. +func requiredCapabilities(home string) []sandboxProbe { + mkdirProbe := func(name, why, dir string) sandboxProbe { + return sandboxProbe{ + name: name, why: why, want: mustAllow, + argv: []string{"/bin/bash", "-c", + fmt.Sprintf("mkdir -p %q && rmdir %q", dir, dir)}, + } + } + return []sandboxProbe{ + { + name: "dns-resolution", + why: "local DNS over unix IPC must keep working or every tool breaks", + want: mustAllow, + argv: []string{"/usr/bin/dscacheutil", "-q", "host", "-a", "name", "localhost"}, + }, + mkdirProbe("home-cache-writes", + "package managers and language toolchains write under $HOME constantly", + home+"/.cache/onecli-audit-probe"), + mkdirProbe("agent-state-writes", + "agents write session state every run; breaking it breaks the agent", + home+"/.codex/onecli-audit-probe"), + { + name: "onecli-config-readable", + why: "the agent must still read its own config", + want: mustAllow, + argv: []string{"/bin/bash", "-c", fmt.Sprintf("ls %q > /dev/null", home+"/.onecli")}, + }, + { + // onecli's own state lives beside the enforcement artifacts, so + // self-protection rules can silently break the product. A broad + // ~/.onecli deny did exactly that; only this probe distinguishes + // "protecting the profile" from "bricking config set". + name: "onecli-config-writable", + why: "onecli config set / auth login must work from inside an enforced session", + want: mustAllow, + argv: []string{"/bin/bash", "-c", + fmt.Sprintf("printf '' >> %q", home+"/.onecli/onecli-audit-probe.tmp")}, + }, + { + name: "git-operations", + why: "git is the agent's primary tool; breaking it makes enforce unusable", + want: mustAllow, + argv: []string{"/usr/bin/git", "--version"}, + }, + { + // These two exist because the first draft of the git-hook deny + // broke BOTH of them: git copies template hooks into every new + // .git/hooks, and writes .git/config to create a repository. A + // rule that stops an agent from cloning is a worse bug than the + // hook it prevents, and only a mustAllow probe catches that. + name: "git-clone", + why: "cloning is table stakes; a hooks rule that blocks it is worse than the vector", + want: mustAllow, + argv: []string{"/bin/bash", "-c", + "s=$(mktemp -d) && /usr/bin/git init -q \"$s\" && " + + "d=$(mktemp -d) && /usr/bin/git clone -q \"$s\" \"$d/clone\" && " + + "test -d \"$d/clone/.git\""}, + }, + { + name: "git-init-and-commit", + why: "creating a repo and committing must work, or the agent cannot do its job", + want: mustAllow, + argv: []string{"/bin/bash", "-c", + "d=$(mktemp -d) && cd \"$d\" && /usr/bin/git init -q . && echo x > f && " + + "/usr/bin/git add f && " + + "/usr/bin/git -c user.email=a@b -c user.name=c commit -qm probe"}, + }, + } +} + +// probeResult is one probe's verdict. +type probeResult struct { + probe sandboxProbe + skipped bool + // held reports whether the guarantee held: the probe did what the + // profile requires (blocked what must block, allowed what must allow). + held bool + detail string + elapsed time.Duration +} + +// runSandboxProbe executes one probe under profilePath. +// +// A blocked probe must also be FAST. Seatbelt refuses connect() +// immediately, whereas an unreachable network hangs until the timeout — +// both surface as a non-zero exit, so without the timing check a machine +// that is simply offline would look perfectly sandboxed. That false +// "secure" reading is worse than no test at all. +func runSandboxProbe(profilePath string, p sandboxProbe) probeResult { + if p.needs != "" { + if _, err := os.Stat(p.needs); err != nil { + return probeResult{probe: p, skipped: true, held: true, + detail: fmt.Sprintf("%s not present on this host", p.needs)} + } + } + if p.precondition != nil { + if ok, reason := p.precondition(); !ok { + return probeResult{probe: p, skipped: true, held: true, detail: reason} + } + } + + argv := append([]string{"-f", profilePath}, p.argv...) + start := time.Now() + out, err := exec.Command(sandbox.LauncherPath(), argv...).CombinedOutput() + elapsed := time.Since(start) + res := probeResult{probe: p, elapsed: elapsed} + + switch p.want { + case mustBlock: + if err == nil { + res.held = false + res.detail = "SUCCEEDED — the sandbox did not stop it: " + firstLine(out) + return res + } + if elapsed > 6*time.Second { + res.held = false + res.detail = fmt.Sprintf("failed only after %v — looks like a network timeout, "+ + "not a sandbox denial; re-run with network up", elapsed.Round(time.Millisecond)) + return res + } + res.held = true + res.detail = "refused by the OS in " + elapsed.Round(time.Millisecond).String() + case mustAllow: + if err != nil { + res.held = false + res.detail = "BROKEN by the profile: " + firstLine(out) + return res + } + res.held = true + res.detail = "works" + } + return res +} + +func firstLine(b []byte) string { + s := strings.TrimSpace(string(b)) + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 100 { + s = s[:100] + "…" + } + if s == "" { + return "(no output)" + } + return s +} + +// SandboxCmd groups sandbox inspection commands. +type SandboxCmd struct { + Audit SandboxAuditCmd `cmd:"" help:"Red-team the enforce-mode sandbox: try every known bypass and report which the OS actually stops."` +} + +// SandboxAuditCmd runs the escape matrix against a real profile. +type SandboxAuditCmd struct { + Agent string `arg:"" optional:"" help:"Agent to audit (codex, cursor, claude, ...). Defaults to the OneCLI-owned sandbox used by every non-native agent."` +} + +// Run executes the audit. +func (c *SandboxAuditCmd) Run() error { + out := output.New() + + if err := sandbox.Available(); err != nil { + return err + } + + // Claude Code takes the NATIVE path: enforcement is delegated to its + // own sandbox, configured via --settings, which this process cannot + // invoke standalone. Saying so plainly beats auditing the wrong + // profile and reporting a guarantee that isn't the one in effect. + if c.Agent != "" { + if spec, known := agentSkillDir(c.Agent); known && enforceSupportedAgents[spec.agentName] { + return c.reportNative(out, spec.agentName) + } + } + + // The profile's network allow is scoped to the forwarder's port, so an + // audit needs a stand-in listener to represent it. Binding one here + // (rather than hardcoding a port) means the "loopback reachable" probe + // tests a real socket, and every other destination is genuinely outside + // the allow — including the host's own LAN interfaces. + stand, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("binding a stand-in forwarder: %w", err) + } + defer func() { _ = stand.Close() }() + go func() { + for { + c, err := stand.Accept() + if err != nil { + return + } + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + _ = c.Close() + } + }() + forwarderPort := uint16(stand.Addr().(*net.TCPAddr).Port) + + // A relay the audit owns, standing in for the unsandboxed listener a + // real bypass would abuse. Owning it means the probe can never pass + // merely because nothing happened to be listening. + lanURL, stopLAN := lanRelayTarget() + defer stopLAN() + + profile, err := sandbox.Materialize(forwarderPort) + if err != nil { + return fmt.Errorf("writing sandbox profile: %w", err) + } + + label := "the OneCLI sandbox (wrap mode)" + if c.Agent != "" { + if spec, known := agentSkillDir(c.Agent); known { + label = spec.agentName + " under the OneCLI sandbox (wrap mode)" + } + } + out.Stderr(fmt.Sprintf("Auditing %s", label)) + out.Stderr(fmt.Sprintf("Profile: %s", profile)) + out.Stderr("") + + var holes, broken int + for _, p := range sandboxProbesFor(forwarderPort, lanURL) { + res := runSandboxProbe(profile, p) + switch { + case res.skipped: + out.Stderr(fmt.Sprintf(" SKIP %-22s %s", p.name, res.detail)) + case res.held: + out.Stderr(fmt.Sprintf(" ok %-22s %s", p.name, res.detail)) + case p.want == mustBlock: + holes++ + out.Stderr(fmt.Sprintf(" HOLE %-22s %s", p.name, res.detail)) + out.Stderr(fmt.Sprintf(" why it matters: %s", p.why)) + default: + broken++ + out.Stderr(fmt.Sprintf(" BROKE %-22s %s", p.name, res.detail)) + out.Stderr(fmt.Sprintf(" why it matters: %s", p.why)) + } + } + + out.Stderr("") + switch { + case holes > 0: + // Fail the command, not just the output: an audit that reports a + // hole and still exits 0 will be wired into CI and ignored. + return fmt.Errorf("%d sandbox hole(s) found — enforce mode does NOT fully contain egress on this host", holes) + case broken > 0: + return fmt.Errorf("%d legitimate capability broken by the profile", broken) + } + out.Stderr("PASS: no bypasses found — every known escape was refused by the OS.") + out.Stderr("") + out.Stderr("This audits the OS boundary only. To confirm traffic is also being") + out.Stderr("governed, run the agent and check that its requests appear in Activity:") + out.Stderr(" onecli run --enforce -- then ask it to curl an API") + out.Stderr("A request that succeeds but never appears in Activity bypassed the gateway.") + return nil +} + +// reportNative explains the native path rather than auditing a profile +// that is not the one in force for this agent. +func (c *SandboxAuditCmd) reportNative(out *output.Writer, agentName string) error { + out.Stderr(fmt.Sprintf("%s uses the NATIVE enforce path.", agentName)) + out.Stderr("") + out.Stderr("Its own OS sandbox is the enforcement layer, configured by OneCLI via") + out.Stderr("--settings with three properties enforcement depends on:") + out.Stderr(" enabled=true sandbox is on") + out.Stderr(" failIfUnavailable=true missing deps abort instead of degrading") + out.Stderr(" allowUnsandboxedCommands=false the documented escape hatch is off") + out.Stderr("") + out.Stderr("That sandbox can't be driven from here, so audit it from inside a session:") + out.Stderr(fmt.Sprintf(" onecli run --enforce -- %s", strings.ToLower(strings.Fields(agentName)[0]))) + out.Stderr("then ask the agent to run each of these. All must fail:") + for _, p := range sandboxProbes() { + if p.want != mustBlock { + continue + } + out.Stderr(" " + strings.Join(p.argv, " ")) + } + out.Stderr("") + out.Stderr("Then ask it to curl a real API: that must succeed AND appear in Activity.") + out.Stderr("Success without an Activity entry means it bypassed the gateway.") + return nil +} diff --git a/cmd/onecli/sandbox_audit_vacuity_live_test.go b/cmd/onecli/sandbox_audit_vacuity_live_test.go new file mode 100644 index 0000000..aea2b73 --- /dev/null +++ b/cmd/onecli/sandbox_audit_vacuity_live_test.go @@ -0,0 +1,64 @@ +//go:build darwin + +package main + +// Meta-test: is each mustBlock probe capable of DETECTING a hole? +// +// A probe that fails for an environmental reason — no IPv6 route, TCC +// consent never granted, a missing binary — reports a confident "ok" +// under the real profile while testing nothing at all. That is worse than +// having no probe, because it manufactures false assurance in exactly the +// place assurance matters. +// +// So every mustBlock probe is run against a profile with NO rules, where +// it must SUCCEED. If it fails even there, the probe cannot distinguish a +// working rule from a broken one and is rejected. +// +// This caught two real cases when it was written: `ipv6-literal` (this +// network has no IPv6 route) and `appleevent-fetch` (macOS TCC denies +// Apple Events independently of our sandbox). Both were reporting "ok" +// while proving nothing. They now carry preconditions and skip honestly. + +import ( + "os" + "os/exec" + "testing" +) + +func TestProbesCanDetectHoles(t *testing.T) { + if _, err := os.Stat("/usr/bin/sandbox-exec"); err != nil { + t.Skip("sandbox-exec not available") + } + + // A profile that denies nothing: every escape must work here. + const permissive = "(version 1)\n(allow default)\n" + path := t.TempDir() + "/permissive.sb" + if err := os.WriteFile(path, []byte(permissive), 0o600); err != nil { + t.Fatal(err) + } + + for _, p := range sandboxProbes() { + if p.want != mustBlock { + continue + } + t.Run(p.name, func(t *testing.T) { + if p.needs != "" { + if _, err := os.Stat(p.needs); err != nil { + t.Skipf("%s not present", p.needs) + } + } + if p.precondition != nil { + if ok, reason := p.precondition(); !ok { + t.Skip(reason) + } + } + argv := append([]string{"-f", path}, p.argv...) + if out, err := exec.Command("/usr/bin/sandbox-exec", argv...).CombinedOutput(); err != nil { + t.Fatalf("probe fails even with NO sandbox rules, so it cannot "+ + "detect a hole and its \"ok\" under the real profile is "+ + "meaningless.\nEither fix the probe or give it a precondition "+ + "that skips honestly.\nerr=%v out=%s", err, out) + } + }) + } +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 0000000..b028f76 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,40 @@ +// Package sandbox provides the OS-level network sandbox for +// `onecli run --enforce`: it confines a process tree so its only egress +// path is a loopback forwarder fronting the OneCLI gateway. Direct dials +// fail at the OS, even if the process strips its proxy env. +// +// The mechanism is platform-specific (macOS Seatbelt today; Linux +// bubblewrap + network namespace planned), selected at compile time via +// build tags: the real implementation lives in sandbox_.go, and +// sandbox_other.go fails closed everywhere else. Non-OS-specific concerns +// (the forwarder, proxy-env rewriting, per-agent quirks, the audit's +// probe matrix) stay in the caller. +package sandbox + +// Available reports whether enforce-wrap is supported on this host, with a +// user-facing reason when it isn't. Callers must check this before +// promising enforcement, and fail closed on error. +func Available() error { return available() } + +// Materialize writes the sandbox profile an enforced run applies and +// returns a handle to it (on macOS, the path to the Seatbelt profile +// under ~/.onecli). It returns the REAL artifact a run uses, so a wrap or +// audit operates on the shipped policy rather than a drifted copy. +func Materialize(forwarderPort uint16) (string, error) { return materialize(forwarderPort) } + +// LauncherPath is the OS launcher that applies the sandbox to a command +// (on macOS, /usr/bin/sandbox-exec). A fixed path, not a PATH lookup: a +// shadowed launcher must never become the enforcement layer. +func LauncherPath() string { return launcherPath() } + +// WrapArgv builds the argv passed to LauncherPath that runs binary+args +// confined by the materialized profile. args already include any +// per-agent quirk flags the caller appended. +func WrapArgv(profile, binary string, args []string) []string { + return wrapArgv(profile, binary, args) +} + +// Profile returns the sandbox profile text. Exposed so the audit can +// derive a deliberately-holed variant to prove the audit itself detects +// holes; normal runs use Materialize. +func Profile(forwarderPort uint16) string { return profile(forwarderPort) } diff --git a/internal/sandbox/sandbox_darwin.go b/internal/sandbox/sandbox_darwin.go new file mode 100644 index 0000000..14d8782 --- /dev/null +++ b/internal/sandbox/sandbox_darwin.go @@ -0,0 +1,220 @@ +//go:build darwin + +package sandbox + +// macOS backend: sandbox-exec with a generated Seatbelt profile that +// denies network-outbound except loopback. Seatbelt is inherited by the +// whole process tree and cannot be shed, so the only egress path is the +// loopback forwarder fronting the gateway. + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// seatbeltProfileTemplate is the Seatbelt profile for enforce-wrap mode. +// The forwarder port doesn't appear because ALL loopback is allowed. +// +// Design notes, validated empirically: +// - Network governance only. Filesystem policy is deliberately absent: +// WHICH hosts an agent may reach is the gateway's policy decision, +// and file confinement is the agent's own sandbox's job (kept where +// it can nest, disabled via the quirks table where it can't). +// - Unix sockets stay allowed — they are local IPC (DNS via +// mDNSResponder, ssh-agent), not egress. Except the Docker daemon: +// a container runs OUTSIDE this sandbox, so docker.sock would be a +// one-line egress bypass (`docker run --net=host curl ...`). The deny +// is a REGEX on the socket name, not a path-literal on /var/run: +// Seatbelt matches the RESOLVED path, and Docker Desktop ships +// /var/run/docker.sock as a symlink into ~/.docker/run, so literal +// rules for the well-known path silently never fire. Verified by +// TestLiveEnforceWrapDockerSocketDenied — with path-literal rules the +// daemon answered /version from inside the sandbox. +// - All loopback is allowed, not just the forwarder port: local dev +// servers must keep working, and loopback traffic cannot leave the +// machine on its own. +// - lsopen is denied: LaunchServices `open` launches the target app +// OUTSIDE the sandbox, which would be a URL-fetch escape. Apple +// Events are denied for the same reason (and further TCC-gated by +// the OS). +// +// The filesystem rules exist FOR the network guarantee, which is why a +// "network-only" profile was never actually network-only in effect. A +// process that cannot dial out today can still write a file that an +// UNSANDBOXED process executes tomorrow — a LaunchAgent, a line in +// ~/.zshrc, a binary on $PATH — and that process has unrestricted egress. +// Deferred execution is a bypass with a delay, so the surfaces granting +// it are denied. Every one was reachable until these rules existed; +// `onecli sandbox audit` keeps them honest. +// +// This is deliberately NOT a cwd-only jail (the shape Claude Code's own +// sandbox uses). Agents legitimately write across $HOME — tool caches, +// language toolchains, session state — and a profile that breaks ordinary +// work gets switched off, which protects nobody. Denying the handful of +// launch-on-boot and run-on-next-shell surfaces closes the escape while +// leaving normal work untouched. +// +// {{HOME}} is materialized by profileFor: Seatbelt performs no +// $HOME expansion, so an unexpanded placeholder would load cleanly and +// match nothing — the same silent-no-op shape as the docker.sock bug. +// +// {{PORT}} is the loopback forwarder's port, and scoping the allow to it +// is load-bearing, not tidiness. Seatbelt's `(remote ip "localhost:*")` +// does NOT mean 127.0.0.1: it means any address local to this machine, +// which includes the host's LAN interfaces. With a wildcard port that let +// a sandboxed agent reach ANY listener bound to the host — a Docker +// container publishing a port, a dev server on 0.0.0.0 — and any such +// listener is unsandboxed, so it relays to the internet. Demonstrated +// end-to-end: with proxy env stripped, a secret was exfiltrated to a +// container via the host's LAN IP while direct egress was blocked. +// +// Seatbelt cannot express "127.0.0.1 but not the LAN" (it rejects any +// host other than * or localhost), so the port is the discriminator. The +// forwarder binds 127.0.0.1 on a random ephemeral port chosen per run, so +// an attacker cannot pre-position a listener there, and the LAN interface +// on that port has nothing listening. +const seatbeltProfileTemplate = `(version 1) +; OneCLI enforce-wrap profile: OS-level network governance. +; The agent's only network path is the loopback auth forwarder fronting +; the OneCLI gateway. Filesystem rules are limited to the surfaces that +; would hand an unsandboxed process the network on the agent's behalf. +(allow default) +(deny network-outbound) +(allow network-outbound (remote unix-socket)) +(deny network-outbound (regex #"docker\.sock$")) +(allow network-outbound (remote tcp "localhost:{{PORT}}")) +(deny lsopen) +(deny appleevent-send) + +; Deferred egress: launchd runs these OUTSIDE the sandbox, at login/boot. +(deny file-write* (subpath "{{HOME}}/Library/LaunchAgents")) +(deny file-write* (subpath "/Library/LaunchAgents")) +(deny file-write* (subpath "/Library/LaunchDaemons")) + +; Deferred egress: the user's next interactive shell is unsandboxed. +(deny file-write* + (literal "{{HOME}}/.zshrc") + (literal "{{HOME}}/.zshenv") + (literal "{{HOME}}/.zprofile") + (literal "{{HOME}}/.bashrc") + (literal "{{HOME}}/.bash_profile") + (literal "{{HOME}}/.profile")) + +; Deferred egress: a dropped binary on $PATH runs unsandboxed later. +(deny file-write* (subpath "/usr/local/bin")) +(deny file-write* (subpath "/opt/homebrew/bin")) + +; Deferred egress: git hooks run UNSANDBOXED on the user's next commit — +; the highest-frequency trigger in this class, and unremarkable coming +; from an agent whose job is editing repos. Path-relative (any .git +; anywhere) because the agent's repo isn't known when this is rendered. +; +; The *.sample carve-out exists because git COPIES the template hooks +; into every new .git/hooks on init and clone; denying them outright +; breaks 'git clone' entirely, which is far worse than the vector. The +; samples are inert: git only ever runs a hook at its exact name, so a +; file ending in .sample is never executed, and cp/mv of a sample onto a +; real hook name is itself denied by the rule above (verified). +; +; Scope limit, stated plainly: this denies the hooks DIRECTORY, not +; core.hooksPath in a repo's own .git/config. That config file cannot be +; denied — git writes it to create a repository, so the deny breaks +; 'git init' and 'git clone' outright. A sandboxed agent can therefore +; still point a repo's hooks at a directory it controls, and that hook +; WILL execute unsandboxed on the next commit (confirmed experimentally, +; not assumed). The GLOBAL gitconfig is denied below because it is +; writable without breaking git and would apply to every repo at once. +; The per-repo path stays open, and closing it needs a mechanism this +; profile doesn't have — most likely inspecting core.hooksPath at commit +; time rather than a filesystem rule. +(deny file-write* (regex #"/\.git/hooks/")) +(allow file-write* (regex #"/\.git/hooks/[^/]*\.sample$")) +(deny file-write* (literal "{{HOME}}/.gitconfig")) +(deny file-write* (regex #"/\.config/git/config$")) + +; Self-protection: the sandbox profile and the Claude enforce settings +; are what make the NEXT run enforced. A sandboxed process that rewrites +; them disables enforcement on itself, so they are denied by name. +; +; Scoped to those two artifacts rather than all of ~/.onecli: the +; directory also holds config.json and credentials, which onecli's own +; commands legitimately write. Denying the whole subpath broke +; 'onecli config set' and 'onecli auth login' from inside an enforced +; session, with a raw "operation not permitted" — a real regression that +; the audit's own probes missed because they only tested writes to a +; made-up path under the directory, never the paths the product uses. +; Reads stay allowed throughout; the agent needs its own config. +(deny file-write* (literal "{{HOME}}/.onecli/enforce-wrap.sb")) +(deny file-write* (literal "{{HOME}}/.onecli/enforce-sandbox-settings.json")) + +; Credential staging: the payload an exfil attempt is after. Egress is +; already governed, but denying the read removes the reward for finding a +; channel we missed. +(deny file-read* (subpath "{{HOME}}/.ssh")) +(deny file-read* (subpath "{{HOME}}/.aws")) +` + +// profileFor renders the profile for a home directory. Exported through +// Profile()/Materialize() so the audit and the enforced run share one +// definition — two copies of a security policy is how the docker.sock +// rule stayed broken while a text assertion passed. +func profileFor(home string, forwarderPort uint16) string { + out := strings.ReplaceAll(seatbeltProfileTemplate, "{{HOME}}", home) + return strings.ReplaceAll(out, "{{PORT}}", strconv.Itoa(int(forwarderPort))) +} + +// sandboxExecPath is where macOS ships sandbox-exec. A fixed path, not +// LookPath: a PATH-shadowed sandbox-exec must never become the +// enforcement layer. +const sandboxExecPath = "/usr/bin/sandbox-exec" + +func available() error { + if _, err := os.Stat(sandboxExecPath); err != nil { + return fmt.Errorf("%s not found — cannot apply the OS sandbox", sandboxExecPath) + } + return nil +} + +func launcherPath() string { return sandboxExecPath } + +// profile returns the rendered profile for the current user. Empty home +// is not silently tolerated: an unrendered {{HOME}} would load cleanly +// and match nothing, which is the silent-no-op failure this package +// exists to avoid. +func profile(forwarderPort uint16) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return profileFor(home, forwarderPort) +} + +// materialize writes the Seatbelt profile under ~/.onecli and returns its +// path, rewriting only when stale. +func materialize(forwarderPort uint16) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home dir: %w", err) + } + dir := filepath.Join(home, ".onecli") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("creating profile dir: %w", err) + } + rendered := profileFor(home, forwarderPort) + path := filepath.Join(dir, "enforce-wrap.sb") + if existing, err := os.ReadFile(path); err == nil && string(existing) == rendered { + return path, nil + } + if err := os.WriteFile(path, []byte(rendered), 0o600); err != nil { + return "", fmt.Errorf("writing sandbox profile: %w", err) + } + return path, nil +} + +// wrapArgv builds the sandbox-exec argv: sandbox-exec -f . +func wrapArgv(profilePath, binary string, args []string) []string { + return append([]string{"sandbox-exec", "-f", profilePath, binary}, args...) +} diff --git a/internal/sandbox/sandbox_darwin_test.go b/internal/sandbox/sandbox_darwin_test.go new file mode 100644 index 0000000..219f976 --- /dev/null +++ b/internal/sandbox/sandbox_darwin_test.go @@ -0,0 +1,150 @@ +//go:build darwin + +package sandbox + +// Unit tests for the Seatbelt profile. They pin SYNTAX; the behavioural +// proof that each rule actually fires lives in the live bypass suite and +// in `onecli sandbox audit`. Both matter: the docker.sock rule passed a +// text assertion for its entire life while matching nothing at runtime. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProfileShape(t *testing.T) { + // The profile is the enforcement layer — pin its structural + // invariants so a refactor can't silently weaken it. These assertions + // pin SYNTAX only; behavioural proof lives in TestLiveEnforceWrapBypasses + // and `onecli sandbox audit`. The distinction is not academic: the + // docker.sock rule below passed a text assertion for its entire life + // while matching nothing at runtime. + profile := profileFor("/Users/probe", 4242) + for _, want := range []string{ + "(deny network-outbound)", + // Scoped to the forwarder's PORT, not "localhost:*". Seatbelt's + // localhost means any address local to this machine, including LAN + // interfaces, so the wildcard let a sandboxed agent reach any + // host-bound listener (a Docker published port, a dev server) and + // relay out through it. Demonstrated end-to-end before this change. + `(allow network-outbound (remote tcp "localhost:4242"))`, + "(allow network-outbound (remote unix-socket))", + // A REGEX on the socket name, not a path-literal: Seatbelt matches + // the RESOLVED path and Docker Desktop symlinks /var/run/docker.sock + // into ~/.docker/run, so the literal form loaded fine and never + // matched. Text assertions can only pin syntax — the behavioural + // proof is TestLiveEnforceWrapBypasses/docker-socket. + `(deny network-outbound (regex #"docker\.sock$"))`, + "(deny lsopen)", + "(deny appleevent-send)", + // Deferred egress: surfaces where a sandboxed write is executed + // LATER by an unsandboxed process. All were reachable until these + // rules existed, so the profile was never network-only in effect. + `(deny file-write* (subpath "/Users/probe/Library/LaunchAgents"))`, + `(literal "/Users/probe/.zshrc")`, + `(deny file-write* (subpath "/usr/local/bin"))`, + // Scoped to the enforcement artifacts, NOT all of ~/.onecli: the + // directory also holds config.json and credentials that onecli's + // own commands write, and a broad deny bricked `onecli config set` + // inside an enforced session. + `(deny file-write* (literal "/Users/probe/.onecli/enforce-wrap.sb"))`, + `(deny file-write* (literal "/Users/probe/.onecli/enforce-sandbox-settings.json"))`, + `(deny file-read* (subpath "/Users/probe/.ssh"))`, + } { + if !strings.Contains(profile, want) { + t.Errorf("profile missing %q", want) + } + } + if !strings.HasPrefix(profile, "(version 1)") { + t.Error("profile must start with (version 1)") + } + // The deny must come BEFORE the loopback allow: Seatbelt applies the + // last matching rule, so an allow preceding the deny would be dead. + deny := strings.Index(profile, "(deny network-outbound)") + allow := strings.Index(profile, `(allow network-outbound (remote tcp "localhost:4242"))`) + // Index returns -1 when absent, which would make the ordering check + // pass vacuously against a renamed rule. Require both to exist first. + if deny < 0 || allow < 0 { + t.Fatalf("network rules not found (deny=%d allow=%d) — update this test", deny, allow) + } + if deny > allow { + t.Error("(deny network-outbound) must precede the loopback allow") + } +} + +// The profile is a Go raw string literal, so a backtick anywhere in it — +// including inside a comment, e.g. quoting a shell command — silently +// terminates the literal and produces a confusing parse error far from +// the real cause. It cost three build breaks while writing these rules, +// so it gets a test with a message that names the fix. +// The wildcard-port form is the exact bug that allowed LAN relay. Assert +// it can never return: it reads as "loopback only" but Seatbelt treats it +// as "any local address", which is how it survived review the first time. +func TestProfileHasNoWildcardLocalAllow(t *testing.T) { + if strings.Contains(seatbeltProfileTemplate, `(remote ip "localhost:*")`) { + t.Error(`profile allows "localhost:*", which includes the host's LAN ` + + `interfaces and lets an agent relay out through any host-bound ` + + `listener — scope the allow to the forwarder port instead`) + } +} + +func TestProfileHasNoBackticks(t *testing.T) { + if strings.Contains(seatbeltProfileTemplate, "`") { + t.Error("profile contains a backtick, which terminates the raw string literal early — " + + "use single quotes when quoting commands in profile comments") + } +} + +// Seatbelt applies the LAST matching rule, so an allow must follow the +// deny it carves out of. The .sample exception is load-bearing (without +// it `git clone` fails), and reordering would silently disable it while +// still loading cleanly. +func TestSampleCarveOutFollowsDeny(t *testing.T) { + profile := profileFor("/Users/probe", 4242) + deny := strings.Index(profile, `(deny file-write* (regex #"/\.git/hooks/"))`) + allow := strings.Index(profile, `(allow file-write* (regex #"/\.git/hooks/[^/]*\.sample$"))`) + if deny < 0 || allow < 0 { + t.Fatalf("git hook rules not found (deny=%d allow=%d)", deny, allow) + } + if allow < deny { + t.Error("the .sample allow must come AFTER the hooks deny, or git clone breaks") + } +} + +func TestMaterializeWritesRenderedProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + path, err := materialize(4242) + if err != nil { + t.Fatalf("materialize: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading profile: %v", err) + } + home, _ := os.UserHomeDir() + if string(data) != profileFor(home, 4242) { + t.Error("written profile differs from the rendered template") + } + // The template must be MATERIALIZED: Seatbelt does no $HOME expansion, + // so a leaked placeholder would load cleanly and match nothing — the + // same silent-no-op shape as the docker.sock bug. + if strings.Contains(string(data), "{{HOME}}") { + t.Error("profile still contains an unexpanded {{HOME}} placeholder") + } + if filepath.Base(filepath.Dir(path)) != ".onecli" { + t.Errorf("profile should live under ~/.onecli, got %s", path) + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0o600 { + t.Errorf("profile mode = %v, want 0600", info.Mode().Perm()) + } + + // Second call: idempotent, same path. + again, err := materialize(4242) + if err != nil || again != path { + t.Errorf("second write: path=%s err=%v, want %s nil", again, err, path) + } +} diff --git a/internal/sandbox/sandbox_live_darwin_test.go b/internal/sandbox/sandbox_live_darwin_test.go new file mode 100644 index 0000000..a9f886d --- /dev/null +++ b/internal/sandbox/sandbox_live_darwin_test.go @@ -0,0 +1,79 @@ +//go:build darwin + +package sandbox + +// Live test for the enforce-wrap Seatbelt profile: verifies on a real +// macOS host that the OS (1) blocks direct egress from inside the +// sandbox and (2) allows the loopback path — the two properties wrap +// mode's guarantee rests on. Local-only: the "allowed" leg talks to a +// listener the test owns, so no external network is required. + +import ( + "fmt" + "net" + "net/http" + "os/exec" + "strings" + "testing" + "time" +) + +func TestLiveProfileConfinesEgress(t *testing.T) { + if err := available(); err != nil { + t.Skipf("sandbox unavailable: %v", err) + } + t.Setenv("HOME", t.TempDir()) + // The listener comes FIRST: the profile's network allow is scoped to + // the forwarder's port, so the port has to exist before the profile + // can be rendered. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { _ = ln.Close() }() + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "wrap-ok") + })} + go func() { _ = srv.Serve(ln) }() + defer func() { _ = srv.Close() }() + + profile, err := materialize(uint16(ln.Addr().(*net.TCPAddr).Port)) + if err != nil { + t.Fatalf("materialize: %v", err) + } + + curl := func(args ...string) (string, error) { + argv := append([]string{"-f", profile, "/usr/bin/curl", "-sS", "--max-time", "5"}, args...) + out, err := exec.Command(sandboxExecPath, argv...).CombinedOutput() + return string(out), err + } + + t.Run("loopback allowed", func(t *testing.T) { + out, err := curl(fmt.Sprintf("http://%s/", ln.Addr())) + if err != nil || !strings.Contains(out, "wrap-ok") { + t.Errorf("loopback request failed: %v %q", err, out) + } + }) + + t.Run("direct egress denied at the OS", func(t *testing.T) { + // Raw IP, so no DNS involved: connect() itself must be refused + // by Seatbelt (instantly), not time out on a missing network. + start := time.Now() + out, err := curl("http://1.1.1.1/") + if err == nil { + t.Fatalf("direct egress unexpectedly succeeded: %q", out) + } + if d := time.Since(start); d > 3*time.Second { + t.Errorf("denial took %v — looks like a network timeout, not a sandbox denial", d) + } + }) + + t.Run("children inherit the sandbox", func(t *testing.T) { + // bash -> curl: the grandchild must still be confined. + argv := []string{"-f", profile, "/bin/bash", "-c", "/usr/bin/curl -sS --max-time 5 http://1.1.1.1/"} + out, err := exec.Command(sandboxExecPath, argv...).CombinedOutput() + if err == nil { + t.Errorf("grandchild egress unexpectedly succeeded: %q", out) + } + }) +} diff --git a/internal/sandbox/sandbox_other.go b/internal/sandbox/sandbox_other.go new file mode 100644 index 0000000..7dceca6 --- /dev/null +++ b/internal/sandbox/sandbox_other.go @@ -0,0 +1,30 @@ +//go:build !darwin + +package sandbox + +// Fallback backend for platforms without an implemented sandbox +// (currently everything but macOS). Linux (bubblewrap + network +// namespace) and Windows are planned; until then enforce-wrap fails +// closed with a clear reason rather than silently running ungoverned. + +import ( + "fmt" + "runtime" +) + +func available() error { + return fmt.Errorf("--enforce for this agent requires the OneCLI sandbox, which supports macOS only for now (got %s)", runtime.GOOS) +} + +func materialize(uint16) (string, error) { return "", available() } + +func launcherPath() string { return "" } + +func profile(uint16) string { return "" } + +func wrapArgv(_, binary string, args []string) []string { + // Unreachable in practice: callers gate on Available() first. Return a + // direct invocation so a misuse runs the command rather than a broken + // argv (unconfined — hence the Available() gate). + return append([]string{binary}, args...) +}