Compare commits

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

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

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

No files matched your search

+82 -40
View File
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -1170,6 +1171,17 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
decisionCommandPrefix = grant.Prefix
}
}
// An explicit additional-permissions request is an elevation only when it
// expands the current effective policy. If the same capability was already
// approved for this session, reuse that grant across equivalent commands
// instead of prompting again merely because their command prefixes differ.
if toolFound && !permissionGranted && isShellCommandTool(call.Name) && options.Sandbox != nil {
if profile, requested, profileErr := inlineAdditionalPermissionsProfile(args, options.Cwd); profileErr == nil && requested && options.Sandbox.CoversRequestPermissions(profile) {
permissionGranted = true
decisionAction = PermissionDecisionAllowForSession
decisionReason = "requested sandbox capabilities already granted for this session"
}
}
var preflightDecision *sandbox.Decision
if toolFound && options.Sandbox != nil {
@@ -1416,17 +1428,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// the agent loop and the MCP server pass through), so result.Output is
// already redacted here and result.Redacted reflects whether it changed.
return ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
@@ -1546,8 +1559,15 @@ func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, resu
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
return false
}
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
return false
if result.ExecutionOutcome != nil {
denial := result.ExecutionOutcome.Denial
if denial == nil || denial.Capability.Kind != execution.CapabilityExternalNetwork {
return false
}
} else {
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
return false
}
}
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
return false
@@ -1600,6 +1620,11 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
}
func sandboxDeniedShellResult(result tools.Result) bool {
if result.ExecutionOutcome != nil {
// Typed denials must be handled by their exact capability path. Never
// turn a structured narrow denial into the legacy unrestricted retry.
return false
}
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
@@ -1728,17 +1753,18 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: display,
LoadedTools: loadedToolsFromResult(meta),
RequestedModel: meta["escalate_to_model"],
}
}
@@ -1998,15 +2024,16 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
Display: result.Display,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
}
}
return ToolResult{
@@ -2570,7 +2597,8 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
scopeText = requestPermissionsScope(profile)
}
reason = userFacingPermissionReason(call.Name, args, reason)
decisionReason := strings.TrimSpace(reason)
reason = userFacingPermissionReason(call.Name, args, reason, safety.Reason)
return PermissionEvent{
ToolCallID: call.ID,
@@ -2583,6 +2611,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
Autonomy: options.Autonomy,
SideEffect: string(safety.SideEffect),
Reason: reason,
DecisionReason: decisionReason,
Scope: scopeText,
Risk: risk,
Block: block,
@@ -2592,18 +2621,31 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}, true
}
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
func userFacingPermissionReason(toolName string, args map[string]any, reason string, fallback string) string {
reason = strings.TrimSpace(reason)
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
if !isShellCommandTool(toolName) {
return reason
}
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
// Policy and sandbox explanations are authoritative. The tool's static
// safety copy is only an internal fallback; presenting it as the reason for
// every ordinary shell approval makes prompts generic and obscures the cases
// where policy has an actionable explanation.
fallback = strings.TrimSpace(fallback)
if reason != "" && reason != fallback && reason != "tool requires approval before execution" && reason != sandbox.ReasonEscalatedSandboxRequired {
return reason
}
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
if shellCommandRequiresEscalated(args) {
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
}
return "This command needs to run outside the sandbox."
}
return reason
// Ordinary shell approvals need no invented explanation. The raw fallback is
// retained separately as DecisionReason for traces and policy auditing.
return ""
}
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
+54
View File
@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
@@ -26,6 +27,59 @@ type mockProvider struct {
requests []zeroruntime.CompletionRequest
}
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
call := ToolCall{Name: tools.ExecCommandToolName}
options := Options{Sandbox: engine}
legacyMeta := map[string]string{
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
}
applicationFailure := tools.Result{
Status: tools.StatusError,
Meta: legacyMeta,
ExecutionOutcome: &execution.Outcome{
State: execution.StateFailed,
Kind: execution.OutcomeApplicationFailure,
Exit: &execution.Exit{Code: 1},
},
}
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
}
protectedDenial := applicationFailure
protectedDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected metadata",
NextAction: execution.DenialNextActionRequestApproval,
},
}
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
}
networkDenial := protectedDenial
networkDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
Source: execution.DenialSourcePlatformSandbox,
Reason: "external network denied",
NextAction: execution.DenialNextActionRequestApproval,
},
}
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
t.Fatal("typed external-network denial should use the narrow network approval path")
}
}
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "npm install",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
t.Fatal("an already-covered capability must not trigger another prompt")
}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("covered capability should still produce an auditable allow event")
}
if event.Action != PermissionActionAllow {
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
}
}
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "make test"}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("generic shell approval must produce an event")
}
if event.Reason != "" {
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
}
if event.DecisionReason != "runs a shell command" {
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
}
}
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "curl https://example.test",
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
"justification": "Run without sandboxing.",
}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("policy approval must produce an event")
}
if event.Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
}
}
+5 -5
View File
@@ -14,13 +14,13 @@ import (
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
// the auto-mode advertised set, including exec_command: auto mode must expose both
// process creation and write_stdin process interaction or the latter is unusable
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
maxEagerToolSchemaTokens = 3550
)
func TestSystemPromptTokenBudget(t *testing.T) {
+5 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -71,7 +72,10 @@ type ToolResult struct {
Meta map[string]string
Redacted bool
ChangedFiles []string
Display tools.Display
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
+4 -74
View File
@@ -3,11 +3,10 @@
package background
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
@@ -22,10 +21,7 @@ var (
// the negative PID (the group), so any process the child forks dies with it
// instead of being orphaned. Must be called before cmd.Start.
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(cmd)
}
// terminateProcess stops a background process. It first asks politely with
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
// back to signalling only the individual PID, which also avoids reporting a false
// success when a non-leader group-signal returns ESRCH.
func terminateProcess(pid int) error {
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
// every process we can signal. A real child PID is always > 1, so never let a
// bogus 0/1 expand into either.
if pid <= 1 {
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
}
// Signal the whole group only when pid leads its own group; otherwise the
// individual process. See the doc comment for why a non-leader is signalled
// directly rather than via its (foreign) group.
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if processGoneError(err) {
return nil // already gone
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if processGoneError(err) {
return nil
}
return err
}
// Poll liveness so we return promptly once it exits, rather than always
// waiting out the full grace period.
deadline := time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if !alive() {
return nil
}
// Still alive after the grace period: force-kill.
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
return err
}
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
// so this helper only reports success once the target is actually gone —
// otherwise the caller gets nil while descendants are still racing.
deadline = time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
// processGoneError reports whether an error means the process group has already
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
// no process in the target group remains.
func processGoneError(err error) bool {
return errors.Is(err, syscall.ESRCH)
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
+6 -26
View File
@@ -3,36 +3,16 @@
package background
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"github.com/Gitlawb/zero/internal/execution"
)
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
func terminateProcess(pid int) error {
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
return execution.TerminateProcessTree(pid, 0, 0)
}
+25 -15
View File
@@ -17,6 +17,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
@@ -678,6 +679,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
executionRunner := execution.NewRunner(nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
executionRunner.SetPreparer(sandboxEngine)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
@@ -712,6 +733,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
})
}
if err != nil {
@@ -738,7 +761,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -760,19 +783,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// unchanged. The interactive surface applies no operator tool filters, so
// enabled/disabled are nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
@@ -789,7 +799,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
+29 -29
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -206,6 +207,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
executionRunner := execution.NewRunner(nil)
// Register the escalate_model tool only when the run opted into mid-run
// escalation. It is registered before --list-tools formatting and
// validateExecToolFilters so the tool is both listable and filter-validatable.
@@ -238,16 +240,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
// registry and collect their hooks + skill roots for the dispatcher and skill
// tool below. Done before --list-tools and filter validation so plugin tools
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
var pluginActivation pluginActivation
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
@@ -290,6 +289,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
executionRunner.SetPreparer(sandboxEngine)
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -357,28 +379,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
images = nil
}
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
// instances registered before config resolve (the registry must exist that
// early for --list-tools and tool-filter validation, which run without a
// provider). This is safe only while two invariants hold:
// 1. Registry.Register replaces by NAME, so every path-confining core
// tool is swapped wholesale; and
// 2. nothing between the initial registration and this point captures a
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
// names lazily; --list-tools and filter validation use names only).
// A new wrapper that snapshots a core tool before this line would silently
// ship nil-scope enforcement — add it below this re-registration instead.
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
@@ -624,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// Build the hooks dispatcher out of the struct literal so its trust skip report
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -114,7 +115,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
// repo trusted. Emit at most one notice when project hooks or the project MCP
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
+12 -6
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
@@ -56,8 +57,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
//
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment); the project layer loads only when that root is trusted.
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
}
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
@@ -72,7 +73,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
// returned trustSkip lets the caller emit one combined notice; the notice itself
// is NOT emitted here.
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
@@ -106,10 +107,15 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
}
config.Hooks = merged
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Execution: runner,
}), skip
}
+8 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -29,7 +30,7 @@ type mcpToolListItem struct {
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
@@ -46,9 +47,15 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
if err != nil {
return nil, skip, err
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
Execution: runner,
WorkspaceRoot: workspaceRoot,
})
return runtime, skip, err
}
+7 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -42,7 +43,7 @@ type pluginActivation struct {
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment). The returned activation carries the skip report so the caller can
// emit one combined notice; the notice itself is NOT emitted here.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
@@ -65,7 +66,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
for _, warning := range result.Warnings {
writePluginActivationWarning(stderr, warning)
}
+214
View File
@@ -0,0 +1,214 @@
package execution
import (
"crypto/sha256"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
maxObservedFiles = 20_000
maxHashedFileBytes = 1 << 20
maxTotalHashedBytes = 32 << 20
)
type ChangeKind string
const (
ChangeCreated ChangeKind = "created"
ChangeModified ChangeKind = "modified"
ChangeDeleted ChangeKind = "deleted"
)
type Change struct {
Path string `json:"path"`
Kind ChangeKind `json:"kind"`
Aggregated bool `json:"aggregated,omitempty"`
Count int `json:"count,omitempty"`
}
type fileFingerprint struct {
Mode fs.FileMode
Size int64
ModTime int64
Digest [sha256.Size]byte
Hashed bool
// Aggregated marks a bounded fingerprint for a generated directory. The
// observer never enumerates that tree into individual changes.
Aggregated bool
Count int
}
// ChangeObserver records a bounded workspace snapshot around a command. It
// deliberately skips Zero/repository control metadata and large generated
// dependency trees; those paths must neither be read as command evidence nor
// flood the Files panel.
type ChangeObserver struct {
root string
before map[string]fileFingerprint
valid bool
}
func NewChangeObserver(root string) *ChangeObserver {
root = filepath.Clean(strings.TrimSpace(root))
before, ok := snapshotWorkspace(root)
return &ChangeObserver{root: root, before: before, valid: ok}
}
func (observer *ChangeObserver) Changes() []Change {
if observer == nil || !observer.valid {
return nil
}
after, ok := snapshotWorkspace(observer.root)
if !ok {
return nil
}
changes := make([]Change, 0)
for path, current := range after {
previous, existed := observer.before[path]
if !existed {
changes = append(changes, changeFromFingerprint(path, ChangeCreated, current))
continue
}
if previous != current {
changes = append(changes, changeFromFingerprint(path, ChangeModified, current))
}
}
for path, previous := range observer.before {
if _, exists := after[path]; !exists {
changes = append(changes, changeFromFingerprint(path, ChangeDeleted, previous))
}
}
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
return changes
}
func snapshotWorkspace(root string) (map[string]fileFingerprint, bool) {
if root == "" || root == "." {
return nil, false
}
files := make(map[string]fileFingerprint)
hashedBytes := int64(0)
ok := true
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if path == root {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil
}
if entry.IsDir() {
if protectedObservationDirectory(entry.Name()) {
return filepath.SkipDir
}
if generatedObservationDirectory(entry.Name()) {
files[filepath.ToSlash(relative)+"/"] = generatedTreeFingerprint(path)
return filepath.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
if len(files) >= maxObservedFiles {
ok = false
return fs.SkipAll
}
info, err := entry.Info()
if err != nil {
return nil
}
fingerprint := fileFingerprint{Mode: info.Mode(), Size: info.Size(), ModTime: info.ModTime().UnixNano()}
if info.Size() <= maxHashedFileBytes && hashedBytes+info.Size() <= maxTotalHashedBytes {
if digest, err := hashObservedFile(path); err == nil {
fingerprint.Digest = digest
fingerprint.Hashed = true
hashedBytes += info.Size()
}
}
files[filepath.ToSlash(relative)] = fingerprint
return nil
})
if err != nil {
return nil, false
}
return files, ok
}
func protectedObservationDirectory(name string) bool {
switch name {
case ".git", ".zero", ".agents":
return true
default:
return false
}
}
func generatedObservationDirectory(name string) bool {
switch name {
case "node_modules", ".pnpm-store", ".yarn", "dist", "coverage", ".cache":
return true
default:
return false
}
}
// generatedTreeFingerprint samples only direct children and caps the work. It
// is intentionally qualitative: the UI needs to say that a generated tree
// changed, not inventory dependency contents.
func generatedTreeFingerprint(root string) fileFingerprint {
directory, err := os.Open(root)
if err != nil {
return fileFingerprint{Aggregated: true, Count: -1}
}
defer directory.Close()
const maxEntries = 256
entries, err := directory.ReadDir(maxEntries + 1)
if err != nil && err != io.EOF {
return fileFingerprint{Aggregated: true, Count: -1}
}
hash := sha256.New()
count := len(entries)
if count > maxEntries {
entries = entries[:maxEntries]
count = -1
}
for _, entry := range entries {
_, _ = io.WriteString(hash, entry.Name())
if info, infoErr := entry.Info(); infoErr == nil {
_, _ = io.WriteString(hash, info.Mode().String())
_, _ = io.WriteString(hash, strconv.FormatInt(info.ModTime().UnixNano(), 10))
}
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return fileFingerprint{Digest: digest, Hashed: true, Aggregated: true, Count: count}
}
func changeFromFingerprint(path string, kind ChangeKind, fingerprint fileFingerprint) Change {
return Change{Path: path, Kind: kind, Aggregated: fingerprint.Aggregated, Count: fingerprint.Count}
}
func hashObservedFile(path string) ([sha256.Size]byte, error) {
file, err := os.Open(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -0,0 +1,67 @@
package execution
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestChangeObserverReportsCreateModifyAndDelete(t *testing.T) {
root := t.TempDir()
modified := filepath.Join(root, "modified.txt")
deleted := filepath.Join(root, "deleted.txt")
if err := os.WriteFile(modified, []byte("before"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(deleted, []byte("delete"), 0o644); err != nil {
t.Fatal(err)
}
observer := NewChangeObserver(root)
if err := os.WriteFile(modified, []byte("after!"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(deleted); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "src", "created.ts"), []byte("export {}"), 0o644); err != nil {
t.Fatal(err)
}
want := []Change{
{Path: "deleted.txt", Kind: ChangeDeleted},
{Path: "modified.txt", Kind: ChangeModified},
{Path: "src/created.ts", Kind: ChangeCreated},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want %#v", got, want)
}
}
func TestChangeObserverSkipsControlMetadataAndSummarizesGeneratedTrees(t *testing.T) {
root := t.TempDir()
observer := NewChangeObserver(root)
for _, path := range []string{
filepath.Join(root, ".zero", "config.json"),
filepath.Join(root, ".agents", "skills", "x"),
filepath.Join(root, ".git", "config"),
filepath.Join(root, "node_modules", "pkg", "index.js"),
filepath.Join(root, "dist", "bundle.js"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
t.Fatal(err)
}
}
want := []Change{
{Path: "dist/", Kind: ChangeCreated, Aggregated: true, Count: 1},
{Path: "node_modules/", Kind: ChangeCreated, Aggregated: true, Count: 1},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want generated summaries without control metadata %#v", got, want)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Package execution defines the platform-neutral command execution contract.
// Frontends and tools use these types without depending on native sandbox or
// process-management details.
package execution
import (
"errors"
"fmt"
"strings"
)
const PolicyVersion = 1
type Origin string
const (
OriginInteractiveCommand Origin = "interactive_command"
OriginHook Origin = "hook"
OriginPlugin Origin = "plugin"
OriginMCPServer Origin = "mcp_server"
OriginBackgroundTask Origin = "background_task"
)
type Mode string
const (
ModeCaptured Mode = "captured"
ModeInteractive Mode = "interactive"
ModeDurable Mode = "durable"
)
type Command struct {
Name string `json:"name"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
}
type CapabilityKind string
const (
CapabilityRead CapabilityKind = "filesystem_read"
CapabilityWorkspaceWrite CapabilityKind = "workspace_write"
CapabilityProtectedMetadata CapabilityKind = "protected_metadata"
CapabilityExternalNetwork CapabilityKind = "external_network"
CapabilityIsolatedLoopback CapabilityKind = "isolated_loopback"
CapabilityHostVisibleBinding CapabilityKind = "host_visible_binding"
CapabilityProcessSpawn CapabilityKind = "process_spawn"
CapabilityProcessControl CapabilityKind = "process_control"
CapabilityEnvironment CapabilityKind = "environment"
CapabilityUnrestricted CapabilityKind = "unrestricted"
)
type Capability struct {
Kind CapabilityKind `json:"kind"`
Scope string `json:"scope,omitempty"`
}
type ApprovalContext struct {
SessionID string `json:"sessionId,omitempty"`
PolicyVersion int `json:"policyVersion"`
}
type Request struct {
Origin Origin `json:"origin"`
Mode Mode `json:"mode"`
Command Command `json:"command"`
WorkingDirectory string `json:"workingDirectory"`
WorkspaceRoots []string `json:"workspaceRoots"`
Capabilities []Capability `json:"capabilities,omitempty"`
Approval ApprovalContext `json:"approval"`
}
func (request Request) Validate() error {
if !validOrigin(request.Origin) {
return errors.New("execution request requires a valid origin")
}
if request.Mode != ModeCaptured && request.Mode != ModeInteractive && request.Mode != ModeDurable {
return errors.New("execution request requires a valid mode")
}
if strings.TrimSpace(request.Command.Name) == "" {
return errors.New("execution request requires a command")
}
if strings.TrimSpace(request.WorkingDirectory) == "" {
return errors.New("execution request requires a working directory")
}
if len(request.WorkspaceRoots) == 0 {
return errors.New("execution request requires at least one workspace root")
}
for _, root := range request.WorkspaceRoots {
if strings.TrimSpace(root) == "" {
return errors.New("execution request contains an empty workspace root")
}
}
for _, capability := range request.Capabilities {
if !validCapabilityKind(capability.Kind) {
return fmt.Errorf("execution request contains invalid capability %q", capability.Kind)
}
}
return nil
}
type State string
const (
StateRequested State = "requested"
StateEvaluated State = "evaluated"
StateAwaitingApproval State = "awaiting_approval"
StatePrepared State = "prepared"
StateRunning State = "running"
StateRetained State = "retained"
StateCompleted State = "completed"
StateDenied State = "denied"
StateFailed State = "failed"
StateCancelled State = "cancelled"
)
type OutcomeKind string
const (
OutcomeRunning OutcomeKind = "running"
OutcomeSuccess OutcomeKind = "success"
OutcomePolicyDenied OutcomeKind = "policy_denied"
OutcomeEnforcementDenied OutcomeKind = "enforcement_denied"
OutcomeApplicationFailure OutcomeKind = "application_failure"
OutcomeSandboxSetupFailure OutcomeKind = "sandbox_setup_failure"
OutcomeExecutableNotFound OutcomeKind = "executable_not_found"
OutcomeCancelled OutcomeKind = "cancelled"
OutcomeTimedOut OutcomeKind = "timed_out"
OutcomeEnforcementDegraded OutcomeKind = "enforcement_degraded"
)
type Exit struct {
Code int `json:"code"`
Signal string `json:"signal,omitempty"`
}
type DenialSource string
const (
DenialSourceConfiguredPolicy DenialSource = "configured_policy"
DenialSourcePlatformSandbox DenialSource = "platform_sandbox"
DenialSourceApproval DenialSource = "approval"
)
type DenialNextAction string
const (
DenialNextActionNone DenialNextAction = "none"
DenialNextActionRequestApproval DenialNextAction = "request_approval"
DenialNextActionChangeCommand DenialNextAction = "change_command"
DenialNextActionEnableSandbox DenialNextAction = "enable_sandbox"
)
type Denial struct {
Capability Capability `json:"capability"`
Source DenialSource `json:"source"`
Reason string `json:"reason"`
Recoverable bool `json:"recoverable"`
NextAction DenialNextAction `json:"nextAction"`
}
type Enforcement struct {
Backend string `json:"backend,omitempty"`
Level string `json:"level,omitempty"`
Degraded bool `json:"degraded,omitempty"`
DowngradeReason string `json:"downgradeReason,omitempty"`
}
type Outcome struct {
State State `json:"state"`
Kind OutcomeKind `json:"kind"`
ProcessID string `json:"processId,omitempty"`
Exit *Exit `json:"exit,omitempty"`
Denial *Denial `json:"denial,omitempty"`
Enforcement Enforcement `json:"enforcement"`
Changes []Change `json:"changes,omitempty"`
}
// AdapterReport is the structured, machine-readable result emitted by a
// platform enforcement adapter. It is separate from stdout and stderr so
// command text cannot impersonate a policy decision.
type AdapterReport struct {
Denial *Denial `json:"denial,omitempty"`
}
func (outcome Outcome) Validate() error {
if outcome.State == "" || outcome.Kind == "" {
return errors.New("execution outcome requires state and kind")
}
switch outcome.State {
case StateRetained, StateRunning:
if outcome.Kind != OutcomeRunning || strings.TrimSpace(outcome.ProcessID) == "" {
return errors.New("running execution outcome requires a process id")
}
case StateCompleted:
if outcome.Kind != OutcomeSuccess || outcome.Exit == nil || outcome.Exit.Code != 0 {
return errors.New("completed execution outcome requires a successful exit")
}
case StateDenied:
if (outcome.Kind != OutcomePolicyDenied && outcome.Kind != OutcomeEnforcementDenied) || outcome.Denial == nil {
return errors.New("denied execution outcome requires structured denial details")
}
if err := outcome.Denial.Validate(); err != nil {
return err
}
case StateFailed:
if outcome.Kind == OutcomeRunning || outcome.Kind == OutcomeSuccess || outcome.Exit == nil {
return errors.New("failed execution outcome requires a failure kind and exit")
}
case StateCancelled:
if outcome.Kind != OutcomeCancelled {
return errors.New("cancelled execution outcome requires cancelled kind")
}
default:
return fmt.Errorf("execution outcome state %q is not terminal or externally observable", outcome.State)
}
return nil
}
func (denial Denial) Validate() error {
if !validCapabilityKind(denial.Capability.Kind) {
return errors.New("execution denial requires a valid capability")
}
if denial.Source == "" || strings.TrimSpace(denial.Reason) == "" || denial.NextAction == "" {
return errors.New("execution denial requires source, reason, and next action")
}
return nil
}
func validOrigin(origin Origin) bool {
switch origin {
case OriginInteractiveCommand, OriginHook, OriginPlugin, OriginMCPServer, OriginBackgroundTask:
return true
default:
return false
}
}
func validCapabilityKind(kind CapabilityKind) bool {
switch kind {
case CapabilityRead, CapabilityWorkspaceWrite, CapabilityProtectedMetadata,
CapabilityExternalNetwork, CapabilityIsolatedLoopback, CapabilityHostVisibleBinding,
CapabilityProcessSpawn, CapabilityProcessControl, CapabilityEnvironment, CapabilityUnrestricted:
return true
default:
return false
}
}
+78
View File
@@ -0,0 +1,78 @@
package execution
import "testing"
func TestRequestValidationRequiresConcreteExecutionContext(t *testing.T) {
request := Request{
Origin: OriginInteractiveCommand,
Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "true"}},
WorkingDirectory: "/workspace",
WorkspaceRoots: []string{"/workspace"},
Capabilities: []Capability{{
Kind: CapabilityWorkspaceWrite,
Scope: "/workspace",
}},
}
if err := request.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
request.Origin = ""
if err := request.Validate(); err == nil {
t.Fatal("request without origin unexpectedly validated")
}
}
func TestOutcomeValidationPinsTerminalStateContracts(t *testing.T) {
tests := []struct {
name string
outcome Outcome
valid bool
}{
{
name: "retained process",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning, ProcessID: "1000"},
valid: true,
},
{
name: "successful completion",
outcome: Outcome{State: StateCompleted, Kind: OutcomeSuccess, Exit: &Exit{Code: 0}},
valid: true,
},
{
name: "structured denial",
outcome: Outcome{
State: StateDenied,
Kind: OutcomePolicyDenied,
Denial: &Denial{
Capability: Capability{Kind: CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created",
NextAction: DenialNextActionRequestApproval,
},
},
valid: true,
},
{
name: "retained without process id",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning},
},
{
name: "denied without denial",
outcome: Outcome{State: StateDenied, Kind: OutcomePolicyDenied},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.outcome.Validate()
if test.valid && err != nil {
t.Fatalf("Validate: %v", err)
}
if !test.valid && err == nil {
t.Fatal("invalid outcome unexpectedly validated")
}
})
}
}
+645
View File
@@ -0,0 +1,645 @@
package execution
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"sort"
"sync"
"time"
)
const (
defaultCompletedRetention = 30 * time.Second
defaultMaxProcesses = 64
maxPendingOutputBytes = 2 * 1024 * 1024
recentOutputBytes = 4096
processStopTimeout = 3 * time.Second
maxInteractiveYield = 30 * time.Second
maxEmptyPollYield = 5 * time.Minute
)
var (
ErrProcessNotFound = errors.New("execution process not found")
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
)
type ProcessManagerOptions struct {
CompletedRetention time.Duration
MaxProcesses int
}
// ProcessManager owns retained interactive-process identity, transport,
// bounded output, continuation, cancellation, completion, and cleanup.
type ProcessManager struct {
mu sync.Mutex
nextID int
processes map[int]*managedProcess
completedRetention time.Duration
maxProcesses int
startTransport processTransportStarter
}
type ProcessStart struct {
Prepared PreparedCommand
Request Request
CommandText string
RelativeCwd string
TTY bool
Metadata map[string]string
// AfterWait may append adapter diagnostics collected by a platform monitor.
AfterWait func() []byte
}
type ProcessContinue struct {
ProcessID int
Input []byte
Interrupt bool
Wait time.Duration
}
type ProcessResult struct {
ProcessID int
CommandText string
RelativeCwd string
TTY bool
Output string
OutputTruncated bool
Exited bool
ExitCode int
Interrupted bool
Request Request
Enforcement Enforcement
Report AdapterReport
ReportErr error
Changes []Change
Metadata map[string]string
}
type ProcessSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
OutputTruncated bool
}
func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
retention := options.CompletedRetention
if retention == 0 {
retention = defaultCompletedRetention
}
maxProcesses := options.MaxProcesses
if maxProcesses == 0 {
maxProcesses = defaultMaxProcesses
}
return &ProcessManager{
nextID: 1000,
processes: make(map[int]*managedProcess),
completedRetention: retention,
maxProcesses: maxProcesses,
startTransport: startProcessTransport,
}
}
func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wait time.Duration) (ProcessResult, error) {
if manager == nil {
return ProcessResult{}, errors.New("execution process manager is not configured")
}
if err := input.Request.Validate(); err != nil {
return ProcessResult{}, fmt.Errorf("invalid execution request: %w", err)
}
if input.Prepared.Command == nil {
return ProcessResult{}, errors.New("prepared execution has no command")
}
command := input.Prepared.Command
buffer := newProcessOutputBuffer()
request := input.Request
observer := NewChangeObserver(request.WorkspaceRoots[0])
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
if err != nil {
if input.Prepared.Cleanup != nil {
input.Prepared.Cleanup()
}
return ProcessResult{}, err
}
if tty {
request.Mode = ModeInteractive
} else {
request.Mode = ModeCaptured
}
now := time.Now()
process := &managedProcess{
id: manager.allocateID(),
commandText: input.CommandText,
cwd: request.WorkingDirectory,
relativeCwd: input.RelativeCwd,
startedAt: now,
lastUsedAt: now,
tty: tty,
command: command,
request: request,
enforcement: input.Prepared.Enforcement,
report: input.Prepared.Report,
cleanup: input.Prepared.Cleanup,
stdin: stdin,
output: buffer,
reaped: make(chan struct{}),
done: make(chan struct{}),
kill: KillProcessTree,
metadata: cloneStringMap(input.Metadata),
}
manager.store(process)
manager.removeCompletedLater(process)
go func() {
waitErr := command.Wait()
close(process.reaped)
if input.AfterWait != nil {
if diagnostic := input.AfterWait(); len(diagnostic) > 0 {
_, _ = buffer.Write(diagnostic)
}
}
if transportCleanup != nil {
transportCleanup()
}
adapterReport, reportErr := AdapterReport{}, error(nil)
if process.report != nil {
adapterReport, reportErr = process.report()
}
changes := observer.Changes()
if process.cleanup != nil {
process.cleanup()
}
process.markDone(waitErr, commandExitCode(waitErr), adapterReport, reportErr, changes)
}()
result := process.collectResult(ctx, clampInitialProcessWait(wait), false)
if ctx != nil && ctx.Err() != nil && !result.Exited {
process.terminate()
more := process.collectResult(context.Background(), time.Second, true)
var mergeTruncated bool
result.Output, mergeTruncated = appendBoundedProcessOutputString(result.Output, more.Output)
result.OutputTruncated = result.OutputTruncated || more.OutputTruncated
result.OutputTruncated = result.OutputTruncated || mergeTruncated
result.Exited = more.Exited
result.ExitCode = more.ExitCode
result.Interrupted = result.Interrupted || more.Interrupted
result.Report = more.Report
result.ReportErr = more.ReportErr
result.Changes = more.Changes
}
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
process, ok := manager.get(input.ProcessID)
if !ok {
return ProcessResult{}, ErrProcessNotFound
}
process.touch()
if input.Interrupt {
process.terminate()
} else if len(input.Input) > 0 {
if !process.tty || process.stdin == nil {
return ProcessResult{}, ErrProcessStdinDisabled
}
if _, err := process.stdin.Write(input.Input); err != nil && !process.doneClosed() {
return ProcessResult{}, err
}
}
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func clampInitialProcessWait(wait time.Duration) time.Duration {
return min(wait, maxInteractiveYield)
}
func clampContinuationWait(wait time.Duration, emptyPoll bool) time.Duration {
if emptyPoll {
return min(wait, maxEmptyPollYield)
}
return min(wait, maxInteractiveYield)
}
func (manager *ProcessManager) List() []ProcessSnapshot {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
snapshots := make([]ProcessSnapshot, 0, len(processes))
for _, process := range processes {
snapshots = append(snapshots, process.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].ID < snapshots[j].ID })
return snapshots
}
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
process, ok := manager.get(id)
if !ok {
return ProcessSnapshot{}, false
}
return process.snapshot(), true
}
func (manager *ProcessManager) Stop(id int) bool {
process, ok := manager.get(id)
if !ok {
return false
}
process.terminate()
return true
}
func (manager *ProcessManager) StopAll() []int {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(processes))
for _, process := range processes {
process.terminate()
ids = append(ids, process.id)
}
waitForProcesses(processes, processStopTimeout)
sort.Ints(ids)
return ids
}
func (manager *ProcessManager) Remove(id int) {
manager.mu.Lock()
delete(manager.processes, id)
manager.mu.Unlock()
}
func (manager *ProcessManager) Len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.processes)
}
func (manager *ProcessManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
process, ok := manager.processes[id]
return process, ok
}
func (manager *ProcessManager) store(process *managedProcess) {
manager.mu.Lock()
var evicted *managedProcess
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
evicted = manager.processToPruneLocked()
if evicted != nil && evicted.doneClosed() {
delete(manager.processes, evicted.id)
evicted = nil
}
}
manager.processes[process.id] = process
manager.mu.Unlock()
if evicted != nil {
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
evicted.terminate()
}
}
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
var processes []*managedProcess
for _, process := range manager.processes {
processes = append(processes, process)
}
sort.Slice(processes, func(i, j int) bool { return processes[i].lastUsed().Before(processes[j].lastUsed()) })
for _, process := range processes {
if process.doneClosed() {
return process
}
}
if len(processes) > 8 {
return processes[0]
}
return nil
}
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
go func() {
<-process.done
if manager.completedRetention > 0 {
timer := time.NewTimer(manager.completedRetention)
<-timer.C
}
manager.Remove(process.id)
}()
}
type managedProcess struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
report func() (AdapterReport, error)
cleanup func()
stdin io.WriteCloser
output *processOutputBuffer
reaped chan struct{}
doneOnce sync.Once
done chan struct{}
kill func(int) error
mu sync.Mutex
exitCode *int
waitErr error
resultReport AdapterReport
reportErr error
changes []Change
metadata map[string]string
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
process.mu.Lock()
process.waitErr = err
process.exitCode = &exitCode
process.resultReport = report
process.reportErr = reportErr
process.changes = append([]Change(nil), changes...)
process.mu.Unlock()
process.doneOnce.Do(func() { close(process.done) })
}
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
output, truncated := process.collect(ctx, wait)
process.mu.Lock()
exitCode := 0
exited := process.exitCode != nil
if exited {
exitCode = *process.exitCode
}
result := ProcessResult{
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
}
func (process *managedProcess) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var output []byte
truncated := false
finish := func() (string, bool) {
truncated = truncated || process.output.consumeTruncated()
return string(output), truncated
}
for {
if chunk := process.output.drain(); len(chunk) > 0 {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, chunk)
truncated = truncated || chunkTruncated
truncated = truncated || process.output.consumeTruncated()
if time.Now().After(deadline) {
return finish()
}
continue
}
if process.doneClosed() {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, process.output.drain())
truncated = truncated || chunkTruncated
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-process.output.notify:
case <-process.done:
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
func appendBoundedProcessOutput(output []byte, chunk []byte) ([]byte, bool) {
if len(chunk) == 0 {
return output, false
}
if len(chunk) >= maxPendingOutputBytes {
bounded := make([]byte, maxPendingOutputBytes)
copy(bounded, chunk[len(chunk)-maxPendingOutputBytes:])
return bounded, len(output) > 0 || len(chunk) > maxPendingOutputBytes
}
if len(output)+len(chunk) <= maxPendingOutputBytes {
return append(output, chunk...), false
}
drop := len(output) + len(chunk) - maxPendingOutputBytes
copy(output, output[drop:])
output = output[:len(output)-drop]
return append(output, chunk...), true
}
func appendBoundedProcessOutputString(output string, chunk string) (string, bool) {
bounded, truncated := appendBoundedProcessOutput([]byte(output), []byte(chunk))
return string(bounded), truncated
}
func (process *managedProcess) doneClosed() bool {
select {
case <-process.done:
return true
default:
return false
}
}
func (process *managedProcess) touch() {
process.mu.Lock()
process.lastUsedAt = time.Now()
process.mu.Unlock()
}
func (process *managedProcess) lastUsed() time.Time {
process.mu.Lock()
defer process.mu.Unlock()
return process.lastUsedAt
}
func (process *managedProcess) terminate() {
if process.reapedClosed() || process.command.Process == nil {
return
}
kill := process.kill
if kill == nil {
kill = KillProcessTree
}
_ = kill(process.command.Process.Pid)
}
func (process *managedProcess) reapedClosed() bool {
if process.reaped == nil {
return false
}
select {
case <-process.reaped:
return true
default:
return false
}
}
func (process *managedProcess) snapshot() ProcessSnapshot {
process.mu.Lock()
defer process.mu.Unlock()
var exit *int
if process.exitCode != nil {
value := *process.exitCode
exit = &value
}
status := "running"
if exit != nil {
status = "exited"
}
return ProcessSnapshot{ID: process.id, Command: process.commandText, Cwd: process.cwd, RelativeCwd: process.relativeCwd,
StartedAt: process.startedAt, LastUsedAt: process.lastUsedAt, TTY: process.tty, Status: status,
ExitCode: exit, RecentOutput: process.output.recentString(), OutputTruncated: process.output.peekTruncated()}
}
func waitForProcesses(processes []*managedProcess, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for _, process := range processes {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-process.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type processOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newProcessOutputBuffer() *processOutputBuffer {
return &processOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *processOutputBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, data...)
if len(buffer.data) > maxPendingOutputBytes {
buffer.data = buffer.data[len(buffer.data)-maxPendingOutputBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, data...)
if len(buffer.recent) > recentOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(data), nil
}
func (buffer *processOutputBuffer) drain() []byte {
buffer.mu.Lock()
defer buffer.mu.Unlock()
data := append([]byte(nil), buffer.data...)
buffer.data = nil
return data
}
func (buffer *processOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *processOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
value := buffer.truncated
buffer.truncated = false
return value
}
func (buffer *processOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}
+275
View File
@@ -0,0 +1,275 @@
package execution
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func processManagerRequest(root string, command *exec.Cmd) Request {
return Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: command.Path, Args: command.Args[1:]},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
}
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
if started.Exited || started.ProcessID < 1000 {
t.Fatalf("initial result = %#v, want retained process", started)
}
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !continued.Exited || continued.ProcessID != started.ProcessID {
t.Fatalf("continued result = %#v, want same completed process", continued)
}
combined := started.Output + continued.Output
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
t.Fatalf("output was lost or duplicated: %q", combined)
}
if manager.Len() != 0 {
t.Fatalf("completed process still retained: %d", manager.Len())
}
}
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
}
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
if err != nil {
t.Fatalf("Continue interrupt: %v", err)
}
if !stopped.Exited || !stopped.Interrupted {
t.Fatalf("interrupt result = %#v", stopped)
}
}
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
reaped := make(chan struct{})
close(reaped)
called := false
process := &managedProcess{
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
reaped: reaped,
kill: func(int) error { called = true; return nil },
}
process.terminate()
if called {
t.Fatal("terminate signaled an already-reaped process")
}
}
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := manager.Start(ctx, ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !result.Exited || !result.Interrupted {
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
}
}
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
target := filepath.Join(root, "created.txt")
manager := NewProcessManager(ProcessManagerOptions{})
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
return nil, false, nil, err
}
return startProcessTransport(command, output, tty)
}
command := exec.Command("/bin/sh", "-c", "true")
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
}
}
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
}
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
t.Fatalf("initial short wait = %v, want caller value", got)
}
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
}
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
}
}
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newProcessOutputBuffer()
chunk := []byte(strings.Repeat("x", 1024))
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
if _, err := buffer.Write(chunk); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxPendingOutputBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
}
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
t.Fatal("drained output should retain the newest bytes")
}
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
t.Fatal("truncation state was not preserved and consumed exactly once")
}
}
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
stop := make(chan struct{})
var writers sync.WaitGroup
for i := 0; i < 8; i++ {
writers.Add(1)
go func() {
defer writers.Done()
chunk := []byte(strings.Repeat("x", 256))
for {
select {
case <-stop:
return
default:
_, _ = process.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() { close(stop); writers.Wait() })
wait := 200 * time.Millisecond
started := time.Now()
_, _ = process.collect(context.Background(), wait)
if elapsed := time.Since(started); elapsed > 5*wait {
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
}
}
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
go func() {
for i := 0; i < 5; i++ {
_, _ = process.output.Write(chunk)
for {
process.output.mu.Lock()
drained := len(process.output.data) == 0
process.output.mu.Unlock()
if drained {
break
}
runtime.Gosched()
}
}
close(process.done)
}()
output, truncated := process.collect(context.Background(), time.Second)
if len(output) > maxPendingOutputBytes {
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
}
if !truncated {
t.Fatal("cumulative output cap must report truncation")
}
}
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
for id := 1000; id < 1012; id++ {
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
}
target := manager.processes[1000]
stop := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
go func() {
defer writer.Done()
for {
select {
case <-stop:
return
default:
target.touch()
}
}
}()
for i := 0; i < 2000; i++ {
manager.mu.Lock()
_ = manager.processToPruneLocked()
manager.mu.Unlock()
}
close(stop)
writer.Wait()
}
+100
View File
@@ -0,0 +1,100 @@
//go:build !windows
package execution
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
)
// ConfigureProcessGroup makes cmd the leader of a process group so lifecycle
// operations cover descendants instead of orphaning them.
func ConfigureProcessGroup(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// KillProcessTree immediately kills pid and, when it is a group leader, its
// descendant process group.
func KillProcessTree(pid int) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
if poll <= 0 {
poll = 50 * time.Millisecond
}
if grace < 0 {
grace = 0
}
deadline := time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if !alive() {
return nil
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
deadline = time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
}
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
return target, nil
}
+37
View File
@@ -0,0 +1,37 @@
//go:build windows
package execution
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
)
func ConfigureProcessGroup(cmd *exec.Cmd) {}
func KillProcessTree(pid int) error {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func TerminateProcessTree(pid int, _, _ time.Duration) error { return KillProcessTree(pid) }
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package execution
import (
"errors"
"io"
"os/exec"
)
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty transport is unavailable on this platform")
}
@@ -1,6 +1,6 @@
//go:build linux
package tools
package execution
import (
"errors"
@@ -14,41 +14,25 @@ import (
"golang.org/x/sys/unix"
)
func startPTYProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, func(), error) {
func startPTYProcess(command *exec.Cmd, output io.Writer) (io.WriteCloser, func(), error) {
master, slave, err := openPTY()
if err != nil {
return nil, nil, err
}
cleanup := func() {
_ = master.Close()
_ = slave.Close()
}
command.Stdin = slave
command.Stdout = slave
command.Stderr = slave
hardenPTYProcessLifetime(command, slave)
cleanup := func() { _ = master.Close(); _ = slave.Close() }
command.Stdin, command.Stdout, command.Stderr = slave, slave, slave
hardenPTYProcess(command)
if err := command.Start(); err != nil {
cleanup()
return nil, nil, err
}
_ = slave.Close()
copied := make(chan struct{})
go func() {
defer close(copied)
_, _ = io.Copy(output, master)
}()
go func() { defer close(copied); _, _ = io.Copy(output, master) }()
return master, func() {
// cleanup runs in the Wait goroutine AFTER command.Wait() returns, so the
// child has exited and its slave fds are closed — the master then EOFs once
// io.Copy drains the remaining PTY output into the buffer. Wait for that copy
// to finish BEFORE closing the master and letting the caller mark the session
// done + remove it; otherwise a command's final output chunk (e.g. a test
// runner's last PASS/FAIL line) can be lost on exit. Closing the master first
// would truncate the unread tail, so the join precedes the Close. Bounded so a
// stuck copy can't hang teardown. (AUDIT-M14)
select {
case <-copied:
case <-time.After(bashWaitDelay):
case <-time.After(processWaitDelay):
}
_ = master.Close()
}, nil
@@ -75,18 +59,17 @@ func openPTY() (*os.File, *os.File, error) {
_ = master.Close()
return nil, nil, err
}
slave := os.NewFile(uintptr(slaveFD), slaveName)
return master, slave, nil
return master, os.NewFile(uintptr(slaveFD), slaveName), nil
}
func hardenPTYProcessLifetime(command *exec.Cmd, slave *os.File) {
func hardenPTYProcess(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.Setsid = true
command.SysProcAttr.Setctty = true
command.SysProcAttr.Ctty = 0
command.WaitDelay = bashWaitDelay
command.WaitDelay = processWaitDelay
command.Cancel = func() error {
if command.Process == nil {
return nil
+25
View File
@@ -0,0 +1,25 @@
//go:build linux
package execution
import (
"os/exec"
"syscall"
"testing"
)
func TestPTYFallbackRestoresOriginalProcessAttributes(t *testing.T) {
original := &syscall.SysProcAttr{Setpgid: true}
command := exec.Command("/bin/true")
command.SysProcAttr = original
saved := cloneSysProcAttr(command.SysProcAttr)
hardenPTYProcess(command)
resetCommandAfterPTYFallback(command, saved)
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
t.Fatalf("fallback attributes = %#v, want original Setpgid", command.SysProcAttr)
}
if command.SysProcAttr.Setsid || command.SysProcAttr.Setctty {
t.Fatalf("fallback retained PTY attributes: %#v", command.SysProcAttr)
}
}
+221
View File
@@ -0,0 +1,221 @@
package execution
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
)
const maxCapturedStreamBytes = 1 << 20
// Preparer is the platform-adapter seam. It turns a platform-neutral request
// into one enforceable OS command while keeping native sandbox mechanics out of
// every caller.
type Preparer interface {
PrepareExecution(context.Context, Request) (PreparedCommand, error)
}
type PreparedCommand struct {
Command *exec.Cmd
Enforcement Enforcement
Report func() (AdapterReport, error)
Cleanup func()
}
type CapturedRequest struct {
Request Request
Stdin []byte
}
type CapturedResult struct {
Stdout string
Stderr string
Truncated bool
Outcome Outcome
Err error
}
// Runner is the deep execution module used by Zero-owned subprocess launchers.
// The preparer may be installed after tool registration, but execution fails
// closed until an adapter is present.
type Runner struct {
mu sync.RWMutex
preparer Preparer
}
func NewRunner(preparer Preparer) *Runner {
return &Runner{preparer: preparer}
}
func (runner *Runner) SetPreparer(preparer Preparer) {
if runner == nil {
return
}
runner.mu.Lock()
runner.preparer = preparer
runner.mu.Unlock()
}
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
if ctx == nil {
ctx = context.Background()
}
if err := input.Request.Validate(); err != nil {
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
}
if input.Request.Mode != ModeCaptured {
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
return capturedSetupFailure(err.Error(), err, Enforcement{})
}
prepared, err := runner.Prepare(ctx, input.Request)
if err != nil {
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
}
if prepared.Cleanup != nil {
defer prepared.Cleanup()
}
if prepared.Command == nil {
err := errors.New("execution adapter returned no command")
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
}
if len(input.Stdin) > 0 {
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
}
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
prepared.Command.Stdout = stdout
prepared.Command.Stderr = stderr
runErr := prepared.Command.Run()
report, reportErr := AdapterReport{}, error(nil)
if prepared.Report != nil {
report, reportErr = prepared.Report()
}
result := CapturedResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Truncated: stdout.Truncated() || stderr.Truncated(),
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
},
}
exitCode := commandExitCode(runErr)
result.Outcome.Exit = &Exit{Code: exitCode}
switch {
case reportErr != nil:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeSandboxSetupFailure
result.Err = reportErr
case report.Denial != nil:
denial := *report.Denial
result.Outcome.State = StateDenied
result.Outcome.Kind = OutcomeEnforcementDenied
result.Outcome.Denial = &denial
case errors.Is(ctx.Err(), context.DeadlineExceeded):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeTimedOut
case errors.Is(ctx.Err(), context.Canceled):
result.Outcome.State = StateCancelled
result.Outcome.Kind = OutcomeCancelled
case runErr == nil:
result.Outcome.State = StateCompleted
result.Outcome.Kind = OutcomeSuccess
case executableNotFound(runErr):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeExecutableNotFound
default:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeApplicationFailure
}
return result
}
// Prepare evaluates and prepares a typed request for callers that require
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
// The returned cleanup remains mandatory; ordinary captured commands should use
// ExecuteCaptured so the Runner owns it automatically.
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
if runner == nil {
return PreparedCommand{}, errors.New("execution runner is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if err := request.Validate(); err != nil {
return PreparedCommand{}, err
}
runner.mu.RLock()
preparer := runner.preparer
runner.mu.RUnlock()
if preparer == nil {
return PreparedCommand{}, errors.New("execution adapter is not configured")
}
return preparer.PrepareExecution(ctx, request)
}
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
return CapturedResult{
Stderr: message,
Err: err,
Outcome: Outcome{
State: StateFailed,
Kind: OutcomeSandboxSetupFailure,
Exit: &Exit{Code: -1},
Enforcement: enforcement,
},
}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
func executableNotFound(err error) bool {
var execErr *exec.Error
return errors.As(err, &execErr)
}
type capturedBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
limit int
truncated bool
}
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
defer buffer.mu.Unlock()
remaining := buffer.limit - buffer.buffer.Len()
if remaining > 0 {
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
}
if len(data) > remaining {
buffer.truncated = true
}
return len(data), nil
}
func (buffer *capturedBuffer) String() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.buffer.String()
}
func (buffer *capturedBuffer) Truncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
var _ io.Writer = (*capturedBuffer)(nil)
+77
View File
@@ -0,0 +1,77 @@
package execution
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
type capturedTestPreparer struct {
request Request
}
func (preparer *capturedTestPreparer) PrepareExecution(_ context.Context, request Request) (PreparedCommand, error) {
preparer.request = request
command := exec.Command(os.Args[0], "-test.run=^TestCapturedRunnerHelperProcess$")
command.Env = append(os.Environ(), "ZERO_CAPTURED_RUNNER_HELPER=1")
return PreparedCommand{
Command: command,
Enforcement: Enforcement{
Backend: "test-adapter",
Level: "native",
},
}, nil
}
func TestCapturedRunnerHelperProcess(t *testing.T) {
if os.Getenv("ZERO_CAPTURED_RUNNER_HELPER") != "1" {
return
}
fmt.Fprint(os.Stdout, "stdout")
fmt.Fprint(os.Stderr, "stderr")
os.Exit(7)
}
func TestRunnerExecutesCapturedRequestThroughAdapter(t *testing.T) {
preparer := &capturedTestPreparer{}
runner := NewRunner(preparer)
request := Request{
Origin: OriginHook,
Mode: ModeCaptured,
Command: Command{Name: "ignored-by-test-adapter"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: request})
if preparer.request.Origin != OriginHook {
t.Fatalf("adapter origin = %q, want hook", preparer.request.Origin)
}
if result.Stdout != "stdout" || result.Stderr != "stderr" {
t.Fatalf("captured output = stdout %q stderr %q", result.Stdout, result.Stderr)
}
if result.Outcome.Kind != OutcomeApplicationFailure || result.Outcome.Exit == nil || result.Outcome.Exit.Code != 7 {
t.Fatalf("outcome = %#v, want application failure exit 7", result.Outcome)
}
if result.Outcome.Enforcement.Backend != "test-adapter" {
t.Fatalf("enforcement = %#v", result.Outcome.Enforcement)
}
}
func TestRunnerWithoutAdapterFailsClosed(t *testing.T) {
runner := NewRunner(nil)
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
Origin: OriginPlugin,
Mode: ModeCaptured,
Command: Command{Name: "true"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
}})
if result.Outcome.Kind != OutcomeSandboxSetupFailure || !strings.Contains(result.Stderr, "adapter") {
t.Fatalf("result = %#v, want fail-closed missing-adapter result", result)
}
}
+51
View File
@@ -0,0 +1,51 @@
package execution
import (
"io"
"os/exec"
"syscall"
"time"
)
const processWaitDelay = 2 * time.Second
type processTransportStarter func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error)
func startProcessTransport(command *exec.Cmd, output io.Writer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
if ttyRequested {
original := cloneSysProcAttr(command.SysProcAttr)
if stdin, cleanup, err := startPTYProcess(command, output); err == nil {
return stdin, true, cleanup, nil
}
resetCommandAfterPTYFallback(command, original)
}
stdin, err := command.StdinPipe()
if err != nil {
return nil, false, nil, err
}
command.Stdout = output
command.Stderr = output
ConfigureProcessGroup(command)
command.WaitDelay = processWaitDelay
if err := command.Start(); err != nil {
return nil, false, nil, err
}
return stdin, false, func() {}, nil
}
func cloneSysProcAttr(attributes *syscall.SysProcAttr) *syscall.SysProcAttr {
if attributes == nil {
return nil
}
cloned := *attributes
return &cloned
}
func resetCommandAfterPTYFallback(command *exec.Cmd, original *syscall.SysProcAttr) {
command.Stdin = nil
command.Stdout = nil
command.Stderr = nil
command.SysProcAttr = original
command.Cancel = nil
command.WaitDelay = 0
}
+51 -8
View File
@@ -9,6 +9,8 @@ import (
"os/exec"
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
@@ -51,13 +53,14 @@ type commandRunner func(ctx context.Context, command string, args []string, stdi
// DispatcherOptions configures a Dispatcher.
type DispatcherOptions struct {
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
now func() time.Time
run commandRunner
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
Execution *execution.Runner // routes hook subprocesses through sandbox policy
now func() time.Time
run commandRunner
}
// Dispatcher selects and runs the hooks configured for a lifecycle event,
@@ -87,7 +90,11 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
run := options.run
if run == nil {
run = execCommandRunner
if options.Execution != nil {
run = executionCommandRunner(options.Execution)
} else {
run = execCommandRunner
}
}
return &Dispatcher{
config: options.Config,
@@ -100,6 +107,42 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
}
func executionCommandRunner(runner *execution.Runner) commandRunner {
return func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
result := runner.ExecuteCaptured(ctx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command, Args: append([]string(nil), args...), Env: append([]string(nil), env...)},
WorkingDirectory: cwd,
WorkspaceRoots: []string{cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
stderr := result.Stderr
if stderr == "" && result.Outcome.Denial != nil {
stderr = result.Outcome.Denial.Reason
}
commandErr := error(nil)
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound:
commandErr = result.Err
}
return commandResult{
ExitCode: exitCode,
Stdout: result.Stdout,
Stderr: stderr,
Err: commandErr,
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
}
}
}
// blocksOn reports whether a non-zero exit for this event should veto the action.
// Only beforeTool gates the tool; other events are observational.
func blocksOn(event Event) bool {
+31
View File
@@ -2,13 +2,25 @@ package hooks
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type hookExecutionPreparer struct {
request execution.Request
}
func (preparer *hookExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
func beforeToolConfig(hooks ...Definition) Config {
return Config{Enabled: true, Hooks: hooks}
}
@@ -186,6 +198,25 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
}
}
func TestDispatcherRoutesHookThroughTypedExecutionOrigin(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses /bin/sh")
}
preparer := &hookExecutionPreparer{}
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "typed", Event: EventBeforeTool, Command: "/bin/sh", Args: []string{"-c", "cat"}, Enabled: true}),
Cwd: t.TempDir(),
Execution: execution.NewRunner(preparer),
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", Payload: map[string]any{"ok": true}})
if outcome.Blocked || outcome.Ran != 1 {
t.Fatalf("dispatch outcome = %#v", outcome)
}
if preparer.request.Origin != execution.OriginHook || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
if result.Err == nil {
+56 -10
View File
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type RemoteTool struct {
@@ -46,6 +48,7 @@ type Client struct {
mu sync.Mutex
closeMu sync.Mutex
nextID int
cleanup func()
// dispatchMu guards the response-dispatch state shared with the single
// reader goroutine. It is never held across a blocking read.
@@ -72,9 +75,18 @@ const (
)
func Connect(ctx context.Context, server Server) (ToolClient, error) {
return ConnectWithOptions(ctx, server, ConnectOptions{})
}
type ConnectOptions struct {
Execution *execution.Runner
WorkspaceRoot string
}
func ConnectWithOptions(ctx context.Context, server Server, options ConnectOptions) (ToolClient, error) {
switch server.Type {
case ServerTypeStdio:
return connectStdio(ctx, server)
return connectStdio(ctx, server, options)
case ServerTypeHTTP:
return connectNetwork(ctx, server)
case ServerTypeSSE:
@@ -120,9 +132,37 @@ func (b *boundedBuffer) String() string {
return b.buf.String()
}
func connectStdio(ctx context.Context, server Server) (*Client, error) {
cmd := exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
var cmd *exec.Cmd
var cleanup func()
cleanupTransferred := false
defer func() {
if cleanup != nil && !cleanupTransferred {
cleanup()
}
}()
if options.Execution != nil {
workspaceRoot := strings.TrimSpace(options.WorkspaceRoot)
if workspaceRoot == "" {
return nil, fmt.Errorf("start MCP server %s: execution workspace root is required", server.Name)
}
prepared, err := options.Execution.Prepare(ctx, execution.Request{
Origin: execution.OriginMCPServer,
Mode: execution.ModeDurable,
Command: execution.Command{Name: server.Command, Args: append([]string(nil), server.Args...), Env: mergeProcessEnv(server.Env)},
WorkingDirectory: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
})
if err != nil {
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
cmd = prepared.Command
cleanup = prepared.Cleanup
} else {
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("open MCP stdin for %s: %w", server.Name, err)
@@ -138,13 +178,15 @@ func connectStdio(ctx context.Context, server Server) (*Client, error) {
}
client := &Client{
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
cleanup: cleanup,
}
cleanupTransferred = true
if err := client.initialize(ctx); err != nil {
_ = client.Close()
message := strings.TrimSpace(stderr.String())
@@ -242,6 +284,10 @@ func (client *Client) Close() error {
}
}
}
if client.cleanup != nil {
client.cleanup()
client.cleanup = nil
}
return err
}
+42
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
@@ -16,8 +17,21 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
)
type mcpExecutionPreparer struct {
request execution.Request
}
func (preparer *mcpExecutionPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
command := exec.CommandContext(ctx, request.Command.Name, request.Command.Args...)
command.Dir = request.WorkingDirectory
command.Env = request.Command.Env
return execution.PreparedCommand{Command: command}, nil
}
func TestStdioClientListsAndCallsTools(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -65,6 +79,34 @@ func TestStdioClientListsAndCallsTools(t *testing.T) {
}
}
func TestStdioClientUsesTypedMCPExecutionOrigin(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
preparer := &mcpExecutionPreparer{}
workspace := t.TempDir()
client, err := ConnectWithOptions(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
}, ConnectOptions{Execution: execution.NewRunner(preparer), WorkspaceRoot: workspace})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
if preparer.request.Origin != execution.OriginMCPServer || preparer.request.Mode != execution.ModeDurable {
t.Fatalf("execution request = %#v", preparer.request)
}
if preparer.request.WorkingDirectory != workspace {
t.Fatalf("working directory = %q, want %q", preparer.request.WorkingDirectory, workspace)
}
}
func TestStdioClientCloseAllowsConcurrentCallers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -26,6 +27,8 @@ type RegisterOptions struct {
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
// defaultConnectTimeout.
ConnectTimeout time.Duration
Execution *execution.Runner
WorkspaceRoot string
}
// SkippedServer records an MCP server that was not registered because it could
@@ -80,7 +83,7 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
factory := options.ClientFactory
if factory == nil {
factory = func(ctx context.Context, server Server) (ToolClient, error) {
return Connect(ctx, server)
return ConnectWithOptions(ctx, server, ConnectOptions{Execution: options.Execution, WorkspaceRoot: options.WorkspaceRoot})
}
}
+42 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/secrets"
@@ -113,6 +114,10 @@ type ActivateOptions struct {
// Env supplies the base environment for plugin tool commands. nil uses the
// current process environment (os.Environ()).
Env []string
// Execution routes plugin tool subprocesses through the shared sandbox and
// process execution seam. Production callers provide it before any tool can
// be invoked; tests may continue to inject runTool directly.
Execution *execution.Runner
runTool toolRunner
}
@@ -141,8 +146,14 @@ func Activate(registry *tools.Registry, loaded []LoadedPlugin, options ActivateO
if timeout <= 0 {
timeout = defaultPluginToolTimeout
}
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
if options.Execution != nil {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommandWithExecution(ctx, options.Execution, command, timeout)
}
} else {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
}
}
}
@@ -689,3 +700,32 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time.
output.Err = err
return output
}
func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := runner.ExecuteCaptured(runCtx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginPlugin,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command.Command, Args: append([]string(nil), command.Args...), Env: append([]string(nil), command.Env...)},
WorkingDirectory: command.Cwd,
WorkspaceRoots: []string{command.Cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: command.Stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
}
if output.Stderr == "" && result.Outcome.Denial != nil {
output.Stderr = result.Outcome.Denial.Reason
}
return output
}
+27
View File
@@ -3,15 +3,27 @@ package plugins
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
)
type pluginExecutionPreparer struct {
request execution.Request
}
func (preparer *pluginExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
// fakeToolRunner records the invocation and returns a canned result so tool
// activation can be exercised without spawning a real process.
type fakeToolRunner struct {
@@ -554,6 +566,21 @@ func TestActivateSkipsDisabledPlugin(t *testing.T) {
}
}
func TestPluginCommandUsesTypedPluginExecutionOrigin(t *testing.T) {
if testing.Short() {
t.Skip("spawns a subprocess")
}
preparer := &pluginExecutionPreparer{}
command := pluginCommand{Command: os.Args[0], Args: []string{"-test.run=^$"}, Cwd: t.TempDir()}
output := execPluginCommandWithExecution(context.Background(), execution.NewRunner(preparer), command, time.Second)
if output.Err != nil || output.ExitCode != 0 {
t.Fatalf("plugin execution output = %#v", output)
}
if preparer.request.Origin != execution.OriginPlugin || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestActivateIsDeterministic(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
+30 -10
View File
@@ -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
}
+8
View File
@@ -49,6 +49,12 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
{name: "npm create", script: "npm create vite@latest .", network: true},
{name: "npm registry query", script: "npm view typescript version --fetch-retries=0", network: true},
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
@@ -56,6 +62,8 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
{name: "gh release download", script: "gh release download v1.0.0", network: true},
{name: "no network", script: "ls -la && echo done", network: false},
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
+7
View File
@@ -80,6 +80,13 @@ func (engine *Engine) CanPersistGrants() bool {
return engine != nil && engine.store != nil
}
func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) {
if engine == nil || engine.store == nil {
return "", nil
}
return engine.store.ConsumeMigrationNotice()
}
func (engine *Engine) GrantCommandPrefixForSession(toolName string, prefix []string) {
if engine == nil || len(prefix) == 0 {
return
@@ -0,0 +1,36 @@
package sandbox
import (
"context"
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/execution"
)
func TestEnginePreparesTypedCapturedExecution(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
workspace := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Message: "test backend unavailable"},
})
runner := execution.NewRunner(engine)
result := runner.ExecuteCaptured(context.Background(), execution.CapturedRequest{Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: "/bin/sh", Args: []string{"-c", "printf adapted"}},
WorkingDirectory: workspace,
WorkspaceRoots: []string{workspace},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}})
if result.Outcome.Kind != execution.OutcomeSuccess || result.Stdout != "adapted" {
t.Fatalf("captured result = %#v", result)
}
if result.Outcome.Enforcement.Level != string(EnforcementDegraded) {
t.Fatalf("enforcement = %#v, want degraded", result.Outcome.Enforcement)
}
}
+210 -12
View File
@@ -12,10 +12,11 @@ import (
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/redaction"
)
const grantSchemaVersion = 2
const grantSchemaVersion = 3
type Grant struct {
ToolName string `json:"toolName"`
@@ -51,8 +52,18 @@ type GrantLookup struct {
type grantFile struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants map[string][]Grant `json:"grants"`
CommandPrefixes map[string][]CommandPrefixGrant `json:"commandPrefixes,omitempty"`
Migration *grantMigration `json:"migration,omitempty"`
}
type grantMigration struct {
FromSchemaVersion int `json:"fromSchemaVersion"`
BackupPath string `json:"backupPath"`
Migrated int `json:"migrated"`
Invalidated int `json:"invalidated"`
NoticePending bool `json:"noticePending"`
}
type GrantStore struct {
@@ -118,6 +129,31 @@ func (store *GrantStore) FilePath() string {
return store.filePath
}
// ConsumeMigrationNotice returns the legacy-grant migration summary once and
// atomically records it as seen. Frontends call this during startup so users are
// told when approvals were invalidated instead of encountering unexplained
// re-prompts. The original file remains available at the reported backup path.
func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
if store == nil {
return "", nil
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return "", err
}
if state.Migration == nil || !state.Migration.NoticePending {
return "", nil
}
migration := *state.Migration
state.Migration.NoticePending = false
if err := store.writeState(state); err != nil {
return "", err
}
return fmt.Sprintf("sandbox grants updated for policy v%d: migrated %d, invalidated %d; backup: %s", execution.PolicyVersion, migration.Migrated, migration.Invalidated, migration.BackupPath), nil
}
func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
grant, err := createGrant(input, store.now)
if err != nil {
@@ -518,25 +554,26 @@ func (store *GrantStore) readState() (grantFile, error) {
// current (v2) map[tool][]Grant shape.
var head struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants json.RawMessage `json:"grants"`
CommandPrefixes json.RawMessage `json:"commandPrefixes"`
Migration *grantMigration `json:"migration"`
}
if err := json.Unmarshal(data, &head); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
}
if head.SchemaVersion != grantSchemaVersion {
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
}
if head.PolicyVersion != execution.PolicyVersion {
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
}
buckets := map[string][]Grant{}
commandPrefixBuckets := map[string][]CommandPrefixGrant{}
switch head.SchemaVersion {
case 1:
legacy := map[string]Grant{}
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
case grantSchemaVersion:
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &buckets); err != nil {
@@ -581,7 +618,167 @@ func (store *GrantStore) readState() (grantFile, error) {
normalizedPrefixes[key] = append(normalizedPrefixes[key], ng)
}
}
return grantFile{SchemaVersion: grantSchemaVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes}, nil
return grantFile{SchemaVersion: grantSchemaVersion, PolicyVersion: execution.PolicyVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes, Migration: head.Migration}, nil
}
func (store *GrantStore) migrateLegacyState(data []byte, schemaVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
if schemaVersion == 1 {
legacy := map[string]Grant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
} else if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
prefixes := map[string][]CommandPrefixGrant{}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeLegacyBackup(data, schemaVersion)
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || legacyShellGrant(normalized) {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for name, bucket := range prefixes {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredCommandPrefixGrant(key, grant)
if err != nil {
invalidated++
continue
}
state.CommandPrefixes[key] = append(state.CommandPrefixes[key], normalized)
migrated++
}
}
state.Migration = &grantMigration{FromSchemaVersion: schemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func (store *GrantStore) migrateChangedPolicy(data []byte, policyVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
prefixes := map[string][]CommandPrefixGrant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeBackup(data, fmt.Sprintf("policy-v%d", policyVersion))
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || normalized.Decision != GrantDeny {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for _, bucket := range prefixes {
invalidated += len(bucket)
}
state.Migration = &grantMigration{FromSchemaVersion: grantSchemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func legacyShellGrant(grant Grant) bool {
if grant.Decision == GrantDeny {
return false
}
switch grant.ToolName {
case "bash", "exec_command", "write_stdin":
return true
default:
return false
}
}
func (store *GrantStore) writeLegacyBackup(data []byte, schemaVersion int) (string, error) {
return store.writeBackup(data, fmt.Sprintf("v%d", schemaVersion))
}
func (store *GrantStore) writeBackup(data []byte, label string) (string, error) {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return "", err
}
base := fmt.Sprintf("%s.%s.backup", store.filePath, label)
for attempt := 0; ; attempt++ {
path := base
if attempt > 0 {
path = fmt.Sprintf("%s.%d", base, attempt)
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}
}
func (store *GrantStore) invalidGrantFile(err error) error {
@@ -657,6 +854,7 @@ func normalizeStoredCommandPrefixGrant(name string, grant CommandPrefixGrant) (C
func emptyGrantState() grantFile {
return grantFile{
SchemaVersion: grantSchemaVersion,
PolicyVersion: execution.PolicyVersion,
Grants: map[string][]Grant{},
CommandPrefixes: map[string][]CommandPrefixGrant{},
}
+102 -11
View File
@@ -70,10 +70,11 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
}
}
func TestGrantStoreReadsV1GrantFile(t *testing.T) {
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":1,"grants":{"bash":{"toolName":"bash","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`); err != nil {
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`
if err := writeText(path, original); err != nil {
t.Fatalf("write v1 grants: %v", err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path, Now: fixedSandboxTime("2026-06-05T15:00:00Z")})
@@ -85,19 +86,80 @@ func TestGrantStoreReadsV1GrantFile(t *testing.T) {
if err != nil {
t.Fatalf("List v1 grants returned error: %v", err)
}
if len(grants) != 1 || grants[0].ToolName != "bash" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
if len(grants) != 1 || grants[0].ToolName != "write_file" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
t.Fatalf("unexpected v1 grants: %#v", grants)
}
if _, err := store.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow}); err != nil {
t.Fatalf("Grant after v1 read returned error: %v", err)
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 0") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
if again, err := store.ConsumeMigrationNotice(); err != nil || again != "" {
t.Fatalf("second migration notice = %q err=%v, want empty", again, err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 2`) || !strings.Contains(string(raw), `"bash": [`) {
t.Fatalf("grant file was not rewritten as v2 buckets:\n%s", raw)
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
if err != nil || string(backup) != original {
t.Fatalf("migration backup = %q err=%v, want original", backup, err)
}
}
func TestGrantStoreInvalidatesLegacyShellAllowButKeepsSafePrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"exec_command":[{"toolName":"exec_command","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 0 {
t.Fatalf("legacy shell allows = %#v err=%v, want invalidated", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("migrated prefixes = %#v err=%v", prefixes, err)
}
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":0,"grants":{"write_file":[{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}],"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("migrated policy grants = %#v err=%v, want deny only", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("changed-policy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".policy-v0.backup")
if err != nil || string(backup) != original {
t.Fatalf("policy backup = %q err=%v", backup, err)
}
}
@@ -207,7 +269,7 @@ func TestGrantStoreRejectsUnsafeInputsAndMalformedFiles(t *testing.T) {
}
}
func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
func TestGrantStoreInvalidatesUnsafeLegacyCommandPrefix(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["find"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`); err != nil {
@@ -217,8 +279,37 @@ func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.ListCommandPrefixes(); err == nil || !strings.Contains(err.Error(), "invalid command prefix") {
t.Fatalf("expected invalid command prefix error, got %v", err)
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("unsafe legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 1") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
}
func TestGrantStoreInvalidatesMalformedLegacyToolKeys(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"../escape":[{"toolName":"../escape","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bad name":[{"toolName":"bad name","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
if grants, err := store.List(); err != nil || len(grants) != 0 {
t.Fatalf("malformed legacy grants = %#v err=%v, want invalidated", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("malformed legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v, want original", backup, err)
}
}
+51 -9
View File
@@ -26,6 +26,7 @@ type LinuxSandboxCommandArgsOptions struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -37,6 +38,7 @@ type LinuxSandboxHelperConfig struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -51,6 +53,16 @@ type LinuxSandboxBwrapOptions struct {
HelperPath string
}
type linuxSandboxBwrapPlan struct {
Args []string
ProtectedCreateTargets []string
}
type linuxBwrapFilesystemPlan struct {
Args []string
ProtectedCreateTargets []string
}
var linuxSandboxHelperCommand = findLinuxSandboxHelperCommand
func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]string, error) {
@@ -86,6 +98,9 @@ func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]str
if options.NoProc {
args = append(args, "--no-proc")
}
if strings.TrimSpace(options.PolicyReportPath) != "" {
args = append(args, "--policy-report-path", options.PolicyReportPath)
}
args = append(args, "--")
args = append(args, options.Command...)
return args, nil
@@ -103,6 +118,7 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
flags.BoolVar(&config.ApplySeccompThenExec, "apply-seccomp-then-exec", false, "apply seccomp before exec")
flags.BoolVar(&config.BlockUnixSockets, "block-unix-sockets", false, "block AF_UNIX sockets before exec")
flags.BoolVar(&config.NoProc, "no-proc", false, "skip proc mount")
flags.StringVar(&config.PolicyReportPath, "policy-report-path", "", "structured policy report path")
if err := flags.Parse(args); err != nil {
return LinuxSandboxHelperConfig{}, err
}
@@ -122,6 +138,7 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
return LinuxSandboxHelperConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
}
config.Command = flags.Args()
config.PolicyReportPath = strings.TrimSpace(config.PolicyReportPath)
if len(config.Command) == 0 {
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
}
@@ -129,16 +146,24 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
}
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
plan, err := buildLinuxSandboxBwrapPlan(options)
if err != nil {
return nil, err
}
return plan.Args, nil
}
func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxBwrapPlan, error) {
config := options.Config
if config.ApplySeccompThenExec {
return nil, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
return linuxSandboxBwrapPlan{}, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
}
if config.UseLandlock {
return nil, errors.New("linux landlock helper mode is not implemented yet")
return linuxSandboxBwrapPlan{}, errors.New("linux landlock helper mode is not implemented yet")
}
helperPath := strings.TrimSpace(options.HelperPath)
if helperPath == "" {
return nil, errors.New("linux sandbox helper path is required")
return linuxSandboxBwrapPlan{}, errors.New("linux sandbox helper path is required")
}
commandCWD := strings.TrimSpace(config.CommandCWD)
if commandCWD == "" {
@@ -154,13 +179,14 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
Command: config.Command,
})
if err != nil {
return nil, err
return linuxSandboxBwrapPlan{}, err
}
args := []string{
"--new-session",
"--die-with-parent",
}
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile)
args = append(args, filesystemPlan.Args...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
@@ -185,10 +211,17 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
}
args = append(args, "--", helperPath)
args = append(args, innerArgs...)
return args, nil
return linuxSandboxBwrapPlan{
Args: args,
ProtectedCreateTargets: filesystemPlan.ProtectedCreateTargets,
}, nil
}
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return buildLinuxBwrapFilesystemPlan(profile).Args
}
func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
// Disabled filesystem policy means no write jail: expose the host root
@@ -200,10 +233,11 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
args = append(args, "--bind", root.Root, root.Root)
}
}
return args
return linuxBwrapFilesystemPlan{Args: args}
}
args := []string{}
protectedCreateTargets := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
@@ -231,7 +265,12 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
args = appendReadOnlyLinuxPathArgs(args, subpath)
}
for _, name := range root.ProtectedMetadataNames {
args = appendReadOnlyLinuxPathArgs(args, filepath.Join(root.Root, name))
path := filepath.Join(root.Root, name)
if pathExists(path) {
args = appendReadOnlyLinuxPathArgs(args, path)
} else {
protectedCreateTargets = append(protectedCreateTargets, path)
}
}
}
for _, path := range fs.DenyWrite {
@@ -240,7 +279,10 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
}
return args
return linuxBwrapFilesystemPlan{
Args: args,
ProtectedCreateTargets: dedupeStrings(protectedCreateTargets),
}
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
+14 -6
View File
@@ -11,8 +11,8 @@ import (
)
var (
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxIsolatedNetworkGuardFilter = ApplyLinuxIsolatedNetworkGuard
)
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
@@ -32,7 +32,7 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": resolve helper path: "+err.Error())
return 125
}
bwrapArgs, err := BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{
bwrapPlan, err := buildLinuxSandboxBwrapPlan(LinuxSandboxBwrapOptions{
Config: config,
HelperPath: helperPath,
})
@@ -45,7 +45,10 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": bubblewrap is not available: "+err.Error())
return 125
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapArgs...), os.Environ()); err != nil {
if len(bwrapPlan.ProtectedCreateTargets) > 0 {
return runLinuxSandboxWithProtectedCreateMonitor(bwrapPath, bwrapPlan, config.PolicyReportPath, stderr)
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapPlan.Args...), os.Environ()); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec bubblewrap: "+err.Error())
return 126
}
@@ -69,9 +72,14 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
return 2
}
// The outer bubblewrap stage already enforces NetworkDeny with a private
// network namespace. Do not install the broad seccomp network filter here:
// it would also block AF_INET/AF_INET6 sockets inside that namespace and
// break isolated localhost test servers. The namespace has loopback only,
// so local bind/connect works while external egress remains unreachable.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
if err := applyLinuxNetworkDenyFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
if err := applyLinuxIsolatedNetworkGuardFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply isolated network guard: "+err.Error())
return 125
}
}
+15 -15
View File
@@ -8,18 +8,18 @@ import (
"testing"
)
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
func TestLinuxSandboxInnerStageReliesOnOuterNamespaceForNetworkDeny(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
originalUnixBlock := applyUnixSocketBlockFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
applyUnixSocketBlockFilter = originalUnixBlock
})
networkDenyCalls := 0
networkGuardCalls := 0
unixBlockCalls := 0
applyLinuxNetworkDenyFilter = func() error {
networkDenyCalls++
applyLinuxIsolatedNetworkGuardFilter = func() error {
networkGuardCalls++
return nil
}
applyUnixSocketBlockFilter = func() error {
@@ -35,24 +35,24 @@ func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 after Unix-socket filter; stderr=%s", code, stderr.String())
}
if networkDenyCalls != 1 {
t.Fatalf("network deny filter calls = %d, want 1", networkDenyCalls)
if networkGuardCalls != 1 {
t.Fatalf("isolated network guard calls = %d, want 1", networkGuardCalls)
}
if unixBlockCalls != 1 {
t.Fatalf("unix socket filter calls = %d, want 1", unixBlockCalls)
}
}
func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
func TestLinuxSandboxInnerStageSkipsIsolatedNetworkGuardWhenNetworkAllowed(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
})
applyLinuxNetworkDenyFilter = func() error {
return errors.New("network deny filter should not run")
applyLinuxIsolatedNetworkGuardFilter = func() error {
return errors.New("isolated network guard should not run")
}
var stderr bytes.Buffer
@@ -62,6 +62,6 @@ func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T)
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 without isolated network guard; stderr=%s", code, stderr.String())
}
}
+29
View File
@@ -236,6 +236,35 @@ func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
}
}
func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T) {
workspace := t.TempDir()
existing := filepath.Join(workspace, ".git")
if err := os.Mkdir(existing, 0o755); err != nil {
t.Fatalf("Mkdir existing metadata: %v", err)
}
missing := filepath.Join(workspace, ".zero")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{
Root: workspace,
ProtectedMetadataNames: []string{".git", ".zero"},
}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
assertArgsContainSequence(t, plan.Args, "--ro-bind", existing, existing)
if argsContainSequence(plan.Args, "--tmpfs", missing) || argsContainSequence(plan.Args, "--ro-bind", missing, missing) {
t.Fatalf("missing protected metadata must remain absent inside the sandbox: %#v", plan.Args)
}
if !reflect.DeepEqual(plan.ProtectedCreateTargets, []string{missing}) {
t.Fatalf("protected create targets = %#v, want %#v", plan.ProtectedCreateTargets, []string{missing})
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
+7
View File
@@ -228,6 +228,9 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
if request.ValidateExecution && preference != SandboxPreferenceForbid && backend.SupportLevel() != BackendSupportNative && policyHasExplicitDeny(policy) {
return SandboxExecutionRequest{}, errors.New("native sandbox unavailable: configured deny_read or deny_write rules cannot be enforced")
}
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
@@ -283,6 +286,10 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
}, nil
}
func policyHasExplicitDeny(policy Policy) bool {
return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0
}
func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) {
execRequest, err := manager.BuildExecutionRequest(request)
if err != nil {
@@ -0,0 +1,87 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
// The implementation mechanism differs by OS, but these observable policy
// semantics must remain stable across adapters.
func TestPlatformAdaptersShareExecutionContract(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
profile := PermissionProfileFromPolicy(root, policy, nil)
if !profile.RequiresPlatformSandbox() {
t.Fatal("baseline profile must require platform enforcement")
}
protected := strings.Join(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, "\x00")
for _, name := range []string{".zero", ".agents"} {
if !strings.Contains(protected, name) {
t.Fatalf("baseline profile missing protected metadata %q: %#v", name, profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
}
}
resolvedRoot := profile.FileSystem.WriteRoots[0].Root
for _, subpath := range []string{filepath.Join(resolvedRoot, ".git", "hooks"), filepath.Join(resolvedRoot, ".git", "config")} {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, subpath) {
t.Fatalf("baseline profile missing git metadata carveout %q: %#v", subpath, profile.FileSystem.WriteRoots[0].ReadOnlySubpaths)
}
}
t.Run("macOS native plan", func(t *testing.T) {
backend := Backend{Name: BackendMacOSSeatbelt, Available: true, Platform: "darwin", CommandWrapping: true, NativeIsolation: true, Executable: "/usr/bin/sandbox-exec"}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendMacOSSeatbelt || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("macOS request = %#v", request)
}
network := networkRuleForProfile(request.PermissionProfile.Network)
if network != "(deny network*)" {
t.Fatalf("macOS restricted network profile = %q, want strict deny", network)
}
})
t.Run("Windows native plan", func(t *testing.T) {
original := windowsSandboxInitialized
windowsSandboxInitialized = func() bool { return true }
t.Cleanup(func() { windowsSandboxInitialized = original })
backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Platform: "windows", CommandWrapping: true, NativeIsolation: true, Executable: `C:\zero\zero.exe`}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "cmd.exe", Args: WindowsShellArgs("echo ok"), Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendWindowsRestrictedToken || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("Windows request = %#v", request)
}
if request.PermissionProfile.Network.Mode != NetworkDeny {
t.Fatalf("Windows native plan lost network restriction: %#v", request.PermissionProfile.Network)
}
})
}
func TestUnavailablePlatformAdaptersFailClosedOnlyForExplicitDenies(t *testing.T) {
root := t.TempDir()
for _, goos := range []string{"darwin", "windows"} {
t.Run(goos, func(t *testing.T) {
backend := unavailableBackend(goos, "native sandbox unavailable")
manager := NewSandboxManager(SandboxManagerOptions{GOOS: goos, Backend: backend})
baseline := DefaultPolicy()
request, err := manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: baseline, Command: CommandSpec{Name: "echo", Dir: root}})
if err != nil || request.EnforcementLevel != EnforcementDegraded || request.CommandWrapped {
t.Fatalf("baseline degraded request=%#v err=%v", request, err)
}
hardened := baseline
hardened.DenyRead = []string{"secrets"}
_, err = manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: hardened, Command: CommandSpec{Name: "echo", Dir: root}, ValidateExecution: true})
if err == nil {
t.Fatal("explicit deny on unavailable adapter must fail closed")
}
})
}
}
+1
View File
@@ -18,6 +18,7 @@ const (
type PermissionProfile struct {
FileSystem FileSystemPolicy `json:"fileSystem"`
Network NetworkPolicy `json:"network"`
Runtime *SandboxRuntime `json:"runtime,omitempty"`
}
type FileSystemPolicy struct {
+283
View File
@@ -0,0 +1,283 @@
//go:build linux
package sandbox
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/unix"
)
const protectedCreatePollInterval = 10 * time.Millisecond
// An absent protected path cannot be mounted read-only without making it
// appear inside the child. The monitor preserves workspace path fidelity and
// treats the kernel's create/move event as the denial signal. The path can
// briefly appear on the host before the command is killed and it is removed;
// parsing the event ensures a fast create+unlink is still reported.
type protectedCreateMonitor struct {
targets []string
stderr io.Writer
inotifyFD int
watches map[int]string
targetSet map[string]struct{}
stop chan struct{}
done chan struct{}
violation atomic.Bool
once sync.Once
path string
}
type synchronizedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (writer *synchronizedWriter) Write(data []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.writer.Write(data)
}
func runLinuxSandboxWithProtectedCreateMonitor(bwrapPath string, plan linuxSandboxBwrapPlan, reportPath string, stderr io.Writer) int {
if stderr == nil {
stderr = io.Discard
}
safeStderr := &synchronizedWriter{writer: stderr}
monitor, err := newProtectedCreateMonitor(plan.ProtectedCreateTargets, safeStderr)
if err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": prepare protected metadata monitor: "+err.Error())
return 125
}
command := exec.Command(bwrapPath, plan.Args...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = safeStderr
command.Env = os.Environ()
if err := command.Start(); err != nil {
monitor.close()
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": start bubblewrap: "+err.Error())
return 126
}
monitor.start(func() {
_ = command.Process.Kill()
})
waitErr := command.Wait()
violated, target := monitor.stopAndCleanup()
if violated {
report := execution.AdapterReport{Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: target},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created by a sandboxed command",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
}}
if err := writeLinuxExecutionReport(reportPath, report); err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": write structured policy report: "+err.Error())
}
return 1
}
if waitErr == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
return exitErr.ExitCode()
}
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": wait for bubblewrap: "+waitErr.Error())
return 126
}
func newProtectedCreateMonitor(targets []string, stderr io.Writer) (*protectedCreateMonitor, error) {
monitor := &protectedCreateMonitor{
targets: dedupeStrings(targets),
stderr: stderr,
inotifyFD: -1,
stop: make(chan struct{}),
done: make(chan struct{}),
watches: make(map[int]string),
targetSet: make(map[string]struct{}),
}
for _, target := range monitor.targets {
target = filepath.Clean(target)
monitor.targetSet[target] = struct{}{}
if _, err := os.Lstat(target); err == nil {
return nil, fmt.Errorf("protected metadata path appeared before sandbox start: %s", target)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("inspect protected metadata path %s: %w", target, err)
}
}
monitor.inotifyFD, monitor.watches = openProtectedCreateInotify(monitor.targets)
return monitor, nil
}
func openProtectedCreateInotify(targets []string) (int, map[int]string) {
fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
return -1, nil
}
parents := make(map[string]struct{}, len(targets))
watches := make(map[int]string, len(targets))
for _, target := range targets {
parent := filepath.Dir(target)
if _, exists := parents[parent]; exists {
continue
}
parents[parent] = struct{}{}
watch, err := unix.InotifyAddWatch(fd, parent, unix.IN_CREATE|unix.IN_MOVED_TO|unix.IN_DELETE_SELF|unix.IN_MOVE_SELF)
if err != nil {
continue
}
watches[watch] = parent
}
if len(watches) == 0 {
_ = unix.Close(fd)
return -1, nil
}
return fd, watches
}
func (monitor *protectedCreateMonitor) start(onViolation func()) {
go func() {
defer close(monitor.done)
for {
monitor.scanAndRemove(onViolation)
monitor.wait(onViolation)
select {
case <-monitor.stop:
return
default:
}
}
}()
}
func (monitor *protectedCreateMonitor) wait(onViolation func()) {
if monitor.inotifyFD < 0 {
select {
case <-monitor.stop:
case <-time.After(protectedCreatePollInterval):
}
return
}
pollFD := []unix.PollFd{{Fd: int32(monitor.inotifyFD), Events: unix.POLLIN}}
_, _ = unix.Poll(pollFD, int(protectedCreatePollInterval/time.Millisecond))
if pollFD[0].Revents&unix.POLLIN != 0 {
monitor.drainInotifyEvents(onViolation)
}
}
func (monitor *protectedCreateMonitor) drainInotifyEvents(onViolation func()) {
var buffer [4096]byte
const eventHeaderSize = 16
for {
read, err := unix.Read(monitor.inotifyFD, buffer[:])
if err != nil {
return
}
for offset := 0; offset+eventHeaderSize <= read; {
watch := int(int32(binary.NativeEndian.Uint32(buffer[offset : offset+4])))
mask := binary.NativeEndian.Uint32(buffer[offset+4 : offset+8])
nameLength := int(binary.NativeEndian.Uint32(buffer[offset+12 : offset+16]))
next := offset + eventHeaderSize + nameLength
if nameLength < 0 || next > read {
break
}
name := strings.TrimRight(string(buffer[offset+eventHeaderSize:next]), "\x00")
if parent, ok := monitor.watches[watch]; ok && mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 && name != "" {
target := filepath.Clean(filepath.Join(parent, name))
if _, protected := monitor.targetSet[target]; protected {
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
offset = next
}
}
}
func (monitor *protectedCreateMonitor) scanAndRemove(onViolation func()) {
for _, target := range monitor.targets {
if _, err := os.Lstat(target); os.IsNotExist(err) {
continue
} else if err != nil {
continue
}
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
func (monitor *protectedCreateMonitor) recordViolation(target string, onViolation func()) {
monitor.once.Do(func() {
monitor.violation.Store(true)
monitor.path = target
fmt.Fprintln(monitor.stderr, "sandbox blocked creation of protected workspace metadata path "+target)
onViolation()
})
}
func removeProtectedCreateTarget(target string) bool {
for attempt := 0; attempt < 100; attempt++ {
_, err := os.Lstat(target)
if os.IsNotExist(err) {
return attempt > 0
}
if err != nil {
return false
}
err = os.RemoveAll(target)
if err == nil || os.IsNotExist(err) {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
func (monitor *protectedCreateMonitor) stopAndCleanup() (bool, string) {
close(monitor.stop)
<-monitor.done
monitor.scanAndRemove(func() {})
monitor.close()
return monitor.violation.Load(), monitor.path
}
func (monitor *protectedCreateMonitor) close() {
if monitor.inotifyFD >= 0 {
_ = unix.Close(monitor.inotifyFD)
monitor.inotifyFD = -1
}
}
func writeLinuxExecutionReport(path string, report execution.AdapterReport) error {
if path == "" {
return errors.New("policy report path is unavailable")
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
encoderErr := json.NewEncoder(file).Encode(report)
closeErr := file.Close()
if encoderErr != nil {
_ = os.Remove(path)
return encoderErr
}
return closeErr
}
@@ -0,0 +1,67 @@
//go:build linux
package sandbox
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestProtectedCreateMonitorStopsCommandAndRemovesTarget(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
started := time.Now()
code := runLinuxSandboxWithProtectedCreateMonitor("/bin/sh", linuxSandboxBwrapPlan{
Args: []string{"-c", "mkdir " + shellQuote(target) + "; while :; do :; done"},
ProtectedCreateTargets: []string{target},
}, "", &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want policy failure 1; stderr=%s", code, stderr.String())
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("protected creation did not stop command promptly; elapsed=%s", elapsed)
}
if !strings.Contains(stderr.String(), "blocked creation of protected workspace metadata path") {
t.Fatalf("missing denial reason: %s", stderr.String())
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("protected target remained after denial: %v", err)
}
}
func TestProtectedCreateMonitorRecordsTransientCreateEvent(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
monitor, err := newProtectedCreateMonitor([]string{target}, &stderr)
if err != nil {
t.Fatalf("newProtectedCreateMonitor: %v", err)
}
if monitor.inotifyFD < 0 {
monitor.close()
t.Skip("inotify is unavailable")
}
if err := os.Mkdir(target, 0o700); err != nil {
monitor.close()
t.Fatal(err)
}
if err := os.Remove(target); err != nil {
monitor.close()
t.Fatal(err)
}
triggered := make(chan struct{}, 1)
monitor.start(func() { triggered <- struct{}{} })
violated, gotTarget := monitor.stopAndCleanup()
if !violated || gotTarget != target {
t.Fatalf("transient creation = violated %t target %q, want true %q; stderr=%s", violated, gotTarget, target, stderr.String())
}
select {
case <-triggered:
default:
t.Fatal("transient create event did not invoke violation callback")
}
}
+32
View File
@@ -311,6 +311,38 @@ func (engine *Engine) GrantRequestPermissions(profile RequestPermissionProfile,
}
}
// CoversRequestPermissions reports whether the engine's current effective
// policy already includes every capability in profile. Callers use this before
// prompting for an explicit per-command permission request: asking for a
// capability that was granted for the session is not a new elevation.
func (engine *Engine) CoversRequestPermissions(profile RequestPermissionProfile) bool {
if engine == nil {
return false
}
policy := engine.effectivePolicy(engine.policy)
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled &&
NormalizeNetworkMode(policy.Network) != NetworkAllow {
return false
}
if profile.FileSystem == nil {
return true
}
workspaceRoot := engine.workspaceRoot
scope := engine.scopeFor(workspaceRoot)
enforceWorkspace := policy.EnforceWorkspace && workspaceRoot != ""
for _, path := range profile.FileSystem.Read {
if validatePathWithPolicy(scope, policy, SideEffectRead, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
for _, path := range profile.FileSystem.Write {
if validatePathWithPolicy(scope, policy, SideEffectWrite, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
return true
}
func permissionRoot(path string) string {
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil && info.IsDir() {
@@ -140,6 +140,40 @@ func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
}
}
func TestCoversRequestPermissionsReusesSessionNetworkCapability(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
if engine.CoversRequestPermissions(profile) {
t.Fatal("default network-deny policy must not cover external network access")
}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeSession)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
cleanup()
if !engine.CoversRequestPermissions(profile) {
t.Fatal("session network grant should cover an equivalent later request")
}
}
func TestCoversRequestPermissionsDoesNotOutliveTurnGrant(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeTurn)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
if !engine.CoversRequestPermissions(profile) {
t.Fatal("active turn grant should cover the requested network capability")
}
cleanup()
if engine.CoversRequestPermissions(profile) {
t.Fatal("cleaned-up turn grant must not cover later requests")
}
}
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
+109 -36
View File
@@ -2,6 +2,9 @@ package sandbox
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
@@ -11,6 +14,7 @@ import (
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -62,6 +66,10 @@ type CommandPlan struct {
// cleanup releases resources tied to the plan's lifetime. It is never
// serialized; callers invoke it via Cleanup() once the command has finished.
cleanup func()
// executionReportPath is an adapter-owned side channel outside the
// workspace. It carries structured policy facts; command output is never
// parsed as the control protocol.
executionReportPath string
}
// Cleanup releases any resources the plan holds. It is safe to call on a zero
@@ -72,6 +80,26 @@ func (plan CommandPlan) Cleanup() {
}
}
// ExecutionReport reads the structured platform-adapter report for a finished
// command. A command with no adapter report returns an empty report.
func (plan CommandPlan) ExecutionReport() (execution.AdapterReport, error) {
if strings.TrimSpace(plan.executionReportPath) == "" {
return execution.AdapterReport{}, nil
}
data, err := os.ReadFile(plan.executionReportPath)
if os.IsNotExist(err) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, fmt.Errorf("read sandbox execution report: %w", err)
}
var report execution.AdapterReport
if err := json.Unmarshal(data, &report); err != nil {
return execution.AdapterReport{}, fmt.Errorf("decode sandbox execution report: %w", err)
}
return report, nil
}
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
if ctx == nil {
ctx = context.Background()
@@ -86,6 +114,35 @@ func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*ex
return command, plan, nil
}
// PrepareExecution adapts the platform-neutral execution interface to the
// sandbox engine. Callers never need to construct native sandbox commands or
// interpret adapter reports themselves.
func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
if err := request.Validate(); err != nil {
return execution.PreparedCommand{}, err
}
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: request.Command.Name,
Args: append([]string(nil), request.Command.Args...),
Dir: request.WorkingDirectory,
Env: append([]string(nil), request.Command.Env...),
})
if err != nil {
return execution.PreparedCommand{}, err
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
},
Report: plan.ExecutionReport,
Cleanup: plan.Cleanup,
}, nil
}
// writeRoots returns the full ordered write-root list for command plans:
// the workspace root plus any granted extra roots. The single-root fallback
// only applies to engines built without a workspace root (NewEngine always
@@ -144,11 +201,20 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
preference = SandboxPreferenceForbid
}
profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope)
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
runtimeCleanup = cleanup
profile = permissionProfileWithRuntime(profile, runtimeState)
}
manager := NewSandboxManager(SandboxManagerOptions{
GOOS: backend.Platform,
Backend: backend,
})
return manager.BuildCommandPlan(SandboxManagerRequest{
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: workspaceRoot,
Command: spec,
Policy: policy,
@@ -157,6 +223,14 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
Preference: preference,
ValidateExecution: true,
})
if err != nil {
if runtimeCleanup != nil {
runtimeCleanup()
}
return CommandPlan{}, err
}
plan.cleanup = combineSandboxCleanups(plan.cleanup, runtimeCleanup)
return plan, nil
}
func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
@@ -201,38 +275,56 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
helper = resolved
}
command := append([]string{spec.Name}, spec.Args...)
reportPath, err := newLinuxExecutionReportPath()
if err != nil {
return CommandPlan{}, err
}
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: execRequest.WorkspaceRoot,
CommandCWD: spec.Dir,
PermissionProfile: execRequest.PermissionProfile,
BlockUnixSockets: policy.BlockUnixSockets,
PolicyReportPath: reportPath,
Command: command,
})
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, execRequest.PermissionProfile.Runtime)
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
}
plan := CommandPlan{
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
executionReportPath: reportPath,
cleanup: func() {
_ = os.Remove(reportPath)
},
}
return withSandboxExecutionMetadata(plan, execRequest), nil
}
func newLinuxExecutionReportPath() (string, error) {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return "", fmt.Errorf("generate sandbox execution report path: %w", err)
}
return filepath.Join("/tmp", "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil
}
func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequest) CommandPlan {
plan.Backend = request.Backend
plan.TargetBackend = request.TargetBackend
@@ -340,6 +432,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, profile.Runtime)
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -382,17 +475,6 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
}
}
func existingBubblewrapMounts() []string {
candidates := []string{"/bin", "/usr", "/lib", "/lib64", "/sbin", "/etc"}
mounts := []string{}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
mounts = append(mounts, candidate)
}
}
return mounts
}
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
@@ -789,13 +871,6 @@ func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string {
return out
}
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
// for a single file. Empty when DenyWrite is unset.
func denyWriteRules(policy Policy) []string {
return denyWriteRulesFromPaths(resolvePolicyPaths(policy.DenyWrite))
}
func denyWriteRulesFromPaths(paths []string) []string {
return denySeatbeltPathRules("file-write*", paths)
}
@@ -823,16 +898,14 @@ func denySeatbeltPathRules(action string, paths []string) []string {
return out
}
// networkRuleFor returns the seatbelt network clause for a policy.
func networkRuleFor(policy Policy) string {
return networkRuleForProfile(NetworkPolicy{Mode: policy.Network})
}
func networkRuleForProfile(network NetworkPolicy) string {
switch network.Mode {
case NetworkAllow:
return "(allow network*)"
default:
// Seatbelt has no private network namespace. Its localhost filters can
// reach services on the host and host interfaces, so the restricted
// profile must deny the entire network surface.
return "(deny network*)"
}
}
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
@@ -44,6 +46,11 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
Name: "/bin/sh",
Args: []string{"-c", strings.Join([]string{
"set -eu",
"test \"$HOME\" != \"$PWD\"",
"test ! -e .zero && test ! -e .agents",
"echo cache > \"$npm_config_cache/zero-runtime-probe\"",
"test ! -e .npm && test ! -e .cache",
"rm -f \"$npm_config_cache/zero-runtime-probe\"",
"echo ok > write-ok.txt",
"test \"$(cat write-ok.txt)\" = ok",
"echo tmp > /tmp/zero-sandbox-smoke",
@@ -167,6 +174,95 @@ func TestLinuxLandlockRealSandboxSmoke(t *testing.T) {
}
}
func TestLinuxHelperAllowsIsolatedLoopbackWithoutExternalEgress(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
python, err := exec.LookPath("python3")
if err != nil || python == "" {
t.Skip("python3 not found; skipping isolated loopback probe")
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
loopbackScript := strings.Join([]string{
"import socket",
"server = socket.socket()",
"server.bind(('127.0.0.1', 0))",
"server.listen()",
"client = socket.socket()",
"client.connect(('127.0.0.1', server.getsockname()[1]))",
"accepted, _ = server.accept()",
"client.send(b'ok')",
"assert accepted.recv(2) == b'ok'",
}, "; ")
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", loopbackScript},
Dir: root,
})
if runErr != nil {
t.Fatalf("isolated loopback failed: %v\n%s", runErr, output)
}
output, runErr = runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 1).close()"},
Dir: root,
})
if runErr == nil {
t.Fatalf("sandbox allowed external egress while isolated loopback was enabled: %s", output)
}
}
func TestLinuxHelperPreservesAbsentProtectedMetadata(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "set -eu; test ! -e .zero; mkdir .zero"},
Dir: root,
})
if err != nil {
t.Fatalf("CommandContext: %v", err)
}
defer plan.Cleanup()
output, runErr := command.CombinedOutput()
if runErr == nil {
t.Fatalf("sandbox reported success after protected metadata creation; output=%s", output)
}
if !strings.Contains(string(output), "blocked creation of protected workspace metadata path") {
t.Fatalf("sandbox did not explain protected metadata denial: %v\n%s", runErr, output)
}
if _, err := os.Lstat(filepath.Join(root, ".zero")); !os.IsNotExist(err) {
t.Fatalf("protected metadata path remained after execution: %v", err)
}
report, err := plan.ExecutionReport()
if err != nil {
t.Fatalf("ExecutionReport: %v", err)
}
if report.Denial == nil || report.Denial.Capability.Kind != execution.CapabilityProtectedMetadata {
t.Fatalf("execution report denial = %#v, want protected metadata", report.Denial)
}
if report.Denial.Capability.Scope != filepath.Join(root, ".zero") {
t.Fatalf("denial scope = %q, want exact protected path", report.Denial.Capability.Scope)
}
}
func runLinuxSandboxSmokeCommand(t *testing.T, engine *Engine, spec CommandSpec) ([]byte, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+5
View File
@@ -399,6 +399,11 @@ func TestSeatbeltProfileConsumesPermissionProfile(t *testing.T) {
t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl)
}
}
for _, forbidden := range []string{"network-bind", "network-inbound", `remote ip "localhost:*"`} {
if strings.Contains(sbpl, forbidden) {
t.Fatalf("restricted Seatbelt profile must not contain host-local rule %q:\n%s", forbidden, sbpl)
}
}
if strings.Contains(sbpl, "(allow file-read*)\n(allow file-write*)") {
t.Fatalf("restricted permission profile must not become full read/write:\n%s", sbpl)
}
+40
View File
@@ -0,0 +1,40 @@
package sandbox
import (
"fmt"
"sync"
)
const sandboxRuntimeLeaseSuffix = ".lease"
type sandboxRuntimeLease struct {
handle runtimeLeaseHandle
once sync.Once
}
func sandboxRuntimeLeasePath(root string) string {
return root + sandboxRuntimeLeaseSuffix
}
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil {
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
}
return &sandboxRuntimeLease{handle: handle}, nil
}
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil || inUse {
return nil, inUse, err
}
return &sandboxRuntimeLease{handle: handle}, false, nil
}
func (lease *sandboxRuntimeLease) release() {
if lease == nil {
return
}
lease.once.Do(lease.handle.release)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !windows
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/unix"
)
type runtimeLeaseHandle struct {
file *os.File
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return runtimeLeaseHandle{file: file}, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return runtimeLeaseHandle{file: file}, false, nil
}
func (lease runtimeLeaseHandle) release() {
if lease.file == nil {
return
}
_ = unix.Flock(int(lease.file.Fd()), unix.LOCK_UN)
_ = lease.file.Close()
}
+53
View File
@@ -0,0 +1,53 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/windows"
)
type runtimeLeaseHandle struct {
file *os.File
overlapped windows.Overlapped
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
handle := runtimeLeaseHandle{file: file}
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return handle, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
handle := runtimeLeaseHandle{file: file}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return handle, false, nil
}
func (lease runtimeLeaseHandle) release() {
if lease.file == nil {
return
}
_ = windows.UnlockFileEx(windows.Handle(lease.file.Fd()), 0, 1, 0, &lease.overlapped)
_ = lease.file.Close()
}
+242
View File
@@ -0,0 +1,242 @@
package sandbox
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
var sandboxUserCacheDir = os.UserCacheDir
var sandboxRuntimeNow = time.Now
const (
sandboxRuntimeMaxAge = 30 * 24 * time.Hour
sandboxRuntimeMaxRoots = 64
)
var fallbackSandboxRuntimes = struct {
sync.Mutex
roots map[string]string
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Home string `json:"home,omitempty"`
Cache string `json:"cache,omitempty"`
Config string `json:"config,omitempty"`
Data string `json:"data,omitempty"`
State string `json:"state,omitempty"`
Temp string `json:"temp,omitempty"`
}
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
if workspaceRoot == "" || workspaceRoot == "." {
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
}
cacheRoot, err := sandboxUserCacheDir()
if err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
}
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
if cacheRoot == "" || cacheRoot == "." {
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
}
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
lease, err = prepareSandboxRuntimeLease(root)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
prepared := false
defer func() {
if !prepared {
lease.release()
}
}()
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
}
directories := []string{
runtimeState.Root,
runtimeState.Home,
runtimeState.Cache,
runtimeState.Config,
runtimeState.Data,
runtimeState.State,
runtimeState.Temp,
filepath.Join(runtimeState.Cache, "npm"),
filepath.Join(runtimeState.Cache, "yarn"),
filepath.Join(runtimeState.Cache, "corepack"),
filepath.Join(runtimeState.Cache, "pip"),
filepath.Join(runtimeState.Cache, "go-build"),
filepath.Join(runtimeState.Data, "go-mod"),
filepath.Join(runtimeState.Data, "cargo"),
}
for _, directory := range directories {
if err := os.MkdirAll(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
}
}
now := sandboxRuntimeNow()
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
}
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
prepared = true
return runtimeState, lease.release, nil
}
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
}
return acquireSandboxRuntimeLease(root)
}
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
// is best-effort and never removes the runtime selected for the current
// command, so cache maintenance cannot turn a valid execution into a failure.
func cleanupSandboxRuntimeRoots(parent, current string, now time.Time) {
entries, err := os.ReadDir(parent)
if err != nil {
return
}
type candidate struct {
path string
modTime time.Time
}
var candidates []candidate
for _, entry := range entries {
path := filepath.Join(parent, entry.Name())
if !entry.IsDir() || filepath.Clean(path) == filepath.Clean(current) {
continue
}
info, infoErr := entry.Info()
if infoErr != nil {
continue
}
if now.Sub(info.ModTime()) > sandboxRuntimeMaxAge {
removeSandboxRuntimeRootIfUnused(path)
continue
}
candidates = append(candidates, candidate{path: path, modTime: info.ModTime()})
}
if len(candidates) < sandboxRuntimeMaxRoots {
return
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].modTime.Before(candidates[j].modTime) })
for len(candidates) >= sandboxRuntimeMaxRoots {
removeSandboxRuntimeRootIfUnused(candidates[0].path)
candidates = candidates[1:]
}
}
func removeSandboxRuntimeRootIfUnused(root string) {
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil || inUse {
return
}
defer lease.release()
_ = os.RemoveAll(root)
}
func combineSandboxCleanups(cleanups ...func()) func() {
var once sync.Once
return func() {
once.Do(func() {
for _, cleanup := range cleanups {
if cleanup != nil {
cleanup()
}
}
})
}
}
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
fallbackSandboxRuntimes.Lock()
defer fallbackSandboxRuntimes.Unlock()
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
return root, nil
}
parent, err := os.MkdirTemp("", "zero-runtime-")
if err != nil {
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
}
root := filepath.Join(parent, "runtime")
if pathWithinRoot(workspaceRoot, root) {
_ = os.RemoveAll(parent)
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
}
fallbackSandboxRuntimes.roots[workspaceRoot] = root
return root, nil
}
func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []string {
if runtimeState == nil || strings.TrimSpace(runtimeState.Root) == "" {
return env
}
overrides := []string{
"HOME=" + runtimeState.Home,
"XDG_CACHE_HOME=" + runtimeState.Cache,
"XDG_CONFIG_HOME=" + runtimeState.Config,
"XDG_DATA_HOME=" + runtimeState.Data,
"XDG_STATE_HOME=" + runtimeState.State,
"TMPDIR=" + runtimeState.Temp,
"TMP=" + runtimeState.Temp,
"TEMP=" + runtimeState.Temp,
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
"GOCACHE=" + filepath.Join(runtimeState.Cache, "go-build"),
"GOMODCACHE=" + filepath.Join(runtimeState.Data, "go-mod"),
"CARGO_HOME=" + filepath.Join(runtimeState.Data, "cargo"),
}
return upsertEnvList(env, overrides...)
}
func permissionProfileWithRuntime(profile PermissionProfile, runtimeState SandboxRuntime) PermissionProfile {
profile.Runtime = &runtimeState
if profile.FileSystem.Kind != FileSystemRestricted || runtimeState.Root == "" {
return profile
}
for _, root := range profile.FileSystem.WriteRoots {
if filepath.Clean(root.Root) == filepath.Clean(runtimeState.Root) {
return profile
}
}
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
return profile
}
+210
View File
@@ -0,0 +1,210 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
}
}
}
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
originalCache := sandboxUserCacheDir
originalNow := sandboxRuntimeNow
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
sandboxRuntimeNow = func() time.Time { return now }
t.Cleanup(func() {
sandboxUserCacheDir = originalCache
sandboxRuntimeNow = originalNow
})
parent := filepath.Join(cacheRoot, "zero", "runtime", "v1")
expired := filepath.Join(parent, "expired")
if err := os.MkdirAll(expired, 0o700); err != nil {
t.Fatal(err)
}
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
if err := os.Chtimes(expired, old, old); err != nil {
t.Fatal(err)
}
_, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
release()
if _, err := os.Stat(expired); !os.IsNotExist(err) {
t.Fatalf("expired runtime still exists: %v", err)
}
}
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
if filepath.Clean(filepath.Dir(runtimeState.Root)) == filepath.Clean(os.TempDir()) {
t.Fatalf("fallback runtime %q must use a private cleanup parent", runtimeState.Root)
}
}
func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
originalCache := sandboxUserCacheDir
originalNow := sandboxRuntimeNow
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
sandboxRuntimeNow = func() time.Time { return now }
t.Cleanup(func() {
sandboxUserCacheDir = originalCache
sandboxRuntimeNow = originalNow
})
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
if err := os.Chtimes(runtimeState.Root, old, old); err != nil {
release()
t.Fatal(err)
}
parent := filepath.Dir(runtimeState.Root)
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
if _, err := os.Stat(runtimeState.Root); err != nil {
release()
t.Fatalf("active runtime was removed: %v", err)
}
release()
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
if _, err := os.Stat(runtimeState.Root); !os.IsNotExist(err) {
t.Fatalf("released expired runtime still exists: %v", err)
}
}
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"HOME=/workspace",
"XDG_CACHE_HOME=/host/cache",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"HOME": runtimeState.Home,
"XDG_CACHE_HOME": runtimeState.Cache,
"XDG_CONFIG_HOME": runtimeState.Config,
"XDG_DATA_HOME": runtimeState.Data,
"XDG_STATE_HOME": runtimeState.State,
"TMPDIR": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
}
}
if got := envListValue(env, "PATH", ""); got != "/usr/bin" {
t.Fatalf("PATH = %q, want preserved caller path", got)
}
}
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{
Name: BackendLinuxBwrap,
Available: true,
Platform: "linux",
Executable: "/usr/bin/zero-linux-sandbox",
CommandWrapping: true,
NativeIsolation: true,
},
})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
defer plan.Cleanup()
if plan.PermissionProfile.Runtime == nil || plan.PermissionProfile.Runtime.Root == "" {
t.Fatal("command plan is missing managed runtime state")
}
if cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root); err != nil {
t.Fatalf("inspect active runtime lease: %v", err)
} else if cleanupLease != nil {
cleanupLease.release()
t.Fatal("command plan did not retain its runtime lease")
} else if !inUse {
t.Fatal("command plan runtime must be marked in use")
}
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
}
foundWriteRoot := false
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
if root.Root == plan.PermissionProfile.Runtime.Root {
foundWriteRoot = true
}
}
if !foundWriteRoot {
t.Fatalf("runtime root is not writable in profile: %#v", plan.PermissionProfile.FileSystem.WriteRoots)
}
plan.Cleanup()
cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root)
if err != nil || inUse || cleanupLease == nil {
t.Fatalf("runtime lease after plan cleanup = lease %v inUse %t err %v", cleanupLease, inUse, err)
}
cleanupLease.release()
}
+47
View File
@@ -82,6 +82,24 @@ var networkDenySyscallsAARCH64 = []uint32{
427, // io_uring_register
}
var isolatedNetworkGuardSyscallsX86_64 = []uint32{
101, // ptrace
310, // process_vm_readv
311, // process_vm_writev
425, // io_uring_setup
426, // io_uring_enter
427, // io_uring_register
}
var isolatedNetworkGuardSyscallsAARCH64 = []uint32{
117, // ptrace
270, // process_vm_readv
271, // process_vm_writev
425, // io_uring_setup
426, // io_uring_enter
427, // io_uring_register
}
// unixSocketBlockFilter builds a classic-BPF seccomp program that denies
// socket(2)/AF_UNIX with EPERM on x86-64 and arm64 and allows everything else.
// An unrecognized architecture is allowed (fail-open on arch is intentional: the
@@ -134,6 +152,35 @@ func networkDenySeccompFilter() []sockFilter {
return program
}
// isolatedNetworkGuardFilter preserves the non-network defense-in-depth rules
// from networkDenySeccompFilter without blocking sockets. The bubblewrap outer
// stage supplies network isolation with a private namespace, so socket, bind,
// and connect must remain available for localhost-only test servers.
func isolatedNetworkGuardFilter() []sockFilter {
x86Section := syscallDenySection(isolatedNetworkGuardSyscallsX86_64)
armSection := syscallDenySection(isolatedNetworkGuardSyscallsAARCH64)
program := []sockFilter{
{Code: bpfLDWABS, K: seccompOffsetArch},
{Code: bpfJEQK, K: auditArchX86_64, Jt: 0, Jf: uint8(len(x86Section))},
}
program = append(program, x86Section...)
program = append(program, sockFilter{Code: bpfJEQK, K: auditArchAARCH64, Jt: 0, Jf: uint8(len(armSection))})
program = append(program, armSection...)
program = append(program, sockFilter{Code: bpfRETK, K: seccompRetAllow})
return program
}
func syscallDenySection(deniedSyscalls []uint32) []sockFilter {
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
for _, nr := range deniedSyscalls {
section = append(section,
sockFilter{Code: bpfJEQK, K: nr, Jt: 0, Jf: 1},
sockFilter{Code: bpfRETK, K: seccompRetErrno | errnoEPERM},
)
}
return append(section, sockFilter{Code: bpfRETK, K: seccompRetAllow})
}
func networkDenySection(socketNr uint32, socketpairNr uint32, deniedSyscalls []uint32) []sockFilter {
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
for _, nr := range deniedSyscalls {
+4
View File
@@ -27,6 +27,10 @@ func ApplyLinuxNetworkDeny() error {
return applySeccompFilter(networkDenySeccompFilter(), "network deny")
}
func ApplyLinuxIsolatedNetworkGuard() error {
return applySeccompFilter(isolatedNetworkGuardFilter(), "isolated network guard")
}
func applySeccompFilter(filters []sockFilter, name string) error {
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return fmt.Errorf("seccomp: set no_new_privs: %w", err)
+8 -2
View File
@@ -8,8 +8,14 @@ import "errors"
// platforms, where seccomp BPF is unavailable.
var ErrSeccompUnsupported = errors.New("seccomp Unix-socket blocking is only supported on Linux")
// ApplyUnixSocketBlock is a no-op on non-Linux platforms.
// ApplyUnixSocketBlock is unsupported on non-Linux platforms and always
// returns ErrSeccompUnsupported.
func ApplyUnixSocketBlock() error { return ErrSeccompUnsupported }
// ApplyLinuxNetworkDeny is a no-op on non-Linux platforms.
// ApplyLinuxNetworkDeny is unsupported on non-Linux platforms and always
// returns ErrSeccompUnsupported.
func ApplyLinuxNetworkDeny() error { return ErrSeccompUnsupported }
// ApplyLinuxIsolatedNetworkGuard is unsupported on non-Linux platforms and
// always returns ErrSeccompUnsupported.
func ApplyLinuxIsolatedNetworkGuard() error { return ErrSeccompUnsupported }
+22
View File
@@ -62,6 +62,28 @@ func TestNetworkDenySeccompFilterStructure(t *testing.T) {
}
}
func TestIsolatedNetworkGuardPreservesProcessRestrictionsWithoutBlockingSockets(t *testing.T) {
prog := isolatedNetworkGuardFilter()
assertValidSockFilterProgram(t, prog)
for _, denied := range []uint32{101, 117, 310, 311, 270, 271, 425, 426, 427} {
if !hasInstruction(prog, bpfJEQK, denied) {
t.Fatalf("isolated network guard missing denied syscall %d", denied)
}
}
for _, socketSyscall := range []uint32{nrSocketX86_64, nrSocketAARCH64, 42, 49, 50, 200, 201, 203} {
if hasInstruction(prog, bpfJEQK, socketSyscall) {
t.Fatalf("isolated network guard blocks socket syscall %d needed by loopback", socketSyscall)
}
}
if !hasInstruction(prog, bpfRETK, seccompRetAllow) {
t.Fatal("isolated network guard has no allow return")
}
if !hasInstruction(prog, bpfRETK, seccompRetErrno|errnoEPERM) {
t.Fatal("isolated network guard has no EPERM block return")
}
}
func assertValidSockFilterProgram(t *testing.T, prog []sockFilter) {
t.Helper()
if len(prog) == 0 {
+1
View File
@@ -327,6 +327,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
return CommandPlan{}, err
}
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
childEnv = sandboxRuntimeEnvironment(childEnv, execRequest.PermissionProfile.Runtime)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
+52 -18
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/secrets"
)
@@ -142,6 +143,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
}
defer plan.Cleanup()
addSandboxMeta(meta, plan)
executionRequest := execExecutionRequest(command, plan, absoluteCwd, false)
changeObserver := execution.NewChangeObserver(plan.WorkspaceRoot)
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
// can't grow Zero's memory before truncation: only the head+tail each stream
@@ -161,6 +164,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
err = command.Run()
exitCode := commandExitCode(err)
adapterReport, reportErr := plan.ExecutionReport()
meta["exit_code"] = strconv.Itoa(exitCode)
stdoutText := stdout.retained()
stderrRetained := stderr.retained()
@@ -170,46 +174,68 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
return Result{
result := Result{
Status: StatusError,
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true)
}
if err != nil {
if exitCode < 0 {
return Result{
result := Result{
Status: StatusError,
Output: "Error executing command: " + err.Error(),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
return Result{
result := Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
return Result{
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
result := Result{
Status: StatusOK,
Output: formatBashOutput(outText, errText, exitCode),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result {
input := execToolResultInput{
exited: true,
exitCode: exitCode,
enforcement: executionEnforcement(plan),
request: request,
report: report,
reportErr: reportErr,
changes: changes,
}
outcome := execExecutionOutcome(input)
if timedOut && report.Denial == nil && reportErr == nil {
outcome.State = execution.StateFailed
outcome.Kind = execution.OutcomeTimedOut
}
result.ExecutionRequest = &request
result.ExecutionOutcome = &outcome
result.ChangedFiles = executionChangedFiles(changes)
result.ChangeSummaries = executionChangeSummaries(changes)
return result
}
func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) *zeroSandbox.Engine {
@@ -287,15 +313,23 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
return command, plan, err
}
directPolicy := zeroSandbox.DefaultPolicy()
directPolicy.Mode = zeroSandbox.ModeDisabled
directPolicy.Network = zeroSandbox.NetworkAllow
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
Name: zeroSandbox.BackendUnavailable,
Message: "sandbox engine not provided",
},
Wrapped: false,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
TargetBackend: zeroSandbox.BackendNone,
WorkspaceRoot: absoluteCwd,
Policy: directPolicy,
PermissionProfile: zeroSandbox.PermissionProfileFromPolicy(absoluteCwd, directPolicy, nil),
Wrapped: false,
EnforcementLevel: zeroSandbox.EnforcementDisabled,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
+4 -11
View File
@@ -3,10 +3,10 @@
package tools
import (
"errors"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// bashWaitDelay bounds how long Wait blocks for the I/O pipes to drain after the
@@ -26,20 +26,13 @@ var bashWaitDelay = 2 * time.Second
// if a child still holds the I/O pipes after the group is killed, Wait gives up
// rather than blocking forever.
func hardenProcessLifetime(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(command)
command.WaitDelay = bashWaitDelay
command.Cancel = func() error {
if command.Process == nil {
return nil
}
// Negative pid targets the process group led by the shell.
if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
return execution.KillProcessTree(command.Process.Pid)
}
}
+2 -17
View File
@@ -3,13 +3,11 @@
package tools
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
@@ -29,23 +27,10 @@ func hardenProcessLifetime(command *exec.Cmd) {
if command.Process == nil {
return nil
}
taskkill := taskkillPath()
_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(command.Process.Pid)).Run()
return nil
return execution.KillProcessTree(command.Process.Pid)
}
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
+28 -2
View File
@@ -13,6 +13,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -101,8 +102,8 @@ func TestCoreToolsExposeShellTools(t *testing.T) {
if tool.Safety().Permission != wantPermission {
t.Fatalf("%s permission = %s, want %s", name, tool.Safety().Permission, wantPermission)
}
if name == "write_stdin" && !tool.Safety().AdvertiseInAuto {
t.Fatalf("write_stdin should stay visible in auto mode for polling and interrupts")
if (name == "exec_command" || name == "write_stdin") && !tool.Safety().AdvertiseInAuto {
t.Fatalf("%s should stay visible in auto mode so process creation and interaction are both available", name)
}
}
}
@@ -350,6 +351,25 @@ func TestBashToolRunsCommandInWorkspace(t *testing.T) {
}
}
func TestBashToolReportsWorkspaceChangesAfterFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell syntax")
}
root := t.TempDir()
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": "printf partial > partial.txt; exit 9",
})
if result.Status != StatusError {
t.Fatalf("expected application failure, got %s: %s", result.Status, result.Output)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "partial.txt" {
t.Fatalf("ChangedFiles = %#v, want partial.txt", result.ChangedFiles)
}
if result.ExecutionOutcome == nil || len(result.ExecutionOutcome.Changes) != 1 || result.ExecutionOutcome.Changes[0].Kind != execution.ChangeCreated {
t.Fatalf("typed changes = %#v, want created partial file", result.ExecutionOutcome)
}
}
// A command with runaway output must not be buffered whole in memory: the capture
// is bounded to head+tail, yet raw_bytes still reports the true (much larger) size.
// If capture were unbounded this would balloon Zero's memory before truncation.
@@ -443,6 +463,9 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
}
func TestBashToolTimesOut(t *testing.T) {
@@ -460,6 +483,9 @@ func TestBashToolTimesOut(t *testing.T) {
if result.Meta["timeout_ms"] != "20" {
t.Fatalf("expected timeout_ms metadata 20, got %q", result.Meta["timeout_ms"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.Kind != execution.OutcomeTimedOut {
t.Fatalf("execution outcome = %#v, want timed_out", result.ExecutionOutcome)
}
}
func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
+198 -558
View File
@@ -2,17 +2,16 @@ package tools
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
@@ -25,9 +24,6 @@ const (
maxPollYieldTimeMS = 300000
defaultMaxOutputTokens = 10000
maxExecOutputTokenRequest = 200000
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
@@ -36,217 +32,18 @@ const (
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
)
type execSessionManager struct {
mu sync.Mutex
nextID int
sessions map[int]*execSession
completedRetention time.Duration
maxSessions int
}
type execSessionManager = execution.ProcessManager
func newExecSessionManager() *execSessionManager {
return &execSessionManager{
nextID: 1000,
sessions: make(map[int]*execSession),
completedRetention: completedSessionRetention,
maxSessions: maxExecSessions,
}
return execution.NewProcessManager(execution.ProcessManagerOptions{})
}
var defaultExecSessionManager = newExecSessionManager()
func (manager *execSessionManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *execSessionManager) store(session *execSession) {
manager.mu.Lock()
var pruned *execSession
removePruned := false
if manager.maxSessions > 0 && len(manager.sessions) >= manager.maxSessions {
pruned = manager.sessionToPruneLocked()
if pruned != nil {
removePruned = pruned.doneClosed()
if removePruned {
delete(manager.sessions, pruned.id)
}
}
}
manager.sessions[session.id] = session
manager.mu.Unlock()
if pruned != nil && !removePruned {
pruned.output.Write([]byte(execSessionEvictedMessage))
pruned.terminate()
}
}
func (manager *execSessionManager) get(id int) (*execSession, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
session, ok := manager.sessions[id]
return session, ok
}
func (manager *execSessionManager) remove(id int) {
manager.mu.Lock()
defer manager.mu.Unlock()
delete(manager.sessions, id)
}
func (manager *execSessionManager) removeCompletedLater(session *execSession) {
retention := manager.completedRetention
go func() {
<-session.done
if retention > 0 {
timer := time.NewTimer(retention)
<-timer.C
}
manager.remove(session.id)
}()
}
func (manager *execSessionManager) len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.sessions)
}
func (manager *execSessionManager) sessionToPruneLocked() *execSession {
if len(manager.sessions) == 0 {
return nil
}
// Snapshot each session's lastUsedAt UNDER session.mu before sorting: touch()
// writes lastUsedAt under session.mu, so reading it here (under manager.mu only)
// was a data race on a multi-word time.Time. Lock order stays manager.mu →
// session.mu (no path takes them the other way). (AUDIT-L15)
type sessionAge struct {
session *execSession
last time.Time
}
ages := make([]sessionAge, 0, len(manager.sessions))
for _, session := range manager.sessions {
ages = append(ages, sessionAge{session: session, last: session.lastUsed()})
}
sort.Slice(ages, func(i, j int) bool {
return ages[i].last.Before(ages[j].last)
})
for _, a := range ages {
if a.session.doneClosed() {
return a.session
}
}
if len(ages) <= 8 {
return nil
}
return ages[0].session
}
// lastUsed returns the session's last-used time under its own lock, so the prune
// comparator never races touch()'s write to lastUsedAt.
func (session *execSession) lastUsed() time.Time {
session.mu.Lock()
defer session.mu.Unlock()
return session.lastUsedAt
}
func (manager *execSessionManager) list() []ExecSessionSnapshot {
manager.mu.Lock()
sessions := make([]*execSession, 0, len(manager.sessions))
for _, session := range manager.sessions {
if !session.doneClosed() {
sessions = append(sessions, session)
}
}
manager.mu.Unlock()
snapshots := make([]ExecSessionSnapshot, 0, len(sessions))
for _, session := range sessions {
snapshots = append(snapshots, session.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].ID < snapshots[j].ID
})
return snapshots
}
func (manager *execSessionManager) stop(id int) bool {
session, ok := manager.get(id)
if !ok {
return false
}
session.terminate()
return true
}
func (manager *execSessionManager) stopAll() []int {
manager.mu.Lock()
sessions := make([]*execSession, 0, len(manager.sessions))
for _, session := range manager.sessions {
if !session.doneClosed() {
sessions = append(sessions, session)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(sessions))
for _, session := range sessions {
session.terminate()
ids = append(ids, session.id)
}
waitForExecSessions(sessions, execSessionStopTimeout)
sort.Ints(ids)
return ids
}
func waitForExecSessions(sessions []*execSession, timeout time.Duration) {
if timeout <= 0 || len(sessions) == 0 {
return
}
deadline := time.Now().Add(timeout)
for _, session := range sessions {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-session.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type ExecSessionSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionSnapshot = execution.ProcessSnapshot
type ExecSessionController interface {
ExecSessions() []ExecSessionSnapshot
@@ -254,172 +51,6 @@ type ExecSessionController interface {
StopAllExecSessions() []int
}
type execSession struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
plan zeroSandbox.CommandPlan
cancel context.CancelFunc
stdin io.WriteCloser
cleanup func()
output *execOutputBuffer
doneOnce sync.Once
done chan struct{}
mu sync.Mutex
exitCode *int
waitErr error
}
func (session *execSession) markDone(err error, exitCode int) {
session.mu.Lock()
session.waitErr = err
session.exitCode = &exitCode
session.mu.Unlock()
session.doneOnce.Do(func() { close(session.done) })
}
func (session *execSession) doneClosed() bool {
select {
case <-session.done:
return true
default:
return false
}
}
func (session *execSession) exitStatus() (int, bool) {
session.mu.Lock()
defer session.mu.Unlock()
if session.exitCode == nil {
return 0, false
}
return *session.exitCode, true
}
func (session *execSession) touch() {
session.mu.Lock()
session.lastUsedAt = time.Now()
session.mu.Unlock()
}
func (session *execSession) terminate() {
if session.cancel != nil {
session.cancel()
}
}
func (session *execSession) snapshot() ExecSessionSnapshot {
session.mu.Lock()
startedAt := session.startedAt
lastUsedAt := session.lastUsedAt
exitCode := session.exitCode
var copiedExit *int
if exitCode != nil {
value := *exitCode
copiedExit = &value
}
session.mu.Unlock()
status := "running"
if copiedExit != nil {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
return &execOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(p), nil
}
func (buffer *execOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *execOutputBuffer) drainString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
if len(buffer.data) == 0 {
return ""
}
out := string(buffer.data)
buffer.data = nil
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
@@ -473,7 +104,12 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
Required: []string{"cmd"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectShell, "Shell commands can read, write, or execute programs."),
safety: Safety{
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
Reason: "Shell commands can read, write, or execute programs.",
AdvertiseInAuto: true,
},
// PTY/shell session — never concurrent.
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: processResourceKeys},
},
@@ -496,15 +132,15 @@ func (tool execCommandTool) RunWithOptions(ctx context.Context, args map[string]
}
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
return tool.manager.list()
return tool.manager.List()
}
func (tool execCommandTool) StopExecSession(id int) bool {
return tool.manager.stop(id)
return tool.manager.Stop(id)
}
func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.stopAll()
return tool.manager.StopAll()
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
@@ -516,7 +152,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultExecYieldTimeMS, 1, maxExecYieldTimeMS)
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultExecYieldTimeMS, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
@@ -548,157 +184,62 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
return errorResult("Error running exec_command: " + err.Error())
}
session, err := tool.startSession(commandText, absoluteCwd, relativeCwd, ttyRequested, engine, sandboxPermissions)
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResultWithBudget(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
}, directBudget)
}
func (tool execCommandTool) startSession(commandText string, absoluteCwd string, relativeCwd string, ttyRequested bool, engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) (*execSession, error) {
id := tool.manager.allocateID()
commandCtx, cancel := context.WithCancel(context.Background())
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
command, plan, err := buildBashCommand(commandCtx, commandText, absoluteCwd, commandEngine)
if err != nil {
cancel()
return nil, err
return errorResult("Error starting exec_command: " + err.Error())
}
output := newExecOutputBuffer()
executionRequest := execExecutionRequest(command, plan, absoluteCwd, ttyRequested)
if err := executionRequest.Validate(); err != nil {
plan.Cleanup()
cancel()
return errorResult("Error starting exec_command: prepare execution request: " + err.Error())
}
meta := map[string]string{}
addSandboxMeta(meta, plan)
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
stdin, tty, cleanup, err := startExecProcess(command, output, ttyRequested)
processResult, err := tool.manager.Start(ctx, execution.ProcessStart{
Prepared: execution.PreparedCommand{
Command: command,
Enforcement: executionEnforcement(plan),
Report: plan.ExecutionReport,
Cleanup: func() {
plan.Cleanup()
cancel()
},
},
Request: executionRequest, CommandText: commandText, RelativeCwd: relativeCwd,
TTY: ttyRequested, Metadata: meta,
AfterWait: func() []byte {
blocks := monitor.Stop()
if len(blocks) == 0 {
return nil
}
return []byte(appendSandboxBlocks("", blocks))
},
}, time.Duration(yieldTimeMS)*time.Millisecond)
if err != nil {
_ = monitor.Stop()
plan.Cleanup()
cancel()
return nil, err
return errorResult("Error starting exec_command: " + err.Error())
}
session := &execSession{
id: id,
commandText: commandText,
cwd: absoluteCwd,
relativeCwd: relativeCwd,
startedAt: time.Now(),
lastUsedAt: time.Now(),
tty: tty,
command: command,
plan: plan,
cancel: cancel,
stdin: stdin,
cleanup: cleanup,
output: output,
done: make(chan struct{}),
}
tool.manager.store(session)
tool.manager.removeCompletedLater(session)
go func() {
err := command.Wait()
if blocks := monitor.Stop(); len(blocks) > 0 {
output.Write([]byte(appendSandboxBlocks("", blocks)))
}
if session.cleanup != nil {
session.cleanup()
}
plan.Cleanup()
cancel()
session.markDone(err, commandExitCode(err))
}()
return session, nil
return execToolResultWithBudget(execToolResultInput{
commandText: processResult.CommandText, output: processResult.Output,
outputBufferTruncated: processResult.OutputTruncated, sessionID: processResult.ProcessID,
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
tty: processResult.TTY, request: processResult.Request, enforcement: processResult.Enforcement,
report: processResult.Report, reportErr: processResult.ReportErr, changes: processResult.Changes,
sandboxMeta: processResult.Metadata,
maxOutputTokens: maxOutputTokens,
}, directBudget)
}
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
// A background process that keeps writing output faster than this
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
// a crash-restart spew) would otherwise never let drainString return
// empty, so the deadline check below — only reached on an empty
// drain — would never fire. That turns `wait` into an unbounded
// block regardless of what the caller asked for. Check it here too,
// so continuous output can never starve the timeout.
if time.Now().After(deadline) {
return finish()
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-session.output.notify:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-session.done:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
func executionEnforcement(plan zeroSandbox.CommandPlan) execution.Enforcement {
return execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == zeroSandbox.EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
}
}
@@ -794,7 +335,11 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultPollYieldTimeMS, 1, maxPollYieldTimeMS)
defaultYieldTimeMS := defaultPollYieldTimeMS
if chars != "" {
defaultYieldTimeMS = 250
}
yieldTimeMS, err := intArg(args, "yield_time_ms", defaultYieldTimeMS, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
@@ -802,41 +347,32 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
}
session, ok := tool.manager.get(sessionID)
snapshot, ok := tool.manager.Snapshot(sessionID)
if !ok {
return errorResult(UnknownExecSessionError(sessionID))
}
session.touch()
interrupted := false
if chars != "" {
if shouldInterruptExecSession(chars, session.tty) {
interrupted = true
session.terminate()
} else if !session.tty {
return errorResult(fmt.Sprintf("Error: exec session_id %d does not accept stdin. Use empty chars to poll, or send chars \"\\u0003\" to interrupt/stop it.", sessionID))
} else if session.stdin != nil {
if _, err := io.WriteString(session.stdin, chars); err != nil && !session.doneClosed() {
return errorResult("Error writing to exec session: " + err.Error())
}
}
interrupted := chars != "" && shouldInterruptExecSession(chars, snapshot.TTY)
processResult, err := tool.manager.Continue(ctx, execution.ProcessContinue{
ProcessID: sessionID, Input: []byte(chars), Interrupt: interrupted,
Wait: time.Duration(yieldTimeMS) * time.Millisecond,
})
if errors.Is(err, execution.ErrProcessNotFound) {
return errorResult(UnknownExecSessionError(sessionID))
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
if errors.Is(err, execution.ErrProcessStdinDisabled) {
return errorResult(fmt.Sprintf("Error: exec session_id %d does not accept stdin. Use empty chars to poll, or send chars \"\\u0003\" to interrupt/stop it.", sessionID))
}
if err != nil {
return errorResult("Error writing to exec session: " + err.Error())
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: processResult.CommandText, output: processResult.Output,
outputBufferTruncated: processResult.OutputTruncated, sessionID: processResult.ProcessID,
exitCode: processResult.ExitCode, exited: processResult.Exited, relativeCwd: processResult.RelativeCwd,
tty: processResult.TTY, interrupted: processResult.Interrupted, request: processResult.Request,
enforcement: processResult.Enforcement, report: processResult.Report, reportErr: processResult.ReportErr,
changes: processResult.Changes, sandboxMeta: processResult.Metadata,
maxOutputTokens: maxOutputTokens,
})
}
@@ -875,7 +411,12 @@ type execToolResultInput struct {
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
request execution.Request
enforcement execution.Enforcement
sandboxMeta map[string]string
report execution.AdapterReport
reportErr error
changes []execution.Change
maxOutputTokens int
}
@@ -893,14 +434,17 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
"cwd": input.relativeCwd,
"tty": strconv.FormatBool(input.tty),
}
addSandboxMeta(meta, input.plan)
for key, value := range input.sandboxMeta {
meta[key] = value
}
outcome := execExecutionOutcome(input)
if input.exited {
meta["exit_code"] = strconv.Itoa(input.exitCode)
if input.interrupted {
meta["interrupted"] = "true"
}
if !input.interrupted {
markLikelySandboxDenial(meta, input.plan, input.exitCode, output)
if input.report.Denial != nil {
markStructuredSandboxDenial(meta, *input.report.Denial)
}
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
@@ -910,7 +454,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
if input.exited && input.exitCode != 0 && !input.interrupted {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
@@ -928,10 +472,14 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Meta: meta,
ExecutionRequest: &input.request,
ExecutionOutcome: &outcome,
ChangedFiles: executionChangedFiles(input.changes),
ChangeSummaries: executionChangeSummaries(input.changes),
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
Kind: "shell",
@@ -939,6 +487,98 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
}
func execExecutionRequest(command *exec.Cmd, plan zeroSandbox.CommandPlan, cwd string, tty bool) execution.Request {
mode := execution.ModeCaptured
if tty {
mode = execution.ModeInteractive
}
workspaceRoot := strings.TrimSpace(plan.WorkspaceRoot)
if workspaceRoot == "" {
workspaceRoot = cwd
}
workspaceRoots := []string{workspaceRoot}
capabilities := []execution.Capability{{Kind: execution.CapabilityProcessSpawn}}
if plan.PermissionProfile.FileSystem.Kind == zeroSandbox.FileSystemUnrestricted {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"})
}
for _, root := range plan.PermissionProfile.FileSystem.ReadRoots {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityRead, Scope: root})
}
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityWorkspaceWrite, Scope: root.Root})
}
if plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkAllow || plan.Policy.Network == zeroSandbox.NetworkAllow {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityExternalNetwork})
} else if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap {
capabilities = append(capabilities, execution.Capability{Kind: execution.CapabilityIsolatedLoopback, Scope: "sandbox"})
}
args := append([]string(nil), command.Args...)
name := command.Path
if len(args) > 0 {
name = args[0]
args = args[1:]
}
return execution.Request{
Origin: execution.OriginInteractiveCommand,
Mode: mode,
Command: execution.Command{Name: name, Args: args},
WorkingDirectory: cwd,
WorkspaceRoots: workspaceRoots,
Capabilities: capabilities,
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
}
}
func execExecutionOutcome(input execToolResultInput) execution.Outcome {
enforcement := input.enforcement
if !input.exited {
return execution.Outcome{
State: execution.StateRetained,
Kind: execution.OutcomeRunning,
ProcessID: strconv.Itoa(input.sessionID),
Enforcement: enforcement,
}
}
exit := &execution.Exit{Code: input.exitCode}
if input.reportErr != nil {
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeSandboxSetupFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
if input.report.Denial != nil {
denial := *input.report.Denial
return execution.Outcome{State: execution.StateDenied, Kind: execution.OutcomeEnforcementDenied, Exit: exit, Denial: &denial, Enforcement: enforcement, Changes: input.changes}
}
if input.interrupted {
return execution.Outcome{State: execution.StateCancelled, Kind: execution.OutcomeCancelled, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
if input.exitCode == 0 {
return execution.Outcome{State: execution.StateCompleted, Kind: execution.OutcomeSuccess, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
return execution.Outcome{State: execution.StateFailed, Kind: execution.OutcomeApplicationFailure, Exit: exit, Enforcement: enforcement, Changes: input.changes}
}
func executionChangedFiles(changes []execution.Change) []string {
if len(changes) == 0 {
return nil
}
paths := make([]string, 0, len(changes))
for _, change := range changes {
if !change.Aggregated && strings.TrimSpace(change.Path) != "" {
paths = append(paths, change.Path)
}
}
return paths
}
func executionChangeSummaries(changes []execution.Change) []execution.Change {
var summaries []execution.Change
for _, change := range changes {
if change.Aggregated && strings.TrimSpace(change.Path) != "" {
summaries = append(summaries, change)
}
}
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
+113 -329
View File
@@ -2,20 +2,18 @@ package tools
import (
"context"
"errors"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -103,6 +101,12 @@ func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
if start.Meta["session_id"] == "" {
t.Fatalf("expected running session metadata, got %#v output=%q", start.Meta, start.Output)
}
if start.ExecutionOutcome == nil || start.ExecutionOutcome.State != execution.StateRetained || start.ExecutionOutcome.Kind != execution.OutcomeRunning {
t.Fatalf("running execution outcome = %#v, want retained/running", start.ExecutionOutcome)
}
if start.ExecutionOutcome.ProcessID != start.Meta["session_id"] {
t.Fatalf("process id = %q, want session id %q", start.ExecutionOutcome.ProcessID, start.Meta["session_id"])
}
if !strings.Contains(start.Output, `chars "\u0003"`) {
t.Fatalf("running session output should explain Ctrl-C cleanup, got %q", start.Output)
}
@@ -124,6 +128,9 @@ func TestExecCommandReturnsSessionAndWriteStdinPollsCompletion(t *testing.T) {
if poll.Meta["exit_code"] != "0" {
t.Fatalf("expected exit_code 0, got %#v", poll.Meta)
}
if poll.ExecutionOutcome == nil || poll.ExecutionOutcome.State != execution.StateCompleted || poll.ExecutionOutcome.Kind != execution.OutcomeSuccess {
t.Fatalf("completed execution outcome = %#v, want completed/success", poll.ExecutionOutcome)
}
}
func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testing.T) {
@@ -158,6 +165,12 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
if result.Meta["sandbox_wrapped"] == "true" {
t.Fatalf("require_escalated exec_command must not be wrapped; meta=%#v", result.Meta)
}
if result.ExecutionRequest == nil || !executionRequestHasCapability(*result.ExecutionRequest, execution.CapabilityUnrestricted, "host") {
t.Fatalf("escalated execution request must record unrestricted host capability: %#v", result.ExecutionRequest)
}
if !executionRequestHasCapability(*result.ExecutionRequest, execution.CapabilityExternalNetwork, "") {
t.Fatalf("escalated execution request must record external network capability: %#v", result.ExecutionRequest)
}
}
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
@@ -218,9 +231,98 @@ func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *tes
if result.Meta["exit_code"] != "0" {
t.Fatalf("exit_code = %#v, want 0", result.Meta)
}
if manager.len() != 0 {
t.Fatalf("completed command should be removed immediately, manager has %d sessions", manager.len())
if manager.Len() != 0 {
t.Fatalf("completed command should be removed immediately, manager has %d sessions", manager.Len())
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateCompleted || result.ExecutionOutcome.Kind != execution.OutcomeSuccess {
t.Fatalf("execution outcome = %#v, want completed/success", result.ExecutionOutcome)
}
}
func TestExecCommandClampsOversizedYieldInsteadOfRejecting(t *testing.T) {
result := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": helperCommand("success"),
"yield_time_ms": 120000,
})
if result.Status != StatusOK || result.Meta["exit_code"] != "0" {
t.Fatalf("oversized yield should be clamped, got status=%s meta=%#v output=%q", result.Status, result.Meta, result.Output)
}
}
func TestExecCommandReportsWorkspaceChanges(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell syntax")
}
root := t.TempDir()
result := NewScopedExecCommandTool(root, nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": "mkdir -p src node_modules/pkg && printf 'export {}' > src/main.ts && printf generated > node_modules/pkg/index.js",
"yield_time_ms": 30000,
})
if result.Status != StatusOK {
t.Fatalf("exec_command status = %s: %s", result.Status, result.Output)
}
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "src/main.ts" {
t.Fatalf("ChangedFiles = %#v, want source file without generated tree", result.ChangedFiles)
}
if result.ExecutionOutcome == nil || len(result.ExecutionOutcome.Changes) != 2 {
t.Fatalf("typed changes = %#v, want source file and bounded generated-tree summary", result.ExecutionOutcome)
}
if len(result.ChangeSummaries) != 1 || result.ChangeSummaries[0].Path != "node_modules/" || !result.ChangeSummaries[0].Aggregated {
t.Fatalf("ChangeSummaries = %#v, want node_modules aggregate", result.ChangeSummaries)
}
}
func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
result := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager()).Run(context.Background(), map[string]any{
"cmd": helperCommand("fail"),
"yield_time_ms": 30000,
})
if result.Status != StatusError {
t.Fatalf("status = %s, want error", result.Status)
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
}
}
func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) {
scope := filepath.Join(t.TempDir(), ".zero")
result := execToolResult(execToolResultInput{
commandText: "mkdir .zero",
exited: true,
exitCode: 1,
enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)},
report: execution.AdapterReport{Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: scope},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
}},
})
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateDenied || result.ExecutionOutcome.Kind != execution.OutcomeEnforcementDenied {
t.Fatalf("execution outcome = %#v, want denied/enforcement_denied", result.ExecutionOutcome)
}
if result.ExecutionOutcome.Denial == nil || result.ExecutionOutcome.Denial.Capability.Scope != scope {
t.Fatalf("structured denial = %#v, want exact scope %q", result.ExecutionOutcome.Denial, scope)
}
if result.Meta["sandbox_denial_capability"] != string(execution.CapabilityProtectedMetadata) || result.Meta["sandbox_denial_scope"] != scope {
t.Fatalf("compatibility metadata lost structured denial: %#v", result.Meta)
}
}
func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool {
for _, capability := range request.Capabilities {
if capability.Kind == kind && (scope == "" || capability.Scope == scope) {
return true
}
}
return false
}
func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
@@ -279,8 +381,7 @@ func parseListeningAddress(output string) string {
func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
root := t.TempDir()
manager := newExecSessionManager()
manager.completedRetention = 10 * time.Millisecond
manager := execution.NewProcessManager(execution.ProcessManagerOptions{CompletedRetention: 10 * time.Millisecond})
execTool := NewScopedExecCommandTool(root, nil, manager)
start := execTool.Run(context.Background(), map[string]any{
@@ -297,223 +398,16 @@ func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
deadline := time.Now().Add(2 * time.Second)
for {
if _, ok := manager.get(sessionID); !ok {
if _, ok := manager.Snapshot(sessionID); !ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("session %d was not reaped; manager has %d sessions", sessionID, manager.len())
t.Fatalf("session %d was not reaped; manager has %d sessions", sessionID, manager.Len())
}
time.Sleep(10 * time.Millisecond)
}
}
func TestStopAllWaitsForSessionsToExit(t *testing.T) {
manager := newExecSessionManager()
release := make(chan struct{})
returned := make(chan struct{})
cancelled := make(chan struct{})
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
session.cancel = func() {
close(cancelled)
go func() {
<-release
session.markDone(nil, -1)
}()
}
manager.sessions[session.id] = session
go func() {
manager.stopAll()
close(returned)
}()
select {
case <-cancelled:
case <-time.After(time.Second):
t.Fatal("stopAll did not terminate the session")
}
select {
case <-returned:
t.Fatal("stopAll returned before session.done closed")
default:
}
close(release)
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("stopAll did not return after session.done closed")
}
}
func TestStoreEvictsLiveSessionWithExplanation(t *testing.T) {
manager := newExecSessionManager()
manager.maxSessions = 9
evicted := &execSession{
id: 1000,
startedAt: time.Unix(1000, 0),
lastUsedAt: time.Unix(1000, 0),
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
evictedDone := make(chan struct{})
evicted.cancel = func() { close(evictedDone) }
manager.sessions[evicted.id] = evicted
for id := 1001; id <= 1008; id++ {
manager.sessions[id] = &execSession{
id: id,
startedAt: time.Unix(int64(id), 0),
lastUsedAt: time.Unix(int64(id), 0),
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
}
manager.store(&execSession{
id: 1009,
startedAt: time.Unix(1009, 0),
lastUsedAt: time.Unix(1009, 0),
output: newExecOutputBuffer(),
done: make(chan struct{}),
})
select {
case <-evictedDone:
case <-time.After(time.Second):
t.Fatal("live pruned session was not terminated")
}
if got := evicted.output.recentString(); !strings.Contains(got, "session evicted") {
t.Fatalf("evicted session output = %q, want explanation", got)
}
if _, ok := manager.get(evicted.id); !ok {
t.Fatal("evicted live session should remain visible until its reaper removes it")
}
}
func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
original := startPTYProcessFunc
t.Cleanup(func() { startPTYProcessFunc = original })
startPTYProcessFunc = func(command *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
command.SysProcAttr = &syscall.SysProcAttr{}
command.Cancel = func() error { return nil }
command.WaitDelay = time.Second
return nil, nil, errors.New("pty start failed")
}
command := exec.CommandContext(context.Background(), os.Args[0], "--zero-bash-helper", "success")
output := newExecOutputBuffer()
stdin, tty, cleanup, err := startExecProcess(command, output, true)
if err != nil {
t.Fatalf("startExecProcess fallback failed: %v", err)
}
if tty {
t.Fatal("fallback process must report tty=false")
}
_ = stdin.Close()
if err := command.Wait(); err != nil {
t.Fatalf("fallback command wait failed: %v", err)
}
cleanup()
if got := output.drainString(); !strings.Contains(got, "hello from bash") {
t.Fatalf("fallback output = %q", got)
}
}
func TestStartExecProcessPTYFallbackPreservesSysProcAttr(t *testing.T) {
original := startPTYProcessFunc
t.Cleanup(func() { startPTYProcessFunc = original })
startPTYProcessFunc = func(command *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty start failed")
}
command := exec.CommandContext(context.Background(), os.Args[0], "--zero-bash-helper", "success")
customAttr := &syscall.SysProcAttr{}
command.SysProcAttr = customAttr
output := newExecOutputBuffer()
stdin, tty, cleanup, err := startExecProcess(command, output, true)
if err != nil {
t.Fatalf("startExecProcess fallback failed: %v", err)
}
if tty {
t.Fatal("fallback process must report tty=false")
}
_ = stdin.Close()
_ = command.Wait()
cleanup()
if command.SysProcAttr != customAttr {
t.Fatalf("SysProcAttr was not preserved: got %+v, want %+v", command.SysProcAttr, customAttr)
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
// tens of gigabytes and got the whole zero process OOM-killed by the OS.
func TestExecOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newExecOutputBuffer()
chunk := strings.Repeat("x", 1024)
writes := (maxExecOutputBufferBytes / len(chunk)) + 10 // comfortably over the cap
for i := 0; i < writes; i++ {
if _, err := buffer.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxExecOutputBufferBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxExecOutputBufferBytes)
}
out := buffer.drainString()
// drainString itself carries no truncation marker: a marker embedded in
// the drained text would always sit ~maxExecOutputBufferBytes before the
// end of the string, past any realistic head/tail truncation window a
// caller applies afterward (see execToolResult), so it's reliably lost.
// The signal lives out of band on consumeTruncated/peekTruncated instead.
if !strings.HasSuffix(out, chunk) {
t.Fatal("drained output should keep the most recent bytes, not the oldest")
}
if len(out) > maxExecOutputBufferBytes {
t.Fatalf("drained output = %d bytes, want <= %d (no marker text mixed in)", len(out), maxExecOutputBufferBytes)
}
if !buffer.peekTruncated() {
t.Fatal("peekTruncated should report the overflow without clearing it")
}
if !buffer.peekTruncated() {
t.Fatal("a second peekTruncated call should still report it — peek must not consume")
}
if !buffer.consumeTruncated() {
t.Fatal("consumeTruncated should report the overflow")
}
if buffer.consumeTruncated() {
t.Fatal("consumeTruncated should reset after being read once")
}
if buffer.peekTruncated() {
t.Fatal("peekTruncated should reflect the reset state after consumeTruncated")
}
// A second drain with no intervening writes must be empty.
if got := buffer.drainString(); got != "" {
t.Fatalf("drainString after a full drain = %q, want empty", got)
}
}
// TestExecToolResultSurfacesBufferTruncationOutsideByteBudget: an earlier
// version embedded the truncation notice directly in the drained text, which
// a review found sits ~maxExecOutputBufferBytes before the end of the
// string — far past the head/tail window truncateExecOutput keeps even at
// the tool's smallest allowed max_output_tokens, so the notice was reliably
// swallowed or chopped. The notice must survive regardless of how small the
// byte budget is, and it must not count against that budget.
func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
hugeOutput := strings.Repeat("y", maxExecOutputBufferBytes+1024)
@@ -547,61 +441,6 @@ func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
// Go's scheduler in practice — the reader still won the race often enough —
// so this test doesn't prove the old code could hang; it just pins down the
// intended behavior (bounded by wait) going forward.
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
stop := make(chan struct{})
var wg sync.WaitGroup
// Several concurrent writers, each pushing a decent-sized chunk in a tight
// loop: a single slow writer lets the reader win the race often enough
// (the runtime schedules a gap between writes) to mask the bug. Enough
// parallel writers keep the buffer non-empty essentially continuously,
// closer to a real chatty process whose PTY reads land in bursts.
const writers = 8
chunk := []byte(strings.Repeat("x", 256))
wg.Add(writers)
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
session.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() {
close(stop)
wg.Wait()
})
const wait = 200 * time.Millisecond
start := time.Now()
_, _ = session.collect(context.Background(), wait)
elapsed := time.Since(start)
// Generous slack over `wait` for scheduling jitter under a continuously
// writing goroutine (worse on Windows CI under load). This must stay a
// small multiple of wait, not "however long the writer keeps going"
// (which is what the bug produced: this test would hang past the 30s
// test timeout without the fix).
if elapsed > 5*wait {
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
// does on cleanup can fail with "being used by another process". Retry the
// removal briefly before giving up.
func resilientTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "zero-exec-interrupt-")
@@ -642,14 +481,6 @@ func TestWriteStdinInterruptTerminatesSession(t *testing.T) {
t.Fatalf("session_id is not numeric: %v", err)
}
// Capture the live session BEFORE the interrupt so we can wait on its done
// channel afterwards (write_stdin removes a finished session from the
// manager, so manager.get would miss it post-interrupt).
session, ok := manager.get(sessionID)
if !ok {
t.Fatalf("session %d not found after start", sessionID)
}
// The operation under test: write_stdin "\x03" must itself terminate the
// session (exec_command.go's Ctrl-C branch). This is what the regression
// guards — terminating the session here directly would let the test pass even
@@ -657,20 +488,9 @@ func TestWriteStdinInterruptTerminatesSession(t *testing.T) {
interrupted := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "\x03",
"yield_time_ms": 1000,
"yield_time_ms": 30000,
})
// De-flake: wait deterministically for the process to be reaped rather than
// relying on the 1000ms yield window being long enough for the async
// SIGKILL + reap to land (which flaked on slow CI, notably Windows smoke). A
// generous safety timeout fails loudly if the kill genuinely hangs; the
// common case returns the instant the process exits.
select {
case <-session.done:
case <-time.After(30 * time.Second):
t.Fatalf("interrupted session %d was not reaped within 30s", sessionID)
}
if interrupted.Status != StatusOK {
t.Fatalf("interrupted session status = %s: %s", interrupted.Status, interrupted.Output)
}
@@ -714,7 +534,7 @@ func TestWriteStdinRejectsInputForNonTTYSession(t *testing.T) {
if !strings.Contains(result.Output, "does not accept stdin") {
t.Fatalf("unexpected output: %q", result.Output)
}
manager.stop(sessionID)
manager.Stop(sessionID)
}
func TestWriteStdinStopIntentTerminatesNonTTYSession(t *testing.T) {
@@ -968,39 +788,3 @@ func TestTruncateExecOutputPreservesUTF8(t *testing.T) {
t.Fatalf("truncated output is not valid UTF-8: %q", truncated)
}
}
func TestExecSessionPruneDoesNotRaceTouch(t *testing.T) {
// AUDIT-L15: the prune comparator read execSession.lastUsedAt under manager.mu
// while touch() writes it under session.mu — a data race on a time.Time. Drive
// both concurrently under -race; with the snapshot-under-session.mu fix it is
// clean, without it the race detector flags lastUsedAt.
mgr := newExecSessionManager()
for i := 0; i < 12; i++ {
s := &execSession{id: 1000 + i, lastUsedAt: time.Now(), done: make(chan struct{})}
mgr.sessions[s.id] = s
}
target := mgr.sessions[1000]
stop := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
go func() {
defer writer.Done()
for {
select {
case <-stop:
return
default:
target.touch()
}
}
}()
for i := 0; i < 2000; i++ {
mgr.mu.Lock()
_ = mgr.sessionToPruneLocked()
mgr.mu.Unlock()
}
close(stop)
writer.Wait()
}
-15
View File
@@ -1,15 +0,0 @@
//go:build !linux
package tools
import (
"errors"
"io"
"os/exec"
)
var errPTYUnavailable = errors.New("pty transport is unavailable on this platform")
func startPTYProcess(_ *exec.Cmd, _ *execOutputBuffer) (io.WriteCloser, func(), error) {
return nil, nil, errPTYUnavailable
}
-43
View File
@@ -1,43 +0,0 @@
package tools
import (
"io"
"os/exec"
"syscall"
)
func startExecProcess(command *exec.Cmd, output *execOutputBuffer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
if ttyRequested {
origSysProcAttr := command.SysProcAttr
if stdin, cleanup, err := startPTYProcessFunc(command, output); err == nil {
return stdin, true, cleanup, nil
}
resetExecCommandForPipeFallback(command, origSysProcAttr)
}
return startPipeProcess(command, output)
}
var startPTYProcessFunc = startPTYProcess
func resetExecCommandForPipeFallback(command *exec.Cmd, origSysProcAttr *syscall.SysProcAttr) {
command.Stdin = nil
command.Stdout = nil
command.Stderr = nil
command.SysProcAttr = origSysProcAttr
command.Cancel = nil
command.WaitDelay = 0
}
func startPipeProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, bool, func(), error) {
stdin, err := command.StdinPipe()
if err != nil {
return nil, false, nil, err
}
command.Stdout = output
command.Stderr = output
hardenProcessLifetime(command)
if err := command.Start(); err != nil {
return nil, false, nil, err
}
return stdin, false, func() {}, nil
}
+11 -117
View File
@@ -1,125 +1,19 @@
package tools
import (
"strings"
import "github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
var sandboxDeniedKeywords = []string{
"operation not permitted",
"permission denied",
"read-only file system",
"seccomp",
"sandbox",
"landlock",
"failed to write file",
}
var sandboxNetworkDeniedKeywords = []string{
"cannot open a network socket",
"cannot open netlink socket",
"uv_interface_addresses",
"listen eperm",
"getaddrinfo eai_again",
"network is unreachable",
}
func markLikelySandboxDenial(meta map[string]string, plan zeroSandbox.CommandPlan, exitCode int, sections ...string) {
kind, keyword := sandboxDenialKind(plan, exitCode, sections...)
if meta == nil || kind == "" {
// markStructuredSandboxDenial mirrors typed adapter facts into legacy metadata
// for presentation and backward-compatible session readers. Classification is
// never inferred from stdout or stderr.
func markStructuredSandboxDenial(meta map[string]string, denial execution.Denial) {
if meta == nil {
return
}
meta[SandboxLikelyDeniedMeta] = "true"
meta[SandboxDenialKindMeta] = kind
meta[SandboxDenialReasonMeta] = "sandbox blocked command execution"
if keyword != "" {
meta[SandboxDenialKeywordMeta] = keyword
meta[SandboxDenialKindMeta] = SandboxDenialKindSandbox
meta[SandboxDenialReasonMeta] = denial.Reason
meta["sandbox_denial_capability"] = string(denial.Capability.Kind)
if denial.Capability.Scope != "" {
meta["sandbox_denial_scope"] = denial.Capability.Scope
}
}
func likelySandboxDenied(plan zeroSandbox.CommandPlan, exitCode int, sections ...string) bool {
kind, _ := sandboxDenialKind(plan, exitCode, sections...)
return kind != ""
}
func sandboxDenialKind(plan zeroSandbox.CommandPlan, exitCode int, sections ...string) (string, string) {
if !plan.Wrapped {
return "", ""
}
if networkDeniedBySandbox(plan) {
if keyword := sandboxNetworkDenialKeyword(sections...); keyword != "" {
return SandboxDenialKindNetwork, keyword
}
}
if exitCode == 0 {
return "", ""
}
if sandboxDenialKeyword(sections...) != "" {
if networkDeniedBySandbox(plan) {
if keyword := sandboxNetworkDenialKeyword(sections...); keyword != "" {
return SandboxDenialKindNetwork, keyword
}
}
return SandboxDenialKindSandbox, sandboxDenialKeyword(sections...)
}
if plan.TargetBackend == zeroSandbox.BackendLinuxBwrap && exitCode == 128+31 {
return SandboxDenialKindSandbox, "seccomp"
}
// Windows restricted-token failures are often SILENT: a spawn the token
// cannot satisfy (executable or DLL unreadable under the restricted-SID
// check, MSYS runtime init death, loader status 0xC0000135/0xC0000142)
// produces a nonzero exit with no output at all, so none of the keyword
// heuristics above can ever fire. Treat "wrapped Windows command failed
// without writing a single byte" as a likely sandbox denial so the caller
// offers the unsandboxed-retry prompt instead of surfacing a bare
// "command completed with no output". A command that legitimately fails
// silently (findstr with no match, for one) costs one extra approval
// prompt; the inverse misclassification silently strands the whole
// session, so the trade goes to prompting.
if windowsWrappedBackend(plan.TargetBackend) && sectionsEmpty(sections...) {
return SandboxDenialKindSandbox, "no output from sandboxed command"
}
return "", ""
}
func windowsWrappedBackend(backend zeroSandbox.BackendName) bool {
return backend == zeroSandbox.BackendWindowsRestrictedToken || backend == zeroSandbox.BackendWindowsElevated
}
func sectionsEmpty(sections ...string) bool {
for _, section := range sections {
if strings.TrimSpace(section) != "" {
return false
}
}
return true
}
func networkDeniedBySandbox(plan zeroSandbox.CommandPlan) bool {
return plan.PermissionProfile.Network.Mode == zeroSandbox.NetworkDeny || plan.Policy.Network == zeroSandbox.NetworkDeny
}
func sandboxNetworkDenialKeyword(sections ...string) string {
for _, section := range sections {
lower := strings.ToLower(section)
for _, keyword := range sandboxNetworkDeniedKeywords {
if strings.Contains(lower, keyword) {
return keyword
}
}
}
return ""
}
func sandboxDenialKeyword(sections ...string) string {
for _, section := range sections {
lower := strings.ToLower(section)
for _, keyword := range sandboxDeniedKeywords {
if strings.Contains(lower, keyword) {
return keyword
}
}
}
return ""
}
+8 -84
View File
@@ -3,92 +3,16 @@ package tools
import (
"testing"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/execution"
)
func TestLikelySandboxDeniedDetectsReferenceKeywords(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendLinuxBwrap,
}
output := "touch: cannot touch '/home/user/.npm/cache': Read-only file system"
if !likelySandboxDenied(plan, 1, output) {
t.Fatalf("expected reference sandbox denial keyword to be classified as sandbox denied")
}
}
func TestLikelySandboxDeniedDetectsNetworkDenialEvenWithZeroExit(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendLinuxBwrap,
Policy: zeroSandbox.Policy{Network: zeroSandbox.NetworkDeny},
PermissionProfile: zeroSandbox.PermissionProfile{
Network: zeroSandbox.NetworkPolicy{Mode: zeroSandbox.NetworkDeny},
},
}
if !likelySandboxDenied(plan, 0, "Cannot open a network socket.") {
t.Fatal("network-denied socket output with exit 0 must be classified as sandbox denied")
}
func TestStructuredSandboxDenialMetadata(t *testing.T) {
meta := map[string]string{}
markLikelySandboxDenial(meta, plan, 0, "Cannot open a network socket.")
if meta[SandboxLikelyDeniedMeta] != "true" || meta[SandboxDenialKindMeta] != SandboxDenialKindNetwork {
t.Fatalf("network denial meta = %#v", meta)
}
}
func TestLikelySandboxDeniedIgnoresUnsandboxedFailure(t *testing.T) {
plan := zeroSandbox.CommandPlan{Wrapped: false}
if likelySandboxDenied(plan, 1, "permission denied") {
t.Fatal("unsandboxed command output must not be classified as a sandbox denial")
}
}
func TestLikelySandboxDeniedDetectsSilentWindowsWrappedFailure(t *testing.T) {
for _, backend := range []zeroSandbox.BackendName{
zeroSandbox.BackendWindowsRestrictedToken,
zeroSandbox.BackendWindowsElevated,
} {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: backend,
}
if !likelySandboxDenied(plan, 1, "", " \n") {
t.Fatalf("wrapped Windows command failing with empty output must be classified as sandbox denied (backend %s)", backend)
}
meta := map[string]string{}
markLikelySandboxDenial(meta, plan, 1, "")
if meta[SandboxLikelyDeniedMeta] != "true" || meta[SandboxDenialKindMeta] != SandboxDenialKindSandbox {
t.Fatalf("silent windows denial meta = %#v (backend %s)", meta, backend)
}
}
}
func TestLikelySandboxDeniedIgnoresSilentWindowsWrappedSuccess(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendWindowsRestrictedToken,
}
if likelySandboxDenied(plan, 0, "") {
t.Fatal("wrapped Windows command exiting 0 with empty output must not be classified as sandbox denied")
}
}
func TestLikelySandboxDeniedIgnoresSilentFailureOnOtherBackends(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendLinuxBwrap,
}
if likelySandboxDenied(plan, 1, "") {
t.Fatal("silent nonzero exit on non-Windows backends must not be classified as sandbox denied")
}
}
func TestLikelySandboxDeniedIgnoresSilentWindowsFailureWithOutput(t *testing.T) {
plan := zeroSandbox.CommandPlan{
Wrapped: true,
TargetBackend: zeroSandbox.BackendWindowsRestrictedToken,
}
if likelySandboxDenied(plan, 1, "fatal: not a git repository") {
t.Fatal("a wrapped Windows command that produced unrelated output must not be classified as sandbox denied")
markStructuredSandboxDenial(meta, execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Reason: "protected metadata is denied",
})
if meta["sandbox_denial_capability"] != string(execution.CapabilityProtectedMetadata) || meta["sandbox_denial_scope"] != "/workspace/.zero" {
t.Fatalf("structured metadata = %#v", meta)
}
}
+10
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
)
@@ -95,6 +96,11 @@ type Result struct {
Truncated bool
Meta map[string]string
SandboxDecision *sandbox.Decision `json:"-"`
// ExecutionRequest and ExecutionOutcome are the typed command protocol used
// by command-oriented tools. Legacy Output and Meta fields remain populated
// while callers migrate away from parsing text and string metadata.
ExecutionRequest *execution.Request `json:"-"`
ExecutionOutcome *execution.Outcome `json:"-"`
// Redacted is set when secret scrubbing altered Output before it left the
// tool-execution boundary.
Redacted bool
@@ -102,6 +108,10 @@ type Result struct {
// entries under a granted extra write root are absolute, since
// workspace-relative would be ambiguous there.
ChangedFiles []string
// ChangeSummaries contains bounded generated-tree changes. These are shown
// in session evidence and the Files panel but are never treated as files to
// open or diagnose individually.
ChangeSummaries []execution.Change
// Display carries a short, structured summary for the TUI / stream.
Display Display
}
+4 -1
View File
@@ -202,7 +202,7 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
func TestChangedFilesRehydration(t *testing.T) {
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`),
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"],"changeSummaries":[{"path":"node_modules/","kind":"created","aggregated":true}]}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
@@ -212,6 +212,9 @@ func TestChangedFilesRehydration(t *testing.T) {
if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" {
t.Fatalf("changedFiles not rehydrated: %v", got)
}
if summaries := rows[0].changeSummaries; len(summaries) != 1 || summaries[0].Path != "node_modules/" || !summaries[0].Aggregated {
t.Fatalf("changeSummaries not rehydrated: %#v", summaries)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
+25 -3
View File
@@ -35,7 +35,9 @@ type touchedFile struct {
edits int // number of mutations
created bool // first touch created the file (write_file "Created …")
failed bool // the latest touch errored
lastRowIndex int // transcript index of the most recent result touching it
summarized bool // generated tree summary; never selectable as a file
changeKind string
lastRowIndex int // transcript index of the most recent result touching it
}
// touchedFiles recovers the session's touched-file roster from the transcript,
@@ -47,7 +49,7 @@ func (m model) touchedFiles() []touchedFile {
var files []touchedFile
index := map[string]int{}
for i, row := range m.transcript {
if row.kind != rowToolResult || len(row.changedFiles) == 0 {
if row.kind != rowToolResult || (len(row.changedFiles) == 0 && len(row.changeSummaries) == 0) {
continue
}
adds, dels := planDiffStat(row.detail)
@@ -85,6 +87,22 @@ func (m model) touchedFiles() []touchedFile {
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
for _, summary := range row.changeSummaries {
if summary.Path == "" {
continue
}
at, seen := index[summary.Path]
if !seen {
index[summary.Path] = len(files)
files = append(files, touchedFile{path: summary.Path, summarized: true, lastRowIndex: i})
at = len(files) - 1
}
files[at].summarized = true
files[at].changeKind = string(summary.Kind)
files[at].edits++
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
}
// Most recently touched first: the file being worked on now belongs at the
// top, like the sidebar's ACTIVITY feed. Sorted by last touch (not reversed
@@ -229,7 +247,9 @@ func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
break
}
shown++
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
if !f.summarized {
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
}
lines = append(lines, m.renderFileRow(f, room))
}
return lines, hits
@@ -241,6 +261,8 @@ func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
func (m model) renderFileRow(f touchedFile, room int) string {
badge := zeroTheme.muted.Render("M")
switch {
case f.summarized:
badge = zeroTheme.muted.Render("Σ")
case f.failed:
badge = zeroTheme.red.Render("✗")
case f.created:
+16
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -30,6 +31,21 @@ func filesPanelTestModel() model {
return m
}
func TestGeneratedTreeSummaryIsVisibleButNotClickable(t *testing.T) {
m := sidebarTestModel()
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, status: tools.StatusOK,
changeSummaries: []execution.Change{{Path: "node_modules/", Kind: execution.ChangeCreated, Aggregated: true}},
})
lines, hits := m.sidebarFileLines(34)
if got := plainRender(t, strings.Join(lines, "\n")); !strings.Contains(got, "Σ") || !strings.Contains(got, "node_modules/") {
t.Fatalf("generated summary missing from Files panel: %q", got)
}
if len(hits) != 0 {
t.Fatalf("generated tree must not be selectable as a file: %#v", hits)
}
}
// TestTouchedFilesAggregates: the roster is recovered from tool-result rows,
// newest-touch first, with per-file diffstat totals, edit counts, and the
// created badge from write_file's confirmation text.
+12 -8
View File
@@ -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,
+25 -7
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -601,13 +602,14 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
}
output := payloadString(payload, "output")
rows = append(rows, transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
changedFiles: payloadStringSlice(payload, "changedFiles"),
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
changedFiles: payloadStringSlice(payload, "changedFiles"),
changeSummaries: payloadExecutionChanges(payload, "changeSummaries"),
})
case sessions.EventError:
if message := payloadString(payload, "message"); message != "" {
@@ -799,6 +801,22 @@ func payloadStringSlice(payload map[string]any, key string) []string {
}
}
func payloadExecutionChanges(payload map[string]any, key string) []execution.Change {
value, ok := payload[key]
if !ok {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var changes []execution.Change
if err := json.Unmarshal(data, &changes); err != nil {
return nil
}
return changes
}
func payloadBool(payload map[string]any, key string) bool {
value := payload[key]
switch typed := value.(type) {
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -45,7 +46,8 @@ type transcriptRow struct {
// changedFiles lists the workspace-relative paths a mutating tool result
// wrote (from tools.Result.ChangedFiles; restored from the session payload on
// resume). The sidebar FILES section derives its roster from these.
changedFiles []string
changedFiles []string
changeSummaries []execution.Change
// specialistInfo holds the specialist card data for rowSpecialist rows.
// Nil for all other row kinds.