Compare commits

..
Author SHA1 Message Date
anandh8x 84d0bc478b fix(sandbox): restore strict macOS isolation
Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces.

Track process reap completion so retained sessions cannot signal a stale numeric PID.
2026-07-21 17:47:36 +05:30
anandh8x b9b96c6f82 fix(sandbox): close execution lifecycle races
Bound retained interactive output, preserve interruption state, and start workspace observation before process launch.

Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback.
2026-07-21 17:27:13 +05:30
anandh8x b8eec2d86e test(sandbox): make platform contracts portable 2026-07-20 23:52:42 +05:30
anandh8x 1550c4131c feat(sandbox): unify command execution and enforcement 2026-07-20 23:39:36 +05:30
99 changed files with 5061 additions and 2558 deletions

No files matched your search

-22
View File
@@ -67,28 +67,6 @@ An approved issue means only that the specific issue has been accepted for work.
It does not approve unrelated future work, future pull requests, or future issues
from the same author. Each issue is reviewed case by case.
## Contribution Volume and Batching
To keep triage and review sustainable while the project stabilizes, please keep
your contribution flow to a size the core team can realistically work through:
- One issue per bug. File each defect as its own issue with its own
reproduction. Please do not bundle several unrelated findings into a single
"audit" issue; separate issues are far easier to triage, approve, and close
individually.
- Keep only a few items open at a time. If you already have several issues or
pull requests awaiting review, let those move through the queue before opening
more. A large simultaneous burst from one contributor slows review for
everyone, even when each item is valid.
- AI-assisted contributions are welcome, held to the same bar. Automated audits
and generated patches are fine, but each issue and pull request must be
individually verified, scoped, and reproducible before you open it. Please do
not post bulk machine-generated findings unfiltered.
These apply to all contributors and are about review bandwidth, not the value of
any single report. Maintainers may ask you to consolidate, split, or hold
submissions to keep the queue manageable.
## Pull Request Requirements
Before opening a community pull request, make sure all of the following are true:
+82 -40
View File
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -1170,6 +1171,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
decisionCommandPrefix = grant.Prefix
}
}
// An explicit additional-permissions request is an elevation only when it
// expands the current effective policy. If the same capability was already
// approved for this session, reuse that grant across equivalent commands
// instead of prompting again merely because their command prefixes differ.
if toolFound && !permissionGranted && isShellCommandTool(call.Name) && options.Sandbox != nil {
if profile, requested, profileErr := inlineAdditionalPermissionsProfile(args, options.Cwd); profileErr == nil && requested && options.Sandbox.CoversRequestPermissions(profile) {
permissionGranted = true
decisionAction = PermissionDecisionAllowForSession
decisionReason = "requested sandbox capabilities already granted for this session"
}
}
var preflightDecision *sandbox.Decision
if toolFound && options.Sandbox != nil {
@@ -1416,17 +1428,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// the agent loop and the MCP server pass through), so result.Output is
// already redacted here and result.Redacted reflects whether it changed.
return ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
@@ -1546,8 +1559,15 @@ func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, resu
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
return false
}
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
return false
if result.ExecutionOutcome != nil {
denial := result.ExecutionOutcome.Denial
if denial == nil || denial.Capability.Kind != execution.CapabilityExternalNetwork {
return false
}
} else {
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
return false
}
}
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
return false
@@ -1600,6 +1620,11 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
}
func sandboxDeniedShellResult(result tools.Result) bool {
if result.ExecutionOutcome != nil {
// Typed denials must be handled by their exact capability path. Never
// turn a structured narrow denial into the legacy unrestricted retry.
return false
}
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
@@ -1728,17 +1753,18 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
}
}
@@ -1998,15 +2024,16 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
}
}
return ToolResult{
@@ -2570,7 +2597,8 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
scopeText = requestPermissionsScope(profile)
}
reason = userFacingPermissionReason(call.Name, args, reason)
decisionReason := strings.TrimSpace(reason)
reason = userFacingPermissionReason(call.Name, args, reason, safety.Reason)
return PermissionEvent{
ToolCallID: call.ID,
@@ -2583,6 +2611,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
Autonomy: options.Autonomy,
SideEffect: string(safety.SideEffect),
Reason: reason,
DecisionReason: decisionReason,
Scope: scopeText,
Risk: risk,
Block: block,
@@ -2592,18 +2621,31 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}, true
}
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
func userFacingPermissionReason(toolName string, args map[string]any, reason string, fallback string) string {
reason = strings.TrimSpace(reason)
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
if !isShellCommandTool(toolName) {
return reason
}
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
// Policy and sandbox explanations are authoritative. The tool's static
// safety copy is only an internal fallback; presenting it as the reason for
// every ordinary shell approval makes prompts generic and obscures the cases
// where policy has an actionable explanation.
fallback = strings.TrimSpace(fallback)
if reason != "" && reason != fallback && reason != "tool requires approval before execution" && reason != sandbox.ReasonEscalatedSandboxRequired {
return reason
}
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
if shellCommandRequiresEscalated(args) {
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
}
return "This command needs to run outside the sandbox."
}
return reason
// Ordinary shell approvals need no invented explanation. The raw fallback is
// retained separately as DecisionReason for traces and policy auditing.
return ""
}
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
+54
View File
@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
@@ -26,6 +27,59 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
options := Options{Sandbox: engine}
legacyMeta := map[string]string{
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
}
applicationFailure := tools.Result{
Status: tools.StatusError,
Meta: legacyMeta,
ExecutionOutcome: &execution.Outcome{
State: execution.StateFailed,
Kind: execution.OutcomeApplicationFailure,
Exit: &execution.Exit{Code: 1},
},
}
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
}
protectedDenial := applicationFailure
protectedDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected metadata",
NextAction: execution.DenialNextActionRequestApproval,
},
}
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
}
networkDenial := protectedDenial
networkDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
Source: execution.DenialSourcePlatformSandbox,
Reason: "external network denied",
NextAction: execution.DenialNextActionRequestApproval,
},
}
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
t.Fatal("typed external-network denial should use the narrow network approval path")
}
}
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "npm install",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
t.Fatal("an already-covered capability must not trigger another prompt")
}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("covered capability should still produce an auditable allow event")
}
if event.Action != PermissionActionAllow {
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
}
}
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "make test"}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("generic shell approval must produce an event")
}
if event.Reason != "" {
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
}
if event.DecisionReason != "runs a shell command" {
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
}
}
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "curl https://example.test",
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
"justification": "Run without sandboxing.",
}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("policy approval must produce an event")
}
if event.Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
}
}
+5 -5
View File
@@ -14,13 +14,13 @@ import (
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
// the auto-mode advertised set, including exec_command: auto mode must expose both
// process creation and write_stdin process interaction or the latter is unusable
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
maxEagerToolSchemaTokens = 3550
)
func TestSystemPromptTokenBudget(t *testing.T) {
+5 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -71,7 +72,10 @@ type ToolResult struct {
Meta map[string]string
Redacted bool
ChangedFiles []string
Display tools.Display
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
+4 -74
View File
@@ -3,11 +3,10 @@
package background
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
@@ -22,10 +21,7 @@ var (
// the negative PID (the group), so any process the child forks dies with it
// instead of being orphaned. Must be called before cmd.Start.
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(cmd)
}
// terminateProcess stops a background process. It first asks politely with
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
// back to signalling only the individual PID, which also avoids reporting a false
// success when a non-leader group-signal returns ESRCH.
func terminateProcess(pid int) error {
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
// every process we can signal. A real child PID is always > 1, so never let a
// bogus 0/1 expand into either.
if pid <= 1 {
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
}
// Signal the whole group only when pid leads its own group; otherwise the
// individual process. See the doc comment for why a non-leader is signalled
// directly rather than via its (foreign) group.
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if processGoneError(err) {
return nil // already gone
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if processGoneError(err) {
return nil
}
return err
}
// Poll liveness so we return promptly once it exits, rather than always
// waiting out the full grace period.
deadline := time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if !alive() {
return nil
}
// Still alive after the grace period: force-kill.
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
return err
}
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
// so this helper only reports success once the target is actually gone —
// otherwise the caller gets nil while descendants are still racing.
deadline = time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
// processGoneError reports whether an error means the process group has already
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
// no process in the target group remains.
func processGoneError(err error) bool {
return errors.Is(err, syscall.ESRCH)
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
+6 -26
View File
@@ -3,36 +3,16 @@
package background
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"github.com/Gitlawb/zero/internal/execution"
)
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
func terminateProcess(pid int) error {
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
return execution.TerminateProcessTree(pid, 0, 0)
}
+25 -15
View File
@@ -17,6 +17,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
@@ -678,6 +679,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
executionRunner := execution.NewRunner(nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
executionRunner.SetPreparer(sandboxEngine)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
@@ -712,6 +733,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
})
}
if err != nil {
@@ -738,7 +761,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -760,19 +783,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// unchanged. The interactive surface applies no operator tool filters, so
// enabled/disabled are nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
@@ -789,7 +799,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
+29 -35
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -206,6 +207,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
executionRunner := execution.NewRunner(nil)
// Register the escalate_model tool only when the run opted into mid-run
// escalation. It is registered before --list-tools formatting and
// validateExecToolFilters so the tool is both listable and filter-validatable.
@@ -238,16 +240,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
// registry and collect their hooks + skill roots for the dispatcher and skill
// tool below. Done before --list-tools and filter validation so plugin tools
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
var pluginActivation pluginActivation
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
@@ -290,6 +289,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
executionRunner.SetPreparer(sandboxEngine)
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -357,28 +379,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
images = nil
}
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
// instances registered before config resolve (the registry must exist that
// early for --list-tools and tool-filter validation, which run without a
// provider). This is safe only while two invariants hold:
// 1. Registry.Register replaces by NAME, so every path-confining core
// tool is swapped wholesale; and
// 2. nothing between the initial registration and this point captures a
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
// names lazily; --list-tools and filter validation use names only).
// A new wrapper that snapshots a core tool before this line would silently
// ship nil-scope enforcement — add it below this re-registration instead.
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
@@ -624,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// Build the hooks dispatcher out of the struct literal so its trust skip report
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
@@ -939,12 +939,6 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
// the default policy.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
// request is allowed and commands run unwrapped. Only reachable from global
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
if cfg.Enabled != nil && !*cfg.Enabled {
policy.Mode = sandbox.ModeDisabled
}
if network := strings.TrimSpace(cfg.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -114,7 +115,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
// repo trusted. Emit at most one notice when project hooks or the project MCP
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
+12 -6
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
@@ -56,8 +57,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
//
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment); the project layer loads only when that root is trusted.
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
}
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
@@ -72,7 +73,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
// returned trustSkip lets the caller emit one combined notice; the notice itself
// is NOT emitted here.
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
@@ -106,10 +107,15 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
}
config.Hooks = merged
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Execution: runner,
}), skip
}
+8 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -29,7 +30,7 @@ type mcpToolListItem struct {
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
@@ -46,9 +47,15 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
if err != nil {
return nil, skip, err
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
Execution: runner,
WorkspaceRoot: workspaceRoot,
})
return runtime, skip, err
}
+7 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -42,7 +43,7 @@ type pluginActivation struct {
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment). The returned activation carries the skip report so the caller can
// emit one combined notice; the notice itself is NOT emitted here.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
@@ -65,7 +66,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
for _, warning := range result.Warnings {
writePluginActivationWarning(stderr, warning)
}
-15
View File
@@ -1001,18 +1001,3 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
}
return ""
}
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
disabled := false
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
}
// Omitted (nil) and explicit true keep the default enforcing mode.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
t.Fatal("omitted enabled must not disable the sandbox")
}
enabled := true
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
t.Fatal("enabled:true must not disable the sandbox")
}
}
-20
View File
@@ -93,16 +93,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
if err != nil {
return ResolvedConfig{}, err
}
// Sandbox.Enabled is NOT accepted from a provider command, for the same
// reason project config cannot set it (see mergeProjectConfig): only
// global config and the CLI may turn the sandbox off. A provider command
// is an arbitrary executable named in config, and LoadProviderCommand
// parses its stdout into a full FileConfig — so without this, a command
// returning a valid provider plus {"sandbox":{"enabled":false}} would
// disable the very sandbox meant to constrain what it can do. Cleared
// here rather than in mergeConfig because that helper is shared with the
// trusted global-config merge, which must keep honouring the setting.
commandConfig.Sandbox.Enabled = nil
mergeConfig(&cfg, commandConfig)
}
@@ -226,9 +216,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP, true)
if src.Sandbox.Enabled != nil {
dst.Sandbox.Enabled = src.Sandbox.Enabled
}
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
@@ -286,10 +273,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
return err
}
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
// repo's .zero/config.json must not be able to disable the sandbox that
// constrains it. Only global config and CLI can turn the sandbox off.
//
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
@@ -705,9 +688,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
}
if overrides.Sandbox.BlockUnixSockets {
cfg.Sandbox.BlockUnixSockets = true
}
-79
View File
@@ -1744,85 +1744,6 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
}
}
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
// Security: a cloned repo's project config must NOT be able to disable the
// sandbox that constrains it. Only global config / CLI can turn it off.
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
}
}
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
// Security: a provider command is an arbitrary executable named in config, and
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
// Sandbox.Enabled, in either direction. Same rule as project config: only global
// config / CLI may turn the sandbox off.
for _, tc := range []struct {
name string
value string
}{
{name: "cannot disable", value: "false"},
{name: "cannot enable", value: "true"},
} {
t.Run(tc.name, func(t *testing.T) {
command := writeCommand(t, commandScript{
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
})
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
// The provider itself must still be applied — only the sandbox toggle is dropped.
if resolved.ActiveProvider != "cmd" {
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
}
if resolved.Sandbox.Enabled != nil {
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
}
})
}
}
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
// A CLI/programmatic override (applied after every config source) must be able
// to disable the sandbox, even with no config file.
disabled := false
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
}
// An explicit true override must win over a lower-precedence global-config false.
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
enabled := true
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
-6
View File
@@ -62,12 +62,6 @@ func HasProviderProfile(profile ProviderProfile) bool {
}
type SandboxConfig struct {
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
// false}`). A pointer distinguishes an explicit false (disable) from an
// omitted key (keep the default: enabled). Honored from the GLOBAL user
// config and CLI only — deliberately NOT project config, so a cloned repo
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
Enabled *bool `json:"enabled,omitempty"`
// Network controls whether shell commands classified as network-touching
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
// Empty keeps the built-in default (deny). Without this knob the engine's
+214
View File
@@ -0,0 +1,214 @@
package execution
import (
"crypto/sha256"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
maxObservedFiles = 20_000
maxHashedFileBytes = 1 << 20
maxTotalHashedBytes = 32 << 20
)
type ChangeKind string
const (
ChangeCreated ChangeKind = "created"
ChangeModified ChangeKind = "modified"
ChangeDeleted ChangeKind = "deleted"
)
type Change struct {
Path string `json:"path"`
Kind ChangeKind `json:"kind"`
Aggregated bool `json:"aggregated,omitempty"`
Count int `json:"count,omitempty"`
}
type fileFingerprint struct {
Mode fs.FileMode
Size int64
ModTime int64
Digest [sha256.Size]byte
Hashed bool
// Aggregated marks a bounded fingerprint for a generated directory. The
// observer never enumerates that tree into individual changes.
Aggregated bool
Count int
}
// ChangeObserver records a bounded workspace snapshot around a command. It
// deliberately skips Zero/repository control metadata and large generated
// dependency trees; those paths must neither be read as command evidence nor
// flood the Files panel.
type ChangeObserver struct {
root string
before map[string]fileFingerprint
valid bool
}
func NewChangeObserver(root string) *ChangeObserver {
root = filepath.Clean(strings.TrimSpace(root))
before, ok := snapshotWorkspace(root)
return &ChangeObserver{root: root, before: before, valid: ok}
}
func (observer *ChangeObserver) Changes() []Change {
if observer == nil || !observer.valid {
return nil
}
after, ok := snapshotWorkspace(observer.root)
if !ok {
return nil
}
changes := make([]Change, 0)
for path, current := range after {
previous, existed := observer.before[path]
if !existed {
changes = append(changes, changeFromFingerprint(path, ChangeCreated, current))
continue
}
if previous != current {
changes = append(changes, changeFromFingerprint(path, ChangeModified, current))
}
}
for path, previous := range observer.before {
if _, exists := after[path]; !exists {
changes = append(changes, changeFromFingerprint(path, ChangeDeleted, previous))
}
}
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
return changes
}
func snapshotWorkspace(root string) (map[string]fileFingerprint, bool) {
if root == "" || root == "." {
return nil, false
}
files := make(map[string]fileFingerprint)
hashedBytes := int64(0)
ok := true
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if path == root {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil
}
if entry.IsDir() {
if protectedObservationDirectory(entry.Name()) {
return filepath.SkipDir
}
if generatedObservationDirectory(entry.Name()) {
files[filepath.ToSlash(relative)+"/"] = generatedTreeFingerprint(path)
return filepath.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
if len(files) >= maxObservedFiles {
ok = false
return fs.SkipAll
}
info, err := entry.Info()
if err != nil {
return nil
}
fingerprint := fileFingerprint{Mode: info.Mode(), Size: info.Size(), ModTime: info.ModTime().UnixNano()}
if info.Size() <= maxHashedFileBytes && hashedBytes+info.Size() <= maxTotalHashedBytes {
if digest, err := hashObservedFile(path); err == nil {
fingerprint.Digest = digest
fingerprint.Hashed = true
hashedBytes += info.Size()
}
}
files[filepath.ToSlash(relative)] = fingerprint
return nil
})
if err != nil {
return nil, false
}
return files, ok
}
func protectedObservationDirectory(name string) bool {
switch name {
case ".git", ".zero", ".agents":
return true
default:
return false
}
}
func generatedObservationDirectory(name string) bool {
switch name {
case "node_modules", ".pnpm-store", ".yarn", "dist", "coverage", ".cache":
return true
default:
return false
}
}
// generatedTreeFingerprint samples only direct children and caps the work. It
// is intentionally qualitative: the UI needs to say that a generated tree
// changed, not inventory dependency contents.
func generatedTreeFingerprint(root string) fileFingerprint {
directory, err := os.Open(root)
if err != nil {
return fileFingerprint{Aggregated: true, Count: -1}
}
defer directory.Close()
const maxEntries = 256
entries, err := directory.ReadDir(maxEntries + 1)
if err != nil && err != io.EOF {
return fileFingerprint{Aggregated: true, Count: -1}
}
hash := sha256.New()
count := len(entries)
if count > maxEntries {
entries = entries[:maxEntries]
count = -1
}
for _, entry := range entries {
_, _ = io.WriteString(hash, entry.Name())
if info, infoErr := entry.Info(); infoErr == nil {
_, _ = io.WriteString(hash, info.Mode().String())
_, _ = io.WriteString(hash, strconv.FormatInt(info.ModTime().UnixNano(), 10))
}
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return fileFingerprint{Digest: digest, Hashed: true, Aggregated: true, Count: count}
}
func changeFromFingerprint(path string, kind ChangeKind, fingerprint fileFingerprint) Change {
return Change{Path: path, Kind: kind, Aggregated: fingerprint.Aggregated, Count: fingerprint.Count}
}
func hashObservedFile(path string) ([sha256.Size]byte, error) {
file, err := os.Open(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -0,0 +1,67 @@
package execution
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestChangeObserverReportsCreateModifyAndDelete(t *testing.T) {
root := t.TempDir()
modified := filepath.Join(root, "modified.txt")
deleted := filepath.Join(root, "deleted.txt")
if err := os.WriteFile(modified, []byte("before"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(deleted, []byte("delete"), 0o644); err != nil {
t.Fatal(err)
}
observer := NewChangeObserver(root)
if err := os.WriteFile(modified, []byte("after!"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(deleted); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "src", "created.ts"), []byte("export {}"), 0o644); err != nil {
t.Fatal(err)
}
want := []Change{
{Path: "deleted.txt", Kind: ChangeDeleted},
{Path: "modified.txt", Kind: ChangeModified},
{Path: "src/created.ts", Kind: ChangeCreated},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want %#v", got, want)
}
}
func TestChangeObserverSkipsControlMetadataAndSummarizesGeneratedTrees(t *testing.T) {
root := t.TempDir()
observer := NewChangeObserver(root)
for _, path := range []string{
filepath.Join(root, ".zero", "config.json"),
filepath.Join(root, ".agents", "skills", "x"),
filepath.Join(root, ".git", "config"),
filepath.Join(root, "node_modules", "pkg", "index.js"),
filepath.Join(root, "dist", "bundle.js"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
t.Fatal(err)
}
}
want := []Change{
{Path: "dist/", Kind: ChangeCreated, Aggregated: true, Count: 1},
{Path: "node_modules/", Kind: ChangeCreated, Aggregated: true, Count: 1},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want generated summaries without control metadata %#v", got, want)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Package execution defines the platform-neutral command execution contract.
// Frontends and tools use these types without depending on native sandbox or
// process-management details.
package execution
import (
"errors"
"fmt"
"strings"
)
const PolicyVersion = 1
type Origin string
const (
OriginInteractiveCommand Origin = "interactive_command"
OriginHook Origin = "hook"
OriginPlugin Origin = "plugin"
OriginMCPServer Origin = "mcp_server"
OriginBackgroundTask Origin = "background_task"
)
type Mode string
const (
ModeCaptured Mode = "captured"
ModeInteractive Mode = "interactive"
ModeDurable Mode = "durable"
)
type Command struct {
Name string `json:"name"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
}
type CapabilityKind string
const (
CapabilityRead CapabilityKind = "filesystem_read"
CapabilityWorkspaceWrite CapabilityKind = "workspace_write"
CapabilityProtectedMetadata CapabilityKind = "protected_metadata"
CapabilityExternalNetwork CapabilityKind = "external_network"
CapabilityIsolatedLoopback CapabilityKind = "isolated_loopback"
CapabilityHostVisibleBinding CapabilityKind = "host_visible_binding"
CapabilityProcessSpawn CapabilityKind = "process_spawn"
CapabilityProcessControl CapabilityKind = "process_control"
CapabilityEnvironment CapabilityKind = "environment"
CapabilityUnrestricted CapabilityKind = "unrestricted"
)
type Capability struct {
Kind CapabilityKind `json:"kind"`
Scope string `json:"scope,omitempty"`
}
type ApprovalContext struct {
SessionID string `json:"sessionId,omitempty"`
PolicyVersion int `json:"policyVersion"`
}
type Request struct {
Origin Origin `json:"origin"`
Mode Mode `json:"mode"`
Command Command `json:"command"`
WorkingDirectory string `json:"workingDirectory"`
WorkspaceRoots []string `json:"workspaceRoots"`
Capabilities []Capability `json:"capabilities,omitempty"`
Approval ApprovalContext `json:"approval"`
}
func (request Request) Validate() error {
if !validOrigin(request.Origin) {
return errors.New("execution request requires a valid origin")
}
if request.Mode != ModeCaptured && request.Mode != ModeInteractive && request.Mode != ModeDurable {
return errors.New("execution request requires a valid mode")
}
if strings.TrimSpace(request.Command.Name) == "" {
return errors.New("execution request requires a command")
}
if strings.TrimSpace(request.WorkingDirectory) == "" {
return errors.New("execution request requires a working directory")
}
if len(request.WorkspaceRoots) == 0 {
return errors.New("execution request requires at least one workspace root")
}
for _, root := range request.WorkspaceRoots {
if strings.TrimSpace(root) == "" {
return errors.New("execution request contains an empty workspace root")
}
}
for _, capability := range request.Capabilities {
if !validCapabilityKind(capability.Kind) {
return fmt.Errorf("execution request contains invalid capability %q", capability.Kind)
}
}
return nil
}
type State string
const (
StateRequested State = "requested"
StateEvaluated State = "evaluated"
StateAwaitingApproval State = "awaiting_approval"
StatePrepared State = "prepared"
StateRunning State = "running"
StateRetained State = "retained"
StateCompleted State = "completed"
StateDenied State = "denied"
StateFailed State = "failed"
StateCancelled State = "cancelled"
)
type OutcomeKind string
const (
OutcomeRunning OutcomeKind = "running"
OutcomeSuccess OutcomeKind = "success"
OutcomePolicyDenied OutcomeKind = "policy_denied"
OutcomeEnforcementDenied OutcomeKind = "enforcement_denied"
OutcomeApplicationFailure OutcomeKind = "application_failure"
OutcomeSandboxSetupFailure OutcomeKind = "sandbox_setup_failure"
OutcomeExecutableNotFound OutcomeKind = "executable_not_found"
OutcomeCancelled OutcomeKind = "cancelled"
OutcomeTimedOut OutcomeKind = "timed_out"
OutcomeEnforcementDegraded OutcomeKind = "enforcement_degraded"
)
type Exit struct {
Code int `json:"code"`
Signal string `json:"signal,omitempty"`
}
type DenialSource string
const (
DenialSourceConfiguredPolicy DenialSource = "configured_policy"
DenialSourcePlatformSandbox DenialSource = "platform_sandbox"
DenialSourceApproval DenialSource = "approval"
)
type DenialNextAction string
const (
DenialNextActionNone DenialNextAction = "none"
DenialNextActionRequestApproval DenialNextAction = "request_approval"
DenialNextActionChangeCommand DenialNextAction = "change_command"
DenialNextActionEnableSandbox DenialNextAction = "enable_sandbox"
)
type Denial struct {
Capability Capability `json:"capability"`
Source DenialSource `json:"source"`
Reason string `json:"reason"`
Recoverable bool `json:"recoverable"`
NextAction DenialNextAction `json:"nextAction"`
}
type Enforcement struct {
Backend string `json:"backend,omitempty"`
Level string `json:"level,omitempty"`
Degraded bool `json:"degraded,omitempty"`
DowngradeReason string `json:"downgradeReason,omitempty"`
}
type Outcome struct {
State State `json:"state"`
Kind OutcomeKind `json:"kind"`
ProcessID string `json:"processId,omitempty"`
Exit *Exit `json:"exit,omitempty"`
Denial *Denial `json:"denial,omitempty"`
Enforcement Enforcement `json:"enforcement"`
Changes []Change `json:"changes,omitempty"`
}
// AdapterReport is the structured, machine-readable result emitted by a
// platform enforcement adapter. It is separate from stdout and stderr so
// command text cannot impersonate a policy decision.
type AdapterReport struct {
Denial *Denial `json:"denial,omitempty"`
}
func (outcome Outcome) Validate() error {
if outcome.State == "" || outcome.Kind == "" {
return errors.New("execution outcome requires state and kind")
}
switch outcome.State {
case StateRetained, StateRunning:
if outcome.Kind != OutcomeRunning || strings.TrimSpace(outcome.ProcessID) == "" {
return errors.New("running execution outcome requires a process id")
}
case StateCompleted:
if outcome.Kind != OutcomeSuccess || outcome.Exit == nil || outcome.Exit.Code != 0 {
return errors.New("completed execution outcome requires a successful exit")
}
case StateDenied:
if (outcome.Kind != OutcomePolicyDenied && outcome.Kind != OutcomeEnforcementDenied) || outcome.Denial == nil {
return errors.New("denied execution outcome requires structured denial details")
}
if err := outcome.Denial.Validate(); err != nil {
return err
}
case StateFailed:
if outcome.Kind == OutcomeRunning || outcome.Kind == OutcomeSuccess || outcome.Exit == nil {
return errors.New("failed execution outcome requires a failure kind and exit")
}
case StateCancelled:
if outcome.Kind != OutcomeCancelled {
return errors.New("cancelled execution outcome requires cancelled kind")
}
default:
return fmt.Errorf("execution outcome state %q is not terminal or externally observable", outcome.State)
}
return nil
}
func (denial Denial) Validate() error {
if !validCapabilityKind(denial.Capability.Kind) {
return errors.New("execution denial requires a valid capability")
}
if denial.Source == "" || strings.TrimSpace(denial.Reason) == "" || denial.NextAction == "" {
return errors.New("execution denial requires source, reason, and next action")
}
return nil
}
func validOrigin(origin Origin) bool {
switch origin {
case OriginInteractiveCommand, OriginHook, OriginPlugin, OriginMCPServer, OriginBackgroundTask:
return true
default:
return false
}
}
func validCapabilityKind(kind CapabilityKind) bool {
switch kind {
case CapabilityRead, CapabilityWorkspaceWrite, CapabilityProtectedMetadata,
CapabilityExternalNetwork, CapabilityIsolatedLoopback, CapabilityHostVisibleBinding,
CapabilityProcessSpawn, CapabilityProcessControl, CapabilityEnvironment, CapabilityUnrestricted:
return true
default:
return false
}
}
+78
View File
@@ -0,0 +1,78 @@
package execution
import "testing"
func TestRequestValidationRequiresConcreteExecutionContext(t *testing.T) {
request := Request{
Origin: OriginInteractiveCommand,
Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "true"}},
WorkingDirectory: "/workspace",
WorkspaceRoots: []string{"/workspace"},
Capabilities: []Capability{{
Kind: CapabilityWorkspaceWrite,
Scope: "/workspace",
}},
}
if err := request.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
request.Origin = ""
if err := request.Validate(); err == nil {
t.Fatal("request without origin unexpectedly validated")
}
}
func TestOutcomeValidationPinsTerminalStateContracts(t *testing.T) {
tests := []struct {
name string
outcome Outcome
valid bool
}{
{
name: "retained process",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning, ProcessID: "1000"},
valid: true,
},
{
name: "successful completion",
outcome: Outcome{State: StateCompleted, Kind: OutcomeSuccess, Exit: &Exit{Code: 0}},
valid: true,
},
{
name: "structured denial",
outcome: Outcome{
State: StateDenied,
Kind: OutcomePolicyDenied,
Denial: &Denial{
Capability: Capability{Kind: CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created",
NextAction: DenialNextActionRequestApproval,
},
},
valid: true,
},
{
name: "retained without process id",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning},
},
{
name: "denied without denial",
outcome: Outcome{State: StateDenied, Kind: OutcomePolicyDenied},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.outcome.Validate()
if test.valid && err != nil {
t.Fatalf("Validate: %v", err)
}
if !test.valid && err == nil {
t.Fatal("invalid outcome unexpectedly validated")
}
})
}
}
+645
View File
@@ -0,0 +1,645 @@
package execution
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"sort"
"sync"
"time"
)
const (
defaultCompletedRetention = 30 * time.Second
defaultMaxProcesses = 64
maxPendingOutputBytes = 2 * 1024 * 1024
recentOutputBytes = 4096
processStopTimeout = 3 * time.Second
maxInteractiveYield = 30 * time.Second
maxEmptyPollYield = 5 * time.Minute
)
var (
ErrProcessNotFound = errors.New("execution process not found")
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
)
type ProcessManagerOptions struct {
CompletedRetention time.Duration
MaxProcesses int
}
// ProcessManager owns retained interactive-process identity, transport,
// bounded output, continuation, cancellation, completion, and cleanup.
type ProcessManager struct {
mu sync.Mutex
nextID int
processes map[int]*managedProcess
completedRetention time.Duration
maxProcesses int
startTransport processTransportStarter
}
type ProcessStart struct {
Prepared PreparedCommand
Request Request
CommandText string
RelativeCwd string
TTY bool
Metadata map[string]string
// AfterWait may append adapter diagnostics collected by a platform monitor.
AfterWait func() []byte
}
type ProcessContinue struct {
ProcessID int
Input []byte
Interrupt bool
Wait time.Duration
}
type ProcessResult struct {
ProcessID int
CommandText string
RelativeCwd string
TTY bool
Output string
OutputTruncated bool
Exited bool
ExitCode int
Interrupted bool
Request Request
Enforcement Enforcement
Report AdapterReport
ReportErr error
Changes []Change
Metadata map[string]string
}
type ProcessSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
OutputTruncated bool
}
func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
retention := options.CompletedRetention
if retention == 0 {
retention = defaultCompletedRetention
}
maxProcesses := options.MaxProcesses
if maxProcesses == 0 {
maxProcesses = defaultMaxProcesses
}
return &ProcessManager{
nextID: 1000,
processes: make(map[int]*managedProcess),
completedRetention: retention,
maxProcesses: maxProcesses,
startTransport: startProcessTransport,
}
}
func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wait time.Duration) (ProcessResult, error) {
if manager == nil {
return ProcessResult{}, errors.New("execution process manager is not configured")
}
if err := input.Request.Validate(); err != nil {
return ProcessResult{}, fmt.Errorf("invalid execution request: %w", err)
}
if input.Prepared.Command == nil {
return ProcessResult{}, errors.New("prepared execution has no command")
}
command := input.Prepared.Command
buffer := newProcessOutputBuffer()
request := input.Request
observer := NewChangeObserver(request.WorkspaceRoots[0])
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
if err != nil {
if input.Prepared.Cleanup != nil {
input.Prepared.Cleanup()
}
return ProcessResult{}, err
}
if tty {
request.Mode = ModeInteractive
} else {
request.Mode = ModeCaptured
}
now := time.Now()
process := &managedProcess{
id: manager.allocateID(),
commandText: input.CommandText,
cwd: request.WorkingDirectory,
relativeCwd: input.RelativeCwd,
startedAt: now,
lastUsedAt: now,
tty: tty,
command: command,
request: request,
enforcement: input.Prepared.Enforcement,
report: input.Prepared.Report,
cleanup: input.Prepared.Cleanup,
stdin: stdin,
output: buffer,
reaped: make(chan struct{}),
done: make(chan struct{}),
kill: KillProcessTree,
metadata: cloneStringMap(input.Metadata),
}
manager.store(process)
manager.removeCompletedLater(process)
go func() {
waitErr := command.Wait()
close(process.reaped)
if input.AfterWait != nil {
if diagnostic := input.AfterWait(); len(diagnostic) > 0 {
_, _ = buffer.Write(diagnostic)
}
}
if transportCleanup != nil {
transportCleanup()
}
adapterReport, reportErr := AdapterReport{}, error(nil)
if process.report != nil {
adapterReport, reportErr = process.report()
}
changes := observer.Changes()
if process.cleanup != nil {
process.cleanup()
}
process.markDone(waitErr, commandExitCode(waitErr), adapterReport, reportErr, changes)
}()
result := process.collectResult(ctx, clampInitialProcessWait(wait), false)
if ctx != nil && ctx.Err() != nil && !result.Exited {
process.terminate()
more := process.collectResult(context.Background(), time.Second, true)
var mergeTruncated bool
result.Output, mergeTruncated = appendBoundedProcessOutputString(result.Output, more.Output)
result.OutputTruncated = result.OutputTruncated || more.OutputTruncated
result.OutputTruncated = result.OutputTruncated || mergeTruncated
result.Exited = more.Exited
result.ExitCode = more.ExitCode
result.Interrupted = result.Interrupted || more.Interrupted
result.Report = more.Report
result.ReportErr = more.ReportErr
result.Changes = more.Changes
}
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
process, ok := manager.get(input.ProcessID)
if !ok {
return ProcessResult{}, ErrProcessNotFound
}
process.touch()
if input.Interrupt {
process.terminate()
} else if len(input.Input) > 0 {
if !process.tty || process.stdin == nil {
return ProcessResult{}, ErrProcessStdinDisabled
}
if _, err := process.stdin.Write(input.Input); err != nil && !process.doneClosed() {
return ProcessResult{}, err
}
}
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func clampInitialProcessWait(wait time.Duration) time.Duration {
return min(wait, maxInteractiveYield)
}
func clampContinuationWait(wait time.Duration, emptyPoll bool) time.Duration {
if emptyPoll {
return min(wait, maxEmptyPollYield)
}
return min(wait, maxInteractiveYield)
}
func (manager *ProcessManager) List() []ProcessSnapshot {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
snapshots := make([]ProcessSnapshot, 0, len(processes))
for _, process := range processes {
snapshots = append(snapshots, process.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].ID < snapshots[j].ID })
return snapshots
}
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
process, ok := manager.get(id)
if !ok {
return ProcessSnapshot{}, false
}
return process.snapshot(), true
}
func (manager *ProcessManager) Stop(id int) bool {
process, ok := manager.get(id)
if !ok {
return false
}
process.terminate()
return true
}
func (manager *ProcessManager) StopAll() []int {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(processes))
for _, process := range processes {
process.terminate()
ids = append(ids, process.id)
}
waitForProcesses(processes, processStopTimeout)
sort.Ints(ids)
return ids
}
func (manager *ProcessManager) Remove(id int) {
manager.mu.Lock()
delete(manager.processes, id)
manager.mu.Unlock()
}
func (manager *ProcessManager) Len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.processes)
}
func (manager *ProcessManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
process, ok := manager.processes[id]
return process, ok
}
func (manager *ProcessManager) store(process *managedProcess) {
manager.mu.Lock()
var evicted *managedProcess
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
evicted = manager.processToPruneLocked()
if evicted != nil && evicted.doneClosed() {
delete(manager.processes, evicted.id)
evicted = nil
}
}
manager.processes[process.id] = process
manager.mu.Unlock()
if evicted != nil {
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
evicted.terminate()
}
}
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
var processes []*managedProcess
for _, process := range manager.processes {
processes = append(processes, process)
}
sort.Slice(processes, func(i, j int) bool { return processes[i].lastUsed().Before(processes[j].lastUsed()) })
for _, process := range processes {
if process.doneClosed() {
return process
}
}
if len(processes) > 8 {
return processes[0]
}
return nil
}
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
go func() {
<-process.done
if manager.completedRetention > 0 {
timer := time.NewTimer(manager.completedRetention)
<-timer.C
}
manager.Remove(process.id)
}()
}
type managedProcess struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
report func() (AdapterReport, error)
cleanup func()
stdin io.WriteCloser
output *processOutputBuffer
reaped chan struct{}
doneOnce sync.Once
done chan struct{}
kill func(int) error
mu sync.Mutex
exitCode *int
waitErr error
resultReport AdapterReport
reportErr error
changes []Change
metadata map[string]string
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
process.mu.Lock()
process.waitErr = err
process.exitCode = &exitCode
process.resultReport = report
process.reportErr = reportErr
process.changes = append([]Change(nil), changes...)
process.mu.Unlock()
process.doneOnce.Do(func() { close(process.done) })
}
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
output, truncated := process.collect(ctx, wait)
process.mu.Lock()
exitCode := 0
exited := process.exitCode != nil
if exited {
exitCode = *process.exitCode
}
result := ProcessResult{
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
}
func (process *managedProcess) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var output []byte
truncated := false
finish := func() (string, bool) {
truncated = truncated || process.output.consumeTruncated()
return string(output), truncated
}
for {
if chunk := process.output.drain(); len(chunk) > 0 {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, chunk)
truncated = truncated || chunkTruncated
truncated = truncated || process.output.consumeTruncated()
if time.Now().After(deadline) {
return finish()
}
continue
}
if process.doneClosed() {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, process.output.drain())
truncated = truncated || chunkTruncated
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-process.output.notify:
case <-process.done:
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
func appendBoundedProcessOutput(output []byte, chunk []byte) ([]byte, bool) {
if len(chunk) == 0 {
return output, false
}
if len(chunk) >= maxPendingOutputBytes {
bounded := make([]byte, maxPendingOutputBytes)
copy(bounded, chunk[len(chunk)-maxPendingOutputBytes:])
return bounded, len(output) > 0 || len(chunk) > maxPendingOutputBytes
}
if len(output)+len(chunk) <= maxPendingOutputBytes {
return append(output, chunk...), false
}
drop := len(output) + len(chunk) - maxPendingOutputBytes
copy(output, output[drop:])
output = output[:len(output)-drop]
return append(output, chunk...), true
}
func appendBoundedProcessOutputString(output string, chunk string) (string, bool) {
bounded, truncated := appendBoundedProcessOutput([]byte(output), []byte(chunk))
return string(bounded), truncated
}
func (process *managedProcess) doneClosed() bool {
select {
case <-process.done:
return true
default:
return false
}
}
func (process *managedProcess) touch() {
process.mu.Lock()
process.lastUsedAt = time.Now()
process.mu.Unlock()
}
func (process *managedProcess) lastUsed() time.Time {
process.mu.Lock()
defer process.mu.Unlock()
return process.lastUsedAt
}
func (process *managedProcess) terminate() {
if process.reapedClosed() || process.command.Process == nil {
return
}
kill := process.kill
if kill == nil {
kill = KillProcessTree
}
_ = kill(process.command.Process.Pid)
}
func (process *managedProcess) reapedClosed() bool {
if process.reaped == nil {
return false
}
select {
case <-process.reaped:
return true
default:
return false
}
}
func (process *managedProcess) snapshot() ProcessSnapshot {
process.mu.Lock()
defer process.mu.Unlock()
var exit *int
if process.exitCode != nil {
value := *process.exitCode
exit = &value
}
status := "running"
if exit != nil {
status = "exited"
}
return ProcessSnapshot{ID: process.id, Command: process.commandText, Cwd: process.cwd, RelativeCwd: process.relativeCwd,
StartedAt: process.startedAt, LastUsedAt: process.lastUsedAt, TTY: process.tty, Status: status,
ExitCode: exit, RecentOutput: process.output.recentString(), OutputTruncated: process.output.peekTruncated()}
}
func waitForProcesses(processes []*managedProcess, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for _, process := range processes {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-process.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type processOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newProcessOutputBuffer() *processOutputBuffer {
return &processOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *processOutputBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, data...)
if len(buffer.data) > maxPendingOutputBytes {
buffer.data = buffer.data[len(buffer.data)-maxPendingOutputBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, data...)
if len(buffer.recent) > recentOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(data), nil
}
func (buffer *processOutputBuffer) drain() []byte {
buffer.mu.Lock()
defer buffer.mu.Unlock()
data := append([]byte(nil), buffer.data...)
buffer.data = nil
return data
}
func (buffer *processOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *processOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
value := buffer.truncated
buffer.truncated = false
return value
}
func (buffer *processOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}
+275
View File
@@ -0,0 +1,275 @@
package execution
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func processManagerRequest(root string, command *exec.Cmd) Request {
return Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: command.Path, Args: command.Args[1:]},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
}
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
if started.Exited || started.ProcessID < 1000 {
t.Fatalf("initial result = %#v, want retained process", started)
}
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !continued.Exited || continued.ProcessID != started.ProcessID {
t.Fatalf("continued result = %#v, want same completed process", continued)
}
combined := started.Output + continued.Output
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
t.Fatalf("output was lost or duplicated: %q", combined)
}
if manager.Len() != 0 {
t.Fatalf("completed process still retained: %d", manager.Len())
}
}
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
}
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
if err != nil {
t.Fatalf("Continue interrupt: %v", err)
}
if !stopped.Exited || !stopped.Interrupted {
t.Fatalf("interrupt result = %#v", stopped)
}
}
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
reaped := make(chan struct{})
close(reaped)
called := false
process := &managedProcess{
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
reaped: reaped,
kill: func(int) error { called = true; return nil },
}
process.terminate()
if called {
t.Fatal("terminate signaled an already-reaped process")
}
}
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := manager.Start(ctx, ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !result.Exited || !result.Interrupted {
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
}
}
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
target := filepath.Join(root, "created.txt")
manager := NewProcessManager(ProcessManagerOptions{})
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
return nil, false, nil, err
}
return startProcessTransport(command, output, tty)
}
command := exec.Command("/bin/sh", "-c", "true")
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
}
}
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
}
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
t.Fatalf("initial short wait = %v, want caller value", got)
}
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
}
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
}
}
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newProcessOutputBuffer()
chunk := []byte(strings.Repeat("x", 1024))
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
if _, err := buffer.Write(chunk); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxPendingOutputBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
}
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
t.Fatal("drained output should retain the newest bytes")
}
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
t.Fatal("truncation state was not preserved and consumed exactly once")
}
}
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
stop := make(chan struct{})
var writers sync.WaitGroup
for i := 0; i < 8; i++ {
writers.Add(1)
go func() {
defer writers.Done()
chunk := []byte(strings.Repeat("x", 256))
for {
select {
case <-stop:
return
default:
_, _ = process.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() { close(stop); writers.Wait() })
wait := 200 * time.Millisecond
started := time.Now()
_, _ = process.collect(context.Background(), wait)
if elapsed := time.Since(started); elapsed > 5*wait {
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
}
}
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
go func() {
for i := 0; i < 5; i++ {
_, _ = process.output.Write(chunk)
for {
process.output.mu.Lock()
drained := len(process.output.data) == 0
process.output.mu.Unlock()
if drained {
break
}
runtime.Gosched()
}
}
close(process.done)
}()
output, truncated := process.collect(context.Background(), time.Second)
if len(output) > maxPendingOutputBytes {
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
}
if !truncated {
t.Fatal("cumulative output cap must report truncation")
}
}
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
for id := 1000; id < 1012; id++ {
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
}
target := manager.processes[1000]
stop := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
go func() {
defer writer.Done()
for {
select {
case <-stop:
return
default:
target.touch()
}
}
}()
for i := 0; i < 2000; i++ {
manager.mu.Lock()
_ = manager.processToPruneLocked()
manager.mu.Unlock()
}
close(stop)
writer.Wait()
}
+100
View File
@@ -0,0 +1,100 @@
//go:build !windows
package execution
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
)
// ConfigureProcessGroup makes cmd the leader of a process group so lifecycle
// operations cover descendants instead of orphaning them.
func ConfigureProcessGroup(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// KillProcessTree immediately kills pid and, when it is a group leader, its
// descendant process group.
func KillProcessTree(pid int) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
if poll <= 0 {
poll = 50 * time.Millisecond
}
if grace < 0 {
grace = 0
}
deadline := time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if !alive() {
return nil
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
deadline = time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
}
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
return target, nil
}
+37
View File
@@ -0,0 +1,37 @@
//go:build windows
package execution
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
)
func ConfigureProcessGroup(cmd *exec.Cmd) {}
func KillProcessTree(pid int) error {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func TerminateProcessTree(pid int, _, _ time.Duration) error { return KillProcessTree(pid) }
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package execution
import (
"errors"
"io"
"os/exec"
)
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty transport is unavailable on this platform")
}
@@ -1,6 +1,6 @@
//go:build linux
package tools
package execution
import (
"errors"
@@ -14,41 +14,25 @@ import (
"golang.org/x/sys/unix"
)
func startPTYProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, func(), error) {
func startPTYProcess(command *exec.Cmd, output io.Writer) (io.WriteCloser, func(), error) {
master, slave, err := openPTY()
if err != nil {
return nil, nil, err
}
cleanup := func() {
_ = master.Close()
_ = slave.Close()
}
command.Stdin = slave
command.Stdout = slave
command.Stderr = slave
hardenPTYProcessLifetime(command, slave)
cleanup := func() { _ = master.Close(); _ = slave.Close() }
command.Stdin, command.Stdout, command.Stderr = slave, slave, slave
hardenPTYProcess(command)
if err := command.Start(); err != nil {
cleanup()
return nil, nil, err
}
_ = slave.Close()
copied := make(chan struct{})
go func() {
defer close(copied)
_, _ = io.Copy(output, master)
}()
go func() { defer close(copied); _, _ = io.Copy(output, master) }()
return master, func() {
// cleanup runs in the Wait goroutine AFTER command.Wait() returns, so the
// child has exited and its slave fds are closed — the master then EOFs once
// io.Copy drains the remaining PTY output into the buffer. Wait for that copy
// to finish BEFORE closing the master and letting the caller mark the session
// done + remove it; otherwise a command's final output chunk (e.g. a test
// runner's last PASS/FAIL line) can be lost on exit. Closing the master first
// would truncate the unread tail, so the join precedes the Close. Bounded so a
// stuck copy can't hang teardown. (AUDIT-M14)
select {
case <-copied:
case <-time.After(bashWaitDelay):
case <-time.After(processWaitDelay):
}
_ = master.Close()
}, nil
@@ -75,18 +59,17 @@ func openPTY() (*os.File, *os.File, error) {
_ = master.Close()
return nil, nil, err
}
slave := os.NewFile(uintptr(slaveFD), slaveName)
return master, slave, nil
return master, os.NewFile(uintptr(slaveFD), slaveName), nil
}
func hardenPTYProcessLifetime(command *exec.Cmd, slave *os.File) {
func hardenPTYProcess(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.Setsid = true
command.SysProcAttr.Setctty = true
command.SysProcAttr.Ctty = 0
command.WaitDelay = bashWaitDelay
command.WaitDelay = processWaitDelay
command.Cancel = func() error {
if command.Process == nil {
return nil
+25
View File
@@ -0,0 +1,25 @@
//go:build linux
package execution
import (
"os/exec"
"syscall"
"testing"
)
func TestPTYFallbackRestoresOriginalProcessAttributes(t *testing.T) {
original := &syscall.SysProcAttr{Setpgid: true}
command := exec.Command("/bin/true")
command.SysProcAttr = original
saved := cloneSysProcAttr(command.SysProcAttr)
hardenPTYProcess(command)
resetCommandAfterPTYFallback(command, saved)
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
t.Fatalf("fallback attributes = %#v, want original Setpgid", command.SysProcAttr)
}
if command.SysProcAttr.Setsid || command.SysProcAttr.Setctty {
t.Fatalf("fallback retained PTY attributes: %#v", command.SysProcAttr)
}
}
+221
View File
@@ -0,0 +1,221 @@
package execution
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
)
const maxCapturedStreamBytes = 1 << 20
// Preparer is the platform-adapter seam. It turns a platform-neutral request
// into one enforceable OS command while keeping native sandbox mechanics out of
// every caller.
type Preparer interface {
PrepareExecution(context.Context, Request) (PreparedCommand, error)
}
type PreparedCommand struct {
Command *exec.Cmd
Enforcement Enforcement
Report func() (AdapterReport, error)
Cleanup func()
}
type CapturedRequest struct {
Request Request
Stdin []byte
}
type CapturedResult struct {
Stdout string
Stderr string
Truncated bool
Outcome Outcome
Err error
}
// Runner is the deep execution module used by Zero-owned subprocess launchers.
// The preparer may be installed after tool registration, but execution fails
// closed until an adapter is present.
type Runner struct {
mu sync.RWMutex
preparer Preparer
}
func NewRunner(preparer Preparer) *Runner {
return &Runner{preparer: preparer}
}
func (runner *Runner) SetPreparer(preparer Preparer) {
if runner == nil {
return
}
runner.mu.Lock()
runner.preparer = preparer
runner.mu.Unlock()
}
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
if ctx == nil {
ctx = context.Background()
}
if err := input.Request.Validate(); err != nil {
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
}
if input.Request.Mode != ModeCaptured {
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
return capturedSetupFailure(err.Error(), err, Enforcement{})
}
prepared, err := runner.Prepare(ctx, input.Request)
if err != nil {
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
}
if prepared.Cleanup != nil {
defer prepared.Cleanup()
}
if prepared.Command == nil {
err := errors.New("execution adapter returned no command")
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
}
if len(input.Stdin) > 0 {
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
}
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
prepared.Command.Stdout = stdout
prepared.Command.Stderr = stderr
runErr := prepared.Command.Run()
report, reportErr := AdapterReport{}, error(nil)
if prepared.Report != nil {
report, reportErr = prepared.Report()
}
result := CapturedResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Truncated: stdout.Truncated() || stderr.Truncated(),
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
},
}
exitCode := commandExitCode(runErr)
result.Outcome.Exit = &Exit{Code: exitCode}
switch {
case reportErr != nil:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeSandboxSetupFailure
result.Err = reportErr
case report.Denial != nil:
denial := *report.Denial
result.Outcome.State = StateDenied
result.Outcome.Kind = OutcomeEnforcementDenied
result.Outcome.Denial = &denial
case errors.Is(ctx.Err(), context.DeadlineExceeded):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeTimedOut
case errors.Is(ctx.Err(), context.Canceled):
result.Outcome.State = StateCancelled
result.Outcome.Kind = OutcomeCancelled
case runErr == nil:
result.Outcome.State = StateCompleted
result.Outcome.Kind = OutcomeSuccess
case executableNotFound(runErr):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeExecutableNotFound
default:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeApplicationFailure
}
return result
}
// Prepare evaluates and prepares a typed request for callers that require
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
// The returned cleanup remains mandatory; ordinary captured commands should use
// ExecuteCaptured so the Runner owns it automatically.
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
if runner == nil {
return PreparedCommand{}, errors.New("execution runner is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if err := request.Validate(); err != nil {
return PreparedCommand{}, err
}
runner.mu.RLock()
preparer := runner.preparer
runner.mu.RUnlock()
if preparer == nil {
return PreparedCommand{}, errors.New("execution adapter is not configured")
}
return preparer.PrepareExecution(ctx, request)
}
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
return CapturedResult{
Stderr: message,
Err: err,
Outcome: Outcome{
State: StateFailed,
Kind: OutcomeSandboxSetupFailure,
Exit: &Exit{Code: -1},
Enforcement: enforcement,
},
}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
func executableNotFound(err error) bool {
var execErr *exec.Error
return errors.As(err, &execErr)
}
type capturedBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
limit int
truncated bool
}
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
defer buffer.mu.Unlock()
remaining := buffer.limit - buffer.buffer.Len()
if remaining > 0 {
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
}
if len(data) > remaining {
buffer.truncated = true
}
return len(data), nil
}
func (buffer *capturedBuffer) String() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.buffer.String()
}
func (buffer *capturedBuffer) Truncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
var _ io.Writer = (*capturedBuffer)(nil)
+77
View File
@@ -0,0 +1,77 @@
package execution
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
type capturedTestPreparer struct {
request Request
}
func (preparer *capturedTestPreparer) PrepareExecution(_ context.Context, request Request) (PreparedCommand, error) {
preparer.request = request
command := exec.Command(os.Args[0], "-test.run=^TestCapturedRunnerHelperProcess$")
command.Env = append(os.Environ(), "ZERO_CAPTURED_RUNNER_HELPER=1")
return PreparedCommand{
Command: command,
Enforcement: Enforcement{
Backend: "test-adapter",
Level: "native",
},
}, nil
}
func TestCapturedRunnerHelperProcess(t *testing.T) {
if os.Getenv("ZERO_CAPTURED_RUNNER_HELPER") != "1" {
return
}
fmt.Fprint(os.Stdout, "stdout")
fmt.Fprint(os.Stderr, "stderr")
os.Exit(7)
}
func TestRunnerExecutesCapturedRequestThroughAdapter(t *testing.T) {
preparer := &capturedTestPreparer{}
runner := NewRunner(preparer)
request := Request{
Origin: OriginHook,
Mode: ModeCaptured,
Command: Command{Name: "ignored-by-test-adapter"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: request})
if preparer.request.Origin != OriginHook {
t.Fatalf("adapter origin = %q, want hook", preparer.request.Origin)
}
if result.Stdout != "stdout" || result.Stderr != "stderr" {
t.Fatalf("captured output = stdout %q stderr %q", result.Stdout, result.Stderr)
}
if result.Outcome.Kind != OutcomeApplicationFailure || result.Outcome.Exit == nil || result.Outcome.Exit.Code != 7 {
t.Fatalf("outcome = %#v, want application failure exit 7", result.Outcome)
}
if result.Outcome.Enforcement.Backend != "test-adapter" {
t.Fatalf("enforcement = %#v", result.Outcome.Enforcement)
}
}
func TestRunnerWithoutAdapterFailsClosed(t *testing.T) {
runner := NewRunner(nil)
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
Origin: OriginPlugin,
Mode: ModeCaptured,
Command: Command{Name: "true"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
}})
if result.Outcome.Kind != OutcomeSandboxSetupFailure || !strings.Contains(result.Stderr, "adapter") {
t.Fatalf("result = %#v, want fail-closed missing-adapter result", result)
}
}
+51
View File
@@ -0,0 +1,51 @@
package execution
import (
"io"
"os/exec"
"syscall"
"time"
)
const processWaitDelay = 2 * time.Second
type processTransportStarter func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error)
func startProcessTransport(command *exec.Cmd, output io.Writer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
if ttyRequested {
original := cloneSysProcAttr(command.SysProcAttr)
if stdin, cleanup, err := startPTYProcess(command, output); err == nil {
return stdin, true, cleanup, nil
}
resetCommandAfterPTYFallback(command, original)
}
stdin, err := command.StdinPipe()
if err != nil {
return nil, false, nil, err
}
command.Stdout = output
command.Stderr = output
ConfigureProcessGroup(command)
command.WaitDelay = processWaitDelay
if err := command.Start(); err != nil {
return nil, false, nil, err
}
return stdin, false, func() {}, nil
}
func cloneSysProcAttr(attributes *syscall.SysProcAttr) *syscall.SysProcAttr {
if attributes == nil {
return nil
}
cloned := *attributes
return &cloned
}
func resetCommandAfterPTYFallback(command *exec.Cmd, original *syscall.SysProcAttr) {
command.Stdin = nil
command.Stdout = nil
command.Stderr = nil
command.SysProcAttr = original
command.Cancel = nil
command.WaitDelay = 0
}
+51 -8
View File
@@ -9,6 +9,8 @@ import (
"os/exec"
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
@@ -51,13 +53,14 @@ type commandRunner func(ctx context.Context, command string, args []string, stdi
// DispatcherOptions configures a Dispatcher.
type DispatcherOptions struct {
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
now func() time.Time
run commandRunner
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
Execution *execution.Runner // routes hook subprocesses through sandbox policy
now func() time.Time
run commandRunner
}
// Dispatcher selects and runs the hooks configured for a lifecycle event,
@@ -87,7 +90,11 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
run := options.run
if run == nil {
run = execCommandRunner
if options.Execution != nil {
run = executionCommandRunner(options.Execution)
} else {
run = execCommandRunner
}
}
return &Dispatcher{
config: options.Config,
@@ -100,6 +107,42 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
}
func executionCommandRunner(runner *execution.Runner) commandRunner {
return func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
result := runner.ExecuteCaptured(ctx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command, Args: append([]string(nil), args...), Env: append([]string(nil), env...)},
WorkingDirectory: cwd,
WorkspaceRoots: []string{cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
stderr := result.Stderr
if stderr == "" && result.Outcome.Denial != nil {
stderr = result.Outcome.Denial.Reason
}
commandErr := error(nil)
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound:
commandErr = result.Err
}
return commandResult{
ExitCode: exitCode,
Stdout: result.Stdout,
Stderr: stderr,
Err: commandErr,
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
}
}
}
// blocksOn reports whether a non-zero exit for this event should veto the action.
// Only beforeTool gates the tool; other events are observational.
func blocksOn(event Event) bool {
+31
View File
@@ -2,13 +2,25 @@ package hooks
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type hookExecutionPreparer struct {
request execution.Request
}
func (preparer *hookExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
func beforeToolConfig(hooks ...Definition) Config {
return Config{Enabled: true, Hooks: hooks}
}
@@ -186,6 +198,25 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
}
}
func TestDispatcherRoutesHookThroughTypedExecutionOrigin(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses /bin/sh")
}
preparer := &hookExecutionPreparer{}
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "typed", Event: EventBeforeTool, Command: "/bin/sh", Args: []string{"-c", "cat"}, Enabled: true}),
Cwd: t.TempDir(),
Execution: execution.NewRunner(preparer),
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", Payload: map[string]any{"ok": true}})
if outcome.Blocked || outcome.Ran != 1 {
t.Fatalf("dispatch outcome = %#v", outcome)
}
if preparer.request.Origin != execution.OriginHook || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
if result.Err == nil {
+56 -10
View File
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type RemoteTool struct {
@@ -46,6 +48,7 @@ type Client struct {
mu sync.Mutex
closeMu sync.Mutex
nextID int
cleanup func()
// dispatchMu guards the response-dispatch state shared with the single
// reader goroutine. It is never held across a blocking read.
@@ -72,9 +75,18 @@ const (
)
func Connect(ctx context.Context, server Server) (ToolClient, error) {
return ConnectWithOptions(ctx, server, ConnectOptions{})
}
type ConnectOptions struct {
Execution *execution.Runner
WorkspaceRoot string
}
func ConnectWithOptions(ctx context.Context, server Server, options ConnectOptions) (ToolClient, error) {
switch server.Type {
case ServerTypeStdio:
return connectStdio(ctx, server)
return connectStdio(ctx, server, options)
case ServerTypeHTTP:
return connectNetwork(ctx, server)
case ServerTypeSSE:
@@ -120,9 +132,37 @@ func (b *boundedBuffer) String() string {
return b.buf.String()
}
func connectStdio(ctx context.Context, server Server) (*Client, error) {
cmd := exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
var cmd *exec.Cmd
var cleanup func()
cleanupTransferred := false
defer func() {
if cleanup != nil && !cleanupTransferred {
cleanup()
}
}()
if options.Execution != nil {
workspaceRoot := strings.TrimSpace(options.WorkspaceRoot)
if workspaceRoot == "" {
return nil, fmt.Errorf("start MCP server %s: execution workspace root is required", server.Name)
}
prepared, err := options.Execution.Prepare(ctx, execution.Request{
Origin: execution.OriginMCPServer,
Mode: execution.ModeDurable,
Command: execution.Command{Name: server.Command, Args: append([]string(nil), server.Args...), Env: mergeProcessEnv(server.Env)},
WorkingDirectory: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
})
if err != nil {
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
cmd = prepared.Command
cleanup = prepared.Cleanup
} else {
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("open MCP stdin for %s: %w", server.Name, err)
@@ -138,13 +178,15 @@ func connectStdio(ctx context.Context, server Server) (*Client, error) {
}
client := &Client{
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
cleanup: cleanup,
}
cleanupTransferred = true
if err := client.initialize(ctx); err != nil {
_ = client.Close()
message := strings.TrimSpace(stderr.String())
@@ -242,6 +284,10 @@ func (client *Client) Close() error {
}
}
}
if client.cleanup != nil {
client.cleanup()
client.cleanup = nil
}
return err
}
+42
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
@@ -16,8 +17,21 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
)
type mcpExecutionPreparer struct {
request execution.Request
}
func (preparer *mcpExecutionPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
command := exec.CommandContext(ctx, request.Command.Name, request.Command.Args...)
command.Dir = request.WorkingDirectory
command.Env = request.Command.Env
return execution.PreparedCommand{Command: command}, nil
}
func TestStdioClientListsAndCallsTools(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -65,6 +79,34 @@ func TestStdioClientListsAndCallsTools(t *testing.T) {
}
}
func TestStdioClientUsesTypedMCPExecutionOrigin(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
preparer := &mcpExecutionPreparer{}
workspace := t.TempDir()
client, err := ConnectWithOptions(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
}, ConnectOptions{Execution: execution.NewRunner(preparer), WorkspaceRoot: workspace})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
if preparer.request.Origin != execution.OriginMCPServer || preparer.request.Mode != execution.ModeDurable {
t.Fatalf("execution request = %#v", preparer.request)
}
if preparer.request.WorkingDirectory != workspace {
t.Fatalf("working directory = %q, want %q", preparer.request.WorkingDirectory, workspace)
}
}
func TestStdioClientCloseAllowsConcurrentCallers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -26,6 +27,8 @@ type RegisterOptions struct {
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
// defaultConnectTimeout.
ConnectTimeout time.Duration
Execution *execution.Runner
WorkspaceRoot string
}
// SkippedServer records an MCP server that was not registered because it could
@@ -80,7 +83,7 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
factory := options.ClientFactory
if factory == nil {
factory = func(ctx context.Context, server Server) (ToolClient, error) {
return Connect(ctx, server)
return ConnectWithOptions(ctx, server, ConnectOptions{Execution: options.Execution, WorkspaceRoot: options.WorkspaceRoot})
}
}
+42 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/secrets"
@@ -113,6 +114,10 @@ type ActivateOptions struct {
// Env supplies the base environment for plugin tool commands. nil uses the
// current process environment (os.Environ()).
Env []string
// Execution routes plugin tool subprocesses through the shared sandbox and
// process execution seam. Production callers provide it before any tool can
// be invoked; tests may continue to inject runTool directly.
Execution *execution.Runner
runTool toolRunner
}
@@ -141,8 +146,14 @@ func Activate(registry *tools.Registry, loaded []LoadedPlugin, options ActivateO
if timeout <= 0 {
timeout = defaultPluginToolTimeout
}
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
if options.Execution != nil {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommandWithExecution(ctx, options.Execution, command, timeout)
}
} else {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
}
}
}
@@ -689,3 +700,32 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time.
output.Err = err
return output
}
func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := runner.ExecuteCaptured(runCtx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginPlugin,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command.Command, Args: append([]string(nil), command.Args...), Env: append([]string(nil), command.Env...)},
WorkingDirectory: command.Cwd,
WorkspaceRoots: []string{command.Cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: command.Stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
}
if output.Stderr == "" && result.Outcome.Denial != nil {
output.Stderr = result.Outcome.Denial.Reason
}
return output
}
+27
View File
@@ -3,15 +3,27 @@ package plugins
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
)
type pluginExecutionPreparer struct {
request execution.Request
}
func (preparer *pluginExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
// fakeToolRunner records the invocation and returns a canned result so tool
// activation can be exercised without spawning a real process.
type fakeToolRunner struct {
@@ -554,6 +566,21 @@ func TestActivateSkipsDisabledPlugin(t *testing.T) {
}
}
func TestPluginCommandUsesTypedPluginExecutionOrigin(t *testing.T) {
if testing.Short() {
t.Skip("spawns a subprocess")
}
preparer := &pluginExecutionPreparer{}
command := pluginCommand{Command: os.Args[0], Args: []string{"-test.run=^$"}, Cwd: t.TempDir()}
output := execPluginCommandWithExecution(context.Background(), execution.NewRunner(preparer), command, time.Second)
if output.Err != nil || output.ExitCode != 0 {
t.Fatalf("plugin execution output = %#v", output)
}
if preparer.request.Origin != execution.OriginPlugin || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestActivateIsDeterministic(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
+30 -80
View File
@@ -65,76 +65,6 @@ func AnalyzeCommand(script string) AnalysisResult {
return result
}
// astCommandFields parses command with the shell parser and returns each simple
// command as its literal field slice (program + args as text), resolving the
// real command positions across quoting, command substitution, subshells, and
// newline separators — the constructs the hand-written splitter in
// safe_command.go mis-handles (issue #473). It returns nil when the command
// cannot be parsed (e.g. a Windows cmd.exe string), so callers fall through to
// the regex path rather than hard-blocking.
func astCommandFields(command string) [][]string {
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil {
return nil
}
var commands [][]string
syntax.Walk(file, func(node syntax.Node) bool {
call, ok := node.(*syntax.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
// EVERY word must be a static literal, not just the program name.
// wordText keeps only the literal/quoted parts of a word and silently
// drops expansions, so a dynamic word anywhere in the call reconstructs
// to something the shell will never run: `$(printf foo)vim` (runs as
// `foovim`) collapses to "vim", and `git $(printf foo)rebase -i` (runs
// as `git foorebase -i`, non-interactive) collapses to `git rebase -i`
// and fabricates an interactive match. Since the runtime value of an
// expansion is unknowable here, skip the whole call rather than classify
// a lossy reconstruction. Skipping is the safe direction: the
// hand-written passes above already ran, and a missed detection falls
// through to the normal permission prompt instead of hard-blocking a
// command the user never wrote.
for _, word := range call.Args {
if !isLiteralWord(word) {
return true
}
}
fields := make([]string, 0, len(call.Args))
for _, word := range call.Args {
fields = append(fields, wordText(word))
}
commands = append(commands, fields)
return true
})
return commands
}
// isLiteralWord reports whether every part of word is a static literal (bare or
// quoted). A word containing a command substitution, parameter/arithmetic
// expansion, process substitution, etc. is dynamic — its runtime value is
// unknown, so its wordText (a partial literal) must not be trusted as a program
// name.
func isLiteralWord(word *syntax.Word) bool {
if word == nil {
return false
}
for _, part := range word.Parts {
switch typed := part.(type) {
case *syntax.Lit, *syntax.SglQuoted:
case *syntax.DblQuoted:
for _, inner := range typed.Parts {
if _, ok := inner.(*syntax.Lit); !ok {
return false
}
}
default:
return false
}
}
return true
}
// analyzeInto parses script and folds its interactive/destructive/network usage
// into result, sharing seen so program names are de-duplicated across recursion.
func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, depth int) {
@@ -220,7 +150,7 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
case "go":
return firstSubcommand(words, nil) == "get"
case "git":
return firstSubcommand(words, nil) == "clone"
return gitUsesNetwork(words)
case "gh":
return ghUsesNetwork(words)
default:
@@ -229,9 +159,15 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
}
func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
if packageManagerOffline(words) {
return false
}
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "publish", "login":
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
return true
case "start", "serve", "dev", "preview":
return true
@@ -239,19 +175,33 @@ func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
second := secondSubcommand(words)
return second == "start" || second == "serve" || second == "dev" || second == "preview"
case "exec":
for _, word := range words {
if word == "" || strings.HasPrefix(word, "-") || isNumericToken(word) {
continue
}
if word == "exec" || word == "x" || word == "dlx" {
continue
}
return localServerPrograms[word]
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
}
return false
}
func packageManagerOffline(words []string) bool {
for _, word := range words {
switch word {
case "--offline", "--no-network":
return true
}
}
return false
}
func gitUsesNetwork(words []string) bool {
switch firstSubcommand(words, nil) {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
return true
default:
return false
}
}
func npxUsesNetwork(_ []string) bool {
return true
}
+8
View File
@@ -49,6 +49,12 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
{name: "npm create", script: "npm create vite@latest .", network: true},
{name: "npm registry query", script: "npm view typescript version --fetch-retries=0", network: true},
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
@@ -56,6 +62,8 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
{name: "gh release download", script: "gh release download v1.0.0", network: true},
{name: "no network", script: "ls -la && echo done", network: false},
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
+7
View File
@@ -80,6 +80,13 @@ func (engine *Engine) CanPersistGrants() bool {
return engine != nil && engine.store != nil
}
func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) {
if engine == nil || engine.store == nil {
return "", nil
}
return engine.store.ConsumeMigrationNotice()
}
func (engine *Engine) GrantCommandPrefixForSession(toolName string, prefix []string) {
if engine == nil || len(prefix) == 0 {
return
@@ -0,0 +1,36 @@
package sandbox
import (
"context"
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/execution"
)
func TestEnginePreparesTypedCapturedExecution(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
workspace := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
})
runner := execution.NewRunner(engine)
result := runner.ExecuteCaptured(context.Background(), execution.CapturedRequest{Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: "/bin/sh", Args: []string{"-c", "printf adapted"}},
WorkingDirectory: workspace,
WorkspaceRoots: []string{workspace},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}})
if result.Outcome.Kind != execution.OutcomeSuccess || result.Stdout != "adapted" {
t.Fatalf("captured result = %#v", result)
}
if result.Outcome.Enforcement.Level != string(EnforcementDegraded) {
t.Fatalf("enforcement = %#v, want degraded", result.Outcome.Enforcement)
}
}
+210 -12
View File
@@ -12,10 +12,11 @@ import (
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/redaction"
)
const grantSchemaVersion = 2
const grantSchemaVersion = 3
type Grant struct {
ToolName string `json:"toolName"`
@@ -51,8 +52,18 @@ type GrantLookup struct {
type grantFile struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants map[string][]Grant `json:"grants"`
CommandPrefixes map[string][]CommandPrefixGrant `json:"commandPrefixes,omitempty"`
Migration *grantMigration `json:"migration,omitempty"`
}
type grantMigration struct {
FromSchemaVersion int `json:"fromSchemaVersion"`
BackupPath string `json:"backupPath"`
Migrated int `json:"migrated"`
Invalidated int `json:"invalidated"`
NoticePending bool `json:"noticePending"`
}
type GrantStore struct {
@@ -118,6 +129,31 @@ func (store *GrantStore) FilePath() string {
return store.filePath
}
// ConsumeMigrationNotice returns the legacy-grant migration summary once and
// atomically records it as seen. Frontends call this during startup so users are
// told when approvals were invalidated instead of encountering unexplained
// re-prompts. The original file remains available at the reported backup path.
func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
if store == nil {
return "", nil
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return "", err
}
if state.Migration == nil || !state.Migration.NoticePending {
return "", nil
}
migration := *state.Migration
state.Migration.NoticePending = false
if err := store.writeState(state); err != nil {
return "", err
}
return fmt.Sprintf("sandbox grants updated for policy v%d: migrated %d, invalidated %d; backup: %s", execution.PolicyVersion, migration.Migrated, migration.Invalidated, migration.BackupPath), nil
}
func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
grant, err := createGrant(input, store.now)
if err != nil {
@@ -518,25 +554,26 @@ func (store *GrantStore) readState() (grantFile, error) {
// current (v2) map[tool][]Grant shape.
var head struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants json.RawMessage `json:"grants"`
CommandPrefixes json.RawMessage `json:"commandPrefixes"`
Migration *grantMigration `json:"migration"`
}
if err := json.Unmarshal(data, &head); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
}
if head.SchemaVersion != grantSchemaVersion {
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
}
if head.PolicyVersion != execution.PolicyVersion {
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
}
buckets := map[string][]Grant{}
commandPrefixBuckets := map[string][]CommandPrefixGrant{}
switch head.SchemaVersion {
case 1:
legacy := map[string]Grant{}
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
case grantSchemaVersion:
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &buckets); err != nil {
@@ -581,7 +618,167 @@ func (store *GrantStore) readState() (grantFile, error) {
normalizedPrefixes[key] = append(normalizedPrefixes[key], ng)
}
}
return grantFile{SchemaVersion: grantSchemaVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes}, nil
return grantFile{SchemaVersion: grantSchemaVersion, PolicyVersion: execution.PolicyVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes, Migration: head.Migration}, nil
}
func (store *GrantStore) migrateLegacyState(data []byte, schemaVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
if schemaVersion == 1 {
legacy := map[string]Grant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
} else if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
prefixes := map[string][]CommandPrefixGrant{}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeLegacyBackup(data, schemaVersion)
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || legacyShellGrant(normalized) {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for name, bucket := range prefixes {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredCommandPrefixGrant(key, grant)
if err != nil {
invalidated++
continue
}
state.CommandPrefixes[key] = append(state.CommandPrefixes[key], normalized)
migrated++
}
}
state.Migration = &grantMigration{FromSchemaVersion: schemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func (store *GrantStore) migrateChangedPolicy(data []byte, policyVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
prefixes := map[string][]CommandPrefixGrant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeBackup(data, fmt.Sprintf("policy-v%d", policyVersion))
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || normalized.Decision != GrantDeny {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for _, bucket := range prefixes {
invalidated += len(bucket)
}
state.Migration = &grantMigration{FromSchemaVersion: grantSchemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func legacyShellGrant(grant Grant) bool {
if grant.Decision == GrantDeny {
return false
}
switch grant.ToolName {
case "bash", "exec_command", "write_stdin":
return true
default:
return false
}
}
func (store *GrantStore) writeLegacyBackup(data []byte, schemaVersion int) (string, error) {
return store.writeBackup(data, fmt.Sprintf("v%d", schemaVersion))
}
func (store *GrantStore) writeBackup(data []byte, label string) (string, error) {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return "", err
}
base := fmt.Sprintf("%s.%s.backup", store.filePath, label)
for attempt := 0; ; attempt++ {
path := base
if attempt > 0 {
path = fmt.Sprintf("%s.%d", base, attempt)
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}
}
func (store *GrantStore) invalidGrantFile(err error) error {
@@ -657,6 +854,7 @@ func normalizeStoredCommandPrefixGrant(name string, grant CommandPrefixGrant) (C
func emptyGrantState() grantFile {
return grantFile{
SchemaVersion: grantSchemaVersion,
PolicyVersion: execution.PolicyVersion,
Grants: map[string][]Grant{},
CommandPrefixes: map[string][]CommandPrefixGrant{},
}
+102 -11
View File
@@ -70,10 +70,11 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
}
}
func TestGrantStoreReadsV1GrantFile(t *testing.T) {
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":1,"grants":{"bash":{"toolName":"bash","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`); err != nil {
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`
if err := writeText(path, original); err != nil {
t.Fatalf("write v1 grants: %v", err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path, Now: fixedSandboxTime("2026-06-05T15:00:00Z")})
@@ -85,19 +86,80 @@ func TestGrantStoreReadsV1GrantFile(t *testing.T) {
if err != nil {
t.Fatalf("List v1 grants returned error: %v", err)
}
if len(grants) != 1 || grants[0].ToolName != "bash" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
if len(grants) != 1 || grants[0].ToolName != "write_file" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
t.Fatalf("unexpected v1 grants: %#v", grants)
}
if _, err := store.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow}); err != nil {
t.Fatalf("Grant after v1 read returned error: %v", err)
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 0") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
if again, err := store.ConsumeMigrationNotice(); err != nil || again != "" {
t.Fatalf("second migration notice = %q err=%v, want empty", again, err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 2`) || !strings.Contains(string(raw), `"bash": [`) {
t.Fatalf("grant file was not rewritten as v2 buckets:\n%s", raw)
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
if err != nil || string(backup) != original {
t.Fatalf("migration backup = %q err=%v, want original", backup, err)
}
}
func TestGrantStoreInvalidatesLegacyShellAllowButKeepsSafePrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"exec_command":[{"toolName":"exec_command","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 0 {
t.Fatalf("legacy shell allows = %#v err=%v, want invalidated", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("migrated prefixes = %#v err=%v", prefixes, err)
}
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":0,"grants":{"write_file":[{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}],"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("migrated policy grants = %#v err=%v, want deny only", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("changed-policy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".policy-v0.backup")
if err != nil || string(backup) != original {
t.Fatalf("policy backup = %q err=%v", backup, err)
}
}
@@ -207,7 +269,7 @@ func TestGrantStoreRejectsUnsafeInputsAndMalformedFiles(t *testing.T) {
}
}
func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
func TestGrantStoreInvalidatesUnsafeLegacyCommandPrefix(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["find"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`); err != nil {
@@ -217,8 +279,37 @@ func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.ListCommandPrefixes(); err == nil || !strings.Contains(err.Error(), "invalid command prefix") {
t.Fatalf("expected invalid command prefix error, got %v", err)
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("unsafe legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 1") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
}
func TestGrantStoreInvalidatesMalformedLegacyToolKeys(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"../escape":[{"toolName":"../escape","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bad name":[{"toolName":"bad name","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
if grants, err := store.List(); err != nil || len(grants) != 0 {
t.Fatalf("malformed legacy grants = %#v err=%v, want invalidated", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("malformed legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v, want original", backup, err)
}
}
+51 -9
View File
@@ -26,6 +26,7 @@ type LinuxSandboxCommandArgsOptions struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -37,6 +38,7 @@ type LinuxSandboxHelperConfig struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -51,6 +53,16 @@ type LinuxSandboxBwrapOptions struct {
HelperPath string
}
type linuxSandboxBwrapPlan struct {
Args []string
ProtectedCreateTargets []string
}
type linuxBwrapFilesystemPlan struct {
Args []string
ProtectedCreateTargets []string
}
var linuxSandboxHelperCommand = findLinuxSandboxHelperCommand
func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]string, error) {
@@ -86,6 +98,9 @@ func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]str
if options.NoProc {
args = append(args, "--no-proc")
}
if strings.TrimSpace(options.PolicyReportPath) != "" {
args = append(args, "--policy-report-path", options.PolicyReportPath)
}
args = append(args, "--")
args = append(args, options.Command...)
return args, nil
@@ -103,6 +118,7 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
flags.BoolVar(&config.ApplySeccompThenExec, "apply-seccomp-then-exec", false, "apply seccomp before exec")
flags.BoolVar(&config.BlockUnixSockets, "block-unix-sockets", false, "block AF_UNIX sockets before exec")
flags.BoolVar(&config.NoProc, "no-proc", false, "skip proc mount")
flags.StringVar(&config.PolicyReportPath, "policy-report-path", "", "structured policy report path")
if err := flags.Parse(args); err != nil {
return LinuxSandboxHelperConfig{}, err
}
@@ -122,6 +138,7 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
return LinuxSandboxHelperConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
}
config.Command = flags.Args()
config.PolicyReportPath = strings.TrimSpace(config.PolicyReportPath)
if len(config.Command) == 0 {
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
}
@@ -129,16 +146,24 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
}
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
plan, err := buildLinuxSandboxBwrapPlan(options)
if err != nil {
return nil, err
}
return plan.Args, nil
}
func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxBwrapPlan, error) {
config := options.Config
if config.ApplySeccompThenExec {
return nil, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
return linuxSandboxBwrapPlan{}, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
}
if config.UseLandlock {
return nil, errors.New("linux landlock helper mode is not implemented yet")
return linuxSandboxBwrapPlan{}, errors.New("linux landlock helper mode is not implemented yet")
}
helperPath := strings.TrimSpace(options.HelperPath)
if helperPath == "" {
return nil, errors.New("linux sandbox helper path is required")
return linuxSandboxBwrapPlan{}, errors.New("linux sandbox helper path is required")
}
commandCWD := strings.TrimSpace(config.CommandCWD)
if commandCWD == "" {
@@ -154,13 +179,14 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
Command: config.Command,
})
if err != nil {
return nil, err
return linuxSandboxBwrapPlan{}, err
}
args := []string{
"--new-session",
"--die-with-parent",
}
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile)
args = append(args, filesystemPlan.Args...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
@@ -185,10 +211,17 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
}
args = append(args, "--", helperPath)
args = append(args, innerArgs...)
return args, nil
return linuxSandboxBwrapPlan{
Args: args,
ProtectedCreateTargets: filesystemPlan.ProtectedCreateTargets,
}, nil
}
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return buildLinuxBwrapFilesystemPlan(profile).Args
}
func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
// Disabled filesystem policy means no write jail: expose the host root
@@ -200,10 +233,11 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
args = append(args, "--bind", root.Root, root.Root)
}
}
return args
return linuxBwrapFilesystemPlan{Args: args}
}
args := []string{}
protectedCreateTargets := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
@@ -231,7 +265,12 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
args = appendReadOnlyLinuxPathArgs(args, subpath)
}
for _, name := range root.ProtectedMetadataNames {
args = appendReadOnlyLinuxPathArgs(args, filepath.Join(root.Root, name))
path := filepath.Join(root.Root, name)
if pathExists(path) {
args = appendReadOnlyLinuxPathArgs(args, path)
} else {
protectedCreateTargets = append(protectedCreateTargets, path)
}
}
}
for _, path := range fs.DenyWrite {
@@ -240,7 +279,10 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
}
return args
return linuxBwrapFilesystemPlan{
Args: args,
ProtectedCreateTargets: dedupeStrings(protectedCreateTargets),
}
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
+14 -6
View File
@@ -11,8 +11,8 @@ import (
)
var (
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxIsolatedNetworkGuardFilter = ApplyLinuxIsolatedNetworkGuard
)
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
@@ -32,7 +32,7 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": resolve helper path: "+err.Error())
return 125
}
bwrapArgs, err := BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{
bwrapPlan, err := buildLinuxSandboxBwrapPlan(LinuxSandboxBwrapOptions{
Config: config,
HelperPath: helperPath,
})
@@ -45,7 +45,10 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": bubblewrap is not available: "+err.Error())
return 125
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapArgs...), os.Environ()); err != nil {
if len(bwrapPlan.ProtectedCreateTargets) > 0 {
return runLinuxSandboxWithProtectedCreateMonitor(bwrapPath, bwrapPlan, config.PolicyReportPath, stderr)
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapPlan.Args...), os.Environ()); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec bubblewrap: "+err.Error())
return 126
}
@@ -69,9 +72,14 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
return 2
}
// The outer bubblewrap stage already enforces NetworkDeny with a private
// network namespace. Do not install the broad seccomp network filter here:
// it would also block AF_INET/AF_INET6 sockets inside that namespace and
// break isolated localhost test servers. The namespace has loopback only,
// so local bind/connect works while external egress remains unreachable.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
if err := applyLinuxNetworkDenyFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
if err := applyLinuxIsolatedNetworkGuardFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply isolated network guard: "+err.Error())
return 125
}
}
+15 -15
View File
@@ -8,18 +8,18 @@ import (
"testing"
)
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
func TestLinuxSandboxInnerStageReliesOnOuterNamespaceForNetworkDeny(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
originalUnixBlock := applyUnixSocketBlockFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
applyUnixSocketBlockFilter = originalUnixBlock
})
networkDenyCalls := 0
networkGuardCalls := 0
unixBlockCalls := 0
applyLinuxNetworkDenyFilter = func() error {
networkDenyCalls++
applyLinuxIsolatedNetworkGuardFilter = func() error {
networkGuardCalls++
return nil
}
applyUnixSocketBlockFilter = func() error {
@@ -35,24 +35,24 @@ func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 after Unix-socket filter; stderr=%s", code, stderr.String())
}
if networkDenyCalls != 1 {
t.Fatalf("network deny filter calls = %d, want 1", networkDenyCalls)
if networkGuardCalls != 1 {
t.Fatalf("isolated network guard calls = %d, want 1", networkGuardCalls)
}
if unixBlockCalls != 1 {
t.Fatalf("unix socket filter calls = %d, want 1", unixBlockCalls)
}
}
func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
func TestLinuxSandboxInnerStageSkipsIsolatedNetworkGuardWhenNetworkAllowed(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
})
applyLinuxNetworkDenyFilter = func() error {
return errors.New("network deny filter should not run")
applyLinuxIsolatedNetworkGuardFilter = func() error {
return errors.New("isolated network guard should not run")
}
var stderr bytes.Buffer
@@ -62,6 +62,6 @@ func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T)
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 without isolated network guard; stderr=%s", code, stderr.String())
}
}
+29
View File
@@ -236,6 +236,35 @@ func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
}
}
func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T) {
workspace := t.TempDir()
existing := filepath.Join(workspace, ".git")
if err := os.Mkdir(existing, 0o755); err != nil {
t.Fatalf("Mkdir existing metadata: %v", err)
}
missing := filepath.Join(workspace, ".zero")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{
Root: workspace,
ProtectedMetadataNames: []string{".git", ".zero"},
}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
assertArgsContainSequence(t, plan.Args, "--ro-bind", existing, existing)
if argsContainSequence(plan.Args, "--tmpfs", missing) || argsContainSequence(plan.Args, "--ro-bind", missing, missing) {
t.Fatalf("missing protected metadata must remain absent inside the sandbox: %#v", plan.Args)
}
if !reflect.DeepEqual(plan.ProtectedCreateTargets, []string{missing}) {
t.Fatalf("protected create targets = %#v, want %#v", plan.ProtectedCreateTargets, []string{missing})
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
+7
View File
@@ -228,6 +228,9 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
if request.ValidateExecution && preference != SandboxPreferenceForbid && backend.SupportLevel() != BackendSupportNative && policyHasExplicitDeny(policy) {
return SandboxExecutionRequest{}, errors.New("native sandbox unavailable: configured deny_read or deny_write rules cannot be enforced")
}
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
@@ -283,6 +286,10 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
}, nil
}
func policyHasExplicitDeny(policy Policy) bool {
return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0
}
func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) {
execRequest, err := manager.BuildExecutionRequest(request)
if err != nil {
@@ -0,0 +1,87 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
// The implementation mechanism differs by OS, but these observable policy
// semantics must remain stable across adapters.
func TestPlatformAdaptersShareExecutionContract(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
profile := PermissionProfileFromPolicy(root, policy, nil)
if !profile.RequiresPlatformSandbox() {
t.Fatal("baseline profile must require platform enforcement")
}
protected := strings.Join(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, "\x00")
for _, name := range []string{".zero", ".agents"} {
if !strings.Contains(protected, name) {
t.Fatalf("baseline profile missing protected metadata %q: %#v", name, profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
}
}
resolvedRoot := profile.FileSystem.WriteRoots[0].Root
for _, subpath := range []string{filepath.Join(resolvedRoot, ".git", "hooks"), filepath.Join(resolvedRoot, ".git", "config")} {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, subpath) {
t.Fatalf("baseline profile missing git metadata carveout %q: %#v", subpath, profile.FileSystem.WriteRoots[0].ReadOnlySubpaths)
}
}
t.Run("macOS native plan", func(t *testing.T) {
backend := Backend{Name: BackendMacOSSeatbelt, Available: true, Platform: "darwin", CommandWrapping: true, NativeIsolation: true, Executable: "/usr/bin/sandbox-exec"}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendMacOSSeatbelt || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("macOS request = %#v", request)
}
network := networkRuleForProfile(request.PermissionProfile.Network)
if network != "(deny network*)" {
t.Fatalf("macOS restricted network profile = %q, want strict deny", network)
}
})
t.Run("Windows native plan", func(t *testing.T) {
original := windowsSandboxInitialized
windowsSandboxInitialized = func() bool { return true }
t.Cleanup(func() { windowsSandboxInitialized = original })
backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Platform: "windows", CommandWrapping: true, NativeIsolation: true, Executable: `C:\zero\zero.exe`}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "cmd.exe", Args: WindowsShellArgs("echo ok"), Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendWindowsRestrictedToken || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("Windows request = %#v", request)
}
if request.PermissionProfile.Network.Mode != NetworkDeny {
t.Fatalf("Windows native plan lost network restriction: %#v", request.PermissionProfile.Network)
}
})
}
func TestUnavailablePlatformAdaptersFailClosedOnlyForExplicitDenies(t *testing.T) {
root := t.TempDir()
for _, goos := range []string{"darwin", "windows"} {
t.Run(goos, func(t *testing.T) {
backend := unavailableBackend(goos, "native sandbox unavailable")
manager := NewSandboxManager(SandboxManagerOptions{GOOS: goos, Backend: backend})
baseline := DefaultPolicy()
request, err := manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: baseline, Command: CommandSpec{Name: "echo", Dir: root}})
if err != nil || request.EnforcementLevel != EnforcementDegraded || request.CommandWrapped {
t.Fatalf("baseline degraded request=%#v err=%v", request, err)
}
hardened := baseline
hardened.DenyRead = []string{"secrets"}
_, err = manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: hardened, Command: CommandSpec{Name: "echo", Dir: root}, ValidateExecution: true})
if err == nil {
t.Fatal("explicit deny on unavailable adapter must fail closed")
}
})
}
}
+1
View File
@@ -18,6 +18,7 @@ const (
type PermissionProfile struct {
FileSystem FileSystemPolicy `json:"fileSystem"`
Network NetworkPolicy `json:"network"`
Runtime *SandboxRuntime `json:"runtime,omitempty"`
}
type FileSystemPolicy struct {
+283
View File
@@ -0,0 +1,283 @@
//go:build linux
package sandbox
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/unix"
)
const protectedCreatePollInterval = 10 * time.Millisecond
// An absent protected path cannot be mounted read-only without making it
// appear inside the child. The monitor preserves workspace path fidelity and
// treats the kernel's create/move event as the denial signal. The path can
// briefly appear on the host before the command is killed and it is removed;
// parsing the event ensures a fast create+unlink is still reported.
type protectedCreateMonitor struct {
targets []string
stderr io.Writer
inotifyFD int
watches map[int]string
targetSet map[string]struct{}
stop chan struct{}
done chan struct{}
violation atomic.Bool
once sync.Once
path string
}
type synchronizedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (writer *synchronizedWriter) Write(data []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.writer.Write(data)
}
func runLinuxSandboxWithProtectedCreateMonitor(bwrapPath string, plan linuxSandboxBwrapPlan, reportPath string, stderr io.Writer) int {
if stderr == nil {
stderr = io.Discard
}
safeStderr := &synchronizedWriter{writer: stderr}
monitor, err := newProtectedCreateMonitor(plan.ProtectedCreateTargets, safeStderr)
if err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": prepare protected metadata monitor: "+err.Error())
return 125
}
command := exec.Command(bwrapPath, plan.Args...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = safeStderr
command.Env = os.Environ()
if err := command.Start(); err != nil {
monitor.close()
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": start bubblewrap: "+err.Error())
return 126
}
monitor.start(func() {
_ = command.Process.Kill()
})
waitErr := command.Wait()
violated, target := monitor.stopAndCleanup()
if violated {
report := execution.AdapterReport{Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: target},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created by a sandboxed command",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
}}
if err := writeLinuxExecutionReport(reportPath, report); err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": write structured policy report: "+err.Error())
}
return 1
}
if waitErr == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
return exitErr.ExitCode()
}
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": wait for bubblewrap: "+waitErr.Error())
return 126
}
func newProtectedCreateMonitor(targets []string, stderr io.Writer) (*protectedCreateMonitor, error) {
monitor := &protectedCreateMonitor{
targets: dedupeStrings(targets),
stderr: stderr,
inotifyFD: -1,
stop: make(chan struct{}),
done: make(chan struct{}),
watches: make(map[int]string),
targetSet: make(map[string]struct{}),
}
for _, target := range monitor.targets {
target = filepath.Clean(target)
monitor.targetSet[target] = struct{}{}
if _, err := os.Lstat(target); err == nil {
return nil, fmt.Errorf("protected metadata path appeared before sandbox start: %s", target)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("inspect protected metadata path %s: %w", target, err)
}
}
monitor.inotifyFD, monitor.watches = openProtectedCreateInotify(monitor.targets)
return monitor, nil
}
func openProtectedCreateInotify(targets []string) (int, map[int]string) {
fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
return -1, nil
}
parents := make(map[string]struct{}, len(targets))
watches := make(map[int]string, len(targets))
for _, target := range targets {
parent := filepath.Dir(target)
if _, exists := parents[parent]; exists {
continue
}
parents[parent] = struct{}{}
watch, err := unix.InotifyAddWatch(fd, parent, unix.IN_CREATE|unix.IN_MOVED_TO|unix.IN_DELETE_SELF|unix.IN_MOVE_SELF)
if err != nil {
continue
}
watches[watch] = parent
}
if len(watches) == 0 {
_ = unix.Close(fd)
return -1, nil
}
return fd, watches
}
func (monitor *protectedCreateMonitor) start(onViolation func()) {
go func() {
defer close(monitor.done)
for {
monitor.scanAndRemove(onViolation)
monitor.wait(onViolation)
select {
case <-monitor.stop:
return
default:
}
}
}()
}
func (monitor *protectedCreateMonitor) wait(onViolation func()) {
if monitor.inotifyFD < 0 {
select {
case <-monitor.stop:
case <-time.After(protectedCreatePollInterval):
}
return
}
pollFD := []unix.PollFd{{Fd: int32(monitor.inotifyFD), Events: unix.POLLIN}}
_, _ = unix.Poll(pollFD, int(protectedCreatePollInterval/time.Millisecond))
if pollFD[0].Revents&unix.POLLIN != 0 {
monitor.drainInotifyEvents(onViolation)
}
}
func (monitor *protectedCreateMonitor) drainInotifyEvents(onViolation func()) {
var buffer [4096]byte
const eventHeaderSize = 16
for {
read, err := unix.Read(monitor.inotifyFD, buffer[:])
if err != nil {
return
}
for offset := 0; offset+eventHeaderSize <= read; {
watch := int(int32(binary.NativeEndian.Uint32(buffer[offset : offset+4])))
mask := binary.NativeEndian.Uint32(buffer[offset+4 : offset+8])
nameLength := int(binary.NativeEndian.Uint32(buffer[offset+12 : offset+16]))
next := offset + eventHeaderSize + nameLength
if nameLength < 0 || next > read {
break
}
name := strings.TrimRight(string(buffer[offset+eventHeaderSize:next]), "\x00")
if parent, ok := monitor.watches[watch]; ok && mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 && name != "" {
target := filepath.Clean(filepath.Join(parent, name))
if _, protected := monitor.targetSet[target]; protected {
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
offset = next
}
}
}
func (monitor *protectedCreateMonitor) scanAndRemove(onViolation func()) {
for _, target := range monitor.targets {
if _, err := os.Lstat(target); os.IsNotExist(err) {
continue
} else if err != nil {
continue
}
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
func (monitor *protectedCreateMonitor) recordViolation(target string, onViolation func()) {
monitor.once.Do(func() {
monitor.violation.Store(true)
monitor.path = target
fmt.Fprintln(monitor.stderr, "sandbox blocked creation of protected workspace metadata path "+target)
onViolation()
})
}
func removeProtectedCreateTarget(target string) bool {
for attempt := 0; attempt < 100; attempt++ {
_, err := os.Lstat(target)
if os.IsNotExist(err) {
return attempt > 0
}
if err != nil {
return false
}
err = os.RemoveAll(target)
if err == nil || os.IsNotExist(err) {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
func (monitor *protectedCreateMonitor) stopAndCleanup() (bool, string) {
close(monitor.stop)
<-monitor.done
monitor.scanAndRemove(func() {})
monitor.close()
return monitor.violation.Load(), monitor.path
}
func (monitor *protectedCreateMonitor) close() {
if monitor.inotifyFD >= 0 {
_ = unix.Close(monitor.inotifyFD)
monitor.inotifyFD = -1
}
}
func writeLinuxExecutionReport(path string, report execution.AdapterReport) error {
if path == "" {
return errors.New("policy report path is unavailable")
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
encoderErr := json.NewEncoder(file).Encode(report)
closeErr := file.Close()
if encoderErr != nil {
_ = os.Remove(path)
return encoderErr
}
return closeErr
}
@@ -0,0 +1,67 @@
//go:build linux
package sandbox
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestProtectedCreateMonitorStopsCommandAndRemovesTarget(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
started := time.Now()
code := runLinuxSandboxWithProtectedCreateMonitor("/bin/sh", linuxSandboxBwrapPlan{
Args: []string{"-c", "mkdir " + shellQuote(target) + "; while :; do :; done"},
ProtectedCreateTargets: []string{target},
}, "", &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want policy failure 1; stderr=%s", code, stderr.String())
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("protected creation did not stop command promptly; elapsed=%s", elapsed)
}
if !strings.Contains(stderr.String(), "blocked creation of protected workspace metadata path") {
t.Fatalf("missing denial reason: %s", stderr.String())
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("protected target remained after denial: %v", err)
}
}
func TestProtectedCreateMonitorRecordsTransientCreateEvent(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
monitor, err := newProtectedCreateMonitor([]string{target}, &stderr)
if err != nil {
t.Fatalf("newProtectedCreateMonitor: %v", err)
}
if monitor.inotifyFD < 0 {
monitor.close()
t.Skip("inotify is unavailable")
}
if err := os.Mkdir(target, 0o700); err != nil {
monitor.close()
t.Fatal(err)
}
if err := os.Remove(target); err != nil {
monitor.close()
t.Fatal(err)
}
triggered := make(chan struct{}, 1)
monitor.start(func() { triggered <- struct{}{} })
violated, gotTarget := monitor.stopAndCleanup()
if !violated || gotTarget != target {
t.Fatalf("transient creation = violated %t target %q, want true %q; stderr=%s", violated, gotTarget, target, stderr.String())
}
select {
case <-triggered:
default:
t.Fatal("transient create event did not invoke violation callback")
}
}
+32
View File
@@ -311,6 +311,38 @@ func (engine *Engine) GrantRequestPermissions(profile RequestPermissionProfile,
}
}
// CoversRequestPermissions reports whether the engine's current effective
// policy already includes every capability in profile. Callers use this before
// prompting for an explicit per-command permission request: asking for a
// capability that was granted for the session is not a new elevation.
func (engine *Engine) CoversRequestPermissions(profile RequestPermissionProfile) bool {
if engine == nil {
return false
}
policy := engine.effectivePolicy(engine.policy)
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled &&
NormalizeNetworkMode(policy.Network) != NetworkAllow {
return false
}
if profile.FileSystem == nil {
return true
}
workspaceRoot := engine.workspaceRoot
scope := engine.scopeFor(workspaceRoot)
enforceWorkspace := policy.EnforceWorkspace && workspaceRoot != ""
for _, path := range profile.FileSystem.Read {
if validatePathWithPolicy(scope, policy, SideEffectRead, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
for _, path := range profile.FileSystem.Write {
if validatePathWithPolicy(scope, policy, SideEffectWrite, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
return true
}
func permissionRoot(path string) string {
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil && info.IsDir() {
@@ -140,6 +140,40 @@ func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
}
}
func TestCoversRequestPermissionsReusesSessionNetworkCapability(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
if engine.CoversRequestPermissions(profile) {
t.Fatal("default network-deny policy must not cover external network access")
}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeSession)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
cleanup()
if !engine.CoversRequestPermissions(profile) {
t.Fatal("session network grant should cover an equivalent later request")
}
}
func TestCoversRequestPermissionsDoesNotOutliveTurnGrant(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeTurn)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
if !engine.CoversRequestPermissions(profile) {
t.Fatal("active turn grant should cover the requested network capability")
}
cleanup()
if engine.CoversRequestPermissions(profile) {
t.Fatal("cleaned-up turn grant must not cover later requests")
}
}
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
+109 -36
View File
@@ -2,6 +2,9 @@ package sandbox
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
@@ -11,6 +14,7 @@ import (
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -62,6 +66,10 @@ type CommandPlan struct {
// cleanup releases resources tied to the plan's lifetime. It is never
// serialized; callers invoke it via Cleanup() once the command has finished.
cleanup func()
// executionReportPath is an adapter-owned side channel outside the
// workspace. It carries structured policy facts; command output is never
// parsed as the control protocol.
executionReportPath string
}
// Cleanup releases any resources the plan holds. It is safe to call on a zero
@@ -72,6 +80,26 @@ func (plan CommandPlan) Cleanup() {
}
}
// ExecutionReport reads the structured platform-adapter report for a finished
// command. A command with no adapter report returns an empty report.
func (plan CommandPlan) ExecutionReport() (execution.AdapterReport, error) {
if strings.TrimSpace(plan.executionReportPath) == "" {
return execution.AdapterReport{}, nil
}
data, err := os.ReadFile(plan.executionReportPath)
if os.IsNotExist(err) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, fmt.Errorf("read sandbox execution report: %w", err)
}
var report execution.AdapterReport
if err := json.Unmarshal(data, &report); err != nil {
return execution.AdapterReport{}, fmt.Errorf("decode sandbox execution report: %w", err)
}
return report, nil
}
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
if ctx == nil {
ctx = context.Background()
@@ -86,6 +114,35 @@ func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*ex
return command, plan, nil
}
// PrepareExecution adapts the platform-neutral execution interface to the
// sandbox engine. Callers never need to construct native sandbox commands or
// interpret adapter reports themselves.
func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
if err := request.Validate(); err != nil {
return execution.PreparedCommand{}, err
}
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: request.Command.Name,
Args: append([]string(nil), request.Command.Args...),
Dir: request.WorkingDirectory,
Env: append([]string(nil), request.Command.Env...),
})
if err != nil {
return execution.PreparedCommand{}, err
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
},
Report: plan.ExecutionReport,
Cleanup: plan.Cleanup,
}, nil
}
// writeRoots returns the full ordered write-root list for command plans:
// the workspace root plus any granted extra roots. The single-root fallback
// only applies to engines built without a workspace root (NewEngine always
@@ -144,11 +201,20 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
preference = SandboxPreferenceForbid
}
profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope)
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
runtimeCleanup = cleanup
profile = permissionProfileWithRuntime(profile, runtimeState)
}
manager := NewSandboxManager(SandboxManagerOptions{
GOOS: backend.Platform,
Backend: backend,
})
return manager.BuildCommandPlan(SandboxManagerRequest{
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: workspaceRoot,
Command: spec,
Policy: policy,
@@ -157,6 +223,14 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
Preference: preference,
ValidateExecution: true,
})
if err != nil {
if runtimeCleanup != nil {
runtimeCleanup()
}
return CommandPlan{}, err
}
plan.cleanup = combineSandboxCleanups(plan.cleanup, runtimeCleanup)
return plan, nil
}
func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
@@ -201,38 +275,56 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
helper = resolved
}
command := append([]string{spec.Name}, spec.Args...)
reportPath, err := newLinuxExecutionReportPath()
if err != nil {
return CommandPlan{}, err
}
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: execRequest.WorkspaceRoot,
CommandCWD: spec.Dir,
PermissionProfile: execRequest.PermissionProfile,
BlockUnixSockets: policy.BlockUnixSockets,
PolicyReportPath: reportPath,
Command: command,
})
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, execRequest.PermissionProfile.Runtime)
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
}
plan := CommandPlan{
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
executionReportPath: reportPath,
cleanup: func() {
_ = os.Remove(reportPath)
},
}
return withSandboxExecutionMetadata(plan, execRequest), nil
}
func newLinuxExecutionReportPath() (string, error) {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return "", fmt.Errorf("generate sandbox execution report path: %w", err)
}
return filepath.Join("/tmp", "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil
}
func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequest) CommandPlan {
plan.Backend = request.Backend
plan.TargetBackend = request.TargetBackend
@@ -340,6 +432,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, profile.Runtime)
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -382,17 +475,6 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
}
}
func existingBubblewrapMounts() []string {
candidates := []string{"/bin", "/usr", "/lib", "/lib64", "/sbin", "/etc"}
mounts := []string{}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
mounts = append(mounts, candidate)
}
}
return mounts
}
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
@@ -789,13 +871,6 @@ func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string {
return out
}
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
// for a single file. Empty when DenyWrite is unset.
func denyWriteRules(policy Policy) []string {
return denyWriteRulesFromPaths(resolvePolicyPaths(policy.DenyWrite))
}
func denyWriteRulesFromPaths(paths []string) []string {
return denySeatbeltPathRules("file-write*", paths)
}
@@ -823,16 +898,14 @@ func denySeatbeltPathRules(action string, paths []string) []string {
return out
}
// networkRuleFor returns the seatbelt network clause for a policy.
func networkRuleFor(policy Policy) string {
return networkRuleForProfile(NetworkPolicy{Mode: policy.Network})
}
func networkRuleForProfile(network NetworkPolicy) string {
switch network.Mode {
case NetworkAllow:
return "(allow network*)"
default:
// Seatbelt has no private network namespace. Its localhost filters can
// reach services on the host and host interfaces, so the restricted
// profile must deny the entire network surface.
return "(deny network*)"
}
}
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
@@ -44,6 +46,11 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
Name: "/bin/sh",
Args: []string{"-c", strings.Join([]string{
"set -eu",
"test \"$HOME\" != \"$PWD\"",
"test ! -e .zero && test ! -e .agents",
"echo cache > \"$npm_config_cache/zero-runtime-probe\"",
"test ! -e .npm && test ! -e .cache",
"rm -f \"$npm_config_cache/zero-runtime-probe\"",
"echo ok > write-ok.txt",
"test \"$(cat write-ok.txt)\" = ok",
"echo tmp > /tmp/zero-sandbox-smoke",
@@ -167,6 +174,95 @@ func TestLinuxLandlockRealSandboxSmoke(t *testing.T) {
}
}
func TestLinuxHelperAllowsIsolatedLoopbackWithoutExternalEgress(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
python, err := exec.LookPath("python3")
if err != nil || python == "" {
t.Skip("python3 not found; skipping isolated loopback probe")
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
loopbackScript := strings.Join([]string{
"import socket",
"server = socket.socket()",
"server.bind(('127.0.0.1', 0))",
"server.listen()",
"client = socket.socket()",
"client.connect(('127.0.0.1', server.getsockname()[1]))",
"accepted, _ = server.accept()",
"client.send(b'ok')",
"assert accepted.recv(2) == b'ok'",
}, "; ")
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", loopbackScript},
Dir: root,
})
if runErr != nil {
t.Fatalf("isolated loopback failed: %v\n%s", runErr, output)
}
output, runErr = runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 1).close()"},
Dir: root,
})
if runErr == nil {
t.Fatalf("sandbox allowed external egress while isolated loopback was enabled: %s", output)
}
}
func TestLinuxHelperPreservesAbsentProtectedMetadata(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "set -eu; test ! -e .zero; mkdir .zero"},
Dir: root,
})
if err != nil {
t.Fatalf("CommandContext: %v", err)
}
defer plan.Cleanup()
output, runErr := command.CombinedOutput()
if runErr == nil {
t.Fatalf("sandbox reported success after protected metadata creation; output=%s", output)
}
if !strings.Contains(string(output), "blocked creation of protected workspace metadata path") {
t.Fatalf("sandbox did not explain protected metadata denial: %v\n%s", runErr, output)
}
if _, err := os.Lstat(filepath.Join(root, ".zero")); !os.IsNotExist(err) {
t.Fatalf("protected metadata path remained after execution: %v", err)
}
report, err := plan.ExecutionReport()
if err != nil {
t.Fatalf("ExecutionReport: %v", err)
}
if report.Denial == nil || report.Denial.Capability.Kind != execution.CapabilityProtectedMetadata {
t.Fatalf("execution report denial = %#v, want protected metadata", report.Denial)
}
if report.Denial.Capability.Scope != filepath.Join(root, ".zero") {
t.Fatalf("denial scope = %q, want exact protected path", report.Denial.Capability.Scope)
}
}
func runLinuxSandboxSmokeCommand(t *testing.T, engine *Engine, spec CommandSpec) ([]byte, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+5
View File
@@ -399,6 +399,11 @@ func TestSeatbeltProfileConsumesPermissionProfile(t *testing.T) {
t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl)
}
}
for _, forbidden := range []string{"network-bind", "network-inbound", `remote ip "localhost:*"`} {
if strings.Contains(sbpl, forbidden) {
t.Fatalf("restricted Seatbelt profile must not contain host-local rule %q:\n%s", forbidden, sbpl)
}
}
if strings.Contains(sbpl, "(allow file-read*)\n(allow file-write*)") {
t.Fatalf("restricted permission profile must not become full read/write:\n%s", sbpl)
}
+40
View File
@@ -0,0 +1,40 @@
package sandbox
import (
"fmt"
"sync"
)
const sandboxRuntimeLeaseSuffix = ".lease"
type sandboxRuntimeLease struct {
handle runtimeLeaseHandle
once sync.Once
}
func sandboxRuntimeLeasePath(root string) string {
return root + sandboxRuntimeLeaseSuffix
}
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil {
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
}
return &sandboxRuntimeLease{handle: handle}, nil
}
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil || inUse {
return nil, inUse, err
}
return &sandboxRuntimeLease{handle: handle}, false, nil
}
func (lease *sandboxRuntimeLease) release() {
if lease == nil {
return
}
lease.once.Do(lease.handle.release)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !windows
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/unix"
)
type runtimeLeaseHandle struct {
file *os.File
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return runtimeLeaseHandle{file: file}, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return runtimeLeaseHandle{file: file}, false, nil
}
func (lease runtimeLeaseHandle) release() {
if lease.file == nil {
return
}
_ = unix.Flock(int(lease.file.Fd()), unix.LOCK_UN)
_ = lease.file.Close()
}
+53
View File
@@ -0,0 +1,53 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/windows"
)
type runtimeLeaseHandle struct {
file *os.File
overlapped windows.Overlapped
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
handle := runtimeLeaseHandle{file: file}
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return handle, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
handle := runtimeLeaseHandle{file: file}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return handle, false, nil
}
func (lease runtimeLeaseHandle) release() {
if lease.file == nil {
return
}
_ = windows.UnlockFileEx(windows.Handle(lease.file.Fd()), 0, 1, 0, &lease.overlapped)
_ = lease.file.Close()
}
+242
View File
@@ -0,0 +1,242 @@
package sandbox
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
var sandboxUserCacheDir = os.UserCacheDir
var sandboxRuntimeNow = time.Now
const (
sandboxRuntimeMaxAge = 30 * 24 * time.Hour
sandboxRuntimeMaxRoots = 64
)
var fallbackSandboxRuntimes = struct {
sync.Mutex
roots map[string]string
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Home string `json:"home,omitempty"`
Cache string `json:"cache,omitempty"`
Config string `json:"config,omitempty"`
Data string `json:"data,omitempty"`
State string `json:"state,omitempty"`
Temp string `json:"temp,omitempty"`
}
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
if workspaceRoot == "" || workspaceRoot == "." {
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
}
cacheRoot, err := sandboxUserCacheDir()
if err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
}
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
if cacheRoot == "" || cacheRoot == "." {
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
}
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
lease, err = prepareSandboxRuntimeLease(root)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
prepared := false
defer func() {
if !prepared {
lease.release()
}
}()
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
}
directories := []string{
runtimeState.Root,
runtimeState.Home,
runtimeState.Cache,
runtimeState.Config,
runtimeState.Data,
runtimeState.State,
runtimeState.Temp,
filepath.Join(runtimeState.Cache, "npm"),
filepath.Join(runtimeState.Cache, "yarn"),
filepath.Join(runtimeState.Cache, "corepack"),
filepath.Join(runtimeState.Cache, "pip"),
filepath.Join(runtimeState.Cache, "go-build"),
filepath.Join(runtimeState.Data, "go-mod"),
filepath.Join(runtimeState.Data, "cargo"),
}
for _, directory := range directories {
if err := os.MkdirAll(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
}
}
now := sandboxRuntimeNow()
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
}
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
prepared = true
return runtimeState, lease.release, nil
}
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
}
return acquireSandboxRuntimeLease(root)
}
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
// is best-effort and never removes the runtime selected for the current
// command, so cache maintenance cannot turn a valid execution into a failure.
func cleanupSandboxRuntimeRoots(parent, current string, now time.Time) {
entries, err := os.ReadDir(parent)
if err != nil {
return
}
type candidate struct {
path string
modTime time.Time
}
var candidates []candidate
for _, entry := range entries {
path := filepath.Join(parent, entry.Name())
if !entry.IsDir() || filepath.Clean(path) == filepath.Clean(current) {
continue
}
info, infoErr := entry.Info()
if infoErr != nil {
continue
}
if now.Sub(info.ModTime()) > sandboxRuntimeMaxAge {
removeSandboxRuntimeRootIfUnused(path)
continue
}
candidates = append(candidates, candidate{path: path, modTime: info.ModTime()})
}
if len(candidates) < sandboxRuntimeMaxRoots {
return
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].modTime.Before(candidates[j].modTime) })
for len(candidates) >= sandboxRuntimeMaxRoots {
removeSandboxRuntimeRootIfUnused(candidates[0].path)
candidates = candidates[1:]
}
}
func removeSandboxRuntimeRootIfUnused(root string) {
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil || inUse {
return
}
defer lease.release()
_ = os.RemoveAll(root)
}
func combineSandboxCleanups(cleanups ...func()) func() {
var once sync.Once
return func() {
once.Do(func() {
for _, cleanup := range cleanups {
if cleanup != nil {
cleanup()
}
}
})
}
}
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
fallbackSandboxRuntimes.Lock()
defer fallbackSandboxRuntimes.Unlock()
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
return root, nil
}
parent, err := os.MkdirTemp("", "zero-runtime-")
if err != nil {
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
}
root := filepath.Join(parent, "runtime")
if pathWithinRoot(workspaceRoot, root) {
_ = os.RemoveAll(parent)
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
}
fallbackSandboxRuntimes.roots[workspaceRoot] = root
return root, nil
}
func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []string {
if runtimeState == nil || strings.TrimSpace(runtimeState.Root) == "" {
return env
}
overrides := []string{
"HOME=" + runtimeState.Home,
"XDG_CACHE_HOME=" + runtimeState.Cache,
"XDG_CONFIG_HOME=" + runtimeState.Config,
"XDG_DATA_HOME=" + runtimeState.Data,
"XDG_STATE_HOME=" + runtimeState.State,
"TMPDIR=" + runtimeState.Temp,
"TMP=" + runtimeState.Temp,
"TEMP=" + runtimeState.Temp,
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
"GOCACHE=" + filepath.Join(runtimeState.Cache, "go-build"),
"GOMODCACHE=" + filepath.Join(runtimeState.Data, "go-mod"),
"CARGO_HOME=" + filepath.Join(runtimeState.Data, "cargo"),
}
return upsertEnvList(env, overrides...)
}
func permissionProfileWithRuntime(profile PermissionProfile, runtimeState SandboxRuntime) PermissionProfile {
profile.Runtime = &runtimeState
if profile.FileSystem.Kind != FileSystemRestricted || runtimeState.Root == "" {
return profile
}
for _, root := range profile.FileSystem.WriteRoots {
if filepath.Clean(root.Root) == filepath.Clean(runtimeState.Root) {
return profile
}
}
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
return profile
}
+210
View File
@@ -0,0 +1,210 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
}
}
}
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
originalCache := sandboxUserCacheDir
originalNow := sandboxRuntimeNow
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
sandboxRuntimeNow = func() time.Time { return now }
t.Cleanup(func() {
sandboxUserCacheDir = originalCache
sandboxRuntimeNow = originalNow
})
parent := filepath.Join(cacheRoot, "zero", "runtime", "v1")
expired := filepath.Join(parent, "expired")
if err := os.MkdirAll(expired, 0o700); err != nil {
t.Fatal(err)
}
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
if err := os.Chtimes(expired, old, old); err != nil {
t.Fatal(err)
}
_, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
release()
if _, err := os.Stat(expired); !os.IsNotExist(err) {
t.Fatalf("expired runtime still exists: %v", err)
}
}
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
if filepath.Clean(filepath.Dir(runtimeState.Root)) == filepath.Clean(os.TempDir()) {
t.Fatalf("fallback runtime %q must use a private cleanup parent", runtimeState.Root)
}
}
func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
originalCache := sandboxUserCacheDir
originalNow := sandboxRuntimeNow
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
sandboxRuntimeNow = func() time.Time { return now }
t.Cleanup(func() {
sandboxUserCacheDir = originalCache
sandboxRuntimeNow = originalNow
})
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
if err := os.Chtimes(runtimeState.Root, old, old); err != nil {
release()
t.Fatal(err)
}
parent := filepath.Dir(runtimeState.Root)
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
if _, err := os.Stat(runtimeState.Root); err != nil {
release()
t.Fatalf("active runtime was removed: %v", err)
}
release()
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
if _, err := os.Stat(runtimeState.Root); !os.IsNotExist(err) {
t.Fatalf("released expired runtime still exists: %v", err)
}
}
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"HOME=/workspace",
"XDG_CACHE_HOME=/host/cache",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"HOME": runtimeState.Home,
"XDG_CACHE_HOME": runtimeState.Cache,
"XDG_CONFIG_HOME": runtimeState.Config,
"XDG_DATA_HOME": runtimeState.Data,
"XDG_STATE_HOME": runtimeState.State,
"TMPDIR": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
}
}
if got := envListValue(env, "PATH", ""); got != "/usr/bin" {
t.Fatalf("PATH = %q, want preserved caller path", got)
}
}
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{
Name: BackendLinuxBwrap,
Available: true,
Platform: "linux",
Executable: "/usr/bin/zero-linux-sandbox",
CommandWrapping: true,
NativeIsolation: true,
},
})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
defer plan.Cleanup()
if plan.PermissionProfile.Runtime == nil || plan.PermissionProfile.Runtime.Root == "" {
t.Fatal("command plan is missing managed runtime state")
}
if cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root); err != nil {
t.Fatalf("inspect active runtime lease: %v", err)
} else if cleanupLease != nil {
cleanupLease.release()
t.Fatal("command plan did not retain its runtime lease")
} else if !inUse {
t.Fatal("command plan runtime must be marked in use")
}
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
}
foundWriteRoot := false
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
if root.Root == plan.PermissionProfile.Runtime.Root {
foundWriteRoot = true
}
}
if !foundWriteRoot {
t.Fatalf("runtime root is not writable in profile: %#v", plan.PermissionProfile.FileSystem.WriteRoots)
}
plan.Cleanup()
cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root)
if err != nil || inUse || cleanupLease == nil {
t.Fatalf("runtime lease after plan cleanup = lease %v inUse %t err %v", cleanupLease, inUse, err)
}
cleanupLease.release()
}
-56
View File
@@ -219,65 +219,9 @@ func DetectInteractiveCommand(command string, goos string) InteractiveCommandRes
}
}
// AST second opinion (issue #473): the hand-written segment split above
// mis-handles interactive programs hidden by unusual quoting, command
// substitution, subshells, or newline separators. Re-extract the real simple
// commands from the parsed shell tree and apply the SAME full pipeline
// (interactive segments, sh -c payload recursion, per-program lookup), so a
// bypass is caught while every program stays classified exactly as before
// (ssh with a trailing command, python -c, etc. remain allowed). A command
// the parser cannot handle (Windows cmd.exe, obfuscation) yields no commands
// and falls through unchanged — the guard never hard-blocks on a parse error.
for _, fields := range astCommandFields(command) {
if result, ok := inspectCommandFields(fields, goos); ok {
return result
}
}
return InteractiveCommandResult{}
}
// inspectCommandFields applies the full interactive-detection pipeline to one
// already-split command's fields: the multi-word interactive segments, then the
// `sh -c <payload>` recursion, then the per-program lookup with its
// non-interactive suppressions (hasNonInteractiveFlag covers REPL flags and
// trailing-command clients like ssh). It mirrors the hand-written passes above
// so an AST-extracted command is classified identically to a plainly-split one.
func inspectCommandFields(fields []string, goos string) (InteractiveCommandResult, bool) {
body := strings.ToLower(commandBody(fields))
for _, seg := range interactiveSegments {
if body == seg.match || strings.HasPrefix(body, seg.match+" ") {
suggestion := seg.suggestion
if goos == "windows" && seg.windowsSuggestion != "" {
suggestion = seg.windowsSuggestion
}
return InteractiveCommandResult{Interactive: true, Command: seg.command, Reason: seg.reason, Suggestion: suggestion}, true
}
}
first := firstProgram(fields)
if first == "" {
return InteractiveCommandResult{}, false
}
if payload := shellDashCPayload(first, fields); payload != "" {
if inner := DetectInteractiveCommand(payload, goos); inner.Interactive {
return inner, true
}
return InteractiveCommandResult{}, false
}
program, ok := interactivePrograms[first]
if !ok || (program.windowsOnly && goos != "windows") {
return InteractiveCommandResult{}, false
}
if hasNonInteractiveFlag(first, fields) {
return InteractiveCommandResult{}, false
}
suggestion := program.suggestion
if goos == "windows" && program.windowsSuggestion != "" {
suggestion = program.windowsSuggestion
}
return InteractiveCommandResult{Interactive: true, Command: first, Reason: program.reason, Suggestion: suggestion}, true
}
// wrapperPrograms are launcher prefixes that precede the real program. After
// one of these we keep scanning for the actual executable.
var wrapperPrograms = map[string]bool{
-84
View File
@@ -406,87 +406,3 @@ func TestDetectInteractiveMongoEvalAndFullPaths(t *testing.T) {
}
}
}
// The hand-written segment splitter misses interactive programs hidden by
// constructs only a real shell parser resolves (a newline separator collapsed
// to a space; a brace group that shifts the real command position). The AST
// second opinion must catch them (issue #473).
func TestDetectInteractiveCommandCatchesParserBypasses(t *testing.T) {
for _, cmd := range []string{
"echo hi\nvim file.txt", // newline separator collapsed to a space by the regex path
"{ vim file.txt; }", // brace group hides the real program position
} {
got := DetectInteractiveCommand(cmd, "linux")
if !got.Interactive {
t.Errorf("DetectInteractiveCommand(%q) = not interactive, want caught (parser bypass)", cmd)
continue
}
if got.Command != "vim" {
t.Errorf("DetectInteractiveCommand(%q).Command = %q, want vim", cmd, got.Command)
}
}
}
// The AST second opinion must not flag an interactive program NAME that appears
// only inside a quoted argument (not a real command position) — the parser
// distinguishes program from argument, so this must stay non-interactive.
func TestDetectInteractiveCommandNoFalsePositiveOnQuotedArgument(t *testing.T) {
got := DetectInteractiveCommand(`echo "please run vim later"`, "linux")
if got.Interactive {
t.Fatalf("interactive name inside a quoted argument must not be flagged, got %#v", got)
}
}
// The AST second opinion must run the FULL pipeline — multi-word interactive
// segments and sh -c payload recursion, not just the per-program lookup — so a
// grouped/nested bypass is caught; and it must not fabricate a program name from
// a command substitution concatenated with a literal (issue #473, review).
func TestDetectInteractiveCommandASTPipelineBoundaries(t *testing.T) {
interactive := []struct{ cmd, wantCmd string }{
{"{ git rebase -i HEAD~1; }", "git rebase -i"}, // multi-word segment in a brace group
{"{ sh -c 'vim file.txt'; }", "vim"}, // sh -c payload in a brace group
}
for _, tc := range interactive {
got := DetectInteractiveCommand(tc.cmd, "linux")
if !got.Interactive || got.Command != tc.wantCmd {
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", tc.cmd, got, tc.wantCmd)
}
}
}
// The AST pass must not fabricate a program name from a dynamic (non-literal)
// program word: `$(printf '%s' foo)vim` runs as `foovim`, not vim. Tested at the
// extractor so the hand-written pass (which pre-empts DetectInteractiveCommand
// with its own, separate handling of that construct) does not mask the guard.
func TestAstCommandFieldsSkipsDynamicProgram(t *testing.T) {
for _, fields := range astCommandFields("$(printf '%s' foo)vim file.txt") {
if firstProgram(fields) == "vim" {
t.Fatalf("astCommandFields fabricated a vim command from a substitution: %v", fields)
}
}
}
// The literalness guard covers every word, not just the program name. A dynamic
// ARGUMENT is dropped by wordText the same way a dynamic program word is, so
// `git $(printf foo)rebase -i HEAD~1` (which runs as `git foorebase -i`, a
// non-existent and non-interactive subcommand) would otherwise be reconstructed
// as `git rebase -i` and blocked — a false positive on a command the user never
// wrote. Asserted through DetectInteractiveCommand as well as the extractor,
// because the whole point is what the caller ends up blocking.
func TestAstCommandFieldsSkipsDynamicArgument(t *testing.T) {
const dynamic = "{ git $(printf foo)rebase -i HEAD~1 ; }"
for _, fields := range astCommandFields(dynamic) {
if firstProgram(fields) == "git" {
t.Fatalf("astCommandFields reconstructed a git command from a dynamic argument: %v", fields)
}
}
if got := DetectInteractiveCommand(dynamic, "linux"); got.Interactive {
t.Errorf("DetectInteractiveCommand(%q) = %+v, want not interactive", dynamic, got)
}
// The genuine form must still be detected — the guard skips lossy
// reconstructions, it does not weaken real detection.
const genuine = "{ git rebase -i HEAD~1 ; }"
if got := DetectInteractiveCommand(genuine, "linux"); !got.Interactive || got.Command != "git rebase -i" {
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", genuine, got, "git rebase -i")
}
}
+47
View File
@@ -82,6 +82,24 @@ var networkDenySyscallsAARCH64 = []uint32{
427, // io_uring_register
}
var isolatedNetworkGuardSyscallsX86_64 = []uint32{
101, // ptrace
310, // process_vm_readv
311, // process_vm_writev
425, // io_uring_setup
426, // io_uring_enter
427, // io_uring_register
}
var isolatedNetworkGuardSyscallsAARCH64 = []uint32{
117, // ptrace
270, // process_vm_readv
271, // process_vm_writev
425, // io_uring_setup
426, // io_uring_enter
427, // io_uring_register
}
// unixSocketBlockFilter builds a classic-BPF seccomp program that denies
// socket(2)/AF_UNIX with EPERM on x86-64 and arm64 and allows everything else.
// An unrecognized architecture is allowed (fail-open on arch is intentional: the
@@ -134,6 +152,35 @@ func networkDenySeccompFilter() []sockFilter {
return program
}
// isolatedNetworkGuardFilter preserves the non-network defense-in-depth rules
// from networkDenySeccompFilter without blocking sockets. The bubblewrap outer
// stage supplies network isolation with a private namespace, so socket, bind,
// and connect must remain available for localhost-only test servers.
func isolatedNetworkGuardFilter() []sockFilter {
x86Section := syscallDenySection(isolatedNetworkGuardSyscallsX86_64)
armSection := syscallDenySection(isolatedNetworkGuardSyscallsAARCH64)
program := []sockFilter{
{Code: bpfLDWABS, K: seccompOffsetArch},
{Code: bpfJEQK, K: auditArchX86_64, Jt: 0, Jf: uint8(len(x86Section))},
}
program = append(program, x86Section...)
program = append(program, sockFilter{Code: bpfJEQK, K: auditArchAARCH64, Jt: 0, Jf: uint8(len(armSection))})
program = append(program, armSection...)
program = append(program, sockFilter{Code: bpfRETK, K: seccompRetAllow})
return program
}
func syscallDenySection(deniedSyscalls []uint32) []sockFilter {
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
for _, nr := range deniedSyscalls {
section = append(section,
sockFilter{Code: bpfJEQK, K: nr, Jt: 0, Jf: 1},
sockFilter{Code: bpfRETK, K: seccompRetErrno | errnoEPERM},
)
}
return append(section, sockFilter{Code: bpfRETK, K: seccompRetAllow})
}
func networkDenySection(socketNr uint32, socketpairNr uint32, deniedSyscalls []uint32) []sockFilter {
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
for _, nr := range deniedSyscalls {
+4
View File
@@ -27,6 +27,10 @@ func ApplyLinuxNetworkDeny() error {
return applySeccompFilter(networkDenySeccompFilter(), "network deny")
}
func ApplyLinuxIsolatedNetworkGuard() error {
return applySeccompFilter(isolatedNetworkGuardFilter(), "isolated network guard")
}
func applySeccompFilter(filters []sockFilter, name string) error {
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return fmt.Errorf("seccomp: set no_new_privs: %w", err)
+8 -2
View File
@@ -8,8 +8,14 @@ import "errors"
// platforms, where seccomp BPF is unavailable.
var ErrSeccompUnsupported = errors.New("seccomp Unix-socket blocking is only supported on Linux")
// ApplyUnixSocketBlock is a no-op on non-Linux platforms.
// ApplyUnixSocketBlock is unsupported on non-Linux platforms and always
// returns ErrSeccompUnsupported.
func ApplyUnixSocketBlock() error { return ErrSeccompUnsupported }
// ApplyLinuxNetworkDeny is a no-op on non-Linux platforms.
// ApplyLinuxNetworkDeny is unsupported on non-Linux platforms and always
// returns ErrSeccompUnsupported.
func ApplyLinuxNetworkDeny() error { return ErrSeccompUnsupported }
// ApplyLinuxIsolatedNetworkGuard is unsupported on non-Linux platforms and
// always returns ErrSeccompUnsupported.
func ApplyLinuxIsolatedNetworkGuard() error { return ErrSeccompUnsupported }
+22
View File
@@ -62,6 +62,28 @@ func TestNetworkDenySeccompFilterStructure(t *testing.T) {
}
}
func TestIsolatedNetworkGuardPreservesProcessRestrictionsWithoutBlockingSockets(t *testing.T) {
prog := isolatedNetworkGuardFilter()
assertValidSockFilterProgram(t, prog)
for _, denied := range []uint32{101, 117, 310, 311, 270, 271, 425, 426, 427} {
if !hasInstruction(prog, bpfJEQK, denied) {
t.Fatalf("isolated network guard missing denied syscall %d", denied)
}
}
for _, socketSyscall := range []uint32{nrSocketX86_64, nrSocketAARCH64, 42, 49, 50, 200, 201, 203} {
if hasInstruction(prog, bpfJEQK, socketSyscall) {
t.Fatalf("isolated network guard blocks socket syscall %d needed by loopback", socketSyscall)
}
}
if !hasInstruction(prog, bpfRETK, seccompRetAllow) {
t.Fatal("isolated network guard has no allow return")
}
if !hasInstruction(prog, bpfRETK, seccompRetErrno|errnoEPERM) {
t.Fatal("isolated network guard has no EPERM block return")
}
}
func assertValidSockFilterProgram(t *testing.T, prog []sockFilter) {
t.Helper()
if len(prog) == 0 {
+1
View File
@@ -327,6 +327,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
return CommandPlan{}, err
}
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
childEnv = sandboxRuntimeEnvironment(childEnv, execRequest.PermissionProfile.Runtime)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
+52 -18
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/secrets"
)
@@ -142,6 +143,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
}
defer plan.Cleanup()
addSandboxMeta(meta, plan)
executionRequest := execExecutionRequest(command, plan, absoluteCwd, false)
changeObserver := execution.NewChangeObserver(plan.WorkspaceRoot)
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
// can't grow Zero's memory before truncation: only the head+tail each stream
@@ -161,6 +164,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
err = command.Run()
exitCode := commandExitCode(err)
adapterReport, reportErr := plan.ExecutionReport()
meta["exit_code"] = strconv.Itoa(exitCode)
stdoutText := stdout.retained()
stderrRetained := stderr.retained()
@@ -170,46 +174,68 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
return Result{
result := Result{
Status: StatusError,
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true)
}
if err != nil {
if exitCode < 0 {
return Result{
result := Result{
Status: StatusError,
Output: "Error executing command: " + err.Error(),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
return Result{
result := Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
return Result{
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
result := Result{
Status: StatusOK,
Output: formatBashOutput(outText, errText, exitCode),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result {
input := execToolResultInput{
exited: true,
exitCode: exitCode,
enforcement: executionEnforcement(plan),
request: request,
report: report,
reportErr: reportErr,
changes: changes,
}
outcome := execExecutionOutcome(input)
if timedOut && report.Denial == nil && reportErr == nil {
outcome.State = execution.StateFailed
outcome.Kind = execution.OutcomeTimedOut
}
result.ExecutionRequest = &request
result.ExecutionOutcome = &outcome
result.ChangedFiles = executionChangedFiles(changes)
result.ChangeSummaries = executionChangeSummaries(changes)
return result
}
func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) *zeroSandbox.Engine {
@@ -287,15 +313,23 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
return command, plan, err
}
directPolicy := zeroSandbox.DefaultPolicy()
directPolicy.Mode = zeroSandbox.ModeDisabled
directPolicy.Network = zeroSandbox.NetworkAllow
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
Name: zeroSandbox.BackendUnavailable,
Message: "sandbox engine not provided",
},
Wrapped: false,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
TargetBackend: zeroSandbox.BackendNone,
WorkspaceRoot: absoluteCwd,
Policy: directPolicy,
PermissionProfile: zeroSandbox.PermissionProfileFromPolicy(absoluteCwd, directPolicy, nil),
Wrapped: false,
EnforcementLevel: zeroSandbox.EnforcementDisabled,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
+4 -11
View File
@@ -3,10 +3,10 @@
package tools
import (
"errors"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// bashWaitDelay bounds how long Wait blocks for the I/O pipes to drain after the
@@ -26,20 +26,13 @@ var bashWaitDelay = 2 * time.Second
// if a child still holds the I/O pipes after the group is killed, Wait gives up
// rather than blocking forever.
func hardenProcessLifetime(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(command)
command.WaitDelay = bashWaitDelay
command.Cancel = func() error {
if command.Process == nil {
return nil
}
// Negative pid targets the process group led by the shell.
if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
return execution.KillProcessTree(command.Process.Pid)
}
}
+2 -17
View File
@@ -3,13 +3,11 @@
package tools
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
@@ -29,23 +27,10 @@ func hardenProcessLifetime(command *exec.Cmd) {
if command.Process == nil {
return nil
}
taskkill := taskkillPath()
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(command.Process.Pid)).Run()
return nil
return execution.KillProcessTree(command.Process.Pid)
}
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
+28 -2
View File
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -101,8 +102,8 @@ func TestCoreToolsExposeShellTools(t *testing.T) {
if tool.Safety().Permission != wantPermission {
t.Fatalf("%s permission = %s, want %s", name, tool.Safety().Permission, wantPermission)
}
if name == "write_stdin" && !tool.Safety().AdvertiseInAuto {
t.Fatalf("write_stdin should stay visible in auto mode for polling and interrupts")
if (name == "exec_command" || name == "write_stdin") && !tool.Safety().AdvertiseInAuto {
t.Fatalf("%s should stay visible in auto mode so process creation and interaction are both available", name)
}
}
}
@@ -350,6 +351,25 @@ func TestBashToolRunsCommandInWorkspace(t *testing.T) {
}
}
func TestBashToolReportsWorkspaceChangesAfterFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell syntax")
}
root := t.TempDir()
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": "printf partial > partial.txt; exit 9",
})
if result.Status != StatusError {
t.Fatalf("expected application failure, got %s: %s", result.Status, result.Output)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "partial.txt" {
t.Fatalf("ChangedFiles = %#v, want partial.txt", result.ChangedFiles)
}
if result.ExecutionOutcome == nil || len(result.ExecutionOutcome.Changes) != 1 || result.ExecutionOutcome.Changes[0].Kind != execution.ChangeCreated {
t.Fatalf("typed changes = %#v, want created partial file", result.ExecutionOutcome)
}
}
// A command with runaway output must not be buffered whole in memory: the capture
// is bounded to head+tail, yet raw_bytes still reports the true (much larger) size.
// If capture were unbounded this would balloon Zero's memory before truncation.
@@ -443,6 +463,9 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
}
func TestBashToolTimesOut(t *testing.T) {
@@ -460,6 +483,9 @@ func TestBashToolTimesOut(t *testing.T) {
if result.Meta["timeout_ms"] != "20" {
t.Fatalf("expected timeout_ms metadata 20, got %q", result.Meta["timeout_ms"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.Kind != execution.OutcomeTimedOut {
t.Fatalf("execution outcome = %#v, want timed_out", result.ExecutionOutcome)
}
}
func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
+198 -558
View File
@@ -2,17 +2,16 @@ package tools
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
@@ -25,9 +24,6 @@ const (
maxPollYieldTimeMS = 300000
defaultMaxOutputTokens = 10000
maxExecOutputTokenRequest = 200000
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
@@ -36,217 +32,18 @@ const (
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
)
type execSessionManager struct {
mu sync.Mutex
nextID int
sessions map[int]*execSession
completedRetention time.Duration
maxSessions int
}
type execSessionManager = execution.ProcessManager
func newExecSessionManager() *execSessionManager {
return &execSessionManager{
nextID: 1000,
sessions: make(map[int]*execSession),
completedRetention: completedSessionRetention,
maxSessions: maxExecSessions,
}
return execution.NewProcessManager(execution.ProcessManagerOptions{})
}
var defaultExecSessionManager = newExecSessionManager()
func (manager *execSessionManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *execSessionManager) store(session *execSession) {
manager.mu.Lock()
var pruned *execSession
removePruned := false
if manager.maxSessions > 0 && len(manager.sessions) >= manager.maxSessions {
pruned = manager.sessionToPruneLocked()
if pruned != nil {
removePruned = pruned.doneClosed()
if removePruned {
delete(manager.sessions, pruned.id)
}
}
}
manager.sessions[session.id] = session
manager.mu.Unlock()
if pruned != nil && !removePruned {
pruned.output.Write([]byte(execSessionEvictedMessage))
pruned.terminate()
}
}
func (manager *execSessionManager) get(id int) (*execSession, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
session, ok := manager.sessions[id]
return session, ok
}
func (manager *execSessionManager) remove(id int) {
manager.mu.Lock()
defer manager.mu.Unlock()
delete(manager.sessions, id)
}
func (manager *execSessionManager) removeCompletedLater(session *execSession) {
retention := manager.completedRetention
go func() {
<-session.done
if retention > 0 {
timer := time.NewTimer(retention)
<-timer.C
}
manager.remove(session.id)
}()
}
func (manager *execSessionManager) len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.sessions)
}
func (manager *execSessionManager) sessionToPruneLocked() *execSession {
if len(manager.sessions) == 0 {
return nil
}
// Snapshot each session's lastUsedAt UNDER session.mu before sorting: touch()
// writes lastUsedAt under session.mu, so reading it here (under manager.mu only)
// was a data race on a multi-word time.Time. Lock order stays manager.mu →
// session.mu (no path takes them the other way). (AUDIT-L15)
type sessionAge struct {
session *execSession
last time.Time
}
ages := make([]sessionAge, 0, len(manager.sessions))
for _, session := range manager.sessions {
ages = append(ages, sessionAge{session: session, last: session.lastUsed()})
}
sort.Slice(ages, func(i, j int) bool {
return ages[i].last.Before(ages[j].last)
})
for _, a := range ages {
if a.session.doneClosed() {
return a.session
}
}
if len(ages) <= 8 {
return nil
}
return ages[0].session
}
// lastUsed returns the session's last-used time under its own lock, so the prune
// comparator never races touch()'s write to lastUsedAt.
func (session *execSession) lastUsed() time.Time {
session.mu.Lock()
defer session.mu.Unlock()
return session.lastUsedAt
}
func (manager *execSessionManager) list() []ExecSessionSnapshot {
manager.mu.Lock()
sessions := make([]*execSession, 0, len(manager.sessions))
for _, session := range manager.sessions {
if !session.doneClosed() {
sessions = append(sessions, session)
}
}
manager.mu.Unlock()
snapshots := make([]ExecSessionSnapshot, 0, len(sessions))
for _, session := range sessions {
snapshots = append(snapshots, session.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].ID < snapshots[j].ID
})
return snapshots
}
func (manager *execSessionManager) stop(id int) bool {
session, ok := manager.get(id)
if !ok {
return false
}
session.terminate()
return true
}
func (manager *execSessionManager) stopAll() []int {
manager.mu.Lock()
sessions := make([]*execSession, 0, len(manager.sessions))
for _, session := range manager.sessions {
if !session.doneClosed() {
sessions = append(sessions, session)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(sessions))
for _, session := range sessions {
session.terminate()
ids = append(ids, session.id)
}
waitForExecSessions(sessions, execSessionStopTimeout)
sort.Ints(ids)
return ids
}
func waitForExecSessions(sessions []*execSession, timeout time.Duration) {
if timeout <= 0 || len(sessions) == 0 {
return
}
deadline := time.Now().Add(timeout)
for _, session := range sessions {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-session.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type ExecSessionSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionSnapshot = execution.ProcessSnapshot
type ExecSessionController interface {
ExecSessions() []ExecSessionSnapshot
@@ -254,172 +51,6 @@ type ExecSessionController interface {
StopAllExecSessions() []int
}
type execSession struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
plan zeroSandbox.CommandPlan
cancel context.CancelFunc
stdin io.WriteCloser
cleanup func()
output *execOutputBuffer
doneOnce sync.Once
done chan struct{}
mu sync.Mutex
exitCode *int
waitErr error
}
func (session *execSession) markDone(err error, exitCode int) {
session.mu.Lock()
session.waitErr = err
session.exitCode = &exitCode
session.mu.Unlock()
session.doneOnce.Do(func() { close(session.done) })
}
func (session *execSession) doneClosed() bool {
select {
case <-session.done:
return true
default:
return false
}
}
func (session *execSession) exitStatus() (int, bool) {
session.mu.Lock()
defer session.mu.Unlock()
if session.exitCode == nil {
return 0, false
}
return *session.exitCode, true
}
func (session *execSession) touch() {
session.mu.Lock()
session.lastUsedAt = time.Now()
session.mu.Unlock()
}
func (session *execSession) terminate() {
if session.cancel != nil {
session.cancel()
}
}
func (session *execSession) snapshot() ExecSessionSnapshot {
session.mu.Lock()
startedAt := session.startedAt
lastUsedAt := session.lastUsedAt
exitCode := session.exitCode
var copiedExit *int
if exitCode != nil {
value := *exitCode
copiedExit = &value
}
session.mu.Unlock()
status := "running"
if copiedExit != nil {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
return &execOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(p), nil
}
func (buffer *execOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *execOutputBuffer) drainString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
if len(buffer.data) == 0 {
return ""
}
out := string(buffer.data)
buffer.data = nil
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
@@ -473,7 +104,12 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
Required: []string{"cmd"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
safety: Safety{
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
Reason: "Shell commands can read, write, or execute programs.",
AdvertiseInAuto: true,
},
// PTY/shell session — never concurrent.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
@@ -496,15 +132,15 @@ func (tool execCommandTool) RunWithOptions(ctx context.Context, args map[string]
}
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
return tool.manager.list()
return tool.manager.List()
}
func (tool execCommandTool) StopExecSession(id int) bool {
return tool.manager.stop(id)
return tool.manager.Stop(id)
}
func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.stopAll()
return tool.manager.StopAll()
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
@@ -516,7 +152,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultExecYieldTimeMS, 1, maxExecYieldTimeMS)
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultExecYieldTimeMS, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
@@ -548,157 +184,62 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
return errorResult("Error running exec_command: " + err.Error())
}
session, err := tool.startSession(commandText, absoluteCwd, relativeCwd, ttyRequested, engine, sandboxPermissions)
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResultWithBudget(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
}, directBudget)
}
func (tool execCommandTool) startSession(commandText string, absoluteCwd string, relativeCwd string, ttyRequested bool, engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) (*execSession, error) {
id := tool.manager.allocateID()
commandCtx, cancel := context.WithCancel(context.Background())
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
if err != nil {
cancel()
return nil, err
return errorResult("Error starting exec_command: " + err.Error())
}
output := newExecOutputBuffer()
executionRequest := execExecutionRequest(command, plan, absoluteCwd, ttyRequested)
if err := executionRequest.Validate(); err != nil {
plan.Cleanup()
cancel()
return errorResult("Error starting exec_command: prepare execution request: " + err.Error())
}
meta := map[string]string{}
addSandboxMeta(meta, plan)
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
stdin, tty, cleanup, err := startExecProcess(command, output, ttyRequested)
processResult, err := tool.manager.Start(ctx, execution.ProcessStart{
Prepared: execution.PreparedCommand{
Command: command,
Enforcement: executionEnforcement(plan),
Report: plan.ExecutionReport,
Cleanup: func() {
plan.Cleanup()
cancel()
},
},
Request: executionRequest, CommandText: commandText, RelativeCwd: relativeCwd,
TTY: ttyRequested, Metadata: meta,
AfterWait: func() []byte {
blocks := monitor.Stop()
if len(blocks) == 0 {
return nil
}
return []byte(appendSandboxBlocks("", blocks))
},
}, time.Duration(yieldTimeMS)*time.Millisecond)
if err != nil {
_ = monitor.Stop()
plan.Cleanup()
cancel()
return nil, err
return errorResult("Error starting exec_command: " + err.Error())
}
session := &execSession{
id: id,
commandText: commandText,
cwd: absoluteCwd,
relativeCwd: relativeCwd,
startedAt: time.Now(),
lastUsedAt: time.Now(),
tty: tty,
command: command,
plan: plan,
cancel: cancel,
stdin: stdin,
cleanup: cleanup,
output: output,
done: make(chan struct{}),
}
tool.manager.store(session)
tool.manager.removeCompletedLater(session)
go func() {
err := command.Wait()
if blocks := monitor.Stop(); len(blocks) > 0 {
output.Write([]byte(appendSandboxBlocks("", blocks)))
}
if session.cleanup != nil {
session.cleanup()
}
plan.Cleanup()
cancel()
session.markDone(err, commandExitCode(err))
}()
return session, nil
return execToolResultWithBudget(execToolResultInput{
commandText: processResult.CommandText, output: processResult.Output,
outputBufferTruncated: processResult.OutputTruncated, sessionID: processResult.ProcessID,
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
tty: processResult.TTY, request: processResult.Request, enforcement: processResult.Enforcement,
report: processResult.Report, reportErr: processResult.ReportErr, changes: processResult.Changes,
sandboxMeta: processResult.Metadata,
maxOutputTokens: maxOutputTokens,
}, directBudget)
}
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
// A background process that keeps writing output faster than this
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
// a crash-restart spew) would otherwise never let drainString return
// empty, so the deadline check below — only reached on an empty
// drain — would never fire. That turns `wait` into an unbounded
// block regardless of what the caller asked for. Check it here too,
// so continuous output can never starve the timeout.
if time.Now().After(deadline) {
return finish()
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-session.output.notify:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-session.done:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
func executionEnforcement(plan zeroSandbox.CommandPlan) execution.Enforcement {
return execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == zeroSandbox.EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
}
}
@@ -794,7 +335,11 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultPollYieldTimeMS, 1, maxPollYieldTimeMS)
defaultYieldTimeMS := defaultPollYieldTimeMS
if chars != "" {
defaultYieldTimeMS = 250
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultYieldTimeMS, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
@@ -802,41 +347,32 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
session, ok := tool.manager.get(sessionID)
snapshot, ok := tool.manager.Snapshot(sessionID)
if !ok {
return errorResult(UnknownExecSessionError(sessionID))
}
session.touch()
interrupted := false
if chars != "" {
if shouldInterruptExecSession(chars, session.tty) {
interrupted = true
session.terminate()
} else if !session.tty {
return errorResult(fmt.Sprintf("Error: exec session_id %d does not accept stdin. Use empty chars to poll, or send chars \"\\u0003\" to interrupt/stop it.", sessionID))
} else if session.stdin != nil {
if _, err := io.WriteString(session.stdin, chars); err != nil && !session.doneClosed() {
return errorResult("Error writing to exec session: " + err.Error())
}
}
interrupted := chars != "" && shouldInterruptExecSession(chars, snapshot.TTY)
processResult, err := tool.manager.Continue(ctx, execution.ProcessContinue{
ProcessID: sessionID, Input: []byte(chars), Interrupt: interrupted,
Wait: time.Duration(yieldTimeMS) * time.Millisecond,
})
if errors.Is(err, execution.ErrProcessNotFound) {
return errorResult(UnknownExecSessionError(sessionID))
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
if errors.Is(err, execution.ErrProcessStdinDisabled) {
return errorResult(fmt.Sprintf("Error: exec session_id %d does not accept stdin. Use empty chars to poll, or send chars \"\\u0003\" to interrupt/stop it.", sessionID))
}
if err != nil {
return errorResult("Error writing to exec session: " + err.Error())
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: processResult.CommandText, output: processResult.Output,
outputBufferTruncated: processResult.OutputTruncated, sessionID: processResult.ProcessID,
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
tty: processResult.TTY, interrupted: processResult.Interrupted, request: processResult.Request,
enforcement: processResult.Enforcement, report: processResult.Report, reportErr: processResult.ReportErr,
changes: processResult.Changes, sandboxMeta: processResult.Metadata,
maxOutputTokens: maxOutputTokens,
})
}
@@ -875,7 +411,12 @@ type execToolResultInput struct {
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
request execution.Request
enforcement execution.Enforcement
sandboxMeta map[string]string
report execution.AdapterReport
reportErr error
changes []execution.Change
maxOutputTokens int
}
@@ -893,14 +434,17 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
"cwd": input.relativeCwd,
"tty": strconv.FormatBool(input.tty),
}
addSandboxMeta(meta, input.plan)
for key, value := range input.sandboxMeta {
meta[key] = value
}
outcome := execExecutionOutcome(input)
if input.exited {
meta["exit_code"] = strconv.Itoa(input.exitCode)
if input.interrupted {
meta["interrupted"] = "true"
}
if !input.interrupted {
markLikelySandboxDenial(meta, input.plan, input.exitCode, output)
if input.report.Denial != nil {
markStructuredSandboxDenial(meta, *input.report.Denial)
}
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
@@ -910,7 +454,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
if input.exited && input.exitCode != 0 && !input.interrupted {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
@@ -928,10 +472,14 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
ExecutionRequest: &input.request,
ExecutionOutcome: &outcome,
ChangedFiles: executionChangedFiles(input.changes),
ChangeSummaries: executionChangeSummaries(input.changes),
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
Kind: "shell",
@@ -939,6 +487,98 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
}
func execExecutionRequest(command *exec.Cmd, plan zeroSandbox.CommandPlan, cwd string, tty bool) execution.Request {
mode := execution.ModeCaptured
if tty {
mode = execution.ModeInteractive
}
workspaceRoot := strings.TrimSpace(plan.WorkspaceRoot)
if workspaceRoot == "" {
workspaceRoot = cwd
}
workspaceRoots := []string{workspaceRoot}
capabilities := []execution.Capability{{Kind: execution.CapabilityProcessSpawn}}
if plan.PermissionProfile.FileSystem.Kind == zeroSandbox.FileSystemUnrestricted {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"})
}
for _, root := range plan.PermissionProfile.FileSystem.ReadRoots {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityRead, Scope: root})
}
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityWorkspaceWrite, Scope: root.Root})
}
if plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkAllow || plan.Policy.Network == zeroSandbox.NetworkAllow {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityExternalNetwork})
} else if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityIsolatedLoopback, Scope: "sandbox"})
}
args := append([]string(nil), command.Args...)
name := command.Path
if len(args) > 0 {
name = args[0]
args = args[1:]
}
return execution.Request{
Origin: execution.OriginInteractiveCommand,
Mode: mode,
Command: execution.Command{Name: name, Args: args},
WorkingDirectory: cwd,
WorkspaceRoots: workspaceRoots,
Capabilities: capabilities,
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}
}
func execExecutionOutcome(input execToolResultInput) execution.Outcome {
enforcement := input.enforcement
if !input.exited {
return execution.Outcome{
State: execution.StateRetained,
Kind: execution.OutcomeRunning,
ProcessID: strconv.Itoa(input.sessionID),
Enforcement: enforcement,
}
}
exit := &execution.Exit{Code: input.exitCode}
if input.reportErr != nil {
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
if input.report.Denial != nil {
denial := *input.report.Denial
return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes}
}
if input.interrupted {
return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
if input.exitCode == 0 {
return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
func executionChangedFiles(changes []execution.Change) []string {
if len(changes) == 0 {
return nil
}
paths := make([]string, 0, len(changes))
for _, change := range changes {
if !change.Aggregated && strings.TrimSpace(change.Path) != "" {
paths = append(paths, change.Path)
}
}
return paths
}
func executionChangeSummaries(changes []execution.Change) []execution.Change {
var summaries []execution.Change
for _, change := range changes {
if change.Aggregated && strings.TrimSpace(change.Path) != "" {
summaries = append(summaries, change)
}
}
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
+113 -329
View File
@@ -2,20 +2,18 @@ package tools
import (
"context"
"errors"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -103,6 +101,12 @@ func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
if start.Meta["session_id"] == "" {
t.Fatalf("expected running session metadata, got %#v output=%q", start.Meta, start.Output)
}
if start.ExecutionOutcome == nil || start.ExecutionOutcome.State != execution.StateRetained || start.ExecutionOutcome.Kind != execution.OutcomeRunning {
t.Fatalf("running execution outcome = %#v, want retained/running", start.ExecutionOutcome)
}
if start.ExecutionOutcome.ProcessID != start.Meta["session_id"] {
t.Fatalf("process id = %q, want session id %q", start.ExecutionOutcome.ProcessID, start.Meta["session_id"])
}
if !strings.Contains(start.Output, `chars "\u0003"`) {
t.Fatalf("running session output should explain Ctrl-C cleanup, got %q", start.Output)
}
@@ -124,6 +128,9 @@ func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
if poll.Meta["exit_code"] != "0" {
t.Fatalf("expected exit_code 0, got %#v", poll.Meta)
}
if poll.ExecutionOutcome == nil || poll.ExecutionOutcome.State != execution.StateCompleted || poll.ExecutionOutcome.Kind != execution.OutcomeSuccess {
t.Fatalf("completed execution outcome = %#v, want completed/success", poll.ExecutionOutcome)
}
}
func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testing.T) {
@@ -158,6 +165,12 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
if result.Meta["sandbox_wrapped"] == "true" {
t.Fatalf("require_escalated exec_command must not be wrapped; meta=%#v", result.Meta)
}
if result.ExecutionRequest == nil || !executionRequestHasCapability(*result.ExecutionRequest, execution.CapabilityUnrestricted, "host") {
t.Fatalf("escalated execution request must record unrestricted host capability: %#v", result.ExecutionRequest)
}
if !executionRequestHasCapability(*result.ExecutionRequest, execution.CapabilityExternalNetwork, "") {
t.Fatalf("escalated execution request must record external network capability: %#v", result.ExecutionRequest)
}
}
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
@@ -218,9 +231,98 @@ func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *tes
if result.Meta["exit_code"] != "0" {
t.Fatalf("exit_code = %#v, want 0", result.Meta)
}
if manager.len() != 0 {
t.Fatalf("completed command should be removed immediately, manager has %d sessions", manager.len())
if manager.Len() != 0 {
t.Fatalf("completed command should be removed immediately, manager has %d sessions", manager.Len())
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateCompleted || result.ExecutionOutcome.Kind != execution.OutcomeSuccess {
t.Fatalf("execution outcome = %#v, want completed/success", result.ExecutionOutcome)
}
}
func TestExecCommandClampsOversizedYieldInsteadOfRejecting(t *testing.T) {
result := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": helperCommand("success"),
"yield_time_ms": 120000,
})
if result.Status != StatusOK || result.Meta["exit_code"] != "0" {
t.Fatalf("oversized yield should be clamped, got status=%s meta=%#v output=%q", result.Status, result.Meta, result.Output)
}
}
func TestExecCommandReportsWorkspaceChanges(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell syntax")
}
root := t.TempDir()
result := NewScopedExecCommandTool(root, nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": "mkdir -p src node_modules/pkg && printf 'export {}' > src/main.ts && printf generated > node_modules/pkg/index.js",
"yield_time_ms": 30000,
})
if result.Status != StatusOK {
t.Fatalf("exec_command status = %s: %s", result.Status, result.Output)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "src/main.ts" {
t.Fatalf("ChangedFiles = %#v, want source file without generated tree", result.ChangedFiles)
}
if result.ExecutionOutcome == nil || len(result.ExecutionOutcome.Changes) != 2 {
t.Fatalf("typed changes = %#v, want source file and bounded generated-tree summary", result.ExecutionOutcome)
}
if len(result.ChangeSummaries) != 1 || result.ChangeSummaries[0].Path != "node_modules/" || !result.ChangeSummaries[0].Aggregated {
t.Fatalf("ChangeSummaries = %#v, want node_modules aggregate", result.ChangeSummaries)
}
}
func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
result := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": helperCommand("fail"),
"yield_time_ms": 30000,
})
if result.Status != StatusError {
t.Fatalf("status = %s, want error", result.Status)
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
}
}
func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) {
scope := filepath.Join(t.TempDir(), ".zero")
result := execToolResult(execToolResultInput{
commandText: "mkdir .zero",
exited: true,
exitCode: 1,
enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)},
report: execution.AdapterReport{Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: scope},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
}},
})
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateDenied || result.ExecutionOutcome.Kind != execution.OutcomeEnforcementDenied {
t.Fatalf("execution outcome = %#v, want denied/enforcement_denied", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Denial == nil || result.ExecutionOutcome.Denial.Capability.Scope != scope {
t.Fatalf("structured denial = %#v, want exact scope %q", result.ExecutionOutcome.Denial, scope)
}
if result.Meta["sandbox_denial_capability"] != string(execution.CapabilityProtectedMetadata) || result.Meta["sandbox_denial_scope"] != scope {
t.Fatalf("compatibility metadata lost structured denial: %#v", result.Meta)
}
}
func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool {
for _, capability := range request.Capabilities {
if capability.Kind == kind && (scope == "" || capability.Scope == scope) {
return true
}
}
return false
}
func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
@@ -279,8 +381,7 @@ func parseListeningAddress(output string) string {
func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
root := t.TempDir()
manager := newExecSessionManager()
manager.completedRetention = 10 * time.Millisecond
manager := execution.NewProcessManager(execution.ProcessManagerOptions{CompletedRetention: 10 * time.Millisecond})
execTool := NewScopedExecCommandTool(root, nil, manager)
start := execTool.Run(context.Background(), map[string]any{
@@ -297,223 +398,16 @@ func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
deadline := time.Now().Add(2 * time.Second)
for {
if _, ok := manager.get(sessionID); !ok {
if _, ok := manager.Snapshot(sessionID); !ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("session %d was not reaped; manager has %d sessions", sessionID, manager.len())
t.Fatalf("session %d was not reaped; manager has %d sessions", sessionID, manager.Len())
}
time.Sleep(10 * time.Millisecond)
}
}
func TestStopAllWaitsForSessionsToExit(t *testing.T) {
manager := newExecSessionManager()
release := make(chan struct{})
returned := make(chan struct{})
cancelled := make(chan struct{})
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
session.cancel = func() {
close(cancelled)
go func() {
<-release
session.markDone(nil, -1)
}()
}
manager.sessions[session.id] = session
go func() {
manager.stopAll()
close(returned)
}()
select {
case <-cancelled:
case <-time.After(time.Second):
t.Fatal("stopAll did not terminate the session")
}
select {
case <-returned:
t.Fatal("stopAll returned before session.done closed")
default:
}
close(release)
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("stopAll did not return after session.done closed")
}
}
func TestStoreEvictsLiveSessionWithExplanation(t *testing.T) {
manager := newExecSessionManager()
manager.maxSessions = 9
evicted := &execSession{
id: 1000,
startedAt: time.Unix(1000, 0),
lastUsedAt: time.Unix(1000, 0),
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
evictedDone := make(chan struct{})
evicted.cancel = func() { close(evictedDone) }
manager.sessions[evicted.id] = evicted
for id := 1001; id <= 1008; id++ {
manager.sessions[id] = &execSession{
id: id,
startedAt: time.Unix(int64(id), 0),
lastUsedAt: time.Unix(int64(id), 0),
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
}
manager.store(&execSession{
id: 1009,
startedAt: time.Unix(1009, 0),
lastUsedAt: time.Unix(1009, 0),
output: newExecOutputBuffer(),
done: make(chan struct{}),
})
select {
case <-evictedDone:
case <-time.After(time.Second):
t.Fatal("live pruned session was not terminated")
}
if got := evicted.output.recentString(); !strings.Contains(got, "session evicted") {
t.Fatalf("evicted session output = %q, want explanation", got)
}
if _, ok := manager.get(evicted.id); !ok {
t.Fatal("evicted live session should remain visible until its reaper removes it")
}
}
func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
original := startPTYProcessFunc
t.Cleanup(func() { startPTYProcessFunc = original })
startPTYProcessFunc = func(command *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
command.SysProcAttr = &syscall.SysProcAttr{}
command.Cancel = func() error { return nil }
command.WaitDelay = time.Second
return nil, nil, errors.New("pty start failed")
}
command := exec.CommandContext(context.Background(), os.Args[0], "--zero-bash-helper", "success")
output := newExecOutputBuffer()
stdin, tty, cleanup, err := startExecProcess(command, output, true)
if err != nil {
t.Fatalf("startExecProcess fallback failed: %v", err)
}
if tty {
t.Fatal("fallback process must report tty=false")
}
_ = stdin.Close()
if err := command.Wait(); err != nil {
t.Fatalf("fallback command wait failed: %v", err)
}
cleanup()
if got := output.drainString(); !strings.Contains(got, "hello from bash") {
t.Fatalf("fallback output = %q", got)
}
}
func TestStartExecProcessPTYFallbackPreservesSysProcAttr(t *testing.T) {
original := startPTYProcessFunc
t.Cleanup(func() { startPTYProcessFunc = original })
startPTYProcessFunc = func(command *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty start failed")
}
command := exec.CommandContext(context.Background(), os.Args[0], "--zero-bash-helper", "success")
customAttr := &syscall.SysProcAttr{}
command.SysProcAttr = customAttr
output := newExecOutputBuffer()
stdin, tty, cleanup, err := startExecProcess(command, output, true)
if err != nil {
t.Fatalf("startExecProcess fallback failed: %v", err)
}
if tty {
t.Fatal("fallback process must report tty=false")
}
_ = stdin.Close()
_ = command.Wait()
cleanup()
if command.SysProcAttr != customAttr {
t.Fatalf("SysProcAttr was not preserved: got %+v, want %+v", command.SysProcAttr, customAttr)
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
// tens of gigabytes and got the whole zero process OOM-killed by the OS.
func TestExecOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newExecOutputBuffer()
chunk := strings.Repeat("x", 1024)
writes := (maxExecOutputBufferBytes / len(chunk)) + 10 // comfortably over the cap
for i := 0; i < writes; i++ {
if _, err := buffer.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxExecOutputBufferBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxExecOutputBufferBytes)
}
out := buffer.drainString()
// drainString itself carries no truncation marker: a marker embedded in
// the drained text would always sit ~maxExecOutputBufferBytes before the
// end of the string, past any realistic head/tail truncation window a
// caller applies afterward (see execToolResult), so it's reliably lost.
// The signal lives out of band on consumeTruncated/peekTruncated instead.
if !strings.HasSuffix(out, chunk) {
t.Fatal("drained output should keep the most recent bytes, not the oldest")
}
if len(out) > maxExecOutputBufferBytes {
t.Fatalf("drained output = %d bytes, want <= %d (no marker text mixed in)", len(out), maxExecOutputBufferBytes)
}
if !buffer.peekTruncated() {
t.Fatal("peekTruncated should report the overflow without clearing it")
}
if !buffer.peekTruncated() {
t.Fatal("a second peekTruncated call should still report it — peek must not consume")
}
if !buffer.consumeTruncated() {
t.Fatal("consumeTruncated should report the overflow")
}
if buffer.consumeTruncated() {
t.Fatal("consumeTruncated should reset after being read once")
}
if buffer.peekTruncated() {
t.Fatal("peekTruncated should reflect the reset state after consumeTruncated")
}
// A second drain with no intervening writes must be empty.
if got := buffer.drainString(); got != "" {
t.Fatalf("drainString after a full drain = %q, want empty", got)
}
}
// TestExecToolResultSurfacesBufferTruncationOutsideByteBudget: an earlier
// version embedded the truncation notice directly in the drained text, which
// a review found sits ~maxExecOutputBufferBytes before the end of the
// string — far past the head/tail window truncateExecOutput keeps even at
// the tool's smallest allowed max_output_tokens, so the notice was reliably
// swallowed or chopped. The notice must survive regardless of how small the
// byte budget is, and it must not count against that budget.
func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
hugeOutput := strings.Repeat("y", maxExecOutputBufferBytes+1024)
@@ -547,61 +441,6 @@ func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
// Go's scheduler in practice — the reader still won the race often enough —
// so this test doesn't prove the old code could hang; it just pins down the
// intended behavior (bounded by wait) going forward.
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
stop := make(chan struct{})
var wg sync.WaitGroup
// Several concurrent writers, each pushing a decent-sized chunk in a tight
// loop: a single slow writer lets the reader win the race often enough
// (the runtime schedules a gap between writes) to mask the bug. Enough
// parallel writers keep the buffer non-empty essentially continuously,
// closer to a real chatty process whose PTY reads land in bursts.
const writers = 8
chunk := []byte(strings.Repeat("x", 256))
wg.Add(writers)
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
session.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() {
close(stop)
wg.Wait()
})
const wait = 200 * time.Millisecond
start := time.Now()
_, _ = session.collect(context.Background(), wait)
elapsed := time.Since(start)
// Generous slack over `wait` for scheduling jitter under a continuously
// writing goroutine (worse on Windows CI under load). This must stay a
// small multiple of wait, not "however long the writer keeps going"
// (which is what the bug produced: this test would hang past the 30s
// test timeout without the fix).
if elapsed > 5*wait {
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
// does on cleanup can fail with "being used by another process". Retry the
// removal briefly before giving up.
func resilientTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "zero-exec-interrupt-")
@@ -642,14 +481,6 @@ func TestWriteStdinInterruptTerminatesSession(t *testing.T) {
t.Fatalf("session_id is not numeric: %v", err)
}
// Capture the live session BEFORE the interrupt so we can wait on its done
// channel afterwards (write_stdin removes a finished session from the
// manager, so manager.get would miss it post-interrupt).
session, ok := manager.get(sessionID)
if !ok {
t.Fatalf("session %d not found after start", sessionID)
}
// The operation under test: write_stdin "\x03" must itself terminate the
// session (exec_command.go's Ctrl-C branch). This is what the regression
// guards — terminating the session here directly would let the test pass even
@@ -657,20 +488,9 @@ func TestWriteStdinInterruptTerminatesSession(t *testing.T) {
interrupted := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "\x03",
"yield_time_ms": 1000,
"yield_time_ms": 30000,
})
// De-flake: wait deterministically for the process to be reaped rather than
// relying on the 1000ms yield window being long enough for the async
// SIGKILL + reap to land (which flaked on slow CI, notably Windows smoke). A
// generous safety timeout fails loudly if the kill genuinely hangs; the
// common case returns the instant the process exits.
select {
case <-session.done:
case <-time.After(30 * time.Second):
t.Fatalf("interrupted session %d was not reaped within 30s", sessionID)
}
if interrupted.Status != StatusOK {
t.Fatalf("interrupted session status = %s: %s", interrupted.Status, interrupted.Output)
}
@@ -714,7 +534,7 @@ func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) {
if !strings.Contains(result.Output, "does not accept stdin") {
t.Fatalf("unexpected output: %q", result.Output)
}
manager.stop(sessionID)
manager.Stop(sessionID)
}
func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
@@ -968,39 +788,3 @@ func TestTruncateExecOutputPreservesUTF8(t *testing.T) {
t.Fatalf("truncated output is not valid UTF-8: %q", truncated)
}
}
func TestExecSessionPruneDoesNotRaceTouch(t *testing.T) {
// AUDIT-L15: the prune comparator read execSession.lastUsedAt under manager.mu
// while touch() writes it under session.mu — a data race on a time.Time. Drive
// both concurrently under -race; with the snapshot-under-session.mu fix it is
// clean, without it the race detector flags lastUsedAt.
mgr := newExecSessionManager()
for i := 0; i < 12; i++ {
s := &execSession{id: 1000 + i, lastUsedAt: time.Now(), done: make(chan struct{})}
mgr.sessions[s.id] = s
}
target := mgr.sessions[1000]
stop := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
go func() {
defer writer.Done()
for {
select {
case <-stop:
return
default:
target.touch()
}
}
}()
for i := 0; i < 2000; i++ {
mgr.mu.Lock()
_ = mgr.sessionToPruneLocked()
mgr.mu.Unlock()
}
close(stop)
writer.Wait()
}
-15
View File
@@ -1,15 +0,0 @@
//go:build !linux
package tools
import (
"errors"
"io"
"os/exec"
)
var errPTYUnavailable = errors.New("pty transport is unavailable on this platform")
func startPTYProcess(_ *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
return nil, nil, errPTYUnavailable
}
-43
View File
@@ -1,43 +0,0 @@
package tools
import (
"io"
"os/exec"
"syscall"
)
func startExecProcess(command *exec.Cmd, output *execOutputBuffer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
if ttyRequested {
origSysProcAttr := command.SysProcAttr
if stdin, cleanup, err := startPTYProcessFunc(command, output); err == nil {
return stdin, true, cleanup, nil
}
resetExecCommandForPipeFallback(command, origSysProcAttr)
}
return startPipeProcess(command, output)
}
var startPTYProcessFunc = startPTYProcess
func resetExecCommandForPipeFallback(command *exec.Cmd, origSysProcAttr *syscall.SysProcAttr) {
command.Stdin = nil
command.Stdout = nil
command.Stderr = nil
command.SysProcAttr = origSysProcAttr
command.Cancel = nil
command.WaitDelay = 0
}
func startPipeProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, bool, func(), error) {
stdin, err := command.StdinPipe()
if err != nil {
return nil, false, nil, err
}
command.Stdout = output
command.Stderr = output
hardenProcessLifetime(command)
if err := command.Start(); err != nil {
return nil, false, nil, err
}
return stdin, false, func() {}, nil
}
-58
View File
@@ -829,61 +829,3 @@ func TestGlobSkipsWorkspaceExcludedDirectoriesAndBinaryFiles(t *testing.T) {
t.Fatalf("expected only keep.txt, got:\n%s", res.Output)
}
}
// A backwards range (end_line < start_line) is a caller-arithmetic slip, not a
// fatal error: read_file recovers by reading just start_line and surfaces a note
// explaining the adjustment, matching how an end_line past EOF is auto-clamped.
// It must never hard-error the way it used to.
func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 3,
"end_line": 2, // backwards
})
if result.Status != StatusOK {
t.Fatalf("backwards range must recover, not error; got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "must be greater than or equal to start_line") {
t.Fatalf("backwards range still hard-errors: %q", result.Output)
}
for _, want := range []string{
"end_line 2 was before start_line 3", // the note
"only line 3 was read",
"3 | gamma",
} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected %q in output, got %q", want, result.Output)
}
}
// It read exactly start_line, nothing adjacent.
if strings.Contains(result.Output, "beta") || strings.Contains(result.Output, "delta") {
t.Fatalf("recovery read the wrong lines: %q", result.Output)
}
}
// A valid range with end_line >= start_line is byte-for-byte unchanged: the
// recovery path must not touch normal reads (no stray note, exact same window).
func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 2,
"end_line": 4,
})
if result.Status != StatusOK {
t.Fatalf("valid range failed: %s: %s", result.Status, result.Output)
}
// Byte-for-byte: header, blank separator, the exact selected lines, and no
// recovery note or trailing content. An altered header/separator or a stray
// out-of-range line would fail this where a substring check would not.
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
if result.Output != want {
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
}
}
+2 -17
View File
@@ -106,18 +106,8 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
if endLine == 0 || endLine > total {
endLine = total
}
// A backwards range (end_line < start_line) is a caller slip, not a fatal
// error. Recover the way an end_line past EOF is already recovered a few lines
// up (clamped down to the last line): clamp end_line UP to start_line and read
// just that one line. Erroring here instead cost the caller a whole retry for
// an off-by-arithmetic mistake, and it was inconsistent — start_line past EOF
// and end_line past EOF both recover gracefully; only this case hard-failed.
// The recovery is surfaced in the output (rangeNote) so it is never silent: the
// caller sees it read a different range than it asked for and why.
rangeNote := ""
if endLine < startLine {
rangeNote = fmt.Sprintf("(note: end_line %d was before start_line %d, so only line %d was read — pass end_line >= start_line to read a wider range)", endLine, startLine, startLine)
endLine = startLine
return errorResult("Error: Invalid arguments for read_file: end_line must be greater than or equal to start_line")
}
truncated := false
@@ -145,12 +135,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
}
budgetedOutput.WriteString(header)
budgetedOutput.WriteString("\n")
if rangeNote != "" {
budgetedOutput.WriteString(rangeNote)
budgetedOutput.WriteString("\n")
}
budgetedOutput.WriteString("\n")
budgetedOutput.WriteString("\n\n")
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
return errorResult("Error reading file " + relativePath + ": " + err.Error())
}
+11 -117
View File
@@ -1,125 +1,19 @@
package tools
import (
"strings"
import "github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
var sandboxDeniedKeywords = []string{
"operation not permitted",
"permission denied",
"read-only file system",
"seccomp",
"sandbox",
"landlock",
"failed to write file",
}
var sandboxNetworkDeniedKeywords = []string{
"cannot open a network socket",
"cannot open netlink socket",
"uv_interface_addresses",
"listen eperm",
"getaddrinfo eai_again",
"network is unreachable",
}
func markLikelySandboxDenial(meta map[string]string, plan zeroSandbox.CommandPlan, exitCode int, sections ...string) {
kind, keyword := sandboxDenialKind(plan, exitCode, sections...)
if meta == nil || kind == "" {
// markStructuredSandboxDenial mirrors typed adapter facts into legacy metadata
// for presentation and backward-compatible session readers. Classification is
// never inferred from stdout or stderr.
func markStructuredSandboxDenial(meta map[string]string, denial execution.Denial) {
if meta == nil {
return
}
meta[SandboxLikelyDeniedMeta] = "true"
meta[SandboxDenialKindMeta] = kind
meta[SandboxDenialReasonMeta] = "sandbox blocked command execution"
if keyword != "" {
meta[SandboxDenialKeywordMeta] = keyword
meta[SandboxDenialKindMeta] = SandboxDenialKindSandbox
meta[SandboxDenialReasonMeta] = denial.Reason
meta["sandbox_denial_capability"] = string(denial.Capability.Kind)
if denial.Capability.Scope != "" {
meta["sandbox_denial_scope"] = denial.Capability.Scope
}
}
func likelySandboxDenied(plan zeroSandbox.CommandPlan, exitCode int, sections ...string) bool {
kind, _ := sandboxDenialKind(plan, exitCode, sections...)
return kind != ""
}
func sandboxDenialKind(plan zeroSandbox.CommandPlan, exitCode int, sections ...string) (string, string) {
if !plan.Wrapped {
return "", ""
}
if networkDeniedBySandbox(plan) {
if keyword := sandboxNetworkDenialKeyword(sections...); keyword != "" {
return SandboxDenialKindNetwork, keyword
}
}
if exitCode == 0 {
return "", ""
}
if sandboxDenialKeyword(sections...) != "" {
if networkDeniedBySandbox(plan) {
if keyword := sandboxNetworkDenialKeyword(sections...); keyword != "" {
return SandboxDenialKindNetwork, keyword
}
}
return SandboxDenialKindSandbox, sandboxDenialKeyword(sections...)
}
if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap && exitCode == 128+31 {
return SandboxDenialKindSandbox, "seccomp"
}
// Windows restricted-token failures are often SILENT: a spawn the token
// cannot satisfy (executable or DLL unreadable under the restricted-SID
// check, MSYS runtime init death, loader status 0xC0000135/0xC0000142)
// produces a nonzero exit with no output at all, so none of the keyword
// heuristics above can ever fire. Treat "wrapped Windows command failed
// without writing a single byte" as a likely sandbox denial so the caller
// offers the unsandboxed-retry prompt instead of surfacing a bare
// "command completed with no output". A command that legitimately fails
// silently (findstr with no match, for one) costs one extra approval
// prompt; the inverse misclassification silently strands the whole
// session, so the trade goes to prompting.
if windowsWrappedBackend(plan.TargetBackend) && sectionsEmpty(sections...) {
return SandboxDenialKindSandbox, "no output from sandboxed command"
}
return "", ""
}
func windowsWrappedBackend(backend zeroSandbox.BackendName) bool {
return backend == zeroSandbox.BackendWindowsRestrictedToken || backend == zeroSandbox.BackendWindowsElevated
}
func sectionsEmpty(sections ...string) bool {
for _, section := range sections {
if strings.TrimSpace(section) != "" {
return false
}
}
return true
}
func networkDeniedBySandbox(plan zeroSandbox.CommandPlan) bool {
return plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkDeny || plan.Policy.Network == zeroSandbox.NetworkDeny
}
func sandboxNetworkDenialKeyword(sections ...string) string {
for _, section := range sections {
lower := strings.ToLower(section)
for _, keyword := range sandboxNetworkDeniedKeywords {
if strings.Contains(lower, keyword) {
return keyword
}
}
}
return ""
}
func sandboxDenialKeyword(sections ...string) string {
for _, section := range sections {
lower := strings.ToLower(section)
for _, keyword := range sandboxDeniedKeywords {
if strings.Contains(lower, keyword) {
return keyword
}
}
}
return ""
}
+8 -84
View File
@@ -3,92 +3,16 @@ package tools
import (
"testing"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/execution"
)
func TestLikelySandboxDeniedDetectsReferenceKeywords(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendLinuxBwrap,
}
output := "touch: cannot touch '/home/user/.npm/cache': Read-only file system"
if !likelySandboxDenied(plan, 1, output) {
t.Fatalf("expected reference sandbox denial keyword to be classified as sandbox denied")
}
}
func TestLikelySandboxDeniedDetectsNetworkDenialEvenWithZeroExit(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendLinuxBwrap,
Policy: zeroSandbox.Policy{Network: zeroSandbox.NetworkDeny},
PermissionProfile: zeroSandbox.PermissionProfile{
Network: zeroSandbox.NetworkPolicy{Mode: zeroSandbox.NetworkDeny},
},
}
if !likelySandboxDenied(plan, 0, "Cannot open a network socket.") {
t.Fatal("network-denied socket output with exit 0 must be classified as sandbox denied")
}
func TestStructuredSandboxDenialMetadata(t *testing.T) {
meta := map[string]string{}
markLikelySandboxDenial(meta, plan, 0, "Cannot open a network socket.")
if meta[SandboxLikelyDeniedMeta] != "true" || meta[SandboxDenialKindMeta] != SandboxDenialKindNetwork {
t.Fatalf("network denial meta = %#v", meta)
}
}
func TestLikelySandboxDeniedIgnoresUnsandboxedFailure(t *testing.T) {
plan := zeroSandbox.CommandPlan{Wrapped: false}
if likelySandboxDenied(plan, 1, "permission denied") {
t.Fatal("unsandboxed command output must not be classified as a sandbox denial")
}
}
func TestLikelySandboxDeniedDetectsSilentWindowsWrappedFailure(t *testing.T) {
for _, backend := range []zeroSandbox.BackendName{
zeroSandbox.BackendWindowsRestrictedToken,
zeroSandbox.BackendWindowsElevated,
} {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: backend,
}
if !likelySandboxDenied(plan, 1, "", " \n") {
t.Fatalf("wrapped Windows command failing with empty output must be classified as sandbox denied (backend %s)", backend)
}
meta := map[string]string{}
markLikelySandboxDenial(meta, plan, 1, "")
if meta[SandboxLikelyDeniedMeta] != "true" || meta[SandboxDenialKindMeta] != SandboxDenialKindSandbox {
t.Fatalf("silent windows denial meta = %#v (backend %s)", meta, backend)
}
}
}
func TestLikelySandboxDeniedIgnoresSilentWindowsWrappedSuccess(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendWindowsRestrictedToken,
}
if likelySandboxDenied(plan, 0, "") {
t.Fatal("wrapped Windows command exiting 0 with empty output must not be classified as sandbox denied")
}
}
func TestLikelySandboxDeniedIgnoresSilentFailureOnOtherBackends(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendLinuxBwrap,
}
if likelySandboxDenied(plan, 1, "") {
t.Fatal("silent nonzero exit on non-Windows backends must not be classified as sandbox denied")
}
}
func TestLikelySandboxDeniedIgnoresSilentWindowsFailureWithOutput(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendWindowsRestrictedToken,
}
if likelySandboxDenied(plan, 1, "fatal: not a git repository") {
t.Fatal("a wrapped Windows command that produced unrelated output must not be classified as sandbox denied")
markStructuredSandboxDenial(meta, execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Reason: "protected metadata is denied",
})
if meta["sandbox_denial_capability"] != string(execution.CapabilityProtectedMetadata) || meta["sandbox_denial_scope"] != "/workspace/.zero" {
t.Fatalf("structured metadata = %#v", meta)
}
}
+10
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -95,6 +96,11 @@ type Result struct {
Truncated bool
Meta map[string]string
SandboxDecision *sandbox.Decision `json:"-"`
// ExecutionRequest and ExecutionOutcome are the typed command protocol used
// by command-oriented tools. Legacy Output and Meta fields remain populated
// while callers migrate away from parsing text and string metadata.
ExecutionRequest *execution.Request `json:"-"`
ExecutionOutcome *execution.Outcome `json:"-"`
// Redacted is set when secret scrubbing altered Output before it left the
// tool-execution boundary.
Redacted bool
@@ -102,6 +108,10 @@ type Result struct {
// entries under a granted extra write root are absolute, since
// workspace-relative would be ambiguous there.
ChangedFiles []string
// ChangeSummaries contains bounded generated-tree changes. These are shown
// in session evidence and the Files panel but are never treated as files to
// open or diagnose individually.
ChangeSummaries []execution.Change
// Display carries a short, structured summary for the TUI / stream.
Display Display
}
+4 -1
View File
@@ -202,7 +202,7 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
func TestChangedFilesRehydration(t *testing.T) {
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`),
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"],"changeSummaries":[{"path":"node_modules/","kind":"created","aggregated":true}]}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
@@ -212,6 +212,9 @@ func TestChangedFilesRehydration(t *testing.T) {
if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" {
t.Fatalf("changedFiles not rehydrated: %v", got)
}
if summaries := rows[0].changeSummaries; len(summaries) != 1 || summaries[0].Path != "node_modules/" || !summaries[0].Aggregated {
t.Fatalf("changeSummaries not rehydrated: %#v", summaries)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
+25 -3
View File
@@ -35,7 +35,9 @@ type touchedFile struct {
edits int // number of mutations
created bool // first touch created the file (write_file "Created …")
failed bool // the latest touch errored
lastRowIndex int // transcript index of the most recent result touching it
summarized bool // generated tree summary; never selectable as a file
changeKind string
lastRowIndex int // transcript index of the most recent result touching it
}
// touchedFiles recovers the session's touched-file roster from the transcript,
@@ -47,7 +49,7 @@ func (m model) touchedFiles() []touchedFile {
var files []touchedFile
index := map[string]int{}
for i, row := range m.transcript {
if row.kind != rowToolResult || len(row.changedFiles) == 0 {
if row.kind != rowToolResult || (len(row.changedFiles) == 0 && len(row.changeSummaries) == 0) {
continue
}
adds, dels := planDiffStat(row.detail)
@@ -85,6 +87,22 @@ func (m model) touchedFiles() []touchedFile {
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
for _, summary := range row.changeSummaries {
if summary.Path == "" {
continue
}
at, seen := index[summary.Path]
if !seen {
index[summary.Path] = len(files)
files = append(files, touchedFile{path: summary.Path, summarized: true, lastRowIndex: i})
at = len(files) - 1
}
files[at].summarized = true
files[at].changeKind = string(summary.Kind)
files[at].edits++
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
}
// Most recently touched first: the file being worked on now belongs at the
// top, like the sidebar's ACTIVITY feed. Sorted by last touch (not reversed
@@ -229,7 +247,9 @@ func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
break
}
shown++
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
if !f.summarized {
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
}
lines = append(lines, m.renderFileRow(f, room))
}
return lines, hits
@@ -241,6 +261,8 @@ func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
func (m model) renderFileRow(f touchedFile, room int) string {
badge := zeroTheme.muted.Render("M")
switch {
case f.summarized:
badge = zeroTheme.muted.Render("Σ")
case f.failed:
badge = zeroTheme.red.Render("✗")
case f.created:
+16
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -30,6 +31,21 @@ func filesPanelTestModel() model {
return m
}
func TestGeneratedTreeSummaryIsVisibleButNotClickable(t *testing.T) {
m := sidebarTestModel()
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, status: tools.StatusOK,
changeSummaries: []execution.Change{{Path: "node_modules/", Kind: execution.ChangeCreated, Aggregated: true}},
})
lines, hits := m.sidebarFileLines(34)
if got := plainRender(t, strings.Join(lines, "\n")); !strings.Contains(got, "Σ") || !strings.Contains(got, "node_modules/") {
t.Fatalf("generated summary missing from Files panel: %q", got)
}
if len(hits) != 0 {
t.Fatalf("generated tree must not be selectable as a file: %#v", hits)
}
}
// TestTouchedFilesAggregates: the roster is recovered from tool-result rows,
// newest-touch first, with per-file diffstat totals, edit counts, and the
// created badge from write_file's confirmation text.
+14 -64
View File
@@ -689,17 +689,6 @@ type pendingPermissionPrompt struct {
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
// Hotkeys resolve the matching request-provided option directly.
cursor int
// typing is true once the user chose "tell Zero what to do differently": the
// card replaces its option list with a free-text field (sharing the composer
// input, like the ask_user questionnaire). Submitting sends a Deny decision
// whose Reason is the typed text, so the model reads it as the tool result and
// adjusts course in the same turn instead of the run being cancelled.
typing bool
// savedDraft holds whatever was in the shared composer input when feedback
// mode was entered. The field is cleared for typing and restored on both
// submit and cancel, so a half-typed or queued next-turn message survives the
// detour (permissionRequestMsg, unlike ask_user, does not clear the composer).
savedDraft string
}
// askUserRequestMsg is the TUI-loop equivalent of permissionRequestMsg: the
@@ -1450,11 +1439,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.pendingAskUser != nil {
return m.escapeAskUser()
}
// Esc in the permission feedback field steps back to the option list
// rather than resolving, so a stray keystroke is recoverable.
if m.pendingPermission != nil && m.pendingPermission.typing {
return m.cancelPermissionTyping()
}
if m.pendingSpecReview != nil {
m.burstCount = 0
return m.cancelSpecReview()
@@ -1675,16 +1659,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case keyBackspace(msg):
// In permission feedback mode Backspace is a plain edit of the feedback
// text. This case runs before the typing branch below and, on an empty
// field (feedback mode clears the composer), would otherwise fall to the
// removeLastAttachment path and silently drop a staged image/doc that
// savedDraft does not restore. Route it to the shared input instead.
if m.pendingPermission != nil && m.pendingPermission.typing {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
@@ -1880,15 +1854,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleSpecReviewKey(msg)
}
if m.pendingPermission != nil {
// Feedback mode: a printable keystroke (and editing keys like
// backspace) types into the shared composer input, mirroring the
// ask_user free-text path above. Enter/Esc/↑/↓ were already handled
// earlier in this switch; the remaining keys reach the input here.
if m.pendingPermission.typing {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
m.burstCount = 0
return m.handlePermissionKey(msg)
}
@@ -2819,16 +2784,6 @@ func (m model) footerView(width int) string {
footer.WriteString(m.statusLine(width))
return footer.String()
}
// A focused permission prompt owns the keyboard: its options (and the feedback
// field) consume every key, so the composer is inert. Suppress it and the idle
// hints/plan panel like the ask_user modal above, keeping only the status line.
// The card itself renders in the transcript body. This also keeps the shared
// input from echoing in two places once "tell Zero what to do differently"
// opens the on-card feedback field.
if m.pendingPermission != nil {
footer.WriteString(m.statusLine(width))
return footer.String()
}
// Pinned plan panel: sits directly above the composer so it stays visible
// while the transcript scrolls underneath (a streaming turn no longer pushes
// the plan off-screen). Budgeted to at most a third of the screen height; a
@@ -3987,22 +3942,13 @@ func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := strings.ToLower(msg.String())
for _, option := range permissionOptions(m.pendingPermission.request) {
if option.hotkey == key {
return m.choosePermissionOption(option.choice)
return m.resolvePermission(option.choice)
}
}
return m, nil
}
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
return m.resolvePermissionWithReason(decision, permissionDecisionReason(decision))
}
// resolvePermissionWithReason resolves the pending prompt with an explicit reason
// string. It backs both the fixed-label choices (reason = permissionDecisionReason)
// and the free-text "tell Zero what to do differently" path, where the reason is
// the user's typed instruction and the action is Deny so the agent surfaces it as
// the tool result and keeps going.
func (m model) resolvePermissionWithReason(decision permissionDecision, reason string) (tea.Model, tea.Cmd) {
pending := m.pendingPermission
if pending == nil {
return m, nil
@@ -4011,7 +3957,7 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
if pending.decide != nil {
pending.decide(agent.PermissionDecision{
Action: decision,
Reason: reason,
Reason: permissionDecisionReason(decision),
})
}
m.pendingPermission = nil
@@ -5202,14 +5148,15 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
}
row := transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
changedFiles: result.ChangedFiles,
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
changedFiles: result.ChangedFiles,
changeSummaries: result.ChangeSummaries,
}
// A Task result is shown by the specialist card, and update_plan by the
// plan panel/sidebar, so skip both redundant transcript rows.
@@ -5242,6 +5189,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
if len(result.ChangedFiles) > 0 {
toolPayload["changedFiles"] = result.ChangedFiles
}
if len(result.ChangeSummaries) > 0 {
toolPayload["changeSummaries"] = result.ChangeSummaries
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventToolResult,
Payload: toolPayload,
+3 -63
View File
@@ -1,8 +1,6 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
@@ -76,9 +74,7 @@ func clampPermissionCursor(cursor int, request agent.PermissionRequest) int {
// the ends. A no-op when no permission prompt is pending. The cursor lives on the
// pending prompt (a pointer), mirroring how the picker's selection moves.
func (m model) movePermissionCursor(delta int) model {
if m.pendingPermission == nil || m.pendingPermission.typing {
// While typing feedback the arrow/Tab keys belong to the text field, not
// the option list.
if m.pendingPermission == nil {
return m
}
n := len(permissionOptions(m.pendingPermission.request))
@@ -91,67 +87,11 @@ func (m model) movePermissionCursor(delta int) model {
}
// confirmPermissionCursor resolves the currently highlighted option. It is the
// Enter-key counterpart to the a/y/d hotkeys and a mouse click. Confirming the
// "tell Zero what to do differently" choice opens the inline feedback field
// instead of resolving immediately.
// Enter-key counterpart to the a/y/d hotkeys and a mouse click.
func (m model) confirmPermissionCursor() (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
if m.pendingPermission.typing {
return m.submitPermissionFeedback()
}
option := permissionOptions(m.pendingPermission.request)[clampPermissionCursor(m.pendingPermission.cursor, m.pendingPermission.request)]
return m.choosePermissionOption(option.choice)
}
// choosePermissionOption applies a chosen decision. The cancel choice (the
// "tell Zero what to do differently" row and its [n] hotkey) opens the inline
// feedback field rather than aborting the run; every other choice resolves
// immediately as before.
func (m model) choosePermissionOption(choice permissionDecision) (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
if choice == permissionDecisionCancel {
m.pendingPermission.typing = true
// Preserve whatever the user had drafted/queued in the composer so it is
// restored when they leave feedback mode (submit or cancel).
m.pendingPermission.savedDraft = m.input.Value()
m.input.SetValue("")
return m, nil
}
return m.resolvePermission(choice)
}
// submitPermissionFeedback ends the feedback field. Non-empty text is sent as a
// Deny decision whose Reason is the text: the agent surfaces that as the tool
// result (deniedPermissionResult) so the model reads the instruction and adjusts
// in the same turn, rather than the run being cancelled. Empty text falls back to
// a plain cancel, matching the option's prior behaviour.
func (m model) submitPermissionFeedback() (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
feedback := strings.TrimSpace(m.input.Value())
// Restore the composer draft the user had before entering feedback mode; the
// feedback text itself is delivered via the decision Reason, not the composer.
m.input.SetValue(m.pendingPermission.savedDraft)
m.pendingPermission.typing = false
if feedback == "" {
return m.resolvePermission(permissionDecisionCancel)
}
return m.resolvePermissionWithReason(permissionDecisionDeny, feedback)
}
// cancelPermissionTyping returns from the feedback field to the option list
// without resolving, so Esc is a safe "I didn't mean to type" back-out.
func (m model) cancelPermissionTyping() (tea.Model, tea.Cmd) {
if m.pendingPermission == nil || !m.pendingPermission.typing {
return m, nil
}
m.pendingPermission.typing = false
m.input.SetValue(m.pendingPermission.savedDraft)
m.pendingPermission.savedDraft = ""
return m, nil
return m.resolvePermission(option.choice)
}
+16 -254
View File
@@ -2,7 +2,6 @@ package tui
import (
"context"
"fmt"
"strings"
"testing"
@@ -11,7 +10,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func pendingPermissionModel(t *testing.T, decide func(agent.PermissionDecision)) model {
@@ -74,7 +72,7 @@ func TestPermissionOptionsExposeApprovalCancelWhenSupplied(t *testing.T) {
t.Fatalf("cancel option = %#v, want cancel on n", options[3])
}
card, _ := renderFocusedPermissionPrompt(request, 3, false, "", 80)
card, _ := renderFocusedPermissionPrompt(request, 3, 80)
got := plainRender(t, card)
for _, want := range []string{"continue without running it", "[d]", "tell Zero what to do differently", "[n]"} {
if !strings.Contains(got, want) {
@@ -97,7 +95,7 @@ func TestPermissionOptionsExposeCommandPrefixApproval(t *testing.T) {
if len(options) != 3 || options[1].choice != permissionDecisionAllowPrefix || options[1].hotkey != "p" {
t.Fatalf("prefix option = %#v, want p hotkey in supplied order", options)
}
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 100)
card, _ := renderFocusedPermissionPrompt(request, 1, 100)
got := plainRender(t, card)
for _, want := range []string{"allow `git status` in this session", "[p]"} {
if !strings.Contains(got, want) {
@@ -116,7 +114,7 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 96)
card, _ := renderFocusedPermissionPrompt(request, 0, 96)
got := plainRender(t, card)
if !strings.Contains(got, "This command needs to run outside the sandbox.") {
t.Fatalf("permission card = %q, missing user-facing sandbox reason", got)
@@ -141,7 +139,7 @@ func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
if len(options) != 4 || options[2].choice != permissionDecisionAlwaysAllowPrefix || options[2].hotkey != "y" {
t.Fatalf("persistent prefix option = %#v, want y hotkey in supplied order", options)
}
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 100)
card, _ := renderFocusedPermissionPrompt(request, 2, 100)
got := plainRender(t, card)
for _, want := range []string{"always allow `git status`", "[y]"} {
if !strings.Contains(got, want) {
@@ -159,7 +157,7 @@ func TestPermissionOptionsCanExposePatchCancelWithoutRecoverableDeny(t *testing.
agent.PermissionDecisionCancel,
},
}
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 80)
card, _ := renderFocusedPermissionPrompt(request, 2, 80)
got := plainRender(t, card)
if !strings.Contains(got, "tell Zero what to do differently") || !strings.Contains(got, "[n]") {
t.Fatalf("permission card = %q, missing cancel option", got)
@@ -182,7 +180,7 @@ func TestRequestPermissionsPromptUsesGrantLabelsAndEscDenies(t *testing.T) {
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 96)
card, _ := renderFocusedPermissionPrompt(request, 1, 96)
got := plainRender(t, card)
for _, want := range []string{
"Grant requested permissions?",
@@ -257,9 +255,7 @@ func TestPermissionHotkeysStillResolveDirectly(t *testing.T) {
}
}
// feedbackRequest is a bash prompt whose decision set includes Cancel, so the
// "tell Zero what to do differently" row (and its [n] hotkey) is present.
func feedbackRequest() agent.PermissionRequest {
func TestPermissionCancelHotkeyResolvesDirectly(t *testing.T) {
request := testPromptPermissionRequest()
request.ToolName = "bash"
request.AvailableDecisions = []agent.PermissionDecisionAction{
@@ -267,87 +263,21 @@ func feedbackRequest() agent.PermissionRequest {
agent.PermissionDecisionDeny,
agent.PermissionDecisionCancel,
}
return request
}
// The [n] "tell Zero what to do differently" hotkey no longer resolves cancel
// immediately: it opens the inline feedback field and sends nothing yet.
func TestPermissionTellDifferentlyOpensFeedbackField(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
got := []permissionDecision{}
m := pendingPermissionModelWithRequest(t, request, func(d agent.PermissionDecision) {
got = append(got, permissionDecision(d.Action))
})
next, _ := m.Update(testKeyText("n"))
nm := next.(model)
if nm.pendingPermission == nil || !nm.pendingPermission.typing {
t.Fatalf("'n' should open the feedback field, got pending=%#v", nm.pendingPermission)
if _, cmd := m.Update(testKeyText("n")); cmd != nil {
t.Fatal("'n' should resolve synchronously")
}
if len(got) != 0 {
t.Fatalf("'n' must not resolve anything yet, got %#v", got)
}
}
// Typing an instruction and pressing Enter sends a Deny whose Reason is the
// text, so the agent surfaces it to the model as the tool result.
func TestPermissionFeedbackSubmitSendsDenyWithText(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
next, _ := m.Update(testKeyText("n"))
nm := typeRunes(t, next.(model), "use apply_patch instead")
after, _ := nm.Update(testKey(tea.KeyEnter))
am := after.(model)
if len(got) != 1 {
t.Fatalf("Enter should resolve exactly one decision, got %#v", got)
}
if got[0].Action != agent.PermissionDecisionDeny {
t.Fatalf("feedback should resolve as Deny, got %s", got[0].Action)
}
if got[0].Reason != "use apply_patch instead" {
t.Fatalf("Deny reason should carry the typed text, got %q", got[0].Reason)
}
if am.pendingPermission != nil {
t.Fatal("prompt should be dismissed after submitting feedback")
}
}
// Enter with an empty field falls back to a plain cancel (the option's prior
// meaning), so opening the field and changing your mind is safe.
func TestPermissionFeedbackEmptySubmitCancels(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
next, _ := m.Update(testKeyText("n"))
after, _ := next.(model).Update(testKey(tea.KeyEnter))
_ = after
if len(got) != 1 || got[0].Action != agent.PermissionDecisionCancel {
t.Fatalf("empty feedback should resolve Cancel, got %#v", got)
}
}
// Esc in the feedback field returns to the option list without resolving.
func TestPermissionFeedbackEscReturnsToOptions(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
next, _ := m.Update(testKeyText("n"))
after, _ := typeRunes(t, next.(model), "half a thought").Update(testKey(tea.KeyEsc))
am := after.(model)
if am.pendingPermission == nil || am.pendingPermission.typing {
t.Fatalf("Esc should return to the option list, got pending=%#v", am.pendingPermission)
}
if len(got) != 0 {
t.Fatalf("Esc must not resolve anything, got %#v", got)
if len(got) != 1 || got[0] != permissionDecisionCancel {
t.Fatalf("'n' should resolve cancel directly, got %#v", got)
}
}
func TestPermissionRenderEmitsHighlightedClickableOffsets(t *testing.T) {
request := agent.PermissionRequest{ToolName: "write_file", AvailableDecisions: testAllPermissionDecisions()}
card, offsets := renderFocusedPermissionPrompt(request, 2, false, "", 60) // cursor on future approval
card, offsets := renderFocusedPermissionPrompt(request, 2, 60) // cursor on future approval
if len(offsets) != len(permissionOptions(request)) {
t.Fatalf("offsets = %d, want %d", len(offsets), len(permissionOptions(request)))
}
@@ -368,7 +298,7 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
Scope: "example.com",
AvailableDecisions: testAllPermissionDecisions(),
}
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 72)
card, _ := renderFocusedPermissionPrompt(request, 1, 72)
got := plainRender(t, card)
for _, want := range []string{"target: example.com", "allow this host for this conversation", "[s]", "allow this host in the future", "[y]"} {
if !strings.Contains(got, want) {
@@ -446,171 +376,3 @@ func TestShiftDownComposerGuard(t *testing.T) {
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
}
}
// The highlighted permission option must use the selected-row tint, not the
// brand chip. zeroTheme.badge is the accent-filled chip for short labels
// (" 0 ", " ASK ", " SPEC REVIEW "); using it for a whole row painted a
// full-brightness accent slab across a card whose palette is deliberately amber
// (warning), and skipped the card tint every other line composes onto. selBg is
// the tint tuned for a highlighted row against a panel, and onSel is what every
// other selectable list in the TUI uses.
func TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip(t *testing.T) {
request := agent.PermissionRequest{ToolName: "exec_command", SideEffect: "shell"}
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 70)
var selected string
for _, line := range strings.Split(card, "\n") {
if strings.Contains(ansiPattern.ReplaceAllString(line, ""), "▸ ") {
selected = line
break
}
}
if selected == "" {
t.Fatal("no highlighted option row rendered")
}
accentBg := backgroundCode(darkPalette.accent)
selBg := backgroundCode(darkPalette.selBg)
if strings.Contains(selected, accentBg) {
t.Errorf("selected row is filled with the brand accent %s (zeroTheme.badge); want the selection tint:\n%q", darkPalette.accent, selected)
}
if !strings.Contains(selected, selBg) {
t.Errorf("selected row should carry the selection tint %s:\n%q", darkPalette.selBg, selected)
}
// The PERMISSION chip keeps its amber fill — the card must still read as a
// warning surface, so this fix must not flatten it.
if !strings.Contains(card, backgroundCode(darkPalette.amber)) {
t.Errorf("PERMISSION badge lost its amber fill:\n%q", card)
}
// The card BODY carries no warm permBg wash any more: it matches the other
// prompt cards (ask_user, spec) whose bodies are transparent. Warning identity
// comes from the amber badge + border, not a full-body tint that clashes on
// cool themes.
if strings.Contains(card, backgroundCode(darkPalette.permBg)) {
t.Errorf("permission card body still tinted with permBg %s; want a transparent body:\n%q", darkPalette.permBg, card)
}
}
// backgroundCode renders the SGR truecolor background sequence for a #rrggbb
// palette entry, so assertions compare against the palette rather than
// hardcoded numbers that drift when a theme is retuned.
func backgroundCode(hex string) string {
var r, g, b int
fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
return fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
}
// A focused permission prompt suppresses the composer: keys drive the card, so a
// "describe a task for zero…" box below it is inert and misleading — and once the
// feedback field is open, the shared input must not echo in both places.
func TestComposerSuppressedDuringPermissionPrompt(t *testing.T) {
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
m.width, m.height = 96, 30
view := plainRender(t, m.View())
if strings.Contains(view, "describe a task for zero") {
t.Errorf("composer must be hidden while a permission prompt is focused:\n%s", view)
}
// While typing feedback, the text lives on the card, not in a second box.
next, _ := m.Update(testKeyText("n"))
typed := typeRunes(t, next.(model), "use apply_patch")
tv := plainRender(t, typed.View())
if strings.Contains(tv, "describe a task for zero") {
t.Errorf("composer must stay hidden in feedback mode:\n%s", tv)
}
if strings.Count(tv, "use apply_patch") != 1 {
t.Errorf("feedback text should appear exactly once (on the card), got %d occurrences:\n%s", strings.Count(tv, "use apply_patch"), tv)
}
}
// Entering feedback mode must not lose a composer draft (a half-typed or queued
// next-turn message). The draft is restored whether feedback is submitted or
// cancelled; only the feedback text (delivered via the decision Reason) is
// consumed.
func TestPermissionFeedbackPreservesComposerDraftOnSubmit(t *testing.T) {
var got []agent.PermissionDecision
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
got = append(got, d)
})
m.input.SetValue("my queued next message")
next, _ := m.Update(testKeyText("n"))
nm := next.(model)
if nm.input.Value() != "" {
t.Fatalf("feedback field should start empty, got %q", nm.input.Value())
}
nm = typeRunes(t, nm, "use apply_patch")
after, _ := nm.Update(testKey(tea.KeyEnter))
am := after.(model)
if len(got) != 1 {
t.Fatalf("expected exactly one permission decision, got %#v", got)
}
if got[0].Reason != "use apply_patch" {
t.Fatalf("feedback text lost: %q", got[0].Reason)
}
if am.input.Value() != "my queued next message" {
t.Fatalf("composer draft not restored after submit, got %q", am.input.Value())
}
}
func TestPermissionFeedbackPreservesComposerDraftOnCancel(t *testing.T) {
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
m.input.SetValue("my queued next message")
next, _ := m.Update(testKeyText("n"))
nm := typeRunes(t, next.(model), "half a thought")
after, _ := nm.Update(testKey(tea.KeyEsc))
am := after.(model)
if am.pendingPermission == nil || am.pendingPermission.typing {
t.Fatal("Esc should return to the option list")
}
if am.input.Value() != "my queued next message" {
t.Fatalf("composer draft not restored after cancel, got %q", am.input.Value())
}
}
// Backspace on an empty permission feedback field must edit the feedback text,
// never drop a staged attachment. The keyBackspace case runs before the typing
// branch and, on an empty composer, would otherwise remove the last staged
// image/doc — which savedDraft does not restore.
func TestPermissionFeedbackBackspaceKeepsStagedAttachment(t *testing.T) {
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
m.pendingImages = []zeroruntime.ImageBlock{{MediaType: "image/png"}}
m.pendingImageLabels = []string{"diagram.png"}
next, _ := m.Update(testKeyText("n")) // open feedback field (clears composer text)
after, _ := next.(model).Update(testKey(tea.KeyBackspace))
am := after.(model)
if len(am.pendingImageLabels) != 1 || len(am.pendingImages) != 1 {
t.Fatalf("Backspace in feedback mode dropped the staged attachment: labels=%v imgs=%d", am.pendingImageLabels, len(am.pendingImages))
}
if !am.pendingPermission.typing {
t.Fatal("Backspace should stay in the feedback field, not exit it")
}
}
// No permission option row is clickable while the feedback field is open: the
// renderer registers zero clickable offsets in typing mode, so a stray click
// (Allow included) has no row to land on. This is the primary safety; the
// !typing guard on the click resolver in handleTranscriptSelectionMouse is the
// explicit second layer for if this early-return is ever refactored away.
func TestPermissionFeedbackRendersNoClickableOptionsWhileTyping(t *testing.T) {
request := feedbackRequest()
// Option mode: rows are clickable.
_, optOffsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
if len(optOffsets) == 0 {
t.Fatal("precondition: option rows should be clickable in option mode")
}
// Feedback mode: no clickable rows.
_, typingOffsets := renderFocusedPermissionPrompt(request, 0, true, "some feedback", 80)
if typingOffsets != nil {
t.Fatalf("feedback mode must register no clickable option offsets, got %#v", typingOffsets)
}
}
+4 -41
View File
@@ -1959,49 +1959,12 @@ func (wizard *providerWizardState) filteredModels() []providerWizardModel {
return models
}
// A model Description is only a good row label when it reads like a display
// NAME — "Grok 4.3" for x-ai/grok-4.3 — which is what the field is for, and why
// the row prefers it over an ugly raw ID.
//
// Providers do not all honour that. Aggregators commonly return a prose blurb
// instead ("Fast DeepSeek model for efficient chat, coding help, and agent
// loops"), which fails as a label twice over: it overflows the row, and because
// such blurbs are written per model FAMILY rather than per model, several
// DIFFERENT models collapse to byte-identical rows. A picker then offers two
// visually identical entries with the distinguishing ID visible only in the
// detail line of whichever one is highlighted.
//
// providerWizardGenericModelDescription cannot catch these: it rejects
// descriptions that are vague ("live model", "catalog default"), whereas these
// are specific — just shared and sentence-shaped. So the test is shape, not
// wording: a name is short and few-worded; anything longer is prose, and the ID
// (unique by construction) makes the better label.
const (
providerWizardMaxModelNameChars = 28
providerWizardMaxModelNameWords = 4
)
func providerWizardDescriptionIsDisplayName(description string) bool {
trimmed := strings.TrimSpace(description)
if trimmed == "" || providerWizardGenericModelDescription(trimmed) {
return false
}
if len(trimmed) > providerWizardMaxModelNameChars {
return false
}
return len(strings.Fields(trimmed)) <= providerWizardMaxModelNameWords
}
func (model providerWizardModel) displayLabel() string {
if providerWizardDescriptionIsDisplayName(model.Description) {
return strings.TrimSpace(model.Description)
description := strings.TrimSpace(model.Description)
if description != "" && !providerWizardGenericModelDescription(description) {
return description
}
if id := strings.TrimSpace(model.ID); id != "" {
return id
}
// ID-less sentinel rows built in currentModel ("model name required",
// "no matching models") would otherwise render blank.
return strings.TrimSpace(model.Description)
return model.ID
}
func (model providerWizardModel) secondaryText() string {
-34
View File
@@ -1787,37 +1787,3 @@ func providerWizardIDs(descriptors []providercatalog.Descriptor) []string {
}
return ids
}
// A provider that returns prose blurbs instead of display names must not
// collapse distinct models into identical rows. Reproduces the reported picker
// where two "Open-weight GPT model..." and two "Kimi multimodal agent model..."
// rows were indistinguishable, with the ID visible only for the highlighted one.
func TestProviderWizardModelRowsStayDistinctWithProseDescriptions(t *testing.T) {
wizard := &providerWizardState{
step: providerWizardStepModel,
modelSource: "models.dev",
models: []providerWizardModel{
{ID: "gpt-oss-120b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
{ID: "gpt-oss-20b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
{ID: "kimi-k2-thinking", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
{ID: "kimi-k2-instruct", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
{ID: "x-ai/grok-4.3", Description: "Grok 4.3"},
},
}
view := plainRender(t, strings.Join(wizard.renderModelStep(96), "\n"))
// every prose-described model is identified by its unique ID
for _, id := range []string{"gpt-oss-120b", "gpt-oss-20b", "kimi-k2-thinking", "kimi-k2-instruct"} {
if !strings.Contains(view, id) {
t.Errorf("model %q is not identifiable in the list:\n%s", id, view)
}
}
// a genuine display name is still preferred over the raw id
if !strings.Contains(view, "Grok 4.3") {
t.Errorf("friendly display name was lost:\n%s", view)
}
// the prose blurb must not be a row label
if strings.Contains(view, "❯ Open-weight GPT model") {
t.Errorf("prose blurb still used as a row label:\n%s", view)
}
}
+4 -30
View File
@@ -1107,19 +1107,12 @@ func wrapDetailBlock(detail string, width int) string {
// renderFocusedPermissionPrompt draws the modal permission card and reports the
// card-relative Y offset of each option line (in permissionOptions order) so the
// caller can register those lines as clickable.
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, typing bool, feedback string, width int) (string, []int) {
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, width int) (string, []int) {
name := strings.TrimSpace(request.ToolName)
if name == "" {
name = "tool"
}
// The card body carries no background fill, matching every other prompt card
// (ask_user, spec review, plan) — see the lipgloss.NewStyle() fills below. The
// permission card used to tint its whole body with permBg, an amber-family
// wash that reads as a warm slab on cool themes (e.g. a brown-yellow box over
// dracula's purples) and made it the one outlier. The amber PERMISSION badge
// and the amber-mixed border still carry the "this is a permission gate"
// signal; the body no longer clashes with the surrounding theme.
fill := func(style lipgloss.Style) lipgloss.Style { return style }
fill := zeroTheme.onPerm
top := zeroTheme.permBadge.Render(" PERMISSION ")
@@ -1141,17 +1134,6 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
lines = append(lines, "")
// Feedback mode: the option list is replaced by a free-text field, like the
// ask_user "type your own answer" surface. What is typed is sent to the model
// as the denial reason, so it reads the instruction and adjusts.
if typing {
lines = append(lines, fill(zeroTheme.muted).Render("Tell Zero what to do differently:"))
lines = append(lines, zeroTheme.userPrompt.Render("❯ ")+fill(zeroTheme.ink).Render(feedback)+fill(zeroTheme.accent).Render("▌"))
lines = append(lines, "")
lines = append(lines, fill(zeroTheme.faint).Render("enter · send to Zero esc · back to options"))
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), nil
}
// Each option is its own line so a click anywhere on that row selects it (no
// per-column hit-testing). The highlighted row gets a ▸ marker and a reverse
// label; the rest stay quiet. styledBlockFill prepends exactly one top-border
@@ -1165,16 +1147,8 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
hotkey := fill(zeroTheme.faint).Render(" [" + option.hotkey + "]")
optionLabel := permissionOptionLabel(option, request)
if index == cursor {
// onSel, not badge. zeroTheme.badge is the brand chip (" 0 ", " ASK ",
// " SPEC REVIEW ") — a full-brightness accent fill meant for short
// labels. Using it for a selected ROW painted a bright accent slab
// across the permission card, fighting the card's amber warning palette
// and ignoring the card tint every other line composes onto. selBg is
// the tint tuned for exactly this job ("separates from the panel while
// ink label contrast stays ~9.4:1"), and onSel is what every other
// selectable list in the TUI uses for its highlighted row.
marker := fill(zeroTheme.accent).Render("▸ ")
label := zeroTheme.onSel(zeroTheme.ink).Bold(true).Render(" " + optionLabel + " ")
label := zeroTheme.badge.Render(" " + optionLabel + " ")
lines = append(lines, marker+label+hotkey)
} else {
label := fill(zeroTheme.ink).Render(optionLabel)
@@ -1189,7 +1163,7 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
}
lines = append(lines, fill(zeroTheme.faint).Render(footer))
return styledBlockFill(width, lines, zeroTheme.permBorder, lipgloss.NewStyle()), offsets
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), offsets
}
func permissionScopeLine(request agent.PermissionRequest, scope string) string {
+1 -1
View File
@@ -1659,7 +1659,7 @@ func TestFocusedPermissionCardShowsBadgeAndKeys(t *testing.T) {
Risk: sandbox.Risk{Level: sandbox.RiskMedium},
AvailableDecisions: testAllPermissionDecisions(),
}
card, offsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
card, offsets := renderFocusedPermissionPrompt(request, 0, 80)
got := plainRender(t, card)
for _, want := range []string{"PERMISSION", "edit_file", "writes internal/agent/exec.go", "Yes, proceed", "[a]", "this session", "[s]", "don't ask again", "[y]", "continue without running it", "[d]", "[esc]"} {
if !strings.Contains(got, want) {
+25 -7
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -601,13 +602,14 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
}
output := payloadString(payload, "output")
rows = append(rows, transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
changedFiles: payloadStringSlice(payload, "changedFiles"),
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
changedFiles: payloadStringSlice(payload, "changedFiles"),
changeSummaries: payloadExecutionChanges(payload, "changeSummaries"),
})
case sessions.EventError:
if message := payloadString(payload, "message"); message != "" {
@@ -799,6 +801,22 @@ func payloadStringSlice(payload map[string]any, key string) []string {
}
}
func payloadExecutionChanges(payload map[string]any, key string) []execution.Change {
value, ok := payload[key]
if !ok {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var changes []execution.Change
if err := json.Unmarshal(data, &changes); err != nil {
return nil
}
return changes
}
func payloadBool(payload map[string]any, key string) bool {
value := payload[key]
switch typed := value.(type) {
+5 -19
View File
@@ -94,26 +94,12 @@ func (m model) sidebarAvailable() bool {
if widthTier(m.width) < tierMedium {
return false
}
// Full-screen overlays (setup, wizards, pickers) take over the chat column and
// render at full width; suppress the second column while any is active so
// their geometry and mouse hit-testing stay full-width as before.
//
// The `/` command palette is deliberately NOT in this list. It is not a
// full-screen overlay: suggestionOverlay draws it via centerRenderedBlock at
// minInt(width, suggestionPaletteMaxWidth) — a centred box capped at 76 cells
// — floating over the chat column rather than replacing it. Suppressing the
// sidebar for it collapsed the whole layout on every keystroke of `/`: the
// plan panel dropped out of the sidebar and re-rendered inline at the bottom
// and the transcript re-wrapped to full width, which is at its most jarring
// mid-run with a live plan on screen. Since the sidebar already requires the
// medium tier (>= 80 cells) the palette is always strictly narrower than the
// chat column here, so the two never contend for the same cells.
//
// Mouse hit-testing is unaffected: sidebarLineAtMouse carries its own
// suggestionsActive() guard, so clicks still go to the palette and not to the
// sidebar rows underneath it.
// Full-screen overlays (setup, wizards, pickers, the empty-state suggestion
// list) take over the chat column and render at full width; suppress the
// second column while any is active so their geometry and mouse hit-testing
// stay full-width as before.
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil {
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return false
}
// Home/welcome screen: stay single-column until there's real conversation, so
-55
View File
@@ -631,58 +631,3 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
func stripSidebar(lines []string) string {
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
}
// The `/` command palette must NOT collapse the sidebar. It is a centred box
// capped at suggestionPaletteMaxWidth floating over the chat column, not a
// full-screen overlay — suppressing the second column for it dropped the plan
// out of the sidebar and re-rendered it inline at the bottom on every `/`,
// which is at its most disruptive mid-run with a live plan on screen. The
// genuinely full-width overlays must still suppress it.
func TestSidebarSurvivesCommandPalette(t *testing.T) {
base := func() model {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
m.headerPrinted = true
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
return m
}
m := base()
if !m.sidebarActive() {
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
}
// `/` palette open: sidebar stays, so the plan keeps its home and the layout
// does not reflow.
m.suggestions = []commandSuggestion{{Name: "/model", Desc: "Pick a model."}, {Name: "/plan", Desc: "Show planning mode status."}}
if !m.suggestionsActive() {
t.Fatal("precondition: suggestions should be active")
}
if !m.sidebarActive() {
t.Error("command palette must not collapse the sidebar")
}
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Errorf("plan must stay in the sidebar, not fall back to the pinned panel:\n%s", got)
}
// The palette never contends for the sidebar's cells: the two-column path
// renders it at width = chatColumnWidth, so it is centred inside the chat
// column and cannot overlap the sidebar.
chatW := m.chatColumnWidth()
for _, line := range strings.Split(plainRender(t, m.suggestionOverlay(chatW)), "\n") {
if w := lipgloss.Width(line); w > chatW {
t.Errorf("palette line is %d wide, wider than the chat column %d — it would overlap the sidebar", w, chatW)
}
}
// Clicks still belong to the palette, not the sidebar rows beneath it.
if _, ok := m.sidebarLineAtMouse(tea.MouseClickMsg{Button: tea.MouseLeft}); ok {
t.Error("sidebar must not take mouse hits while the palette is open")
}
// A genuinely full-width overlay still suppresses the sidebar.
full := base()
full.picker = &commandPicker{}
if full.sidebarActive() {
t.Error("a full-screen picker must still collapse the sidebar")
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -45,7 +46,8 @@ type transcriptRow struct {
// changedFiles lists the workspace-relative paths a mutating tool result
// wrote (from tools.Result.ChangedFiles; restored from the session payload on
// resume). The sidebar FILES section derives its roster from these.
changedFiles []string
changedFiles []string
changeSummaries []execution.Change
// specialistInfo holds the specialist card data for rowSpecialist rows.
// Nil for all other row kinds.
+3 -9
View File
@@ -454,7 +454,7 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed
rowIndex: -1,
heightCacheStable: false, // the highlight changes with the cursor
render: func(startBodyY int) transcriptBodyRenderedItem {
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, perm.typing, m.input.Value(), width)
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, width)
options := permissionOptions(perm.request)
selectable := make([]transcriptSelectableLine, 0, len(offsets))
for index, offset := range offsets {
@@ -1401,14 +1401,8 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
}
return m, nil, false
}
if line.permOption && !(m.pendingPermission != nil && m.pendingPermission.typing) {
// A left-click on a permission-popup option resolves it directly. The
// typing guard is defence-in-depth: renderFocusedPermissionPrompt already
// returns nil offsets in feedback mode, so no option row is registered as
// clickable then — but that single early-return is the only thing keeping
// a stray click (Allow included) off the decision path, and it lives in a
// function other PRs also edit. Guarding here makes the safety explicit
// rather than emergent.
if line.permOption {
// A left-click on a permission-popup option resolves it directly.
next, cmd := m.resolvePermission(line.permChoice)
return next.(model), cmd, true
}