Compare commits

...
Author SHA1 Message Date
Gnanam 1141d258ec feat(exec): container-trusted sandbox-off mode + run deadline
Two opt-in controls for `zero exec`, for headless/automated runs inside an
already-isolated environment (container/CI sandbox):

- `--sandbox off` (env ZERO_SANDBOX off|disabled|none|0): disables ZERO's
  in-process filesystem + network confinement so the agent can write outside the
  workspace and reach the network. OFF BY DEFAULT — the default policy stays
  enforcing (workspace-confined, network-denied), guarded by
  TestExecSandboxPolicyDefaultUnchanged. Emits a loud warning when enabled; safe
  only inside an isolated container, never on a host.
- `--deadline <dur>` (env ZERO_DEADLINE): wall-clock budget; ZERO yields a
  best-effort result + flushed output at the deadline instead of running
  unbounded, and reports a deadline stop distinctly from an interrupt.

Both use flag>env precedence; invalid/empty values are inert (no deadline /
sandbox stays on). Tests cover the policy mapping, value resolution, and arg
parsing for both flag and inline =value spellings.
2026-06-22 10:43:07 +05:30
4 changed files with 221 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -52,7 +52,7 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
if err != nil {
return nil, nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope, false)
if err != nil {
return nil, nil, err
}
+90 -6
View File
@@ -105,6 +105,16 @@ type execOptions struct {
// additional write roots for this run. Unioned with
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
addDirs []string
// sandboxMode is the raw --sandbox flag value ("" = default enforce, "off"/
// "disabled" = container-trusted). Resolved (with the ZERO_SANDBOX env
// fallback) into a disable decision in runExec. Off by default; the disabled
// path is an EXPLICIT, loudly-warned opt-in meant ONLY for use inside an
// already-isolated container (CI / benchmark).
sandboxMode string
// deadlineRaw is the raw --deadline flag value (a Go duration like "900s").
// Resolved (with the ZERO_DEADLINE env fallback) into a wall-clock budget for
// the run. Empty = no deadline (current behavior).
deadlineRaw string
}
type execUsageError struct {
@@ -313,7 +323,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
sandboxOff := resolveSandboxOff(options.sandboxMode)
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope, sandboxOff)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
@@ -458,6 +469,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitCrash
}
}
if sandboxOff {
writer.warning("Sandbox disabled (container-trusted mode): filesystem and network are unrestricted. Use only inside an isolated container.")
if writer.err != nil {
return exitCrash
}
}
sessionRecorder := execSessionRecorder{prepared: preparedSession}
// Surface a best-effort session-recording failure once, on every exit path.
@@ -475,6 +492,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// cleanly (the loop honors context cancellation) instead of being killed.
runCtx, stopSignals := signalContext()
defer stopSignals()
// --deadline / ZERO_DEADLINE bound the run by wall-clock time (Terminal-Bench
// budgets by time, not turns). When the deadline fires, runCtx is cancelled,
// the loop stops, and the run_end below reports status "deadline" with the
// trajectory already flushed. Empty = no deadline (unchanged behavior).
if runDeadline := resolveDeadline(options.deadlineRaw); runDeadline > 0 {
var cancelDeadline context.CancelFunc
runCtx, cancelDeadline = context.WithTimeout(runCtx, runDeadline)
defer cancelDeadline()
}
// --self-correct opts the run into the post-edit verify-and-correct loop. Off
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
@@ -565,11 +591,18 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
// Distinguish a wall-clock --deadline stop (status "deadline") from a
// Ctrl+C / SIGTERM interrupt; both are clean shutdowns, not errors, and
// the trajectory is flushed before exit either way.
cancelStatus, cancelMsg := "interrupted", "run cancelled by signal"
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
cancelStatus, cancelMsg = "deadline", "run stopped: wall-clock deadline reached"
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": cancelStatus})
switch options.outputFormat {
case execOutputStreamJSON:
writer.errorEvent("interrupted", "run cancelled by signal", false)
writer.runEnd("interrupted", exitInterrupted)
writer.errorEvent(cancelStatus, cancelMsg, false)
writer.runEnd(cancelStatus, exitInterrupted)
if writer.err != nil {
return exitCrash
}
@@ -688,12 +721,63 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
registry.Register(tools.NewToolSearchTool(registry))
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope) (*sandbox.Engine, error) {
// resolveSandboxOff decides whether to disable confinement for an exec run.
// Precedence: the --sandbox flag value wins; otherwise the ZERO_SANDBOX env var.
// Recognized "off" values: off / disabled / none / 0 (case-insensitive). Anything
// else, including empty, keeps the default enforcing sandbox.
func resolveSandboxOff(flagValue string) bool {
value := strings.TrimSpace(flagValue)
if value == "" {
value = strings.TrimSpace(os.Getenv("ZERO_SANDBOX"))
}
switch strings.ToLower(value) {
case "off", "disabled", "none", "0":
return true
default:
return false
}
}
// resolveDeadline parses the run's wall-clock budget. Precedence: the --deadline
// flag wins; otherwise the ZERO_DEADLINE env var. Empty = no deadline. An
// unparseable or non-positive value is treated as no deadline.
func resolveDeadline(flagValue string) time.Duration {
value := strings.TrimSpace(flagValue)
if value == "" {
value = strings.TrimSpace(os.Getenv("ZERO_DEADLINE"))
}
if value == "" {
return 0
}
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return 0
}
return d
}
// execSandboxPolicy builds the sandbox policy for an exec run. By default it is
// the configured, enforcing policy (DefaultPolicy + config overlay). With
// disableSandbox it becomes container-trusted: Mode=disabled, Network=allow,
// EnforceWorkspace=false — flipping all three gates so both the in-process
// Evaluate path and the OS wrapping are permissive. Pure, so the default-unchanged
// and off-mode behavior are unit-tested directly.
func execSandboxPolicy(cfg config.SandboxConfig, disableSandbox bool) sandbox.Policy {
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
if disableSandbox {
policy.Mode = sandbox.ModeDisabled
policy.Network = sandbox.NetworkAllow
policy.EnforceWorkspace = false
}
return policy
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope, disableSandbox bool) (*sandbox.Engine, error) {
store, err := deps.newSandboxStore()
if err != nil {
return nil, err
}
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
policy := execSandboxPolicy(resolved.Sandbox, disableSandbox)
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
+18
View File
@@ -52,6 +52,24 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--auto="):
options.autonomy = strings.TrimSpace(strings.TrimPrefix(arg, "--auto="))
case arg == "--sandbox":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.sandboxMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--sandbox="):
options.sandboxMode = strings.TrimSpace(strings.TrimPrefix(arg, "--sandbox="))
case arg == "--deadline":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.deadlineRaw = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--deadline="):
options.deadlineRaw = strings.TrimSpace(strings.TrimPrefix(arg, "--deadline="))
case arg == "--enabled-tools":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
+112
View File
@@ -0,0 +1,112 @@
package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestExecSandboxPolicyDefaultUnchanged is the load-bearing guard for real users:
// WITHOUT --sandbox off / ZERO_SANDBOX, the exec policy MUST be the default
// enforcing one (workspace-confined, network denied). If this ever changes, the
// container-trusted off-switch has leaked into the default.
func TestExecSandboxPolicyDefaultUnchanged(t *testing.T) {
policy := execSandboxPolicy(config.SandboxConfig{}, false)
if policy.Mode != sandbox.ModeEnforce {
t.Errorf("Mode = %q, want %q (default must not change)", policy.Mode, sandbox.ModeEnforce)
}
if policy.Network != sandbox.NetworkDeny {
t.Errorf("Network = %q, want %q (default must not change)", policy.Network, sandbox.NetworkDeny)
}
if !policy.EnforceWorkspace {
t.Error("EnforceWorkspace = false, want true (default must not change)")
}
}
// TestExecSandboxPolicyOff: with the off-switch, all three confinement gates drop
// so writes-anywhere and network both work inside an isolated container.
func TestExecSandboxPolicyOff(t *testing.T) {
policy := execSandboxPolicy(config.SandboxConfig{}, true)
if policy.Mode != sandbox.ModeDisabled {
t.Errorf("Mode = %q, want %q", policy.Mode, sandbox.ModeDisabled)
}
if policy.Network != sandbox.NetworkAllow {
t.Errorf("Network = %q, want %q", policy.Network, sandbox.NetworkAllow)
}
if policy.EnforceWorkspace {
t.Error("EnforceWorkspace = true, want false")
}
}
func TestResolveSandboxOff(t *testing.T) {
cases := []struct {
flag, env string
want bool
}{
{"", "", false},
{"off", "", true},
{"disabled", "", true},
{"none", "", true},
{"0", "", true},
{"OFF", "", true}, // case-insensitive
{"enforce", "", false},
{"", "off", true}, // env fallback
{"", "disabled", true},
{"", "1", false}, // unrecognized env value
{"enforce", "off", false}, // flag wins over env
{"off", "enforce", true}, // flag wins over env
}
for _, c := range cases {
t.Setenv("ZERO_SANDBOX", c.env)
if got := resolveSandboxOff(c.flag); got != c.want {
t.Errorf("resolveSandboxOff(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
}
}
}
func TestResolveDeadline(t *testing.T) {
cases := []struct {
flag, env string
want time.Duration
}{
{"", "", 0},
{"900s", "", 15 * time.Minute},
{"15m", "", 15 * time.Minute},
{"", "300s", 5 * time.Minute}, // env fallback
{"60s", "300s", time.Minute}, // flag wins over env
{"bogus", "", 0}, // unparseable -> no deadline
{"-5s", "", 0}, // non-positive -> no deadline
{"0s", "", 0}, // zero -> no deadline
}
for _, c := range cases {
t.Setenv("ZERO_DEADLINE", c.env)
if got := resolveDeadline(c.flag); got != c.want {
t.Errorf("resolveDeadline(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
}
}
}
// TestParseExecArgsSandboxAndDeadline: both --flag value and --flag=value spellings
// thread through to the options.
func TestParseExecArgsSandboxAndDeadline(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--sandbox", "off", "--deadline", "900s", "do the task"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if opts.sandboxMode != "off" {
t.Errorf("sandboxMode = %q, want off", opts.sandboxMode)
}
if opts.deadlineRaw != "900s" {
t.Errorf("deadlineRaw = %q, want 900s", opts.deadlineRaw)
}
opts2, _, err := parseExecArgs([]string{"--sandbox=disabled", "--deadline=15m", "x"})
if err != nil {
t.Fatalf("parseExecArgs inline: %v", err)
}
if opts2.sandboxMode != "disabled" || opts2.deadlineRaw != "15m" {
t.Errorf("inline parse: sandboxMode=%q deadlineRaw=%q, want disabled/15m", opts2.sandboxMode, opts2.deadlineRaw)
}
}