mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* Align shell approval sandbox execution Make approved shell command-prefix executions use the escalated sandbox path when denied-read policy allows it, so remembered approvals do not still hit the restrictive sandbox. Allow auto sandbox mode to degrade to a direct command plan when native command wrapping is unavailable, while strict sandbox require continues to fail closed. Update regression tests for degraded execution, prefix-approved shell calls, and unavailable native sandbox metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsWithDegradedUnavailableNativeSandbox|TestBashToolRunsWithDegradedUnavailableNativeSandbox|TestBashRequireEscalatedBypassesNativeSandboxAfterApproval|TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval' Tested: GOCACHE=/tmp/zero-go-cache go test ./... -run '^$' Tested: git diff --check * Align sandbox command approval behavior Evaluate reusable shell approvals across plain command segments so prefix grants can cover commands with safe read-only tails without relying on command-specific sandbox exceptions. Keep process inspection commands sandboxed by default; unsandboxed host access continues to require explicit escalation or a narrow command-prefix approval. Improve shell tool guidance around sandboxed additional permissions, full escalation, justifications, and narrow reusable prefixes. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/agent Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... * Normalize explicit sandbox backend platforms Infer the platform from a caller-provided sandbox backend name before choosing the manager runtime platform. This keeps Linux, macOS, and Windows backend plans deterministic across host OSes when tests or callers provide a backend without an explicit Platform field. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...
121 lines
3.9 KiB
Go
121 lines
3.9 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|