mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d0bc478b | ||
|
|
b9b96c6f82 | ||
|
|
b8eec2d86e | ||
|
|
1550c4131c | ||
|
|
da9fb50f54 | ||
|
|
684901165d | ||
|
|
e1975c1b39 | ||
|
|
8c6d3022bd | ||
|
|
974fc036c2 | ||
|
|
4cbd144d11 | ||
|
|
c0b6b9e5c9 | ||
|
|
d74ceb1127 | ||
|
|
fbf85984f6 | ||
|
|
9242b9e037 |
No files matched your search
@@ -384,6 +384,15 @@ go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
```
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
run them directly. If it isn't, add it:
|
||||
|
||||
```bash
|
||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
|
||||
```bash
|
||||
|
||||
@@ -169,6 +169,37 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #702's fix: the unknown-session error's normalized
|
||||
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||
// and the halt would never fire.
|
||||
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||
if a != b {
|
||||
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: with an id-invariant signature, probing a different unknown id
|
||||
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||
// before the fix it never would.
|
||||
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||
var state guardState
|
||||
var stoppedAt int
|
||||
for i := 1; i <= toolFailureStopAt; i++ {
|
||||
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||
if out.Stop {
|
||||
stoppedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if stoppedAt != toolFailureStopAt {
|
||||
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||
var state guardState
|
||||
toolOnly := zeroruntime.CollectedStream{
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -100,6 +100,9 @@ work.
|
||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,22 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// SetActiveProvider only ever matches profiles persisted in config.json
|
||||
// (see config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, switching to
|
||||
// that provider by name always fails with a confusing "not found" even
|
||||
// though it is genuinely usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderUse(stdout, stderr, deps, options, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.SetActiveProvider(configPath, options.name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -352,6 +368,22 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
name := options.names[0]
|
||||
// RemoveProvider only ever matches profiles persisted in config.json (see
|
||||
// config.ProviderPersisted), but a provider can be visible in
|
||||
// `zero providers list`/the TUI picker purely because Resolve()
|
||||
// synthesized it in-memory from an ambient env var (e.g. OPENAI_API_KEY)
|
||||
// without ever writing a row to disk. Without this check, deleting that
|
||||
// provider by name always fails with a confusing "not found" even though
|
||||
// it is genuinely visible/usable this session (issue #707).
|
||||
persisted, err := config.ProviderPersisted(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRemove(stdout, stderr, deps, name, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RemoveProvider(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -428,6 +460,16 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
oldName := options.names[0]
|
||||
persisted, err := config.ProviderPersisted(configPath, oldName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if !persisted {
|
||||
if exit, handled := reportUnpersistedProviderRename(stdout, stderr, deps, oldName, options.json, configPath); handled {
|
||||
return exit
|
||||
}
|
||||
}
|
||||
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -447,3 +489,120 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// providerResolvedByName reports whether name matches a provider in a
|
||||
// resolved provider list — used to tell a genuinely unknown name (a typo)
|
||||
// apart from a real, env-derived provider that just has no config.json row.
|
||||
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, provider := range providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderUse handles `zero providers use <name>` for a
|
||||
// provider that is not persisted in config.json. If it's not resolvable at
|
||||
// all (an unknown/misspelled name), it returns handled=false so the caller
|
||||
// falls through to SetActiveProvider's real "not found" error. If it IS
|
||||
// resolvable — an env-derived profile (e.g. ambient OPENAI_API_KEY) —
|
||||
// SetActiveProvider would only ever fail "not found" against it, so this
|
||||
// reports the situation plainly instead of that confusing error (issue
|
||||
// #707).
|
||||
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
// resolveCommandCenterConfig already wrote its own error to stderr;
|
||||
// stop here instead of letting the caller try SetActiveProvider too.
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, options.name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to switch to.\nIt is available whenever its environment variable is set, but is only active when selected (for example via ZERO_PROVIDER); unset its environment variable to stop Zero from detecting it automatically.",
|
||||
options.name,
|
||||
)
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
// reportUnpersistedProviderRemove handles `zero providers remove <name>` for
|
||||
// a provider that is not persisted in config.json, mirroring the TUI
|
||||
// provider manager's delete handling (internal/tui/provider_manager.go). If
|
||||
// name isn't resolvable at all, it returns handled=false so the caller falls
|
||||
// through to RemoveProvider's real "not found" error.
|
||||
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable) — nothing to remove there.\nUnset its environment variable to stop Zero from detecting it automatically.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"removed": "",
|
||||
"keyRemoved": false,
|
||||
"activeProvider": resolved.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
|
||||
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode, true
|
||||
}
|
||||
if !providerResolvedByName(resolved.Providers, name) {
|
||||
return exitCode, false
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Provider %q is not saved in config.json (likely set via an environment variable), so there is no saved profile to rename.",
|
||||
name,
|
||||
)
|
||||
if jsonOutput {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"renamed": nil,
|
||||
"configPath": configPath,
|
||||
"persisted": false,
|
||||
"message": message,
|
||||
}); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, message); err != nil {
|
||||
return exitCrash, true
|
||||
}
|
||||
return exitSuccess, true
|
||||
}
|
||||
@@ -75,6 +75,111 @@ func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte("{"), 0o600); err != nil {
|
||||
t.Fatalf("write malformed config: %v", err)
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d", exitCode, exitCrash)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
||||
t.Fatalf("stderr = %q, want malformed-config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Removed string `json:"removed"`
|
||||
KeyRemoved bool `json:"keyRemoved"`
|
||||
Persisted bool `json:"persisted"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Removed != "" || payload.KeyRemoved || payload.Persisted || payload.ConfigPath != configPath {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "no saved profile to rename") {
|
||||
t.Fatalf("stdout = %q, want unpersisted explanation", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-env")
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Renamed *struct{} `json:"renamed"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
Persisted bool `json:"persisted"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Renamed != nil || payload.ConfigPath != configPath || payload.Persisted {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -177,6 +177,33 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// ProviderPersisted reports whether a provider profile named name actually has
|
||||
// a row in the config file at path. A provider can appear in the resolved/
|
||||
// in-memory provider list without ever being written to config.json — e.g.
|
||||
// applyProviderEnv synthesizes an "openai" profile purely from an ambient
|
||||
// OPENAI_API_KEY environment variable on every Resolve() call, without ever
|
||||
// persisting it. RemoveProvider/SetActiveProvider/SetProviderModel only ever
|
||||
// look at what's on disk, so a caller offering to mutate a provider by name
|
||||
// should check this first: "not on disk" needs different handling (nothing to
|
||||
// persist/remove there) than a name that doesn't exist anywhere at all.
|
||||
func ProviderPersisted(path string, name string) (bool, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
name = strings.TrimSpace(name)
|
||||
if path == "" || name == "" {
|
||||
return false, nil
|
||||
}
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RemoveProvider deletes the named provider profile from the config at path.
|
||||
// When the removed profile was active, activeProvider hands off to the first
|
||||
// remaining provider (or clears when none remain) so the config never points at
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@ func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now
|
||||
if err := validateTokenEndpoint(endpoint); err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Refuse redirects so a device-authorization redirect can't replay the
|
||||
// client_id/client_secret POST body to an unvalidated origin.
|
||||
client = withoutRedirects(client)
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
@@ -157,9 +157,9 @@ func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, device
|
||||
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Refuse redirects so a poll redirect can't replay the device_code/secret
|
||||
// POST body to an unvalidated origin.
|
||||
client = withoutRedirects(client)
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", deviceGrantType)
|
||||
form.Set("device_code", deviceCode)
|
||||
|
||||
@@ -2,6 +2,7 @@ package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -28,6 +29,30 @@ func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodeRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
|
||||
_, err := RequestDeviceCode(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: endpoint}, nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("RequestDeviceCode err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — client_secret replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceOnceRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
|
||||
_, err := pollDeviceOnce(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", ClientSecret: "shh", TokenEndpoint: endpoint}, "device-code", nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("pollDeviceOnce err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — device_code/secret replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
|
||||
|
||||
+20
-3
@@ -116,6 +116,23 @@ func validateTokenEndpoint(endpoint string) error {
|
||||
return ValidateEndpointURL(endpoint)
|
||||
}
|
||||
|
||||
// withoutRedirects returns a shallow copy of client whose redirect policy
|
||||
// refuses to follow ANY redirect. A credential-bearing OAuth POST (token
|
||||
// exchange/refresh, device authorization, device-token poll) must never let a
|
||||
// 307/308 replay its form body — codes, PKCE verifiers, refresh tokens, client
|
||||
// secrets — to a target that was never endpoint-validated. The caller's client
|
||||
// is left unmodified.
|
||||
func withoutRedirects(client *http.Client) *http.Client {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
copied := *client
|
||||
copied.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return ErrUnsafeRedirect
|
||||
}
|
||||
return &copied
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
@@ -176,9 +193,9 @@ func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, f
|
||||
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// Refuse redirects so a 307/308 from the token endpoint can't replay this
|
||||
// credential-bearing POST body to another (unvalidated) origin.
|
||||
client = withoutRedirects(client)
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -126,6 +128,64 @@ func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// redirectTrap starts an "attacker" server that counts any hit, and a credential
|
||||
// endpoint that redirects to it with the given status. It returns the endpoint
|
||||
// URL and a func reporting how many times the attacker was contacted — a
|
||||
// non-zero count means the credential-bearing POST body was replayed (#729).
|
||||
func redirectTrap(t *testing.T, status int) (endpoint string, attackerHits func() int32) {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
_, _ = io.WriteString(w, `{"access_token":"leaked","refresh_token":"leaked"}`)
|
||||
}))
|
||||
t.Cleanup(attacker.Close)
|
||||
endp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, attacker.URL, status)
|
||||
}))
|
||||
t.Cleanup(endp.Close)
|
||||
return endp.URL, func() int32 { return atomic.LoadInt32(&hits) }
|
||||
}
|
||||
|
||||
func TestPostTokenRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", "the-code")
|
||||
form.Set("client_secret", "shh")
|
||||
_, err := PostToken(context.Background(), http.DefaultClient, endpoint, form, Token{}, nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("PostToken err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — credential body was replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusPermanentRedirect) // 308
|
||||
_, err := ExchangeCode(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", TokenEndpoint: endpoint}, "the-code", "verifier", "http://127.0.0.1/cb", nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("ExchangeCode err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — code/verifier replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRefusesRedirect(t *testing.T) {
|
||||
endpoint, hits := redirectTrap(t, http.StatusTemporaryRedirect) // 307
|
||||
_, err := Refresh(context.Background(), http.DefaultClient,
|
||||
Config{ClientID: "c", TokenEndpoint: endpoint}, Token{RefreshToken: "rt"}, nil)
|
||||
if !errors.Is(err, ErrUnsafeRedirect) {
|
||||
t.Fatalf("Refresh err = %v, want ErrUnsafeRedirect", err)
|
||||
}
|
||||
if n := hits(); n != 0 {
|
||||
t.Fatalf("attacker received %d request(s) — refresh_token replayed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
|
||||
@@ -90,6 +90,11 @@ var (
|
||||
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
|
||||
// non-https, non-loopback endpoint.
|
||||
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
|
||||
// ErrUnsafeRedirect is returned when a credential-bearing OAuth POST (token
|
||||
// exchange/refresh, device authorization, or device-token poll) is redirected.
|
||||
// Following it would replay the form body — codes, PKCE verifiers, refresh
|
||||
// tokens, client secrets — to a target that never passed endpoint validation.
|
||||
ErrUnsafeRedirect = errors.New("oauth: refusing to follow a redirect from a credential endpoint")
|
||||
// ErrNoRefreshToken is returned when a refresh is attempted without one.
|
||||
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
|
||||
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
|
||||
|
||||
@@ -578,28 +578,54 @@ func WriteTurnBenchJSON(w io.Writer, result TurnBenchResult) error {
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
// execExitIncomplete mirrors internal/cli.exitIncomplete (the `zero exec` exit
|
||||
// code, 4) for a headless run the completion gate marked INCOMPLETE: the run
|
||||
// stopped without a completion signal (e.g. it couldn't self-verify because its
|
||||
// sandboxed shell was unavailable under --auto member), which is distinct from a
|
||||
// crash (1), usage error (2), provider failure (3), or interruption (130). It is
|
||||
// the ONLY nonzero exit an oracle-bearing task may defer to the oracle for; every
|
||||
// other nonzero exit stays authoritative. Kept as a local literal so perfbench
|
||||
// doesn't import the cli package; must stay in sync with that constant.
|
||||
const execExitIncomplete = 4
|
||||
|
||||
// NewTurnExecRunner builds the production turn-benchmark runner: it invokes
|
||||
// headless `zero exec` with stream-json output AND `--trace <tmpfile>`, then
|
||||
// parses the emitted NDJSON trace into a *trace.TurnTrace. binary is the path to
|
||||
// the `zero` binary; extraArgs are appended to every invocation. Pass/fail is
|
||||
// decided from the stream-json run_end exit code (and the task's
|
||||
// VerificationCommand when present), exactly like NewExecRunner.
|
||||
// decided by the task's oracle when it has one. The oracle that gates the verdict
|
||||
// is the VerificationCommand (which compiles and runs the stamped OracleTest via
|
||||
// `go test`, plus any greps against the fixture); it is ground truth, so an
|
||||
// oracle-bearing task that applied the right edit but exited INCOMPLETE (exit 4,
|
||||
// its sandboxed self-verification couldn't run) still passes. Any OTHER nonzero
|
||||
// exit (crash/usage/provider/interrupt) stays authoritative and fails even an
|
||||
// oracle-bearing task. A latency-only task has no VerificationCommand, so any
|
||||
// nonzero exit is its only signal and fails it.
|
||||
func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
return func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
|
||||
// buildTurnExecArgs grants the write + sandboxed-shell tool set to EVERY
|
||||
// invocation, so a task MUST run inside an isolated fixture copy — never the
|
||||
// caller's cwd. The grant is unconditional, so the isolation can't be
|
||||
// optional: a fixtureless task would turn an agent with write/shell tools
|
||||
// loose in the caller's working directory (their real repo). Enforce the
|
||||
// invariant instead of trusting it — reject a fixtureless task up front,
|
||||
// before anything is launched, so the grant and the guard can't drift apart.
|
||||
// Every shipped task declares a fixture, so this only fires on a malformed or
|
||||
// newly added task, and it fails loudly rather than silently.
|
||||
if strings.TrimSpace(task.WorkspaceFixture) == "" {
|
||||
return TurnTaskOutcome{Err: errors.New("benchmark task has no workspaceFixture: the run grants write/shell tools and must execute in an isolated fixture copy, not the caller's cwd")}
|
||||
}
|
||||
// Isolate the workspace: copy the fixture into a fresh temp dir so a
|
||||
// mutating task (edit/fix/refactor) can't dirty the shared, checked-in
|
||||
// fixture or bleed into a later iteration of the same task. When no
|
||||
// fixture is configured the agent runs in the caller's cwd as before.
|
||||
if fixture := strings.TrimSpace(task.WorkspaceFixture); fixture != "" {
|
||||
copyDir, parent, cerr := copyFixture(fixture)
|
||||
if cerr != nil {
|
||||
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
|
||||
}
|
||||
// Clean the whole unique parent (which owns copyDir) so the
|
||||
// per-invocation scratch dir never leaks.
|
||||
defer os.RemoveAll(parent)
|
||||
task.WorkspaceFixture = copyDir
|
||||
// fixture or bleed into a later iteration of the same task. The guard above
|
||||
// guarantees WorkspaceFixture is set, so this always runs.
|
||||
copyDir, parent, cerr := copyFixture(strings.TrimSpace(task.WorkspaceFixture))
|
||||
if cerr != nil {
|
||||
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
|
||||
}
|
||||
// Clean the whole unique parent (which owns copyDir) so the per-invocation
|
||||
// scratch dir never leaks.
|
||||
defer os.RemoveAll(parent)
|
||||
task.WorkspaceFixture = copyDir
|
||||
|
||||
traceFile, err := os.CreateTemp("", "zero-turn-trace-*.ndjson")
|
||||
if err != nil {
|
||||
@@ -656,10 +682,23 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// A nonzero agent exit already decided failure; don't run verification or
|
||||
// mark the task passed.
|
||||
// Decide what a nonzero exit means. We defer to the oracle for exactly ONE
|
||||
// case: an oracle-bearing task that exited INCOMPLETE (exit 4). That is the
|
||||
// completion gate downgrading a run whose edits may be correct but that
|
||||
// couldn't emit a completion signal (e.g. its sandboxed self-verify couldn't
|
||||
// run under --auto member) — the oracle below (the compiled test / grep run
|
||||
// against the actual fixture files) is ground truth, so if the edit really
|
||||
// landed it passes, and if the run abandoned the task the oracle fails it.
|
||||
// Every OTHER nonzero exit stays authoritative: a crash (1), usage error
|
||||
// (2), provider failure (3), or interruption (130) is a genuine failure, so
|
||||
// a partial edit that happens to satisfy the oracle can't launder it into a
|
||||
// pass. A latency-only task has no oracle to defer to, so any nonzero exit
|
||||
// fails it.
|
||||
if outcome.VerifyErr != "" {
|
||||
return outcome
|
||||
if len(task.VerificationCommand) == 0 || exitCode != execExitIncomplete {
|
||||
return outcome
|
||||
}
|
||||
outcome.VerifyErr = ""
|
||||
}
|
||||
// Stamp the compiler-backed oracle and capture the agent's final answer
|
||||
// BEFORE running the verification command. Both write into the fixture
|
||||
@@ -790,7 +829,23 @@ func stampOracleAndAnswer(task BenchTask, outBuf []byte) error {
|
||||
}
|
||||
|
||||
func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArgs []string) []string {
|
||||
args := []string{"exec", "--output-format", "stream-json", "--trace", tracePath}
|
||||
// --auto member is REQUIRED, not optional: without a permission grant `zero
|
||||
// exec` runs in its default read-only posture, which exposes no write or shell
|
||||
// tools (no edit_file/apply_patch/write_file/exec_command/bash). The mutating
|
||||
// task classes (edit/fix/refactor) then cannot apply any change, so every run
|
||||
// grinds to the turn ceiling or reports a no-tool blocker, and the only tasks
|
||||
// that "pass" are ones whose oracle grep matches the stamped answer text rather
|
||||
// than a real edit. member-auto grants the write + sandboxed-shell tools the
|
||||
// benchmark needs while keeping the workspace/network/destructive safeguards, so
|
||||
// it is preferred over the broader --skip-permissions-unsafe (which also drops
|
||||
// those guards and auto-retries blocked commands unsandboxed). Note the shell it
|
||||
// grants is sandboxed: on a host without sandbox setup the agent's own
|
||||
// self-verification (go test/build) can't run and the turn exits INCOMPLETE even
|
||||
// after a correct edit — the runner treats the stamped oracle, not that exit
|
||||
// code, as ground truth for oracle-bearing tasks (see NewTurnExecRunner), so a
|
||||
// correct edit still passes. Each task also runs in an isolated, throwaway
|
||||
// fixture copy, so the granted tools have nothing outside the task to harm.
|
||||
args := []string{"exec", "--auto", "member", "--output-format", "stream-json", "--trace", tracePath}
|
||||
if model := strings.TrimSpace(rc.Model); model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -750,6 +751,92 @@ echo '{"type":"run_end","exitCode":0}'
|
||||
}
|
||||
}
|
||||
|
||||
// TestOracleAuthoritativeOnIncompleteExit is the companion to the --auto member
|
||||
// switch: under member-auto the agent's shell is sandboxed, so on a host without
|
||||
// sandbox setup its self-verification (go test/build) can't run and the turn
|
||||
// exits INCOMPLETE (exit 4) even after a correct edit. For an ORACLE-bearing task
|
||||
// the runner must treat the stamped oracle — not that INCOMPLETE exit — as ground
|
||||
// truth: here the stub applies the real edit-01 rename but reports exitCode 4, and
|
||||
// the task must still pass because the fixture is correct. This defers ONLY for
|
||||
// exit 4; TestNonIncompleteExitStaysAuthoritative pins the other side.
|
||||
func TestOracleAuthoritativeOnIncompleteExit(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, `sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":4}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("incomplete-exit with a correct edit should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("a correct edit that exited INCOMPLETE (exit 4) must still pass its oracle: %+v", outcome)
|
||||
}
|
||||
if strings.TrimSpace(outcome.VerifyErr) != "" {
|
||||
t.Fatalf("a passing oracle must clear the exit-code VerifyErr, got %q", outcome.VerifyErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonIncompleteExitStaysAuthoritative is the guard the oracle-authoritative
|
||||
// change MUST NOT weaken: only INCOMPLETE (exit 4) defers to the oracle. A crash
|
||||
// (1), provider failure (3), or interruption (130) is a genuine failure, so it
|
||||
// stays authoritative even for an oracle-bearing task — otherwise a partial edit
|
||||
// that happens to satisfy the oracle would launder a crashed or interrupted run
|
||||
// into a pass. Each case applies the CORRECT edit-01 rename (so the oracle WOULD
|
||||
// pass) but reports the failing exit code; the task must still fail, and the
|
||||
// failure must be attributed to the exit code, not the oracle.
|
||||
func TestNonIncompleteExitStaysAuthoritative(t *testing.T) {
|
||||
for _, code := range []int{1, 3, 130} {
|
||||
t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, fmt.Sprintf(`sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":%d}'
|
||||
`, code))
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("a nonzero exit should be a task fail, not a harness error: %v", outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("a run that exited %d must not pass even when the edit satisfies the oracle: %+v", code, outcome)
|
||||
}
|
||||
if want := fmt.Sprintf("exit code %d", code); !strings.Contains(outcome.VerifyErr, want) {
|
||||
t.Fatalf("a non-INCOMPLETE nonzero exit must stay authoritative and surface its code, got VerifyErr=%q", outcome.VerifyErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIncompleteExitStillFailsWhenOracleFails pins the OTHER half of the exit-4
|
||||
// deferral: deferring to the oracle on INCOMPLETE must still MEAN the oracle is
|
||||
// consulted, not a blanket pass. Here the stub does no real work and exits
|
||||
// INCOMPLETE (4); the edit-01 rename never happened, so the oracle fails and the
|
||||
// task fails. Without this, a regression that turned exit 4 into an unconditional
|
||||
// pass would slip through, because its sibling TestOracleAuthoritativeOnIncomplete-
|
||||
// Exit applies a CORRECT edit and would pass either way.
|
||||
func TestIncompleteExitStillFailsWhenOracleFails(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
|
||||
`)
|
||||
assertVerifyFailed(t, "incomplete exit with no edit applied", outcome)
|
||||
}
|
||||
|
||||
// TestNonzeroExitStillFailsLatencyOnly is the guard on the other side of that
|
||||
// change: a latency-only task has no oracle to appeal to, so the exit code is
|
||||
// the ONLY correctness signal and a nonzero exit must still fail it. Without
|
||||
// this, dropping the exit-code gate for oracle tasks could be misread as
|
||||
// dropping it everywhere.
|
||||
func TestNonzeroExitStillFailsLatencyOnly(t *testing.T) {
|
||||
task := loadBaselineTask(t, "longproc-01")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":4}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("latency-only nonzero exit should be a verify fail, not a harness error: %v", outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("a latency-only task that exited nonzero must not pass: %+v", outcome)
|
||||
}
|
||||
if strings.TrimSpace(outcome.VerifyErr) == "" {
|
||||
t.Fatalf("a latency-only nonzero exit must surface a VerifyErr, got none: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Satisfiable tests: the oracle PASSES the right thing (real fix applied) ---
|
||||
|
||||
// TestStampedOraclePassesWhenRefactorHappened proves the refactor-01 oracle is
|
||||
@@ -1192,6 +1279,57 @@ func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every benchmark invocation MUST grant the write/shell tool set via --auto
|
||||
// member. Without a permission grant the agent runs read-only and cannot apply
|
||||
// any edit, so the mutating classes measure nothing but oracle/answer
|
||||
// contamination. member-auto is the RIGHT grant: it exposes the write +
|
||||
// sandboxed-shell tools the benchmark needs while keeping the workspace/network/
|
||||
// destructive safeguards, so the args must NOT reach for the broader
|
||||
// --skip-permissions-unsafe (which drops those guards). This is a correctness
|
||||
// contract, not a preference.
|
||||
func TestBuildTurnExecArgsGrantsWriteTools(t *testing.T) {
|
||||
args := buildTurnExecArgs(BenchTask{ID: "t", Prompt: "edit the file"}, RunContext{Model: "m"}, "trace.ndjson", nil)
|
||||
autoMember := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "--auto" && args[i+1] == "member" {
|
||||
autoMember = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !autoMember {
|
||||
t.Fatalf("benchmark exec args must include --auto member so the agent can apply edits, got %v", args)
|
||||
}
|
||||
for _, arg := range args {
|
||||
if arg == "--skip-permissions-unsafe" {
|
||||
t.Fatalf("benchmark exec args must NOT use --skip-permissions-unsafe (member-auto keeps the sandbox/network guards), got %v", args)
|
||||
}
|
||||
}
|
||||
if args[len(args)-1] != "edit the file" {
|
||||
t.Fatalf("prompt must stay the last argument, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnRunnerRejectsFixturelessTask is the other half of the write-tools
|
||||
// contract: because buildTurnExecArgs grants the write + sandboxed-shell tool
|
||||
// set to every invocation, a task with no fixture would run an agent with those
|
||||
// tools in the caller's cwd. The runner must reject such a task BEFORE launching
|
||||
// zero exec. Passing a binary path that does not exist proves the rejection is
|
||||
// pre-launch — the outcome carries the fixture error, not a spawn error — so this
|
||||
// runs on every OS (it never reaches the POSIX-only exec stub).
|
||||
func TestTurnRunnerRejectsFixturelessTask(t *testing.T) {
|
||||
task := BenchTask{ID: "no-fixture", Prompt: "do a thing"} // no WorkspaceFixture set
|
||||
outcome := NewTurnExecRunner(filepath.Join(t.TempDir(), "does-not-exist-zero"))(context.Background(), task, RunContext{Model: "m"})
|
||||
if outcome.Err == nil {
|
||||
t.Fatalf("a fixtureless task must be rejected before launch, got %+v", outcome)
|
||||
}
|
||||
if !strings.Contains(outcome.Err.Error(), "workspaceFixture") {
|
||||
t.Fatalf("rejection must name the missing workspaceFixture, got: %v", outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("a rejected task must not pass, got %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// The configured profile must reach the runner's RunContext and be stamped
|
||||
// into the result, so a profile A/B report is self-describing. The boundary
|
||||
// canonicalizes (case/whitespace) so equivalent postures always carry the same
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -93,6 +93,40 @@ func TestSandboxedBashAutoAllowDoesNotAffectNonShell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forged re-entrancy markers (ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND at
|
||||
// an unsandboxed process boundary) must NOT let a shell command combine an
|
||||
// auto-allow decision with an unwrapped pass-through plan (issue #727). The
|
||||
// runner skips wrapping when the markers are present, so the policy must stop
|
||||
// auto-allowing on the basis of an "active sandbox" — the command takes the
|
||||
// normal prompt instead. Without the markers the identical setup auto-allows
|
||||
// (TestSandboxedBashAutoAllowedWhenSandboxActive), so this is the before/after.
|
||||
func TestForgedReentrancyMarkersDoNotAutoAllowUnwrappedShell(t *testing.T) {
|
||||
t.Setenv(EnvSandboxed, "1")
|
||||
t.Setenv(EnvSandboxBackend, string(BackendLinuxBwrap))
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: DefaultPolicy(),
|
||||
Backend: nativeWrappingBackend,
|
||||
})
|
||||
|
||||
// Policy side: the shell command must NOT be auto-allowed.
|
||||
decision := engine.Evaluate(context.Background(), bashRequest())
|
||||
if decision.Action != ActionPrompt || decision.AutoAllowed {
|
||||
t.Fatalf("forged markers must not auto-allow the shell command, got %#v", decision)
|
||||
}
|
||||
|
||||
// Planning side: the same command is an unwrapped pass-through. Together with
|
||||
// the prompt above, this proves auto-allow and unwrapped can no longer combine.
|
||||
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "echo hi"}, Dir: root})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCommandPlan: %v", err)
|
||||
}
|
||||
if plan.Wrapped {
|
||||
t.Fatalf("re-entrancy plan must be an unwrapped pass-through, got wrapped=%v", plan.Wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShellSandboxActive reports correctly across backends and policy modes.
|
||||
func TestShellSandboxActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -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
|
||||
@@ -232,6 +239,15 @@ func (engine *Engine) shellSandboxActive(policy Policy) bool {
|
||||
if policy.Mode == ModeDisabled {
|
||||
return false
|
||||
}
|
||||
// Re-entrancy skips wrapping: when the process carries the sandbox markers,
|
||||
// the runner returns a pass-through (unwrapped) plan, so the native sandbox
|
||||
// is NOT the boundary for THIS command. Because the markers are ambient and
|
||||
// forgeable at an unsandboxed process boundary (issue #727), auto-allowing a
|
||||
// shell command on that basis would run it unwrapped with no prompt. Mirror
|
||||
// the runner: report inactive so the command takes the normal approval path.
|
||||
if IsAlreadySandboxed() {
|
||||
return false
|
||||
}
|
||||
backend := engine.backend
|
||||
if !(backend.Available && backend.Executable != "" && backend.CommandWrapping && backend.NativeIsolation) {
|
||||
return false
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+226
-566
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +248,18 @@ type writeStdinTool struct {
|
||||
manager *execSessionManager
|
||||
}
|
||||
|
||||
// UnknownExecSessionError is the result returned when write_stdin targets a
|
||||
// session_id with no live exec session. The stable recovery guidance leads and
|
||||
// the numeric id trails on purpose: the agent's repeated-failure guard keys on
|
||||
// a normalized, truncated prefix of the error string, so keeping the id out of
|
||||
// that prefix makes a model probing ids 1, 2, 3, … produce ONE signature that
|
||||
// finally trips the halt instead of a fresh signature per id — while also
|
||||
// telling the model how to actually recover. See TestUnknownExecSessionErrorSignatureIsIDInvariant
|
||||
// in internal/agent, which pins this invariant against the real guard.
|
||||
func UnknownExecSessionError(sessionID int) string {
|
||||
return fmt.Sprintf("Error: write_stdin needs a session_id returned by a still-running exec_command; do not guess or probe session ids. Start a process with exec_command, or use write_file/edit_file/apply_patch for file changes. (no live session %d)", sessionID)
|
||||
}
|
||||
|
||||
func (writeStdinTool) outputCategory(map[string]any) outputCategory {
|
||||
return outputCategoryProcess
|
||||
}
|
||||
@@ -722,7 +275,7 @@ func NewWriteStdinTool(manager *execSessionManager) Tool {
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"session_id": {Type: "integer", Description: "Identifier of the running unified exec session."},
|
||||
"session_id": {Type: "integer", Description: "Identifier of a running unified exec session, as returned by exec_command. Never guess or probe ids.", Minimum: intPtr(1)},
|
||||
"chars": {Type: "string", Description: "Bytes to write to stdin. Defaults to empty, which polls without writing.", Default: ""},
|
||||
"yield_time_ms": {Type: "integer", Description: "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.", Default: defaultPollYieldTimeMS, Minimum: intPtr(1), Maximum: intPtr(maxPollYieldTimeMS)},
|
||||
"max_output_tokens": {Type: "integer", Description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.", Default: defaultMaxOutputTokens, Minimum: intPtr(1), Maximum: intPtr(maxExecOutputTokenRequest)},
|
||||
@@ -763,18 +316,30 @@ func (tool writeStdinTool) Run(ctx context.Context, args map[string]any) Result
|
||||
}
|
||||
|
||||
func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]any, _ RunOptions) Result {
|
||||
if value, ok := args["session_id"]; !ok || value == nil {
|
||||
return errorResult("Error: Invalid arguments for write_stdin: session_id is required")
|
||||
}
|
||||
sessionID, err := intArg(args, "session_id", 0, 1, 0)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_stdin: " + err.Error())
|
||||
// A missing, non-integer, or < 1 session_id all mean the same thing: the model
|
||||
// has no live session to write to. Route them to the SAME recovery guidance the
|
||||
// no-live-session case uses (UnknownExecSessionError) instead of a terse
|
||||
// "session_id must be at least 1". The terse error gives no way forward and, by
|
||||
// naming the minimum, nudges the model to try 1, 2, 3... — the exact id-probing
|
||||
// #749 works to suppress. The recovery message instead tells it to start a
|
||||
// session or edit files directly, and because that message is id-invariant, the
|
||||
// missing/zero/non-integer entry points and id-probing collapse to ONE
|
||||
// repeated-failure signature, so any mix of them accumulates toward the halt
|
||||
// rather than resetting the streak on each class of mistake.
|
||||
value, present := args["session_id"]
|
||||
sessionID, sessionErr := intArg(args, "session_id", 0, 1, 0)
|
||||
if !present || value == nil || sessionErr != nil {
|
||||
return errorResult(UnknownExecSessionError(sessionID))
|
||||
}
|
||||
chars, err := stringArgWithEmpty(args, "chars", "", false, true)
|
||||
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())
|
||||
}
|
||||
@@ -782,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(fmt.Sprintf("Error: Unknown exec session_id %d.", sessionID))
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -855,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
|
||||
}
|
||||
|
||||
@@ -873,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)
|
||||
@@ -890,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)
|
||||
@@ -908,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",
|
||||
@@ -919,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{}
|
||||
|
||||
+159
-342
@@ -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) {
|
||||
@@ -881,7 +701,7 @@ func TestRegistryHonorsWriteStdinArgumentPermission(t *testing.T) {
|
||||
registry.Register(NewWriteStdinTool(newExecSessionManager()))
|
||||
|
||||
poll := registry.Run(context.Background(), WriteStdinToolName, map[string]any{"session_id": 9999})
|
||||
if poll.Status != StatusError || !strings.Contains(poll.Output, "Unknown exec session_id") {
|
||||
if poll.Status != StatusError || !strings.Contains(poll.Output, "still-running exec_command") {
|
||||
t.Fatalf("empty poll should reach tool without permission prompt, got status=%s output=%q", poll.Status, poll.Output)
|
||||
}
|
||||
|
||||
@@ -901,24 +721,57 @@ func TestWriteStdinReportsUnknownSession(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %s, want error", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Unknown exec session_id 1234") {
|
||||
t.Fatalf("unexpected output: %q", result.Output)
|
||||
// The message must lead with recovery guidance and still identify the id.
|
||||
if !strings.Contains(result.Output, "still-running exec_command") {
|
||||
t.Fatalf("unknown-session error must carry recovery guidance, got: %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "1234") {
|
||||
t.Fatalf("unknown-session error must name the offending id, got: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime rejects session_id < 1 (intArg min 1); the advertised schema must
|
||||
// say the same so a model sees the constraint before it probes id 0.
|
||||
func TestWriteStdinSchemaPinsSessionIDMinimum(t *testing.T) {
|
||||
schema := NewWriteStdinTool(nil).(writeStdinTool).parameters
|
||||
prop, ok := schema.Properties["session_id"]
|
||||
if !ok {
|
||||
t.Fatal("session_id property missing from write_stdin schema")
|
||||
}
|
||||
if prop.Minimum == nil || *prop.Minimum != 1 {
|
||||
t.Fatalf("session_id schema Minimum = %v, want 1 to match the runtime floor", prop.Minimum)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing, zero, negative, or non-integer session_id all mean the model has no
|
||||
// live session, so write_stdin returns the SAME recovery guidance the no-live-
|
||||
// session case uses (start a session with exec_command, or edit files directly),
|
||||
// not a terse "session_id must be at least 1" that gives no way forward and names
|
||||
// a minimum that nudges the model to probe ids 1, 2, 3... Sharing one id-invariant
|
||||
// message also keeps a single repeated-failure signature, so any mix of these
|
||||
// mistakes accumulates toward the halt instead of resetting the streak (#749).
|
||||
func TestWriteStdinRequiresPositiveSessionID(t *testing.T) {
|
||||
tool := NewWriteStdinTool(newExecSessionManager())
|
||||
for _, args := range []map[string]any{
|
||||
{},
|
||||
{"session_id": 0},
|
||||
want := UnknownExecSessionError(0)
|
||||
for name, args := range map[string]map[string]any{
|
||||
"missing": {},
|
||||
"nil": {"session_id": nil},
|
||||
"zero": {"session_id": 0},
|
||||
"negative": {"session_id": -3},
|
||||
"non-integer": {"session_id": "abc"},
|
||||
} {
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("Run(%#v) status = %s, want error", args, result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Invalid arguments for write_stdin") {
|
||||
t.Fatalf("Run(%#v) output = %q, want invalid arguments", args, result.Output)
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := tool.Run(context.Background(), args)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %s, want error", result.Status)
|
||||
}
|
||||
if result.Output != want {
|
||||
t.Fatalf("output = %q,\n want the recovery message %q", result.Output, want)
|
||||
}
|
||||
if strings.Contains(result.Output, "must be at least 1") {
|
||||
t.Fatalf("still returns the terse minimum error: %q", result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,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
|
||||
}
|
||||
|
||||
@@ -214,6 +214,12 @@ func (m model) setDoctorStatusRow(text string) model {
|
||||
for i := len(m.transcript) - 1; i >= 0; i-- {
|
||||
if m.transcript[i].id == doctorStatusRowID {
|
||||
m.transcript[i] = row
|
||||
// Settled alt-screen rows are cached across frames; force a rebuild
|
||||
// so an in-place update (e.g. the spinner tick) is reflected
|
||||
// immediately instead of serving the stale cached snapshot.
|
||||
if i < m.flushed {
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
@@ -691,6 +697,14 @@ func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, error
|
||||
if model == "" {
|
||||
return false, nil
|
||||
}
|
||||
persisted, err := config.ProviderPersisted(path, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !persisted {
|
||||
// Env-derived providers have no config.json row to update.
|
||||
return false, nil
|
||||
}
|
||||
if _, err := config.SetProviderModel(path, name, model); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/rewind",
|
||||
aliases: []string{"/undo"},
|
||||
usage: "/rewind [latest|<sequence>]",
|
||||
group: commandGroupSession,
|
||||
description: "Restore workspace files to a checkpoint and truncate the session.",
|
||||
|
||||
@@ -985,6 +985,31 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// /undo is an alias for /rewind (issue #698): previously it fell through to
|
||||
// commandUnknown.
|
||||
func TestUndoAliasesRewind(t *testing.T) {
|
||||
rewind, ok := resolveCommand("/rewind")
|
||||
if !ok {
|
||||
t.Fatal("expected /rewind to resolve")
|
||||
}
|
||||
alias, ok := resolveCommand("/undo")
|
||||
if !ok || alias.kind != rewind.kind {
|
||||
t.Fatalf("expected /undo to alias /rewind, got ok=%v command=%#v", ok, alias)
|
||||
}
|
||||
// End-to-end: a typed /undo dispatches to the rewind handler with args intact,
|
||||
// for both the "latest" and numeric-<sequence> forms of the /rewind contract.
|
||||
if parsed := parseCommand("/undo latest"); parsed.kind != commandRewind || parsed.text != "latest" {
|
||||
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=latest", "/undo latest", parsed)
|
||||
}
|
||||
if parsed := parseCommand("/undo 123"); parsed.kind != commandRewind || parsed.text != "123" {
|
||||
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=123", "/undo 123", parsed)
|
||||
}
|
||||
// Guard: without the alias it would be commandUnknown.
|
||||
if parsed := parseCommand("/undo"); parsed.kind == commandUnknown {
|
||||
t.Fatal("/undo resolved to commandUnknown — the alias is not wired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSelectionRequiresInputFromUsage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -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:
|
||||
@@ -348,12 +370,29 @@ func (m model) lastRowIndexForFile(path string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *model) setSelectedFile(path string) {
|
||||
if m.selectedFile == path {
|
||||
return
|
||||
}
|
||||
oldPath := m.selectedFile
|
||||
m.selectedFile = path
|
||||
for i := 0; i < m.flushed && i < len(m.transcript); i++ {
|
||||
for _, changedPath := range m.transcript[i].changedFiles {
|
||||
if changedPath == oldPath || changedPath == path {
|
||||
m.altScreenSettledWidth = 0
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// selectFile marks path as the selected file and scrolls the transcript so its
|
||||
// most recent edit card is in view; the card tint comes from the renderers
|
||||
// reading selectedFile (rowTouchesSelectedFile).
|
||||
func (m model) selectFile(path string) model {
|
||||
m.selectedFile = path
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
|
||||
rowIndex := m.lastRowIndexForFile(path)
|
||||
m.setSelectedFile(path)
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(rowIndex); ok {
|
||||
m.chatScrollOffset = offset
|
||||
if offset == 0 {
|
||||
m.chatBodyLines = 0
|
||||
|
||||
@@ -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.
|
||||
|
||||
+61
-9
@@ -69,13 +69,6 @@ func (m model) settledRow(row transcriptRow, rc rowContext) bool {
|
||||
// of the unflushed tail is rendered once and queued for scrollback. It returns
|
||||
// the (possibly nil) print command for the queued batch.
|
||||
func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
// In alt-screen mode there is no native scrollback surface for tea.Println:
|
||||
// Bubble Tea drops that output by design. Keep rows in the managed view so
|
||||
// the chat behaves like a fullscreen app and cannot reveal prior shell
|
||||
// history by scrolling.
|
||||
if m.altScreen {
|
||||
return m, nil
|
||||
}
|
||||
// Never freeze history at the pre-WindowSizeMsg default width: the first
|
||||
// real WindowSizeMsg arrives before any user-visible content settles, and
|
||||
// flushing earlier would hard-wrap startup rows at 96 cols forever.
|
||||
@@ -87,10 +80,14 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
// frontier themselves; this is a safety net).
|
||||
m.flushed = len(m.transcript)
|
||||
}
|
||||
rc := buildRowContext(m.transcript)
|
||||
oldFlushed := m.flushed
|
||||
// An unsettled row blocks the frontier, so every cross-row dependency needed
|
||||
// to decide or render the tail (call/result and prompt/decision pairs) also
|
||||
// lives in the tail. Avoid rescanning the settled prefix on every tick.
|
||||
rc := buildRowContext(m.transcript[m.flushed:])
|
||||
width := chatWidth(m.width)
|
||||
batch := []string{}
|
||||
previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
previousKind, havePreviousKind := m.flushedPreviousKind, m.flushedHavePreviousKind
|
||||
for m.flushed < len(m.transcript) {
|
||||
row := m.transcript[m.flushed]
|
||||
if !m.settledRow(row, rc) {
|
||||
@@ -100,6 +97,15 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
if row.kind == rowWelcome || rc.skip(row) {
|
||||
continue
|
||||
}
|
||||
// Bubble Tea intentionally discards tea.Println in alt-screen mode. The
|
||||
// bookkeeping still advances; the settled prefix is retained in the body
|
||||
// item cache rebuilt below.
|
||||
if m.altScreen {
|
||||
m.flushedAny = true
|
||||
previousKind = row.kind
|
||||
havePreviousKind = true
|
||||
continue
|
||||
}
|
||||
rendered := m.renderRowMode(row, width, rc, true)
|
||||
if rendered == "" {
|
||||
continue
|
||||
@@ -115,6 +121,15 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
previousKind = row.kind
|
||||
havePreviousKind = true
|
||||
}
|
||||
m.flushedPreviousKind = previousKind
|
||||
m.flushedHavePreviousKind = havePreviousKind
|
||||
if m.altScreen {
|
||||
bodyWidth := m.chatColumnWidth()
|
||||
if m.flushed != oldFlushed || m.altScreenSettledWidth != bodyWidth || m.altScreenSettledFrontier != m.flushed {
|
||||
m.rebuildAltScreenSettledItems(bodyWidth)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
m.flushQueue = append(m.flushQueue, strings.Join(batch, "\n"))
|
||||
}
|
||||
@@ -139,6 +154,11 @@ func (m model) drainFlushQueue() (model, tea.Cmd) {
|
||||
// surface ended.
|
||||
func (m *model) resetFlushFrontier(divider string) {
|
||||
m.flushed = 0
|
||||
m.flushedPreviousKind = rowWelcome
|
||||
m.flushedHavePreviousKind = false
|
||||
m.altScreenSettledItems = nil
|
||||
m.altScreenSettledWidth = 0
|
||||
m.altScreenSettledFrontier = 0
|
||||
// Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind,
|
||||
// /compact); a transcript-hover target's bodyY would otherwise risk
|
||||
// coincidentally matching an unrelated row in the rebuilt transcript.
|
||||
@@ -147,3 +167,35 @@ func (m *model) resetFlushFrontier(divider string) {
|
||||
m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider))
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildAltScreenSettledItems snapshots the stable prefix once when the
|
||||
// frontier (or width) changes. View then reuses these item descriptors and only
|
||||
// constructs descriptors/context for the live tail.
|
||||
func (m *model) rebuildAltScreenSettledItems(width int) {
|
||||
if !m.altScreen || m.flushed == 0 {
|
||||
m.altScreenSettledItems = nil
|
||||
m.altScreenSettledWidth = width
|
||||
m.altScreenSettledFrontier = m.flushed
|
||||
return
|
||||
}
|
||||
snapshot := *m
|
||||
snapshot.transcript = m.transcript[:m.flushed]
|
||||
snapshot.flushed = 0
|
||||
snapshot.flushedAny = false
|
||||
snapshot.flushedHavePreviousKind = false
|
||||
snapshot.altScreenSettledItems = nil
|
||||
snapshot.altScreenSettledWidth = 0
|
||||
snapshot.altScreenSettledFrontier = 0
|
||||
snapshot.pending = false
|
||||
snapshot.pendingSpecReview = nil
|
||||
snapshot.fileView = fileViewState{}
|
||||
built := snapshot.transcriptBodyItems(width, "", false)
|
||||
// Keep scratch capacity for the live tail. transcriptBodyItems can then
|
||||
// append transient rows without copying the entire settled prefix. The cache
|
||||
// length never includes those scratch entries, and the next frame overwrites
|
||||
// them on the single Bubble Tea render goroutine.
|
||||
m.altScreenSettledItems = make([]transcriptBodyItem, len(built), len(built)+256)
|
||||
copy(m.altScreenSettledItems, built)
|
||||
m.altScreenSettledWidth = width
|
||||
m.altScreenSettledFrontier = m.flushed
|
||||
}
|
||||
+147
-4
@@ -36,7 +36,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
|
||||
func TestAltScreenAdvancesFrontierAndCachesSettledRows(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
@@ -47,12 +47,155 @@ func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
|
||||
if cmd != nil {
|
||||
t.Fatal("alt-screen mode should not print rows into native scrollback")
|
||||
}
|
||||
if next.flushed != 0 {
|
||||
t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed)
|
||||
if next.flushed != len(next.transcript) {
|
||||
t.Fatalf("alt-screen mode should advance the frontier to %d, got %d", len(next.transcript), next.flushed)
|
||||
}
|
||||
view := viewString(next.View())
|
||||
if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") {
|
||||
t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view)
|
||||
t.Fatalf("cached settled rows should remain in the managed alt-screen view, got %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheRebuildsAfterRowToggle(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"})
|
||||
m, _ = m.settleTranscript()
|
||||
|
||||
m = m.toggleTranscriptRow(len(m.transcript) - 1)
|
||||
m, _ = m.settleTranscript()
|
||||
if !m.transcript[len(m.transcript)-1].expanded {
|
||||
t.Fatal("expected reasoning row to be expanded")
|
||||
}
|
||||
if m.altScreenSettledWidth == 0 || len(m.altScreenSettledItems) == 0 {
|
||||
t.Fatal("expected invalidated settled cache to be rebuilt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheRebuildsAfterDoctorStatusUpdate(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
m = m.setDoctorStatusRow("frame 0")
|
||||
m, _ = m.settleTranscript()
|
||||
if m.altScreenSettledWidth == 0 {
|
||||
t.Fatal("precondition: settled cache should be populated")
|
||||
}
|
||||
|
||||
// An in-place update to an already-settled doctor row (e.g. a spinner tick)
|
||||
// must invalidate the settled cache, just like toggleTranscriptRow does for
|
||||
// expand/collapse, otherwise the alt-screen view keeps serving the stale
|
||||
// snapshot forever.
|
||||
m = m.setDoctorStatusRow("frame 1")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("expected in-place doctor status update to invalidate the settled cache")
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
view := viewString(m.View())
|
||||
if !strings.Contains(view, "frame 1") {
|
||||
t.Fatalf("expected updated doctor status text in view, got %q", view)
|
||||
}
|
||||
if strings.Contains(view, "frame 0") {
|
||||
t.Fatalf("stale doctor status text must not remain in view, got %q", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.altScreen = true
|
||||
m.width = 80
|
||||
m.height = 30
|
||||
m, _ = m.settleTranscript()
|
||||
if m.altScreenSettledWidth == 0 {
|
||||
t.Fatal("precondition: settled cache should be populated")
|
||||
}
|
||||
|
||||
m = m.selectFile("internal/tui/sidebar.go")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
selectedView := viewString(m.View())
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
m = updated.(model)
|
||||
if m.selectedFile != "" {
|
||||
t.Fatalf("Esc should clear the selected file, got %q", m.selectedFile)
|
||||
}
|
||||
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
|
||||
t.Fatal("clearing a selected file should rebuild the invalidated cache")
|
||||
}
|
||||
if clearedView := viewString(m.View()); clearedView == selectedView {
|
||||
t.Fatal("clearing a selected file should remove the settled row's selection tint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
|
||||
const path = "internal/tui/sidebar.go"
|
||||
m := model{
|
||||
transcript: []transcriptRow{
|
||||
{kind: rowToolResult, changedFiles: []string{path}},
|
||||
{kind: rowToolResult, changedFiles: []string{path}},
|
||||
},
|
||||
flushed: 1,
|
||||
altScreenSettledWidth: 80,
|
||||
}
|
||||
|
||||
m.setSelectedFile(path)
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("selecting a file with an older settled card must invalidate the cache")
|
||||
}
|
||||
|
||||
m.altScreenSettledWidth = 80
|
||||
m.setSelectedFile("")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("clearing a file with an older settled card must invalidate the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltScreenRepeatedStatusCollapseRewindsSettledFrontier(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{AltScreen: true})
|
||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
|
||||
m = updated.(model)
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.transcript = appendTranscriptRow(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, id: "old", tool: "swarm_status", runID: 7},
|
||||
)
|
||||
m.transcript = appendTranscriptRow(m.transcript,
|
||||
transcriptRow{kind: rowToolResult, id: "old", tool: "swarm_status", status: tools.StatusOK, detail: "1 running", runID: 7},
|
||||
)
|
||||
m.transcript = appendTranscriptRow(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, id: "new", tool: "swarm_status", runID: 7},
|
||||
)
|
||||
m, _ = m.settleTranscript()
|
||||
if m.flushed != len(m.transcript)-1 {
|
||||
t.Fatalf("precondition: frontier=%d, want %d", m.flushed, len(m.transcript)-1)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(agentRowMsg{
|
||||
runID: 7,
|
||||
row: transcriptRow{
|
||||
kind: rowToolResult, id: "new", tool: "swarm_status", status: tools.StatusError, detail: "1 running", runID: 7,
|
||||
},
|
||||
})
|
||||
m = updated.(model)
|
||||
if len(m.transcript) != 3 {
|
||||
t.Fatalf("repeated status should collapse to welcome plus the new call/result pair, got %d rows", len(m.transcript))
|
||||
}
|
||||
if m.flushed != len(m.transcript) {
|
||||
t.Fatalf("collapsed replacement should settle through %d rows, got frontier %d", len(m.transcript), m.flushed)
|
||||
}
|
||||
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
|
||||
t.Fatal("collapsed replacement should rebuild the invalidated settled cache")
|
||||
}
|
||||
cachedView := viewString(m.View())
|
||||
rebuilt := m
|
||||
rebuilt.altScreenSettledWidth = 0
|
||||
rebuilt, _ = rebuilt.settleTranscript()
|
||||
if rebuiltView := viewString(rebuilt.View()); rebuiltView != cachedView {
|
||||
t.Fatal("collapsed replacement cache is stale compared with a forced rebuild")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-18
@@ -312,16 +312,23 @@ type model struct {
|
||||
chatBodyLines int
|
||||
|
||||
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
|
||||
// is already in native scrollback; in alt-screen mode this frontier stays
|
||||
// idle so history cannot reveal prior shell output.
|
||||
// is already in native scrollback. Alt-screen mode advances the same
|
||||
// frontier, but keeps the settled prefix as cached body items so fullscreen
|
||||
// scrolling still exposes the complete transcript without rebuilding it on
|
||||
// every frame.
|
||||
// flushedAny gates the first turn-separator blank line; flushQueue/
|
||||
// printInFlight serialize ordered scrollback prints; headerPrinted records
|
||||
// the one-time inline title-bar print at startup.
|
||||
flushed int
|
||||
flushedAny bool
|
||||
flushQueue []string
|
||||
printInFlight bool
|
||||
headerPrinted bool
|
||||
flushed int
|
||||
flushedAny bool
|
||||
flushedPreviousKind rowKind
|
||||
flushedHavePreviousKind bool
|
||||
flushQueue []string
|
||||
printInFlight bool
|
||||
headerPrinted bool
|
||||
altScreenSettledItems []transcriptBodyItem
|
||||
altScreenSettledWidth int
|
||||
altScreenSettledFrontier int
|
||||
|
||||
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
|
||||
// lastPrompt is the verbatim text of the most recent submitted prompt, so
|
||||
@@ -401,8 +408,9 @@ type model struct {
|
||||
// mouseReleased, when true, forces terminal mouse capture OFF so the user can
|
||||
// drag-select and copy text natively (Ctrl+E toggles it). App mouse features
|
||||
// (clickable suggestions, right-click paste, transcript select) pause while on.
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
transcriptInteraction *transcriptRenderInteraction
|
||||
// hover identifies the single clickable row (if any) currently under the
|
||||
// mouse cursor with no button pressed, so it renders in a distinct style —
|
||||
// the visual cue that it's clickable. Requires AllMotion mouse reporting
|
||||
@@ -841,6 +849,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
transcriptInteraction: &transcriptRenderInteraction{},
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
@@ -1472,7 +1481,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// selection is a passive highlight, so Esc dropping it is cheap and
|
||||
// expected (mirrors how editors clear selection on Esc).
|
||||
if m.selectedFile != "" {
|
||||
m.selectedFile = ""
|
||||
m.setSelectedFile("")
|
||||
return m, nil
|
||||
}
|
||||
if m.hasQueuedMessage() {
|
||||
@@ -2469,7 +2478,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// Collapse a repeated swarm status/collect card so re-checks don't flood
|
||||
// the chat with identical blocks.
|
||||
beforeCollapse := len(m.transcript)
|
||||
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
|
||||
if removed := beforeCollapse - len(m.transcript); removed > 0 {
|
||||
m.flushed = max(0, m.flushed-removed)
|
||||
m.altScreenSettledWidth = 0
|
||||
}
|
||||
m.transcript = appendTranscriptRow(m.transcript, msg.row)
|
||||
m = m.captureStepWork(msg.row)
|
||||
// A finished command tool may have mutated files git can see but no
|
||||
@@ -5134,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.
|
||||
@@ -5174,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,
|
||||
|
||||
@@ -347,22 +347,42 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) {
|
||||
wizard.manageStatus = "No user config path — cannot delete."
|
||||
return m, nil
|
||||
}
|
||||
cfg, err := config.RemoveProvider(m.userConfigPath, name)
|
||||
|
||||
persisted, err := config.ProviderPersisted(m.userConfigPath, name)
|
||||
if err != nil {
|
||||
wizard.manageStatus = "Delete failed: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
var notes []string
|
||||
var activeAfter string
|
||||
var cleanup tea.Cmd
|
||||
if persisted {
|
||||
cfg, err := config.RemoveProvider(m.userConfigPath, name)
|
||||
if err != nil {
|
||||
wizard.manageStatus = "Delete failed: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
activeAfter = cfg.ActiveProvider
|
||||
notes = []string{"Deleted " + name + "."}
|
||||
cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile)
|
||||
} else {
|
||||
// Env-derived providers have no persisted profile or credential to
|
||||
// delete. Keep this path session-only.
|
||||
notes = []string{
|
||||
"Removed " + name + " from this session.",
|
||||
"It wasn't saved in config.json (likely set via an environment variable) — unset it to stop Zero from detecting it automatically.",
|
||||
}
|
||||
}
|
||||
|
||||
// Surgical removal — see saveManagerEdit for why the raw cfg.Providers list
|
||||
// must not replace the resolved/filtered savedProviders wholesale.
|
||||
m.savedProviders = removeSavedProvider(m.savedProviders, name)
|
||||
|
||||
notes := []string{"Deleted " + name + "."}
|
||||
if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) {
|
||||
notes = append(notes, "This session keeps running on it until you switch.")
|
||||
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
|
||||
notes = append(notes, "Active provider: "+active+".")
|
||||
} else if activeAfter != "" && !strings.EqualFold(activeAfter, name) {
|
||||
notes = append(notes, "Active provider: "+activeAfter+".")
|
||||
}
|
||||
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
|
||||
|
||||
if len(m.savedProviders) == 0 {
|
||||
m.providerWizard = nil
|
||||
@@ -570,6 +590,15 @@ func (m model) saveManagerEdit() (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
oldName := strings.TrimSpace(wizard.editOriginal.Name)
|
||||
persisted, err := config.ProviderPersisted(m.userConfigPath, oldName)
|
||||
if err != nil {
|
||||
wizard.err = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
if !persisted {
|
||||
wizard.err = "provider " + oldName + " is not saved in config.json, so there is no saved profile to edit"
|
||||
return m, nil
|
||||
}
|
||||
newName := strings.TrimSpace(wizard.editDraft.Name)
|
||||
if newName == "" {
|
||||
wizard.err = "name cannot be empty"
|
||||
|
||||
@@ -66,6 +66,11 @@ func rcKey(runID int, id string) string {
|
||||
}
|
||||
|
||||
func buildRowContext(rows []transcriptRow) rowContext {
|
||||
if len(rows) == 0 {
|
||||
// Nil maps are safe for all rowContext lookups. This is the steady-state
|
||||
// frontier-at-tail path, so avoid allocating maps on every View.
|
||||
return rowContext{}
|
||||
}
|
||||
rc := rowContext{
|
||||
resolved: map[string]bool{},
|
||||
hints: map[string]string{},
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user