mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d0bc478b | ||
|
|
b9b96c6f82 | ||
|
|
b8eec2d86e | ||
|
|
1550c4131c |
No files matched your search
+82
-40
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
type PermissionProfile struct {
|
||||
FileSystem FileSystemPolicy `json:"fileSystem"`
|
||||
Network NetworkPolicy `json:"network"`
|
||||
Runtime *SandboxRuntime `json:"runtime,omitempty"`
|
||||
}
|
||||
|
||||
type FileSystemPolicy struct {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user