diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 05691295a..0b1f8dffb 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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 { diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index a47e06285..69913a420 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -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) diff --git a/internal/agent/permission_additional_perms_test.go b/internal/agent/permission_additional_perms_test.go index 3602f7d65..44969a74e 100644 --- a/internal/agent/permission_additional_perms_test.go +++ b/internal/agent/permission_additional_perms_test.go @@ -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) + } +} diff --git a/internal/agent/prompt_budget_test.go b/internal/agent/prompt_budget_test.go index b538ca8a9..88e019011 100644 --- a/internal/agent/prompt_budget_test.go +++ b/internal/agent/prompt_budget_test.go @@ -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) { diff --git a/internal/agent/types.go b/internal/agent/types.go index 7055b3ecf..0e3391b9e 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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 diff --git a/internal/background/process_posix.go b/internal/background/process_posix.go index e4b100a38..62a30deac 100644 --- a/internal/background/process_posix.go +++ b/internal/background/process_posix.go @@ -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) } diff --git a/internal/background/process_windows.go b/internal/background/process_windows.go index b032e2004..c248bc853 100644 --- a/internal/background/process_windows.go +++ b/internal/background/process_windows.go @@ -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) } diff --git a/internal/cli/app.go b/internal/cli/app.go index 24002236a..a5cbf7ced 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -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, diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 239c6bd51..b35f795dc 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -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, diff --git a/internal/cli/exec_spec.go b/internal/cli/exec_spec.go index f049417f7..fc22eed35 100644 --- a/internal/cli/exec_spec.go +++ b/internal/cli/exec_spec.go @@ -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, diff --git a/internal/cli/hook_dispatch.go b/internal/cli/hook_dispatch.go index 5d122c900..76a33f271 100644 --- a/internal/cli/hook_dispatch.go +++ b/internal/cli/hook_dispatch.go @@ -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 } diff --git a/internal/cli/mcp_tools.go b/internal/cli/mcp_tools.go index 3f933e024..022d64001 100644 --- a/internal/cli/mcp_tools.go +++ b/internal/cli/mcp_tools.go @@ -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 } diff --git a/internal/cli/plugin_activate.go b/internal/cli/plugin_activate.go index 64ff72758..5634d93fd 100644 --- a/internal/cli/plugin_activate.go +++ b/internal/cli/plugin_activate.go @@ -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) } diff --git a/internal/execution/change_observer.go b/internal/execution/change_observer.go new file mode 100644 index 000000000..322bf3919 --- /dev/null +++ b/internal/execution/change_observer.go @@ -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 +} diff --git a/internal/execution/change_observer_test.go b/internal/execution/change_observer_test.go new file mode 100644 index 000000000..ada96f24a --- /dev/null +++ b/internal/execution/change_observer_test.go @@ -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) + } +} diff --git a/internal/execution/contracts.go b/internal/execution/contracts.go new file mode 100644 index 000000000..3003513a4 --- /dev/null +++ b/internal/execution/contracts.go @@ -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 + } +} diff --git a/internal/execution/contracts_test.go b/internal/execution/contracts_test.go new file mode 100644 index 000000000..4a49c9b4b --- /dev/null +++ b/internal/execution/contracts_test.go @@ -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") + } + }) + } +} diff --git a/internal/execution/process_manager.go b/internal/execution/process_manager.go new file mode 100644 index 000000000..7e4005fc8 --- /dev/null +++ b/internal/execution/process_manager.go @@ -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 +} diff --git a/internal/execution/process_manager_test.go b/internal/execution/process_manager_test.go new file mode 100644 index 000000000..fff3ca987 --- /dev/null +++ b/internal/execution/process_manager_test.go @@ -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() +} diff --git a/internal/execution/process_unix.go b/internal/execution/process_unix.go new file mode 100644 index 000000000..27302afdf --- /dev/null +++ b/internal/execution/process_unix.go @@ -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 +} diff --git a/internal/execution/process_windows.go b/internal/execution/process_windows.go new file mode 100644 index 000000000..beb919535 --- /dev/null +++ b/internal/execution/process_windows.go @@ -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") +} diff --git a/internal/execution/pty_fallback.go b/internal/execution/pty_fallback.go new file mode 100644 index 000000000..6aa17e7dd --- /dev/null +++ b/internal/execution/pty_fallback.go @@ -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") +} diff --git a/internal/tools/exec_pty_linux.go b/internal/execution/pty_linux.go similarity index 55% rename from internal/tools/exec_pty_linux.go rename to internal/execution/pty_linux.go index c96ab60a0..d52e1d758 100644 --- a/internal/tools/exec_pty_linux.go +++ b/internal/execution/pty_linux.go @@ -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 diff --git a/internal/execution/pty_linux_test.go b/internal/execution/pty_linux_test.go new file mode 100644 index 000000000..d770d8973 --- /dev/null +++ b/internal/execution/pty_linux_test.go @@ -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) + } +} diff --git a/internal/execution/runner.go b/internal/execution/runner.go new file mode 100644 index 000000000..9e3ecbf9a --- /dev/null +++ b/internal/execution/runner.go @@ -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) diff --git a/internal/execution/runner_test.go b/internal/execution/runner_test.go new file mode 100644 index 000000000..3214b41bb --- /dev/null +++ b/internal/execution/runner_test.go @@ -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) + } +} diff --git a/internal/execution/transport.go b/internal/execution/transport.go new file mode 100644 index 000000000..d86a2ec98 --- /dev/null +++ b/internal/execution/transport.go @@ -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 +} diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index e11d3df3d..d5bb13e3c 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -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 { diff --git a/internal/hooks/dispatch_test.go b/internal/hooks/dispatch_test.go index 3b6e88018..40d6e295f 100644 --- a/internal/hooks/dispatch_test.go +++ b/internal/hooks/dispatch_test.go @@ -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 { diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 83bc0343c..665e0dfa2 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -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 } diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index 2cca52d65..8a66b7f69 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -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() diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 4dbe13607..8c9dac7bd 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -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}) } } diff --git a/internal/plugins/activate.go b/internal/plugins/activate.go index 443e74bde..a684bf1c5 100644 --- a/internal/plugins/activate.go +++ b/internal/plugins/activate.go @@ -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 +} diff --git a/internal/plugins/activate_test.go b/internal/plugins/activate_test.go index 63f4097fb..7d100c9e7 100644 --- a/internal/plugins/activate_test.go +++ b/internal/plugins/activate_test.go @@ -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() diff --git a/internal/sandbox/analyzer.go b/internal/sandbox/analyzer.go index 9509c5a81..15fd399d1 100644 --- a/internal/sandbox/analyzer.go +++ b/internal/sandbox/analyzer.go @@ -150,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: @@ -159,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 @@ -169,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 } diff --git a/internal/sandbox/analyzer_test.go b/internal/sandbox/analyzer_test.go index 1154922b4..303de9999 100644 --- a/internal/sandbox/analyzer_test.go +++ b/internal/sandbox/analyzer_test.go @@ -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}, diff --git a/internal/sandbox/engine.go b/internal/sandbox/engine.go index 20bd9d63b..db66d54a9 100644 --- a/internal/sandbox/engine.go +++ b/internal/sandbox/engine.go @@ -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 diff --git a/internal/sandbox/execution_adapter_test.go b/internal/sandbox/execution_adapter_test.go new file mode 100644 index 000000000..65fbd35ef --- /dev/null +++ b/internal/sandbox/execution_adapter_test.go @@ -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) + } +} diff --git a/internal/sandbox/grants.go b/internal/sandbox/grants.go index ad2170c87..ebccc630a 100644 --- a/internal/sandbox/grants.go +++ b/internal/sandbox/grants.go @@ -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{}, } diff --git a/internal/sandbox/grants_test.go b/internal/sandbox/grants_test.go index ae0a81bce..271ede30c 100644 --- a/internal/sandbox/grants_test.go +++ b/internal/sandbox/grants_test.go @@ -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) } } diff --git a/internal/sandbox/linux_helper.go b/internal/sandbox/linux_helper.go index abf05b7e6..1be897810 100644 --- a/internal/sandbox/linux_helper.go +++ b/internal/sandbox/linux_helper.go @@ -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 { diff --git a/internal/sandbox/linux_helper_linux.go b/internal/sandbox/linux_helper_linux.go index 42e7506ce..a4687f9a9 100644 --- a/internal/sandbox/linux_helper_linux.go +++ b/internal/sandbox/linux_helper_linux.go @@ -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 } } diff --git a/internal/sandbox/linux_helper_linux_test.go b/internal/sandbox/linux_helper_linux_test.go index 2184d76b8..0f054fcd7 100644 --- a/internal/sandbox/linux_helper_linux_test.go +++ b/internal/sandbox/linux_helper_linux_test.go @@ -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()) } } diff --git a/internal/sandbox/linux_helper_test.go b/internal/sandbox/linux_helper_test.go index 4b6602657..077de65da 100644 --- a/internal/sandbox/linux_helper_test.go +++ b/internal/sandbox/linux_helper_test.go @@ -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{ diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 8cc784741..44fd6f0a0 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -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 { diff --git a/internal/sandbox/platform_contract_test.go b/internal/sandbox/platform_contract_test.go new file mode 100644 index 000000000..7090c06c0 --- /dev/null +++ b/internal/sandbox/platform_contract_test.go @@ -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") + } + }) + } +} diff --git a/internal/sandbox/profile.go b/internal/sandbox/profile.go index 194736667..915bb92be 100644 --- a/internal/sandbox/profile.go +++ b/internal/sandbox/profile.go @@ -18,6 +18,7 @@ const ( type PermissionProfile struct { FileSystem FileSystemPolicy `json:"fileSystem"` Network NetworkPolicy `json:"network"` + Runtime *SandboxRuntime `json:"runtime,omitempty"` } type FileSystemPolicy struct { diff --git a/internal/sandbox/protected_create_linux.go b/internal/sandbox/protected_create_linux.go new file mode 100644 index 000000000..e021266bc --- /dev/null +++ b/internal/sandbox/protected_create_linux.go @@ -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 +} diff --git a/internal/sandbox/protected_create_linux_test.go b/internal/sandbox/protected_create_linux_test.go new file mode 100644 index 000000000..b5027a07b --- /dev/null +++ b/internal/sandbox/protected_create_linux_test.go @@ -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") + } +} diff --git a/internal/sandbox/request_permissions.go b/internal/sandbox/request_permissions.go index ad471ad97..413914d26 100644 --- a/internal/sandbox/request_permissions.go +++ b/internal/sandbox/request_permissions.go @@ -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() { diff --git a/internal/sandbox/request_permissions_test.go b/internal/sandbox/request_permissions_test.go index 1381ecf8f..330cf69bc 100644 --- a/internal/sandbox/request_permissions_test.go +++ b/internal/sandbox/request_permissions_test.go @@ -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) diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go index 506acb393..248c0a528 100644 --- a/internal/sandbox/runner.go +++ b/internal/sandbox/runner.go @@ -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*)" } } diff --git a/internal/sandbox/runner_linux_integration_test.go b/internal/sandbox/runner_linux_integration_test.go index 699fdbaea..8352cebf2 100644 --- a/internal/sandbox/runner_linux_integration_test.go +++ b/internal/sandbox/runner_linux_integration_test.go @@ -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) diff --git a/internal/sandbox/runner_test.go b/internal/sandbox/runner_test.go index 67b66866c..dde080333 100644 --- a/internal/sandbox/runner_test.go +++ b/internal/sandbox/runner_test.go @@ -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) } diff --git a/internal/sandbox/runtime_lease.go b/internal/sandbox/runtime_lease.go new file mode 100644 index 000000000..b05530fae --- /dev/null +++ b/internal/sandbox/runtime_lease.go @@ -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) +} diff --git a/internal/sandbox/runtime_lease_unix.go b/internal/sandbox/runtime_lease_unix.go new file mode 100644 index 000000000..3f51f6d90 --- /dev/null +++ b/internal/sandbox/runtime_lease_unix.go @@ -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() +} diff --git a/internal/sandbox/runtime_lease_windows.go b/internal/sandbox/runtime_lease_windows.go new file mode 100644 index 000000000..a27594728 --- /dev/null +++ b/internal/sandbox/runtime_lease_windows.go @@ -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() +} diff --git a/internal/sandbox/runtime_state.go b/internal/sandbox/runtime_state.go new file mode 100644 index 000000000..a738396fc --- /dev/null +++ b/internal/sandbox/runtime_state.go @@ -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 +} diff --git a/internal/sandbox/runtime_state_test.go b/internal/sandbox/runtime_state_test.go new file mode 100644 index 000000000..999ad2fd3 --- /dev/null +++ b/internal/sandbox/runtime_state_test.go @@ -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() +} diff --git a/internal/sandbox/seccomp.go b/internal/sandbox/seccomp.go index af21a82b0..829552edc 100644 --- a/internal/sandbox/seccomp.go +++ b/internal/sandbox/seccomp.go @@ -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 { diff --git a/internal/sandbox/seccomp_linux.go b/internal/sandbox/seccomp_linux.go index 3ec0bdb18..e5d8c26e9 100644 --- a/internal/sandbox/seccomp_linux.go +++ b/internal/sandbox/seccomp_linux.go @@ -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) diff --git a/internal/sandbox/seccomp_other.go b/internal/sandbox/seccomp_other.go index 249887abb..0fdc658bf 100644 --- a/internal/sandbox/seccomp_other.go +++ b/internal/sandbox/seccomp_other.go @@ -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 } diff --git a/internal/sandbox/seccomp_test.go b/internal/sandbox/seccomp_test.go index 2bc8fb25f..16774e426 100644 --- a/internal/sandbox/seccomp_test.go +++ b/internal/sandbox/seccomp_test.go @@ -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 { diff --git a/internal/sandbox/windows_runner.go b/internal/sandbox/windows_runner.go index ac8292ca2..032f2a844 100644 --- a/internal/sandbox/windows_runner.go +++ b/internal/sandbox/windows_runner.go @@ -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. diff --git a/internal/tools/bash.go b/internal/tools/bash.go index 661e2f524..a417fb8a7 100644 --- a/internal/tools/bash.go +++ b/internal/tools/bash.go @@ -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 diff --git a/internal/tools/bash_proc_unix.go b/internal/tools/bash_proc_unix.go index 3735890e1..5c9b13733 100644 --- a/internal/tools/bash_proc_unix.go +++ b/internal/tools/bash_proc_unix.go @@ -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) } } diff --git a/internal/tools/bash_proc_windows.go b/internal/tools/bash_proc_windows.go index 4b3cc7876..589da5677 100644 --- a/internal/tools/bash_proc_windows.go +++ b/internal/tools/bash_proc_windows.go @@ -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 diff --git a/internal/tools/bash_tool_test.go b/internal/tools/bash_tool_test.go index 270b14e6b..b13ac0031 100644 --- a/internal/tools/bash_tool_test.go +++ b/internal/tools/bash_tool_test.go @@ -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) { diff --git a/internal/tools/exec_command.go b/internal/tools/exec_command.go index 20ab68de0..b1b902b3d 100644 --- a/internal/tools/exec_command.go +++ b/internal/tools/exec_command.go @@ -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 - } - 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 errorResult("Error starting exec_command: " + err.Error()) + } + 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{} diff --git a/internal/tools/exec_command_test.go b/internal/tools/exec_command_test.go index 7f5eee375..8b4f11c8f 100644 --- a/internal/tools/exec_command_test.go +++ b/internal/tools/exec_command_test.go @@ -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,11 +231,100 @@ 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) { root := t.TempDir() manager := newExecSessionManager() @@ -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() -} diff --git a/internal/tools/exec_pty_fallback.go b/internal/tools/exec_pty_fallback.go deleted file mode 100644 index c260f8894..000000000 --- a/internal/tools/exec_pty_fallback.go +++ /dev/null @@ -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 -} diff --git a/internal/tools/exec_transport.go b/internal/tools/exec_transport.go deleted file mode 100644 index 210caf252..000000000 --- a/internal/tools/exec_transport.go +++ /dev/null @@ -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 -} diff --git a/internal/tools/sandbox_denial.go b/internal/tools/sandbox_denial.go index 2431ea5d6..ab9fa7dd1 100644 --- a/internal/tools/sandbox_denial.go +++ b/internal/tools/sandbox_denial.go @@ -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 - } -} - -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 - } - } + 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 } - return "" } diff --git a/internal/tools/sandbox_denial_test.go b/internal/tools/sandbox_denial_test.go index f9dbbbdd5..51d53fc8a 100644 --- a/internal/tools/sandbox_denial_test.go +++ b/internal/tools/sandbox_denial_test.go @@ -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) } } diff --git a/internal/tools/types.go b/internal/tools/types.go index 08137cd24..d21c1f19b 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -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 } diff --git a/internal/tui/file_view_test.go b/internal/tui/file_view_test.go index 3856ea6db..1f9edc202 100644 --- a/internal/tui/file_view_test.go +++ b/internal/tui/file_view_test.go @@ -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 diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 0f69ad7b0..624b72ffa 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -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: diff --git a/internal/tui/files_panel_test.go b/internal/tui/files_panel_test.go index 909771703..63a50dc48 100644 --- a/internal/tui/files_panel_test.go +++ b/internal/tui/files_panel_test.go @@ -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. diff --git a/internal/tui/model.go b/internal/tui/model.go index 12cedaed2..713c22eb7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5148,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. @@ -5188,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, diff --git a/internal/tui/session.go b/internal/tui/session.go index 06eccf8f5..9f2a6f544 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -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) { diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index a31f7b5a8..e051bfc85 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -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.