Files
zero/internal/sandbox/auto_allow_bash_test.go
KRATOSandKRATOS 684901165d fix(sandbox): don't auto-allow shell when re-entrancy skips wrapping (#727) (#744)
The ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND markers are user-controlled
at an unsandboxed process boundary. Policy could auto-allow an ordinary shell
command (backend reports a native wrapping sandbox) while the runner, seeing the
same markers, returned an unwrapped pass-through plan — the command then ran
directly on the host with no isolation and no prompt.

shellSandboxActive now mirrors the runner: when IsAlreadySandboxed() (the markers
are present, so wrapping is skipped) the sandbox is not the boundary for this
command, so it reports inactive and the shell command takes the normal approval
path instead of auto-allowing. Immediate mitigation per the issue; authenticated
launch provenance is the longer-term fix.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:56:36 +05:30

155 lines
5.5 KiB
Go

package sandbox
import (
"context"
"testing"
)
func sandboxedShellEngine(t *testing.T, backend Backend) *Engine {
t.Helper()
return NewEngine(EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: DefaultPolicy(),
Backend: backend,
})
}
func bashRequest() Request {
return Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"command": "echo hi"},
}
}
var nativeWrappingBackend = Backend{
Name: BackendLinuxBwrap,
Available: true,
Executable: "/usr/bin/zero-linux-sandbox",
CommandWrapping: true,
NativeIsolation: true,
}
func TestSandboxedBashAutoAllowedWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
decision := engine.Evaluate(context.Background(), bashRequest())
if decision.Action != ActionAllow {
t.Fatalf("decision = %#v, want allow (sandbox active)", decision)
}
}
func TestSandboxedBashRequireEscalatedStillPromptsWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
request := bashRequest()
request.Args["sandbox_permissions"] = "require_escalated"
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionPrompt || decision.Reason != ReasonEscalatedSandboxRequired {
t.Fatalf("decision = %#v, want require_escalated prompt", decision)
}
}
func TestSandboxedBashProcessListingAutoAllowedWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
request := bashRequest()
request.Args["command"] = "ps aux"
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionAllow {
t.Fatalf("decision = %#v, want allow (sandbox active)", decision)
}
if len(decision.Risk.Categories) != 1 || !HasRiskCategory(decision.Risk, "shell") {
t.Fatalf("risk = %#v, want only shell category", decision.Risk)
}
if !decision.AutoAllowed {
t.Fatalf("sandboxed shell command should be auto-allowed: %#v", decision)
}
}
func TestSandboxedBashStillPromptsWithoutSandbox(t *testing.T) {
engine := sandboxedShellEngine(t, Backend{Name: BackendUnavailable})
decision := engine.Evaluate(context.Background(), bashRequest())
if decision.Action != ActionPrompt {
t.Fatalf("decision = %#v, want prompt (no active sandbox)", decision)
}
}
func TestSandboxedBashAutoAllowDoesNotAffectNonShell(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
// A non-shell prompt tool must still prompt; auto-allow is shell-only. Use a
// generic write tool name so the workspace file-tool auto-allow does not
// apply.
decision := engine.Evaluate(context.Background(), Request{
ToolName: "custom_writer",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"path": "notes.txt"},
})
if decision.Action != ActionPrompt {
t.Fatalf("decision = %#v, want prompt (auto-allow is shell-only)", decision)
}
}
// Forged re-entrancy markers (ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND at
// an unsandboxed process boundary) must NOT let a shell command combine an
// auto-allow decision with an unwrapped pass-through plan (issue #727). The
// runner skips wrapping when the markers are present, so the policy must stop
// auto-allowing on the basis of an "active sandbox" — the command takes the
// normal prompt instead. Without the markers the identical setup auto-allows
// (TestSandboxedBashAutoAllowedWhenSandboxActive), so this is the before/after.
func TestForgedReentrancyMarkersDoNotAutoAllowUnwrappedShell(t *testing.T) {
t.Setenv(EnvSandboxed, "1")
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Backend: nativeWrappingBackend,
})
// Policy side: the shell command must NOT be auto-allowed.
decision := engine.Evaluate(context.Background(), bashRequest())
if decision.Action != ActionPrompt || decision.AutoAllowed {
t.Fatalf("forged markers must not auto-allow the shell command, got %#v", decision)
}
// Planning side: the same command is an unwrapped pass-through. Together with
// the prompt above, this proves auto-allow and unwrapped can no longer combine.
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "echo hi"}, Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped {
t.Fatalf("re-entrancy plan must be an unwrapped pass-through, got wrapped=%v", plan.Wrapped)
}
}
// TestShellSandboxActive reports correctly across backends and policy modes.
func TestShellSandboxActive(t *testing.T) {
root := t.TempDir()
native := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: nativeWrappingBackend})
if !native.shellSandboxActive(DefaultPolicy()) {
t.Fatal("native wrapping backend must be sandbox-active")
}
unavailable := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: Backend{Name: BackendUnavailable}})
if unavailable.shellSandboxActive(DefaultPolicy()) {
t.Fatal("unavailable backend must NOT be sandbox-active")
}
disabled := DefaultPolicy()
disabled.Mode = ModeDisabled
if native.shellSandboxActive(disabled) {
t.Fatal("disabled policy must NOT be sandbox-active")
}
var nilEngine *Engine
if nilEngine.shellSandboxActive(DefaultPolicy()) {
t.Fatal("nil engine must NOT be sandbox-active")
}
}