mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
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]>
155 lines
5.5 KiB
Go
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")
|
|
}
|
|
}
|