Compare commits

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

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

Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback.
2026-07-21 17:27:13 +05:30
anandh8x b8eec2d86e test(sandbox): make platform contracts portable 2026-07-20 23:52:42 +05:30
anandh8x 1550c4131c feat(sandbox): unify command execution and enforcement 2026-07-20 23:39:36 +05:30
Vasanth T da9fb50f54 fix(tools): give write_stdin's invalid-session errors the same recovery guidance (#749 follow-up) (#768)
* fix(tools): give write_stdin's invalid-session errors the same recovery guidance

Follow-up to #749 (write_stdin session-id probing) / #702.

write_stdin had three "no valid live session" error paths with three different
messages and signatures: a missing session_id returned "session_id is required",
a zero/negative/non-integer one returned "session_id must be at least 1", and
only a valid-but-unknown id got the rich UnknownExecSessionError recovery guidance
("do not guess or probe; start a session with exec_command, or use edit_file/
write_file"). So a model that called write_stdin with session_id 0 (the common
"I have no id" placeholder) got a terse error with no way forward, and naming the
minimum nudged it toward trying 1, 2, 3... the exact id-probing #749 suppresses.
The three signatures also meant a model moving 0 to 1 to 2 reset the
repeated-failure streak on each class of mistake.

Route the missing / non-integer / <1 cases through the same UnknownExecSessionError
message. Now every "no live session" mistake gives the same recovery guidance and,
because that message is id-invariant, shares one repeated-failure signature, so any
mix of missing/zero/probing accumulates toward the halt.

Updates TestWriteStdinRequiresPositiveSessionID to cover missing/zero/negative/
non-integer and assert the recovery message. Guardrail signature/halt tests
unchanged.

* test(tools): cover the explicit session_id: nil case for write_stdin
2026-07-20 13:59:11 +05:30
KRATOSandKRATOS 684901165d fix(sandbox): don't auto-allow shell when re-entrancy skips wrapping (#727) (#744)
The ambient ZERO_SANDBOXED=1 + ZERO_SANDBOX_BACKEND markers are user-controlled
at an unsandboxed process boundary. Policy could auto-allow an ordinary shell
command (backend reports a native wrapping sandbox) while the runner, seeing the
same markers, returned an unwrapped pass-through plan — the command then ran
directly on the host with no isolation and no prompt.

shellSandboxActive now mirrors the runner: when IsAlreadySandboxed() (the markers
are present, so wrapping is skipped) the sandbox is not the boundary for this
command, so it reports inactive and the shell command takes the normal approval
path instead of auto-allowing. Immediate mitigation per the issue; authenticated
launch provenance is the longer-term fix.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:56:36 +05:30
Vasanth T e1975c1b39 fix(perfbench): grant write tools so mutating tasks measure real edits (#763)
* fix(perfbench): grant write tools so mutating tasks measure real edits

The turn benchmark invoked `zero exec` in its default read-only posture,
which exposes no write or shell tools (no edit_file/apply_patch/
write_file/exec_command/bash). So the mutating classes (edit/fix/
refactor) could never apply a change: a run either ground to the turn
ceiling hunting for an edit tool or reported a no-tool blocker, and the
only tasks that 'passed' were ones whose oracle grep matched the stamped
.zero-answer.txt narration rather than a real edit.

This invalidated every mutating-class number in both baselines. A live
trace of fix-07 (a one-line admin->user prefix change) showed the model
calling only read_file/grep/lsp_navigate/update_plan and then stating it
had no file-writing tool. Adding --skip-permissions-unsafe exposes the
full tool set; each task already runs in an isolated throwaway fixture
copy, so there is nothing to protect. With the flag, edit-01/fix-01/
fix-07/refactor-01 go from failing (or false-passing) to 4/4 real
passes, and tool_execution rises from ~0.4% to ~29% of wall as the write
tools are actually exercised.

Adds TestBuildTurnExecArgsGrantsWriteTools pinning the flag as a
correctness contract.

* fix(perfbench): use --auto member and let the oracle judge incomplete exits

Reviewer feedback on the write-tools grant: --skip-permissions-unsafe is
broader than the benchmark needs. It also drops the workspace, network,
and destructive-command guards and auto-retries sandbox-blocked commands
unsandboxed. Switch to --auto member, which grants exactly the write +
sandboxed-shell tools the mutating classes need while keeping those
safeguards.

member-auto's shell is sandboxed, though, so 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 used to treat
any nonzero exit as failure before consulting the oracle, which would
false-fail those correct edits. Make the oracle authoritative for
oracle-bearing tasks: the stamped test/grep against the actual fixture
files is ground truth, so a correct edit passes regardless of the exit
code. Latency-only tasks have no oracle, so a nonzero exit still fails
them.

Verified with a 4-task glm-5.2 smoke (edit-01/fix-01/fix-07/refactor-01):
4/4 real passes under --auto member. Updates the exec-args test to assert
--auto member is present and --skip-permissions-unsafe is absent, and adds
TestOracleAuthoritativeOnIncompleteExit and
TestNonzeroExitStillFailsLatencyOnly.

* fix(perfbench): reject fixtureless tasks so the write-tools grant can't escape isolation

CodeRabbit and gnanam both flagged the same structural gap: buildTurnExecArgs
grants the write + sandboxed-shell tools to every invocation, but the fixture
isolation was conditional (a task with no workspaceFixture ran in the caller's
cwd). So the grant and the guard could drift apart, and a fixtureless task would
run an agent with write/shell tools in the caller's real repo dir.

Make the isolation a checked precondition: NewTurnExecRunner now rejects a task
with no workspaceFixture before launching zero exec, failing loudly rather than
silently running in cwd. Every shipped task already declares a fixture, so this
only fires on a malformed or newly added one.

Adds TestTurnRunnerRejectsFixturelessTask (rejection is pre-launch: it returns
the fixture error even with a nonexistent binary).

* fix(perfbench): defer to the oracle only for INCOMPLETE (exit 4), not every nonzero exit

anand's follow-up: the previous commit cleared VerifyErr for any nonzero
run_end whenever a VerificationCommand was present, which would also convert
a crash (1), usage error (2), provider failure (3), or interruption (130)
into a passing result whenever a partial edit happened to satisfy the oracle.

Gate the oracle override on exitCode == exitIncomplete (4): the completion
gate's "work maybe done but not signaled" case, the only one where a correct
edit can legitimately exit nonzero (its sandboxed self-verify couldn't run
under --auto member). Every other nonzero exit stays authoritative, so a
partial edit can't launder a crashed or interrupted run into a pass.

The 4-task glm-5.2 smoke stays 4/4 (those tasks exit INCOMPLETE, not some
other code). Adds TestNonIncompleteExitStaysAuthoritative (a correct edit that
reports exit 1 must still fail) and mirrors internal/cli.exitIncomplete as a
local execExitIncomplete constant.

* test(perfbench): pin the exit-4 oracle deferral from both sides; drop dead fixture branch

Follow-ups from a self-review of the exit-4 gating:

- Add TestIncompleteExitStillFailsWhenOracleFails: an INCOMPLETE (exit 4) run
  that did NO work must still FAIL, because deferring to the oracle still means
  the oracle is consulted. The sibling TestOracleAuthoritativeOnIncompleteExit
  applies a correct edit and would pass either way, so without this a regression
  that turned exit 4 into a blanket pass would go unnoticed.
- Broaden TestNonIncompleteExitStaysAuthoritative to a table over exit 1/3/130
  (crash/provider/interrupt), not just 1, so every named launder-risk code is
  covered.
- Tighten the NewTurnExecRunner doc: the verdict gate is the VerificationCommand
  (which runs the stamped OracleTest via go test), not OracleTest on its own.
- Drop the now-dead "if WorkspaceFixture != empty" branch: the fixtureless guard
  added earlier already guarantees it is set.

No behavior change; the 4-task glm-5.2 smoke stays 4/4.
2026-07-20 11:34:20 +05:30
KRATOSandKRATOS 8c6d3022bd feat(tui): add /undo as an alias for /rewind (#698) (#747)
* feat(tui): add /undo as an alias for /rewind (#698)

/undo was unregistered and fell through to commandUnknown. Add it to the
/rewind command's aliases (the same mechanism /quit, /find, /mcp-status use), so
it dispatches to the existing rewind handler with args intact.

* test(tui): cover the /undo numeric-sequence form (#698 review)

Add a parseCommand("/undo 123") assertion so both the latest and numeric
<sequence> forms of the /rewind contract are exercised through the alias.

---------

Co-authored-by: KRATOS <[email protected]>
2026-07-20 11:32:44 +05:30
KRATOSandKRATOS 974fc036c2 fix(oauth): refuse redirects on credential POSTs (#729) (#741)
Token exchange/refresh and the device authorization/poll POSTs validated only
the initial endpoint, then let the http.Client follow a 307/308 that replays the
form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated
origin.

Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every
redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh,
and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's
client is never mutated; legitimate token endpoints that return 200 are unaffected.

Co-authored-by: KRATOS <[email protected]>
2026-07-20 08:19:37 +05:30
PierrunoYT 4cbd144d11 fix(providers): stop "provider not found" for env-derived profiles (#716)
* fix(providers): stop "provider not found" for env-derived profiles

* fix(providers): address env profile review feedback

* fix(providers): keep env JSON response schemas stable
2026-07-20 10:23:44 +08:00
euxaristiaandClaude Fable 5 c0b6b9e5c9 docs(readme): note govulncheck/golangci-lint install to GOPATH/bin (#711)
* docs(readme): note govulncheck/golangci-lint install to GOPATH/bin

The installed binaries land in $GOPATH/bin (default ~/go/bin), which must
be on PATH to run govulncheck directly. Document the export snippet so the
lint/security tooling from the contributor setup actually resolves.

* docs(readme): say "them" instead of singling out govulncheck

golangci-lint installs to the same $GOPATH/bin as govulncheck, so the
PATH note applies to both, not just the one named.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(readme): honor GOBIN and make the PATH snippet idempotent

go install writes to GOBIN when set, with GOPATH/bin as the fallback, and
rerunning the old snippet appended the same directory repeatedly.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-20 10:21:54 +08:00
PierrunoYT d74ceb1127 fix(tui): cache settled alt-screen transcript (#647)
* fix(tui): cache settled alt-screen transcript

* fix(tui): invalidate settled alt-screen cache on in-place status row updates

setDoctorStatusRow, setSandboxSetupStatusRow, and setCompactStatusRow all
mutate an already-flushed transcript row in place (m.transcript[i] = row)
without invalidating the alt-screen settled cache. Since none of frontier,
width, or row count change on such an update, settleTranscript's rebuild
guard never fires and the View fast path serves the stale cached snapshot -
e.g. the doctor connectivity spinner freezes on its first frame.

Zero altScreenSettledWidth on the in-place path, mirroring the existing
pattern in toggleTranscriptRow, so the next settle rebuilds the cache.

Addresses PR #647 review feedback from Vasanthdev2004.

* fix(tui): invalidate settled transcript cache

* fix(tui): invalidate settled file selection cards
2026-07-20 10:20:46 +08:00
Vasanth T fbf85984f6 fix(agent): stop write_stdin session_id probing thrash (#702) (#749)
A model that calls write_stdin with no live session probed sequential
session_ids (1, 2, 3, …), each failing, and the run ground on for a long
time before halting. The repeated-failure guard keys on a normalized,
80-char-truncated signature of the tool error, and the old message
"Unknown exec session_id %d" put the varying id inside that prefix, so
every probe produced a fresh signature and the 6x-same-error halt never
fired.

- Rewrite the unknown-session error so stable recovery guidance leads and
  the id trails past the signature window: probing now collapses to one
  signature and trips the halt, and the model is told how to recover
  (start an exec_command, or use the file-edit tools).
- Pin session_id's schema Minimum to 1 so the advertised schema matches
  the runtime floor (intArg min 1).
- system_prompt: state that session_id is only ever an id returned by a
  still-running exec_command and must never be guessed or probed.

Tests pin the id-invariance of the signature and that distinct-id probing
now halts at the guard's stop threshold, plus the schema floor and the
recovery text. The TUI "Sent input" label on a failed write_stdin
(issue criterion 4) is a separate cosmetic concern in a shared hot path
and is left as a follow-up.
2026-07-19 22:13:35 +05:30
Anandan 9242b9e037 Add transcript-grounded structured task state (#761)
* Add transcript-grounded structured task state

Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot.

Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check

* Preserve task objective across repeated compaction

Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript.

Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check
2026-07-19 21:38:58 +05:30
Anandan 2e267bdbee feat(tui): add isolated /btw conversations (#748)
* feat(tui): add isolated btw conversations

Add /btw with inline-question support, isolated non-resumable session forks, background main-run routing, and safe return behavior.

Refs #637

* test(tui): cover btw return controls

Preserve drafts when Ctrl+C is pressed during an active BTW run and cover returning to the parent through the documented /btw toggle.

* Address BTW isolation review feedback

* Reject explicit resumes of side sessions

* Harden BTW isolation lifecycle
2026-07-19 20:37:38 +05:30
Anandan 739a47e3ea perf(agent): preserve prompt cache prefixes (#760)
* perf(agent): preserve prompt cache prefixes

Build the system prompt once per run, fingerprint the exact emitted prompt and ordered tool definitions, and expose the full system-prompt hash in traces.

Add normalized and serialized two-turn regressions covering append-only messages, stable tools, and a stable session cache key.

* fix(tools): stabilize registry iteration order

Return registered tools in name order so provider-visible tool lists and prompt-prefix fingerprints cannot drift with Go map iteration. Add a regression covering the registry ordering contract.
2026-07-19 20:29:58 +05:30
138 changed files with 8392 additions and 1909 deletions

No files matched your search

+11 -1
View File
@@ -195,7 +195,7 @@ Useful controls:
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
| `Shift+Tab` | cycle permission mode |
| `Ctrl+B` | show/hide the sidebar |
| `Ctrl+C` | cancel or exit |
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
Common slash commands:
@@ -205,6 +205,7 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
@@ -383,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
+12 -3
View File
@@ -56,6 +56,10 @@ type CompactionOptions struct {
// injected so Compact stays pure and testable; the agent loop wires it to a
// real provider call.
Summarize func(toSummarize []zeroruntime.Message) (string, error)
// taskState is a snapshot supplied by the running agent. Its immutable
// objective is always preserved; mutable fields are admitted only when its
// plan projection still matches the transcript.
taskState *taskStateSnapshot
}
// CompactionResult is the metadata-bearing result returned by CompactMessages.
@@ -220,7 +224,7 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
// Preserve structured state (active plan + loaded skills) from the elided
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
compacted = append(compacted, messages[:systemEnd]...)
@@ -326,6 +330,7 @@ type compactionState struct {
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
task *taskState
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
@@ -365,13 +370,15 @@ func (state *compactionState) calibratedTokens(raw int) int {
return int(float64(raw) * state.calibrationRatio)
}
func newCompactionState(options Options) *compactionState {
return &compactionState{
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
}
return state
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
@@ -418,6 +425,7 @@ func (state *compactionState) maybeCompact(
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: summarizeClosure(ctx, provider, state.onUsage),
taskState: state.task.snapshotForCompaction(messages),
})
if err != nil {
// Summarizer failed: keep the original history. The reactive path (or a
@@ -468,6 +476,7 @@ func (state *compactionState) recover(
result, compactErr := Compact(messages, CompactionOptions{
PreserveLast: state.preserveLast,
Summarize: summarizeClosure(ctx, provider, state.onUsage),
taskState: state.task.snapshotForCompaction(messages),
})
if compactErr != nil {
// A genuine compaction attempt was made (and failed): the budget is spent
+60 -29
View File
@@ -30,6 +30,10 @@ const (
const (
maxRecentEdits = 20
maxEditNoteBytes = 160
// maxTaskObjectiveBytes keeps the original objective recognizable after
// compaction without allowing an unusually large prompt to recreate the
// context pressure the compaction just removed.
maxTaskObjectiveBytes = 512
)
const (
@@ -76,32 +80,14 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
// "" on malformed arguments or an empty plan.
func formatPlanArguments(arguments string) string {
var parsed struct {
Plan []struct {
Content string `json:"content"`
Step string `json:"step"`
Status string `json:"status"`
Notes string `json:"notes"`
} `json:"plan"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
plan, ok := parseTaskPlan(arguments)
if !ok {
return ""
}
lines := make([]string, 0, len(parsed.Plan))
for _, item := range parsed.Plan {
content := strings.TrimSpace(item.Content)
if content == "" {
content = strings.TrimSpace(item.Step)
}
if content == "" {
continue
}
status := strings.TrimSpace(item.Status)
if status == "" {
status = "pending"
}
line := "- [" + status + "] " + content
if notes := strings.TrimSpace(item.Notes); notes != "" {
lines := make([]string, 0, len(plan))
for _, item := range plan {
line := "- [" + item.Status + "] " + item.Content
if notes := item.Notes; notes != "" {
line += "\n Notes: " + notes
}
lines = append(lines, line)
@@ -431,12 +417,25 @@ func capBody(body string) string {
// preservedState is the JSON shape of the carried-across-compaction block.
type preservedState struct {
Plan string `json:"plan,omitempty"`
Task *preservedTaskState `json:"task,omitempty"`
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
Tools []preservedTool `json:"tools,omitempty"`
Skills []preservedSkill `json:"skills,omitempty"`
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
}
type preservedTaskState struct {
Objective string `json:"objective"`
Status taskStatus `json:"status,omitempty"`
Pending int `json:"pending,omitempty"`
InProgress int `json:"in_progress,omitempty"`
Completed int `json:"completed,omitempty"`
Failed int `json:"failed,omitempty"`
VerificationPassed int `json:"verification_passed,omitempty"`
VerificationFailed int `json:"verification_failed,omitempty"`
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
}
type preservedEdit struct {
Path string `json:"path"`
Note string `json:"note,omitempty"`
@@ -464,8 +463,27 @@ type preservedInstruction struct {
// may live only inside the injected summary message, which on a later compaction
// lands in middle with no real tool calls left to extract. Fresh tool calls and
// instruction blocks override the carried-forward copy by name/source.
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
task := priorState.Task
if taskSnapshot != nil {
task = &preservedTaskState{
Objective: capTaskObjective(taskSnapshot.Objective),
}
// Plan parity corroborates only the mutable task projection. The objective
// comes directly from the run prompt and is immutable, so it must survive
// even after compaction removes the plan tool call needed for comparison.
if taskSnapshot.PlanParity == taskPlanParityMatch {
task.Status = taskSnapshot.Status
task.Pending = taskSnapshot.Plan.Pending
task.InProgress = taskSnapshot.Plan.InProgress
task.Completed = taskSnapshot.Plan.Completed
task.Failed = taskSnapshot.Plan.Failed
task.VerificationPassed = taskSnapshot.Verification.Passed
task.VerificationFailed = taskSnapshot.Verification.Failed
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
}
}
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
// plan preserved by an earlier compaction.
@@ -493,12 +511,25 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
projectInstructionEntries(middle),
)
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
summary += "\n\n" + block
}
return summary
}
func capTaskObjective(objective string) string {
objective = strings.TrimSpace(objective)
if len(objective) <= maxTaskObjectiveBytes {
return objective
}
const suffix = "…"
limit := maxTaskObjectiveBytes - len(suffix)
for limit > 0 && !utf8.RuneStart(objective[limit]) {
limit--
}
return strings.TrimSpace(objective[:limit]) + suffix
}
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
// original slot), a path touched again by a fresh edit MOVES to the newest
@@ -555,11 +586,11 @@ func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
// formatPreservedState renders state as the labelled, single-line
// JSON block. Returns "" when there is nothing to preserve.
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
return ""
}
state := preservedState{Plan: plan}
state := preservedState{Plan: plan, Task: task}
for _, e := range edits {
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
}
+101 -5
View File
@@ -1,6 +1,7 @@
package agent
import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
@@ -59,6 +60,103 @@ func TestCompactPreservesActivePlan(t *testing.T) {
}
}
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
objective := strings.Repeat("世", maxTaskObjectiveBytes)
task := newTaskState(objective, nil)
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
messages := stateConversation()
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatalf("Compact: %v", err)
}
state := parsePreservedStateBlock(compacted[1].Content)
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
t.Fatalf("unexpected compact task state: %#v", state.Task)
}
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
}
}
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
encoded, err := json.Marshal(prior)
if err != nil {
t.Fatal(err)
}
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
{Role: zeroruntime.MessageRoleUser, Content: "more"},
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
{Role: zeroruntime.MessageRoleUser, Content: "again"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
}
compacted, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
taskState: &taskStateSnapshot{
Objective: "current objective",
PlanParity: taskPlanParityMismatch,
},
})
if err != nil {
t.Fatalf("Compact: %v", err)
}
state := parsePreservedStateBlock(compacted[1].Content)
if state.Task == nil || state.Task.Objective != "current objective" {
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
}
if state.Task.Status != "" || state.Task.Pending != 0 {
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
}
}
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
task := newTaskState("keep this objective", nil)
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
messages := stateConversation()
first, err := Compact(messages, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
taskState: task.snapshotForCompaction(messages),
})
if err != nil {
t.Fatalf("first Compact: %v", err)
}
secondInput := append(append([]zeroruntime.Message{}, first...),
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
)
snapshot := task.snapshotForCompaction(secondInput)
if snapshot.PlanParity != taskPlanParityMismatch {
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
}
second, err := Compact(secondInput, CompactionOptions{
PreserveLast: 2,
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
taskState: snapshot,
})
if err != nil {
t.Fatalf("second Compact: %v", err)
}
state := parsePreservedStateBlock(second[1].Content)
if state.Task == nil || state.Task.Objective != "keep this objective" {
t.Fatalf("objective lost after second compaction: %#v", state.Task)
}
if state.Task.Status != "" || state.Task.InProgress != 0 {
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
}
}
func TestCompactPreservesLoadedSkills(t *testing.T) {
summary := compactStateConversation(t, stateConversation())
if !strings.Contains(summary, preservedStateLabel) {
@@ -283,12 +381,10 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
}
}
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
if !strings.Contains(got, want) {
t.Fatalf("expected %q in formatted plan, got %q", want, got)
}
if got != "" {
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
}
}
+2 -2
View File
@@ -581,7 +581,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
}
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
}}}
@@ -628,7 +628,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
}
func TestRecoverDisabledIsNoop(t *testing.T) {
st := newCompactionState(Options{ContextWindow: 0})
st := newCompactionState(Options{ContextWindow: 0}, nil)
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
called := false
// recover must not invoke the provider/summarize when disabled, even on a
+2 -2
View File
@@ -39,7 +39,7 @@ func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
}
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
func (policy *completionPolicy) evaluate(text string, context completionContext) completionEvaluation {
// A direct admission is strong local evidence and takes precedence over all
// weaker signals, avoiding both continuation nudges and semantic checks.
if reason := selfReportedIncompletion(text); reason != "" {
@@ -50,7 +50,7 @@ func (policy *completionPolicy) evaluate(text string, planPending bool) completi
// weak evidence because bookkeeping can be stale. Both get a bounded chance
// to continue, but only a persisted cue is ultimately incomplete.
cue := endsWithContinuationCue(text)
if cue || planPending {
if cue || context.PlanPending {
if policy.continueNudges < maxContinueNudges {
policy.continueNudges++
reason := "your message ended mid-step"
+20 -9
View File
@@ -1,16 +1,19 @@
package agent
import "testing"
import (
"strings"
"testing"
)
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(false)
complete := policy.evaluate("Done. All required checks pass.", false)
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
if complete.Decision != CompletionComplete {
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
}
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", completionContext{})
if incomplete.Decision != CompletionIncomplete {
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
}
@@ -19,13 +22,13 @@ func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T)
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("Let me inspect the remaining configuration:", true)
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("Let me inspect the remaining configuration:", true)
got := policy.evaluate("Let me inspect the remaining configuration:", completionContext{PlanPending: true})
if got.Decision != CompletionIncomplete {
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
}
@@ -34,13 +37,13 @@ func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("All set.", true)
got := policy.evaluate("All set.", completionContext{PlanPending: true})
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("All set.", true)
got := policy.evaluate("All set.", completionContext{PlanPending: true})
if got.Decision != CompletionComplete {
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
}
@@ -49,12 +52,12 @@ func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(true)
first := policy.evaluate("Implemented and tested.", false)
first := policy.evaluate("Implemented and tested.", completionContext{})
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
}
second := policy.evaluate("PASS. The result meets the task criterion.", false)
second := policy.evaluate("PASS. The result meets the task criterion.", completionContext{})
if second.Decision != CompletionComplete {
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
}
@@ -62,3 +65,11 @@ func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
t.Fatal("semantic check was requested more than once")
}
}
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
objective := "make the requested behavior observable"
nudge := acceptanceVerificationNudge(objective)
if !strings.Contains(nudge, "Task objective: "+objective) {
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
}
}
+11 -13
View File
@@ -1,7 +1,6 @@
package agent
import (
"encoding/json"
"strconv"
"strings"
@@ -127,9 +126,8 @@ func endsWithContinuationCue(text string) bool {
// which the update_plan tool coerces to "pending") counts as remaining, matching
// that tool's own status normalization.
func planStatusRemaining(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
switch normalizeTaskPlanStatus(status) {
case "completed", "failed":
return false
default:
return true
@@ -307,8 +305,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
// passing treated as the objective being met; and a result that merely matches a
// baseline the task asked to beat or improve. General — no task-specific content.
func acceptanceVerificationNudge() string {
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
func acceptanceVerificationNudge(objective string) string {
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
"result merely matches a baseline you were asked to beat or improve. " +
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
@@ -316,6 +314,10 @@ func acceptanceVerificationNudge() string {
"thing the task asked you to produce, recover, fix, or optimize. " +
"If that check passes, reply PASS and cite the evidence. " +
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
if objective = capTaskObjective(objective); objective != "" {
nudge += "\n\nTask objective: " + objective
}
return nudge
}
// toolFailureHintMarker is a stable substring for tests.
@@ -553,16 +555,12 @@ func (state *guardState) pendingPlanItems() bool {
// many items are still remaining. Malformed arguments leave the prior count
// unchanged (best-effort — the plan panel itself tolerates the same).
func (state *guardState) observePlanUpdate(arguments string) {
var parsed struct {
Plan []struct {
Status string `json:"status"`
} `json:"plan"`
}
if json.Unmarshal([]byte(arguments), &parsed) != nil {
plan, ok := parseTaskPlan(arguments)
if !ok {
return
}
pending := 0
for _, item := range parsed.Plan {
for _, item := range plan {
if planStatusRemaining(item.Status) {
pending++
}
+31
View File
@@ -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{
+113 -52
View File
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -181,10 +182,20 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.runPermissions = runPermissions
defer runPermissions.cleanup()
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
promptParts := buildSystemPromptParts(options)
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
guards := newGuardState()
compactor := newCompactionState(options)
task := newTaskState(prompt, options.Trace)
compactor := newCompactionState(options, task)
defer func() {
// A final transcript comparison is observational. It records drift but
// never rewrites the result or changes execution after the fact.
task.observePlanParity(result.Messages)
// Tool-result observations are intentionally coalesced; this final event
// guarantees their aggregate counts are present even on an early return.
task.emit()
}()
// The execution-profile posture controller. nil Options.Profile (every
// existing caller) makes every observe/decide call a no-op, keeping the
// loop byte-identical for unprofiled runs.
@@ -268,16 +279,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
toolPartitionSpan.End()
// Prompt-prefix fingerprint: hash the seven cacheable sub-components
// of this turn's request (system prompt sections, project context,
// skills, partitioned tool list, tool schemas) and emit one trace
// event. Stable across turns => cacheable. Drift in any sub-hash
// names the sub-component that broke the cache for this turn. The
// computation is pure and cheap (seven SHA-256s of small strings)
// and is a no-op when tracing is off.
// Fingerprint the exact system prompt built at run start plus this turn's
// provider-visible tool definitions in their emitted order. Reusing the
// retained prompt parts avoids rebuilding workspace context while ensuring
// the trace describes the same bytes carried by the request.
if options.Trace != nil {
fp := ComputePrefixFingerprint(options, exposed)
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
options.Trace.EmitPrefixHash(trace.PrefixHash{
SystemPromptHash: fp.SystemPromptHash,
BaseInstructionsHash: fp.BaseInstructionsHash,
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
ProjectContextHash: fp.ProjectContextHash,
@@ -571,7 +580,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// model's final answer ONLY when the work is actually done. Default off
// (RequireCompletionSignal), so interactive runs stay byte-identical.
if options.RequireCompletionSignal {
evaluation := completionPolicy.evaluate(collected.Text, guards.pendingPlanItems())
completionContext := task.completionContext(messages, guards.pendingPlanItems())
evaluation := completionPolicy.evaluate(collected.Text, completionContext)
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
switch evaluation.Decision {
case CompletionIncomplete:
result.Incomplete = true
@@ -596,13 +607,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: acceptanceVerificationNudge(),
Content: acceptanceVerificationNudge(completionContext.Objective),
})
}
continue
case CompletionComplete:
// Local evidence is sufficient; proceed to final diagnostics.
}
} else {
task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
}
// Finalization diagnostics gate: edits from this run may still have
// checks in flight — the per-turn drain waits only briefly and defers
@@ -626,6 +639,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// executing so the empty-turn counter resets and plan-tracking signals
// stay current.
guards.observeTurn(collected)
for _, call := range collected.ToolCalls {
if call.Name == planToolName {
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: call.Arguments})
}
}
failureHint := ""
// turnRequestedModel records the FIRST mid-run escalation target requested
@@ -677,6 +695,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, toolResult)
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -758,6 +777,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
feedback, selfCorrectOutcome := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch))
posture.observeSelfCorrect(selfCorrectOutcome)
task.observe(taskStateEvent{kind: taskStateEventVerification, verification: selfCorrectOutcome})
if feedback != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -1151,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 {
@@ -1397,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
@@ -1527,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
@@ -1581,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"
}
@@ -1709,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"],
}
}
@@ -1979,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{
@@ -2551,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,
@@ -2564,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,
@@ -2573,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 {
+95
View File
@@ -8,10 +8,12 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"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"
@@ -25,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)
@@ -1258,6 +1313,46 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
}
}
func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
if _, err := Run(context.Background(), "read notes", provider, Options{
Cwd: root,
Registry: registry,
SessionID: "session-stable-prefix",
}); err != nil {
t.Fatal(err)
}
if len(provider.requests) != 2 {
t.Fatalf("provider requests = %d, want 2", len(provider.requests))
}
first, second := provider.requests[0], provider.requests[1]
if len(second.Messages) < len(first.Messages) || !reflect.DeepEqual(second.Messages[:len(first.Messages)], first.Messages) {
t.Fatalf("first request messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", first.Messages, second.Messages)
}
if !reflect.DeepEqual(first.Tools, second.Tools) {
t.Fatalf("tool definitions drifted between turns:\nfirst=%#v\nsecond=%#v", first.Tools, second.Tools)
}
if first.PromptCacheKey != "session-stable-prefix" || second.PromptCacheKey != first.PromptCacheKey {
t.Fatalf("prompt cache key must remain stable: first=%q second=%q", first.PromptCacheKey, second.PromptCacheKey)
}
}
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -68,3 +68,61 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "npm install",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
t.Fatal("an already-covered capability must not trigger another prompt")
}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("covered capability should still produce an auditable allow event")
}
if event.Action != PermissionActionAllow {
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
}
}
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "make test"}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("generic shell approval must produce an event")
}
if event.Reason != "" {
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
}
if event.DecisionReason != "runs a shell command" {
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
}
}
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "curl https://example.test",
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
"justification": "Run without sandboxing.",
}
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("policy approval must produce an event")
}
if event.Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
}
}
+5 -5
View File
@@ -14,13 +14,13 @@ import (
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
// the auto-mode advertised set, including exec_command: auto mode must expose both
// process creation and write_stdin process interaction or the latter is unusable
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
maxEagerToolSchemaTokens = 3550
)
func TestSystemPromptTokenBudget(t *testing.T) {
+42 -98
View File
@@ -11,12 +11,11 @@ import (
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// promptSubstrings are the seven cacheable sub-components of the prompt a run
// sends to a model. Each is the literal content the sub-component would emit
// into the system prompt or tool list, hashed so a downstream observer can
// detect drift turn-over-turn. The substrings are produced by
// buildPromptSubstrings and consumed by ComputePrefixFingerprint.
// promptSubstrings are the cacheable components of the prompt a run sends to a
// model. systemPrompt is the exact joined system-message content. The remaining
// fields retain useful diagnostic boundaries within that prompt and tool list.
type promptSubstrings struct {
systemPrompt string
baseInstructions string
confirmationPolicy string
projectContext string
@@ -25,92 +24,55 @@ type promptSubstrings struct {
schema string
}
// buildPromptSubstrings assembles the seven cacheable sub-components of the
// prompt without joining them. The system prompt sections are produced by the
// same private builders that buildSystemPrompt uses, so the substrings are
// byte-identical to what would have appeared in the joined prompt. The tools
// and schema substrings are derived from the partitioned tool list (caller-
// provided) so this helper does not need to re-run the partition.
//
// This is the seam ComputePrefixFingerprint reads from. It exists as a
// separate function (rather than returning both prompt and substrings from
// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected
// and the substrings helper is independently testable.
// buildPromptSubstrings builds the prompt and retains both its exact joined
// form and narrower diagnostic components. Callers that already built the
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
// reads.
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
core := strings.TrimSpace(options.SystemPrompt)
if core == "" {
core = strings.TrimSpace(coreSystemPrompt)
}
if core == "" {
core = fallbackSystemPrompt
}
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
}
// The confirmation policy is appended unconditionally in buildSystemPrompt;
// the substring here is the same constant.
policy := strings.TrimSpace(confirmationPolicy)
// projectContext is the joined output of workspaceContext, which itself
// walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the
// joined string captures every guideline file's contribution and the
// ordering between them.
project := workspaceContext(options.Cwd)
// skills is the joined output of skillsContext, with each skill on its own
// line. The substring is the literal text the model would see.
skills := skillsContext(options)
// tools and schema are derived from the partitioned tool list. The list
// passed in is already in stable order (partitionToolsCached is the
// canonical stable partitioner) so concatenating in slice order is
// sufficient — we sort names again defensively in case a future caller
// hands us a list from a different partitioner.
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
return promptSubstrings{
baseInstructions: core,
confirmationPolicy: policy,
projectContext: project,
skills: skills,
systemPrompt: parts.prompt,
baseInstructions: parts.baseInstructions,
confirmationPolicy: parts.confirmationPolicy,
projectContext: parts.projectContext,
skills: parts.skills,
tools: toolsSubstr,
schema: schemaSubstr,
}
}
// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool
// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of
// the partition produces a different hash. The schema substring is the
// concatenation of each tool's JSON schema in name order, so a schema change
// is detected independently of the tool ordering.
// toolSubstrings preserves the emitted tool order. Names and descriptions are
// fingerprinted separately from schemas so either kind of drift is identifiable.
// Order is part of request identity: silently sorting here would hide a provider-
// visible reordering and could report a false cache hit.
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
if len(exposed) == 0 {
return "", ""
}
names := make([]string, 0, len(exposed))
byName := make(map[string]zeroruntime.ToolDefinition, len(exposed))
for _, def := range exposed {
names = append(names, def.Name)
byName[def.Name] = def
}
sort.Strings(names)
var toolsSB, schemaSB strings.Builder
for i, name := range names {
if i > 0 {
toolsSB.WriteByte('\n')
}
toolsSB.WriteString(name)
def := byName[name]
for _, def := range exposed {
writeLengthPrefixed(&toolsSB, def.Name)
writeLengthPrefixed(&toolsSB, def.Description)
// Parameters is rendered to a canonical JSON string per tool. The
// schema-render cache in internal/agent (used by partitionToolsCached)
// guarantees the same render is byte-identical across turns for the
// same tool, so this substring is a stable hash input.
schemaSB.WriteString(name)
schemaSB.WriteByte('\n')
schemaSB.WriteString(canonicalSchemaString(def.Parameters))
schemaSB.WriteByte('\n')
writeLengthPrefixed(&schemaSB, def.Name)
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
}
return toolsSB.String(), schemaSB.String()
}
func writeLengthPrefixed(sb *strings.Builder, value string) {
fmt.Fprintf(sb, "%d:", len(value))
sb.WriteString(value)
}
// canonicalSchemaString renders a tool's parameter schema to a stable string.
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
@@ -227,36 +189,16 @@ func writeStable(sb *strings.Builder, v any) {
}
}
// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint
// of the prompt prefix) for one turn of a run. The seven sub-hashes are
// independent SHA-256s of the corresponding sub-component; the complete-prefix
// hash is a SHA-256 of the canonical concatenation of the other six, so any
// sub-component drift is observable both individually and in aggregate.
//
// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs
// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md /
// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace-
// context work is performed separately by buildSystemPrompt a few lines
// later in the request-build path, so on every traced turn the file and
// git reads happen twice. The duplication is intentional for this PR (a
// follow-up will pass substrings through) and is a known perf cost only on
// the opt-in trace path.
//
// A stable CompletePrefixHash across turns means the four captured
// sub-components (baseInstructions, confirmationPolicy, projectContext,
// skills) are byte-identical. It does NOT rule out drift in the seven
// uncaptured sections of buildSystemPrompt (modelPromptAddendum,
// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext,
// userGuidelines, specialistDelegationContext, responseStyleContext). For
// default Options the four captured substrings are the full prompt; for
// non-default Options the seven uncaptured sections may contribute, and a
// consumer correlating CompletePrefix stability with cached_input_tokens
// must cross-check the model_switches counter (modelPromptAddendum changes
// on a model switch) to disambiguate "no drift" from "drift in an
// uncaptured section."
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
// not already have one. The agent loop uses computePrefixFingerprint with the
// prompt parts it built once at run start, ensuring tracing observes the exact
// request content without repeating workspace I/O.
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
subs := buildPromptSubstrings(options, exposed)
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
}
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
systemPrompt := sha256hex(subs.systemPrompt)
base := sha256hex(subs.baseInstructions)
policy := sha256hex(subs.confirmationPolicy)
project := sha256hex(subs.projectContext)
@@ -264,9 +206,10 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
toolsH := sha256hex(subs.tools)
schema := sha256hex(subs.schema)
complete := sha256hex(strings.Join([]string{
base, policy, project, skills, toolsH, schema,
systemPrompt, toolsH, schema,
}, "|"))
return prefixFingerprint{
SystemPromptHash: systemPrompt,
BaseInstructionsHash: base,
ConfirmationPolicyHash: policy,
ProjectContextHash: project,
@@ -282,6 +225,7 @@ func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinit
// in loop.go) so the trace package does not need to import this one. The field
// names match the trace.PrefixHash JSON tags 1:1.
type prefixFingerprint struct {
SystemPromptHash string
BaseInstructionsHash string
ConfirmationPolicyHash string
ProjectContextHash string
+37 -31
View File
@@ -28,38 +28,28 @@ func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
}
}
// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the
// name-set sensitivity of the ToolsHash. The test name used to read as
// "reorder changes hash" but the assertion is the opposite: the hash is
// name-set sensitive, not name-order sensitive (toolSubstrings sorts
// names before hashing). The partitioner is expected to produce a stable
// order, so a name-order change is not a hash signal — a name-set change
// is. The test asserts both: permutation of the same tools produces the
// same hash, and adding a tool produces a different hash.
func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) {
// Tool order and descriptions are provider-visible and therefore part of the
// cacheable prefix identity.
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
a := []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
}
b := []zeroruntime.ToolDefinition{
{Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}},
{Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}},
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
}
// toolSubstrings sorts names internally, so the ToolsHash is identical
// for a permutation of the same tools. We assert that here to document
// the contract: name-order independence, name-set sensitivity.
fpA := ComputePrefixFingerprint(opts, a)
fpB := ComputePrefixFingerprint(opts, b)
if fpA.ToolsHash != fpB.ToolsHash {
t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
if fpA.ToolsHash == fpB.ToolsHash {
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
}
// A different tool set must produce a different ToolsHash.
c := append([]zeroruntime.ToolDefinition{}, a...)
c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}})
c := append([]zeroruntime.ToolDefinition(nil), a...)
c[0].Description = "read a file"
fpC := ComputePrefixFingerprint(opts, c)
if fpA.ToolsHash == fpC.ToolsHash {
t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
}
}
@@ -101,25 +91,35 @@ func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T)
}
}
// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the
// canonical-join property: CompletePrefixHash must depend on every sub-hash.
// This is the regression catch for "did anyone reorder or drop a sub-hash
// from the canonical join without updating CompletePrefixHash."
// CompletePrefixHash represents the exact joined system prompt and the ordered
// tool definitions. Narrower system-section hashes remain diagnostic only.
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
fp := ComputePrefixFingerprint(opts, exposed)
// Manually compute what the canonical join should be.
expected := sha256hex(strings.Join([]string{
fp.BaseInstructionsHash,
fp.ConfirmationPolicyHash,
fp.ProjectContextHash,
fp.SkillsHash,
fp.SystemPromptHash,
fp.ToolsHash,
fp.SchemaHash,
}, "|"))
if fp.CompletePrefixHash != expected {
t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
}
}
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
changed := base
changed.ResponseStyle = "concise"
fpBase := ComputePrefixFingerprint(base, nil)
fpChanged := ComputePrefixFingerprint(changed, nil)
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
t.Fatal("SystemPromptHash must change when any rendered system section changes")
}
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
t.Fatal("CompletePrefixHash must include the exact full system prompt")
}
}
@@ -141,6 +141,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
if subs.baseInstructions != "test core" {
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
}
if subs.systemPrompt != buildSystemPrompt(opts) {
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
}
if subs.confirmationPolicy == "" {
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
}
@@ -151,6 +154,9 @@ func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
// sha256hex of the substrings, with no truncation or reformatting
// between the two layers.
fp := ComputePrefixFingerprint(opts, nil)
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
}
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
}
+30 -5
View File
@@ -70,6 +70,22 @@ const (
// guidelines), and the safety confirmation policy. It is built once per run so
// every turn shares one (cacheable) system turn.
func buildSystemPrompt(options Options) string {
return buildSystemPromptParts(options).prompt
}
// systemPromptParts retains both the exact joined prompt and the diagnostic
// sections used by prompt-prefix tracing. Building these together prevents the
// tracing path from re-reading workspace state and guarantees that the hash is
// computed from the same prompt bytes placed in the request.
type systemPromptParts struct {
prompt string
baseInstructions string
confirmationPolicy string
projectContext string
skills string
}
func buildSystemPromptParts(options Options) systemPromptParts {
core := strings.TrimSpace(options.SystemPrompt)
if core == "" {
core = strings.TrimSpace(coreSystemPrompt)
@@ -97,22 +113,31 @@ func buildSystemPrompt(options Options) string {
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
project := workspaceContext(options.Cwd)
if project != "" {
sections = append(sections, project)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
skillsBlock := skillsContext(options)
if skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
policy := strings.TrimSpace(confirmationPolicy)
if policy != "" {
sections = append(sections, policy)
}
return strings.Join(sections, "\n\n")
return systemPromptParts{
prompt: strings.Join(sections, "\n\n"),
baseInstructions: core,
confirmationPolicy: policy,
projectContext: project,
skills: skillsBlock,
}
}
// responseStyleContext renders the operator-selected reply style (TUI /style) as
+3
View File
@@ -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
+359
View File
@@ -0,0 +1,359 @@
package agent
import (
"encoding/json"
"reflect"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type taskStatus string
const (
taskStatusActive taskStatus = "active"
taskStatusComplete taskStatus = "complete"
taskStatusIncomplete taskStatus = "incomplete"
)
type taskPlanParity string
const (
taskPlanParityUnknown taskPlanParity = "unknown"
taskPlanParityMatch taskPlanParity = "match"
taskPlanParityMismatch taskPlanParity = "mismatch"
)
type taskPlanItem struct {
Content string `json:"content"`
Status string `json:"status"`
Notes string `json:"notes,omitempty"`
}
type taskPlanState struct {
Items []taskPlanItem `json:"items,omitempty"`
Pending int `json:"pending"`
InProgress int `json:"in_progress"`
Completed int `json:"completed"`
Failed int `json:"failed"`
}
type taskToolState struct {
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
type taskVerificationState struct {
Passed int `json:"passed"`
Failed int `json:"failed"`
LastOutcome Outcome `json:"last_outcome,omitempty"`
}
type taskStateSnapshot struct {
Revision int `json:"revision"`
Objective string `json:"objective"`
Status taskStatus `json:"status"`
Plan taskPlanState `json:"plan"`
Tools taskToolState `json:"tools"`
Verification taskVerificationState `json:"verification"`
ChangedFiles []string `json:"changed_files,omitempty"`
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
CompletionReason string `json:"completion_reason,omitempty"`
PlanParity taskPlanParity `json:"plan_parity"`
}
type taskStateEventKind int
const (
taskStateEventPlan taskStateEventKind = iota + 1
taskStateEventToolResult
taskStateEventVerification
taskStateEventCompletion
)
type taskStateEvent struct {
kind taskStateEventKind
arguments string
toolResult ToolResult
verification Outcome
completion completionEvaluation
}
// taskState is a deterministic projection of facts the loop already observes.
// It does not initiate work or replace the transcript; consumers must check
// transcript parity before using its compact representation.
type taskState struct {
snapshotValue taskStateSnapshot
changedFiles map[string]struct{}
recorder *trace.Recorder
planObserved bool
}
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
state := &taskState{
snapshotValue: taskStateSnapshot{
Objective: strings.TrimSpace(objective),
Status: taskStatusActive,
PlanParity: taskPlanParityUnknown,
},
changedFiles: map[string]struct{}{},
}
state.recorder = recorder
return state
}
func (state *taskState) observe(event taskStateEvent) bool {
if state == nil {
return false
}
changed := false
switch event.kind {
case taskStateEventPlan:
plan, ok := parseTaskPlan(event.arguments)
if !ok {
return false
}
state.snapshotValue.Plan = summarizeTaskPlan(plan)
state.planObserved = true
state.markActive()
changed = true
case taskStateEventToolResult:
state.markActive()
if event.toolResult.Status == tools.StatusOK {
state.snapshotValue.Tools.Succeeded++
} else {
state.snapshotValue.Tools.Failed++
}
for _, path := range event.toolResult.ChangedFiles {
path = strings.TrimSpace(path)
if path != "" {
state.changedFiles[path] = struct{}{}
}
}
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
changed = true
case taskStateEventVerification:
if event.verification == OutcomeDisabled {
return false
}
state.markActive()
state.snapshotValue.Verification.LastOutcome = event.verification
switch event.verification {
case OutcomePassed:
state.snapshotValue.Verification.Passed++
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
state.snapshotValue.Verification.Failed++
}
changed = true
case taskStateEventCompletion:
state.snapshotValue.CompletionDecision = event.completion.Decision
state.snapshotValue.CompletionReason = event.completion.Reason
switch event.completion.Decision {
case CompletionComplete:
state.snapshotValue.Status = taskStatusComplete
case CompletionIncomplete:
state.snapshotValue.Status = taskStatusIncomplete
default:
state.snapshotValue.Status = taskStatusActive
}
changed = true
}
if changed {
state.snapshotValue.Revision++
if event.kind != taskStateEventToolResult {
state.emit()
}
}
return changed
}
func (state *taskState) markActive() {
state.snapshotValue.Status = taskStatusActive
state.snapshotValue.CompletionDecision = ""
state.snapshotValue.CompletionReason = ""
}
func (state *taskState) snapshot() taskStateSnapshot {
if state == nil {
return taskStateSnapshot{}
}
snapshot := state.snapshotValue
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
return snapshot
}
// observePlanParity compares only the latest plan projection with the plan tool
// calls still present in messages. It mutates the snapshot and emits when the
// parity value changes; objective, tool, and verification fields are not part of
// this comparison.
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
if state == nil {
return taskPlanParityUnknown
}
transcriptPlan, found, valid := latestTaskPlan(messages)
tracked := state.snapshotValue.Plan.Items
parity := taskPlanParityMatch
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
parity = taskPlanParityMismatch
}
if state.snapshotValue.PlanParity != parity {
state.snapshotValue.PlanParity = parity
state.snapshotValue.Revision++
state.emit()
}
return parity
}
type completionContext struct {
Objective string
PlanPending bool
PlanMatchesTranscript bool
}
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
context := completionContext{PlanPending: transcriptPlanPending}
if state == nil {
return context
}
context.Objective = state.snapshotValue.Objective
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
if context.PlanMatchesTranscript {
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
}
return context
}
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
if state == nil {
return nil
}
state.observePlanParity(messages)
snapshot := state.snapshot()
return &snapshot
}
func (state *taskState) emit() {
if state == nil || state.recorder == nil {
return
}
snapshot := state.snapshotValue
state.recorder.EmitTaskState(trace.TaskStateEvent{
Revision: snapshot.Revision,
Status: string(snapshot.Status),
PlanPending: snapshot.Plan.Pending,
PlanInProgress: snapshot.Plan.InProgress,
PlanCompleted: snapshot.Plan.Completed,
PlanFailed: snapshot.Plan.Failed,
ToolsSucceeded: snapshot.Tools.Succeeded,
ToolsFailed: snapshot.Tools.Failed,
VerificationPassed: snapshot.Verification.Passed,
VerificationFailed: snapshot.Verification.Failed,
VerificationOutcome: string(snapshot.Verification.LastOutcome),
ChangedFileCount: len(snapshot.ChangedFiles),
CompletionDecision: string(snapshot.CompletionDecision),
PlanParity: string(snapshot.PlanParity),
})
}
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
var object map[string]json.RawMessage
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
return nil, false
}
rawPlan, ok := object["plan"]
if !ok || string(rawPlan) == "null" {
return nil, false
}
var parsed []struct {
ID *string `json:"id"`
Content string `json:"content"`
Status string `json:"status"`
Notes string `json:"notes"`
}
if json.Unmarshal(rawPlan, &parsed) != nil {
return nil, false
}
plan := make([]taskPlanItem, 0, len(parsed))
for _, raw := range parsed {
if raw.Content == "" {
return nil, false
}
plan = append(plan, taskPlanItem{
Content: raw.Content,
Status: normalizeTaskPlanStatus(raw.Status),
Notes: raw.Notes,
})
}
lastInProgress := -1
for index := range plan {
if plan[index].Status == "in_progress" {
if lastInProgress >= 0 {
plan[lastInProgress].Status = "completed"
}
lastInProgress = index
}
}
if len(plan) == 0 {
return nil, true
}
return plan, true
}
func normalizeTaskPlanStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
return "completed"
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
return "in_progress"
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
return "failed"
default:
return "pending"
}
}
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
for _, item := range items {
switch item.Status {
case "completed":
plan.Completed++
case "in_progress":
plan.InProgress++
case "failed":
plan.Failed++
default:
plan.Pending++
}
}
return plan
}
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
for i := len(messages) - 1; i >= 0; i-- {
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
call := messages[i].ToolCalls[j]
if call.Name != planToolName {
continue
}
plan, ok := parseTaskPlan(call.Arguments)
return plan, true, ok
}
}
return nil, false, true
}
func sortedKeys(values map[string]struct{}) []string {
keys := make([]string, 0, len(values))
for value := range values {
keys = append(keys, value)
}
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
// deterministic even if events originate from parallel read batches later.
sort.Strings(keys)
return keys
}
+204
View File
@@ -0,0 +1,204 @@
package agent
import (
"context"
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestTaskStateReplayIsDeterministic(t *testing.T) {
events := []taskStateEvent{
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
{kind: taskStateEventVerification, verification: OutcomePassed},
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
}
first := newTaskState("ship the change", nil)
second := newTaskState("ship the change", nil)
for _, event := range events {
first.observe(event)
second.observe(event)
}
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
}
got := first.snapshot()
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
t.Fatalf("unexpected plan snapshot: %#v", got)
}
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
t.Fatalf("unexpected evidence snapshot: %#v", got)
}
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
}
}
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
recorder := trace.NewRecorder("session", "run", "")
state := newTaskState("objective", recorder)
for range 20 {
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
}
if events := recorder.Finish().TaskStates; len(events) != 0 {
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
}
recorder = trace.NewRecorder("session", "run-2", "")
state = newTaskState("objective", recorder)
for range 20 {
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
}
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
events := recorder.Finish().TaskStates
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
}
}
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("session", "run", "")
result, err := Run(context.Background(), "implement the change", provider, Options{
Registry: registry,
Trace: recorder,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
events := recorder.Finish().TaskStates
if len(events) < 3 {
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
}
last := events[len(events)-1]
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
t.Fatalf("unexpected final task snapshot: %#v", last)
}
}
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
snapshot := state.snapshot()
snapshot.Plan.Items[0].Content = "mutated"
snapshot.ChangedFiles[0] = "mutated.go"
fresh := state.snapshot()
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
}
}
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
}}}
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
t.Fatalf("parity = %q, want match", parity)
}
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
t.Fatalf("parity = %q, want mismatch", parity)
}
}
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
state := newTaskState("objective", nil)
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
snapshot := state.snapshot()
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
}
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
}
empty := newTaskState("objective", nil)
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
}
}
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
}}}
context := state.completionContext(messages, true)
if !context.PlanPending || context.PlanMatchesTranscript {
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
}
if context.Objective != "objective" {
t.Fatalf("objective lost from completion context: %#v", context)
}
}
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
state := newTaskState("objective", nil)
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
}}}
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
}
mismatching := append([]zeroruntime.Message(nil), matching...)
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
}
}
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
for _, arguments := range []string{
`{}`,
`{"plan":null}`,
`{"plan":[{"step":"alias is not accepted"}]}`,
`{"plan":[{"content":""}]}`,
`{"plan":[{"content":"valid"},{"content":""}]}`,
`{"plan":[{"id":4,"content":"valid"}]}`,
} {
if plan, ok := parseTaskPlan(arguments); ok {
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
}
}
}
+5 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -71,7 +72,10 @@ type ToolResult struct {
Meta map[string]string
Redacted bool
ChangedFiles []string
Display tools.Display
// ChangeSummaries are non-selectable generated-tree summaries emitted by
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
+4 -74
View File
@@ -3,11 +3,10 @@
package background
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// terminationGracePeriod is how long a process has to exit after SIGTERM before
@@ -22,10 +21,7 @@ var (
// the negative PID (the group), so any process the child forks dies with it
// instead of being orphaned. Must be called before cmd.Start.
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(cmd)
}
// terminateProcess stops a background process. It first asks politely with
@@ -41,71 +37,5 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
// back to signalling only the individual PID, which also avoids reporting a false
// success when a non-leader group-signal returns ESRCH.
func terminateProcess(pid int) error {
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
// every process we can signal. A real child PID is always > 1, so never let a
// bogus 0/1 expand into either.
if pid <= 1 {
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
}
// Signal the whole group only when pid leads its own group; otherwise the
// individual process. See the doc comment for why a non-leader is signalled
// directly rather than via its (foreign) group.
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if processGoneError(err) {
return nil // already gone
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if processGoneError(err) {
return nil
}
return err
}
// Poll liveness so we return promptly once it exits, rather than always
// waiting out the full grace period.
deadline := time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if !alive() {
return nil
}
// Still alive after the grace period: force-kill.
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
return err
}
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
// so this helper only reports success once the target is actually gone —
// otherwise the caller gets nil while descendants are still racing.
deadline = time.Now().Add(terminationGracePeriod)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(terminationPollInterval)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
// processGoneError reports whether an error means the process group has already
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
// no process in the target group remains.
func processGoneError(err error) bool {
return errors.Is(err, syscall.ESRCH)
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
+6 -26
View File
@@ -3,36 +3,16 @@
package background
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"github.com/Gitlawb/zero/internal/execution"
)
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
func terminateProcess(pid int) error {
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
return execution.TerminateProcessTree(pid, 0, 0)
}
+25 -15
View File
@@ -17,6 +17,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
@@ -678,6 +679,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
executionRunner := execution.NewRunner(nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
if notice, err := sandboxStore.ConsumeMigrationNotice(); err != nil {
return writeAppError(stderr, "failed to migrate sandbox grants: "+err.Error(), 1)
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
executionRunner.SetPreparer(sandboxEngine)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
@@ -712,6 +733,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
})
}
if err != nil {
@@ -738,7 +761,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -760,19 +783,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// unchanged. The interactive surface applies no operator tool filters, so
// enabled/disabled are nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: sandboxBackend,
Scope: scope,
SensitiveEnvKeys: providerSensitiveEnvKeys(resolved),
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
@@ -789,7 +799,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
+29 -29
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -206,6 +207,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
registry := newCoreRegistry(workspaceRoot)
executionRunner := execution.NewRunner(nil)
// Register the escalate_model tool only when the run opted into mid-run
// escalation. It is registered before --list-tools formatting and
// validateExecToolFilters so the tool is both listable and filter-validatable.
@@ -238,16 +240,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
var mcpRuntime mcpToolRuntime
var mcpSkip trustSkip
// Make local plugins live for this run: register their declared tools into the
// registry and collect their hooks + skill roots for the dispatcher and skill
// tool below. Done before --list-tools and filter validation so plugin tools
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
var pluginActivation pluginActivation
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
@@ -290,6 +289,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
if notice, err := sandboxEngine.ConsumeGrantMigrationNotice(); err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", "migrate sandbox grants: "+err.Error())
} else if notice != "" {
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
}
executionRunner.SetPreparer(sandboxEngine)
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
defer closeMCPRuntime(stderr, mcpRuntime)
pluginActivation = activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot, executionRunner)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
@@ -357,28 +379,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
images = nil
}
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
// Re-register the core tools with the run scope, OVERWRITING the nil-scope
// instances registered before config resolve (the registry must exist that
// early for --list-tools and tool-filter validation, which run without a
// provider). This is safe only while two invariants hold:
// 1. Registry.Register replaces by NAME, so every path-confining core
// tool is swapped wholesale; and
// 2. nothing between the initial registration and this point captures a
// core-tool INSTANCE (tool_search holds the *Registry* and resolves
// names lazily; --list-tools and filter validation use names only).
// A new wrapper that snapshots a core tool before this line would silently
// ship nil-scope enforcement — add it below this re-registration instead.
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
runReasoningEffort := options.reasoningEffort
if options.useSpec && options.specReasoningEffort != "" {
runReasoningEffort = options.specReasoningEffort
@@ -624,7 +624,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// Build the hooks dispatcher out of the struct literal so its trust skip report
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
+4 -1
View File
@@ -72,8 +72,11 @@ func preflightExecSession(options execOptions) error {
if session == nil {
return execUsageError{"Zero session not found: " + options.resume}
}
if !sessions.IsResumableKind(session.SessionKind) {
return execUsageError{"Zero session is not resumable: " + options.resume}
}
case options.resumeLatest:
latest, err := store.Latest()
latest, err := store.LatestResumable()
if err != nil {
return err
}
+26
View File
@@ -0,0 +1,26 @@
package cli
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestPreflightRejectsSideSessions(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
store := sessions.NewStore(sessions.StoreOptions{})
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide}); err != nil {
t.Fatalf("create side session: %v", err)
}
err := preflightExecSession(execOptions{resumeLatest: true})
if err == nil || !strings.Contains(err.Error(), "No Zero sessions available to resume") {
t.Fatalf("preflightExecSession error = %v, want no resumable sessions", err)
}
err = preflightExecSession(execOptions{resume: "side"})
if err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: side") {
t.Fatalf("explicit side-session preflight error = %v, want non-resumable rejection", err)
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -114,7 +115,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
// repo trusted. Emit at most one notice when project hooks or the project MCP
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
+12 -6
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
@@ -56,8 +57,8 @@ func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool
//
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment); the project layer loads only when that root is trusted.
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
func newHookDispatcher(workspaceRoot string, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot, runners...)
}
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
@@ -72,7 +73,7 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
// returned trustSkip lets the caller emit one combined notice; the notice itself
// is NOT emitted here.
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string, runners ...*execution.Runner) (*hooks.Dispatcher, trustSkip) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
@@ -106,10 +107,15 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
}
config.Hooks = merged
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Config: config,
Audit: audit,
Cwd: workspaceRoot,
Execution: runner,
}), skip
}
+8 -1
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -29,7 +30,7 @@ type mcpToolListItem struct {
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string, runners ...*execution.Runner) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
@@ -46,9 +47,15 @@ func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, reg
if err != nil {
return nil, skip, err
}
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
Execution: runner,
WorkspaceRoot: workspaceRoot,
})
return runtime, skip, err
}
+7 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -42,7 +43,7 @@ type pluginActivation struct {
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment). The returned activation carries the skip report so the caller can
// emit one combined notice; the notice itself is NOT emitted here.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string, runners ...*execution.Runner) pluginActivation {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
@@ -65,7 +66,11 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
writePluginActivationWarning(stderr, formatLoadDiagnostic(diagnostic))
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot})
var runner *execution.Runner
if len(runners) > 0 {
runner = runners[0]
}
result := plugins.Activate(registry, loaded.Plugins, plugins.ActivateOptions{Cwd: workspaceRoot, Execution: runner})
for _, warning := range result.Warnings {
writePluginActivationWarning(stderr, warning)
}
+159
View File
@@ -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
}
+105
View File
@@ -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
+3 -3
View File
@@ -216,10 +216,10 @@ func parseNonEmptySessionsFlag(flag string, value string) (string, error) {
func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
kind := sessions.SessionKind(strings.ToLower(strings.TrimSpace(value)))
switch kind {
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
case sessions.SessionKindFork, sessions.SessionKindChild, sessions.SessionKindSide, sessions.SessionKindSpecDraft, sessions.SessionKindSpecImpl:
return kind, nil
default:
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, spec-draft, or spec-impl.", value)}
return "", execUsageError{fmt.Sprintf("invalid --kind %q. Expected fork, child, side, spec-draft, or spec-impl.", value)}
}
}
@@ -535,7 +535,7 @@ Commands:
Flags:
--json Print JSON output
--kind <kind> Filter list by fork, child, spec-draft, or spec-impl
--kind <kind> Filter list by fork, child, side, spec-draft, or spec-impl
--sequence <n> Rewind target sequence (rewind-plan, rewind)
--event <id> Rewind target event id (rewind-plan, rewind)
--exclude-target Drop the target event (rewind-plan, rewind)
+14
View File
@@ -233,6 +233,9 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
if _, err := store.Create(sessions.CreateInput{SessionID: "side", SessionKind: sessions.SessionKindSide, Title: "BTW side"}); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -249,6 +252,17 @@ func TestRunSessionsListFiltersByKind(t *testing.T) {
t.Fatalf("filtered sessions output = %q", output)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sessions", "list", "--kind", "side"}, &stdout, &stderr, appDeps{
newSessionStore: func() *sessions.Store {
return store
},
})
if exitCode != exitSuccess || !strings.Contains(stdout.String(), "side") || strings.Contains(stdout.String(), "regular") {
t.Fatalf("sessions list --kind side exit=%d stdout=%q stderr=%q", exitCode, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sessions", "tree", "draft", "--kind", "spec-draft"}, &stdout, &stderr, appDeps{
+27
View File
@@ -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
+214
View File
@@ -0,0 +1,214 @@
package execution
import (
"crypto/sha256"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
maxObservedFiles = 20_000
maxHashedFileBytes = 1 << 20
maxTotalHashedBytes = 32 << 20
)
type ChangeKind string
const (
ChangeCreated ChangeKind = "created"
ChangeModified ChangeKind = "modified"
ChangeDeleted ChangeKind = "deleted"
)
type Change struct {
Path string `json:"path"`
Kind ChangeKind `json:"kind"`
Aggregated bool `json:"aggregated,omitempty"`
Count int `json:"count,omitempty"`
}
type fileFingerprint struct {
Mode fs.FileMode
Size int64
ModTime int64
Digest [sha256.Size]byte
Hashed bool
// Aggregated marks a bounded fingerprint for a generated directory. The
// observer never enumerates that tree into individual changes.
Aggregated bool
Count int
}
// ChangeObserver records a bounded workspace snapshot around a command. It
// deliberately skips Zero/repository control metadata and large generated
// dependency trees; those paths must neither be read as command evidence nor
// flood the Files panel.
type ChangeObserver struct {
root string
before map[string]fileFingerprint
valid bool
}
func NewChangeObserver(root string) *ChangeObserver {
root = filepath.Clean(strings.TrimSpace(root))
before, ok := snapshotWorkspace(root)
return &ChangeObserver{root: root, before: before, valid: ok}
}
func (observer *ChangeObserver) Changes() []Change {
if observer == nil || !observer.valid {
return nil
}
after, ok := snapshotWorkspace(observer.root)
if !ok {
return nil
}
changes := make([]Change, 0)
for path, current := range after {
previous, existed := observer.before[path]
if !existed {
changes = append(changes, changeFromFingerprint(path, ChangeCreated, current))
continue
}
if previous != current {
changes = append(changes, changeFromFingerprint(path, ChangeModified, current))
}
}
for path, previous := range observer.before {
if _, exists := after[path]; !exists {
changes = append(changes, changeFromFingerprint(path, ChangeDeleted, previous))
}
}
sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path })
return changes
}
func snapshotWorkspace(root string) (map[string]fileFingerprint, bool) {
if root == "" || root == "." {
return nil, false
}
files := make(map[string]fileFingerprint)
hashedBytes := int64(0)
ok := true
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if path == root {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil
}
if entry.IsDir() {
if protectedObservationDirectory(entry.Name()) {
return filepath.SkipDir
}
if generatedObservationDirectory(entry.Name()) {
files[filepath.ToSlash(relative)+"/"] = generatedTreeFingerprint(path)
return filepath.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
if len(files) >= maxObservedFiles {
ok = false
return fs.SkipAll
}
info, err := entry.Info()
if err != nil {
return nil
}
fingerprint := fileFingerprint{Mode: info.Mode(), Size: info.Size(), ModTime: info.ModTime().UnixNano()}
if info.Size() <= maxHashedFileBytes && hashedBytes+info.Size() <= maxTotalHashedBytes {
if digest, err := hashObservedFile(path); err == nil {
fingerprint.Digest = digest
fingerprint.Hashed = true
hashedBytes += info.Size()
}
}
files[filepath.ToSlash(relative)] = fingerprint
return nil
})
if err != nil {
return nil, false
}
return files, ok
}
func protectedObservationDirectory(name string) bool {
switch name {
case ".git", ".zero", ".agents":
return true
default:
return false
}
}
func generatedObservationDirectory(name string) bool {
switch name {
case "node_modules", ".pnpm-store", ".yarn", "dist", "coverage", ".cache":
return true
default:
return false
}
}
// generatedTreeFingerprint samples only direct children and caps the work. It
// is intentionally qualitative: the UI needs to say that a generated tree
// changed, not inventory dependency contents.
func generatedTreeFingerprint(root string) fileFingerprint {
directory, err := os.Open(root)
if err != nil {
return fileFingerprint{Aggregated: true, Count: -1}
}
defer directory.Close()
const maxEntries = 256
entries, err := directory.ReadDir(maxEntries + 1)
if err != nil && err != io.EOF {
return fileFingerprint{Aggregated: true, Count: -1}
}
hash := sha256.New()
count := len(entries)
if count > maxEntries {
entries = entries[:maxEntries]
count = -1
}
for _, entry := range entries {
_, _ = io.WriteString(hash, entry.Name())
if info, infoErr := entry.Info(); infoErr == nil {
_, _ = io.WriteString(hash, info.Mode().String())
_, _ = io.WriteString(hash, strconv.FormatInt(info.ModTime().UnixNano(), 10))
}
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return fileFingerprint{Digest: digest, Hashed: true, Aggregated: true, Count: count}
}
func changeFromFingerprint(path string, kind ChangeKind, fingerprint fileFingerprint) Change {
return Change{Path: path, Kind: kind, Aggregated: fingerprint.Aggregated, Count: fingerprint.Count}
}
func hashObservedFile(path string) ([sha256.Size]byte, error) {
file, err := os.Open(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -0,0 +1,67 @@
package execution
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestChangeObserverReportsCreateModifyAndDelete(t *testing.T) {
root := t.TempDir()
modified := filepath.Join(root, "modified.txt")
deleted := filepath.Join(root, "deleted.txt")
if err := os.WriteFile(modified, []byte("before"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(deleted, []byte("delete"), 0o644); err != nil {
t.Fatal(err)
}
observer := NewChangeObserver(root)
if err := os.WriteFile(modified, []byte("after!"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(deleted); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "src", "created.ts"), []byte("export {}"), 0o644); err != nil {
t.Fatal(err)
}
want := []Change{
{Path: "deleted.txt", Kind: ChangeDeleted},
{Path: "modified.txt", Kind: ChangeModified},
{Path: "src/created.ts", Kind: ChangeCreated},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want %#v", got, want)
}
}
func TestChangeObserverSkipsControlMetadataAndSummarizesGeneratedTrees(t *testing.T) {
root := t.TempDir()
observer := NewChangeObserver(root)
for _, path := range []string{
filepath.Join(root, ".zero", "config.json"),
filepath.Join(root, ".agents", "skills", "x"),
filepath.Join(root, ".git", "config"),
filepath.Join(root, "node_modules", "pkg", "index.js"),
filepath.Join(root, "dist", "bundle.js"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
t.Fatal(err)
}
}
want := []Change{
{Path: "dist/", Kind: ChangeCreated, Aggregated: true, Count: 1},
{Path: "node_modules/", Kind: ChangeCreated, Aggregated: true, Count: 1},
}
if got := observer.Changes(); !reflect.DeepEqual(got, want) {
t.Fatalf("Changes() = %#v, want generated summaries without control metadata %#v", got, want)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Package execution defines the platform-neutral command execution contract.
// Frontends and tools use these types without depending on native sandbox or
// process-management details.
package execution
import (
"errors"
"fmt"
"strings"
)
const PolicyVersion = 1
type Origin string
const (
OriginInteractiveCommand Origin = "interactive_command"
OriginHook Origin = "hook"
OriginPlugin Origin = "plugin"
OriginMCPServer Origin = "mcp_server"
OriginBackgroundTask Origin = "background_task"
)
type Mode string
const (
ModeCaptured Mode = "captured"
ModeInteractive Mode = "interactive"
ModeDurable Mode = "durable"
)
type Command struct {
Name string `json:"name"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
}
type CapabilityKind string
const (
CapabilityRead CapabilityKind = "filesystem_read"
CapabilityWorkspaceWrite CapabilityKind = "workspace_write"
CapabilityProtectedMetadata CapabilityKind = "protected_metadata"
CapabilityExternalNetwork CapabilityKind = "external_network"
CapabilityIsolatedLoopback CapabilityKind = "isolated_loopback"
CapabilityHostVisibleBinding CapabilityKind = "host_visible_binding"
CapabilityProcessSpawn CapabilityKind = "process_spawn"
CapabilityProcessControl CapabilityKind = "process_control"
CapabilityEnvironment CapabilityKind = "environment"
CapabilityUnrestricted CapabilityKind = "unrestricted"
)
type Capability struct {
Kind CapabilityKind `json:"kind"`
Scope string `json:"scope,omitempty"`
}
type ApprovalContext struct {
SessionID string `json:"sessionId,omitempty"`
PolicyVersion int `json:"policyVersion"`
}
type Request struct {
Origin Origin `json:"origin"`
Mode Mode `json:"mode"`
Command Command `json:"command"`
WorkingDirectory string `json:"workingDirectory"`
WorkspaceRoots []string `json:"workspaceRoots"`
Capabilities []Capability `json:"capabilities,omitempty"`
Approval ApprovalContext `json:"approval"`
}
func (request Request) Validate() error {
if !validOrigin(request.Origin) {
return errors.New("execution request requires a valid origin")
}
if request.Mode != ModeCaptured && request.Mode != ModeInteractive && request.Mode != ModeDurable {
return errors.New("execution request requires a valid mode")
}
if strings.TrimSpace(request.Command.Name) == "" {
return errors.New("execution request requires a command")
}
if strings.TrimSpace(request.WorkingDirectory) == "" {
return errors.New("execution request requires a working directory")
}
if len(request.WorkspaceRoots) == 0 {
return errors.New("execution request requires at least one workspace root")
}
for _, root := range request.WorkspaceRoots {
if strings.TrimSpace(root) == "" {
return errors.New("execution request contains an empty workspace root")
}
}
for _, capability := range request.Capabilities {
if !validCapabilityKind(capability.Kind) {
return fmt.Errorf("execution request contains invalid capability %q", capability.Kind)
}
}
return nil
}
type State string
const (
StateRequested State = "requested"
StateEvaluated State = "evaluated"
StateAwaitingApproval State = "awaiting_approval"
StatePrepared State = "prepared"
StateRunning State = "running"
StateRetained State = "retained"
StateCompleted State = "completed"
StateDenied State = "denied"
StateFailed State = "failed"
StateCancelled State = "cancelled"
)
type OutcomeKind string
const (
OutcomeRunning OutcomeKind = "running"
OutcomeSuccess OutcomeKind = "success"
OutcomePolicyDenied OutcomeKind = "policy_denied"
OutcomeEnforcementDenied OutcomeKind = "enforcement_denied"
OutcomeApplicationFailure OutcomeKind = "application_failure"
OutcomeSandboxSetupFailure OutcomeKind = "sandbox_setup_failure"
OutcomeExecutableNotFound OutcomeKind = "executable_not_found"
OutcomeCancelled OutcomeKind = "cancelled"
OutcomeTimedOut OutcomeKind = "timed_out"
OutcomeEnforcementDegraded OutcomeKind = "enforcement_degraded"
)
type Exit struct {
Code int `json:"code"`
Signal string `json:"signal,omitempty"`
}
type DenialSource string
const (
DenialSourceConfiguredPolicy DenialSource = "configured_policy"
DenialSourcePlatformSandbox DenialSource = "platform_sandbox"
DenialSourceApproval DenialSource = "approval"
)
type DenialNextAction string
const (
DenialNextActionNone DenialNextAction = "none"
DenialNextActionRequestApproval DenialNextAction = "request_approval"
DenialNextActionChangeCommand DenialNextAction = "change_command"
DenialNextActionEnableSandbox DenialNextAction = "enable_sandbox"
)
type Denial struct {
Capability Capability `json:"capability"`
Source DenialSource `json:"source"`
Reason string `json:"reason"`
Recoverable bool `json:"recoverable"`
NextAction DenialNextAction `json:"nextAction"`
}
type Enforcement struct {
Backend string `json:"backend,omitempty"`
Level string `json:"level,omitempty"`
Degraded bool `json:"degraded,omitempty"`
DowngradeReason string `json:"downgradeReason,omitempty"`
}
type Outcome struct {
State State `json:"state"`
Kind OutcomeKind `json:"kind"`
ProcessID string `json:"processId,omitempty"`
Exit *Exit `json:"exit,omitempty"`
Denial *Denial `json:"denial,omitempty"`
Enforcement Enforcement `json:"enforcement"`
Changes []Change `json:"changes,omitempty"`
}
// AdapterReport is the structured, machine-readable result emitted by a
// platform enforcement adapter. It is separate from stdout and stderr so
// command text cannot impersonate a policy decision.
type AdapterReport struct {
Denial *Denial `json:"denial,omitempty"`
}
func (outcome Outcome) Validate() error {
if outcome.State == "" || outcome.Kind == "" {
return errors.New("execution outcome requires state and kind")
}
switch outcome.State {
case StateRetained, StateRunning:
if outcome.Kind != OutcomeRunning || strings.TrimSpace(outcome.ProcessID) == "" {
return errors.New("running execution outcome requires a process id")
}
case StateCompleted:
if outcome.Kind != OutcomeSuccess || outcome.Exit == nil || outcome.Exit.Code != 0 {
return errors.New("completed execution outcome requires a successful exit")
}
case StateDenied:
if (outcome.Kind != OutcomePolicyDenied && outcome.Kind != OutcomeEnforcementDenied) || outcome.Denial == nil {
return errors.New("denied execution outcome requires structured denial details")
}
if err := outcome.Denial.Validate(); err != nil {
return err
}
case StateFailed:
if outcome.Kind == OutcomeRunning || outcome.Kind == OutcomeSuccess || outcome.Exit == nil {
return errors.New("failed execution outcome requires a failure kind and exit")
}
case StateCancelled:
if outcome.Kind != OutcomeCancelled {
return errors.New("cancelled execution outcome requires cancelled kind")
}
default:
return fmt.Errorf("execution outcome state %q is not terminal or externally observable", outcome.State)
}
return nil
}
func (denial Denial) Validate() error {
if !validCapabilityKind(denial.Capability.Kind) {
return errors.New("execution denial requires a valid capability")
}
if denial.Source == "" || strings.TrimSpace(denial.Reason) == "" || denial.NextAction == "" {
return errors.New("execution denial requires source, reason, and next action")
}
return nil
}
func validOrigin(origin Origin) bool {
switch origin {
case OriginInteractiveCommand, OriginHook, OriginPlugin, OriginMCPServer, OriginBackgroundTask:
return true
default:
return false
}
}
func validCapabilityKind(kind CapabilityKind) bool {
switch kind {
case CapabilityRead, CapabilityWorkspaceWrite, CapabilityProtectedMetadata,
CapabilityExternalNetwork, CapabilityIsolatedLoopback, CapabilityHostVisibleBinding,
CapabilityProcessSpawn, CapabilityProcessControl, CapabilityEnvironment, CapabilityUnrestricted:
return true
default:
return false
}
}
+78
View File
@@ -0,0 +1,78 @@
package execution
import "testing"
func TestRequestValidationRequiresConcreteExecutionContext(t *testing.T) {
request := Request{
Origin: OriginInteractiveCommand,
Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "true"}},
WorkingDirectory: "/workspace",
WorkspaceRoots: []string{"/workspace"},
Capabilities: []Capability{{
Kind: CapabilityWorkspaceWrite,
Scope: "/workspace",
}},
}
if err := request.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
request.Origin = ""
if err := request.Validate(); err == nil {
t.Fatal("request without origin unexpectedly validated")
}
}
func TestOutcomeValidationPinsTerminalStateContracts(t *testing.T) {
tests := []struct {
name string
outcome Outcome
valid bool
}{
{
name: "retained process",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning, ProcessID: "1000"},
valid: true,
},
{
name: "successful completion",
outcome: Outcome{State: StateCompleted, Kind: OutcomeSuccess, Exit: &Exit{Code: 0}},
valid: true,
},
{
name: "structured denial",
outcome: Outcome{
State: StateDenied,
Kind: OutcomePolicyDenied,
Denial: &Denial{
Capability: Capability{Kind: CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
Source: DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created",
NextAction: DenialNextActionRequestApproval,
},
},
valid: true,
},
{
name: "retained without process id",
outcome: Outcome{State: StateRetained, Kind: OutcomeRunning},
},
{
name: "denied without denial",
outcome: Outcome{State: StateDenied, Kind: OutcomePolicyDenied},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.outcome.Validate()
if test.valid && err != nil {
t.Fatalf("Validate: %v", err)
}
if !test.valid && err == nil {
t.Fatal("invalid outcome unexpectedly validated")
}
})
}
}
+645
View File
@@ -0,0 +1,645 @@
package execution
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"sort"
"sync"
"time"
)
const (
defaultCompletedRetention = 30 * time.Second
defaultMaxProcesses = 64
maxPendingOutputBytes = 2 * 1024 * 1024
recentOutputBytes = 4096
processStopTimeout = 3 * time.Second
maxInteractiveYield = 30 * time.Second
maxEmptyPollYield = 5 * time.Minute
)
var (
ErrProcessNotFound = errors.New("execution process not found")
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
)
type ProcessManagerOptions struct {
CompletedRetention time.Duration
MaxProcesses int
}
// ProcessManager owns retained interactive-process identity, transport,
// bounded output, continuation, cancellation, completion, and cleanup.
type ProcessManager struct {
mu sync.Mutex
nextID int
processes map[int]*managedProcess
completedRetention time.Duration
maxProcesses int
startTransport processTransportStarter
}
type ProcessStart struct {
Prepared PreparedCommand
Request Request
CommandText string
RelativeCwd string
TTY bool
Metadata map[string]string
// AfterWait may append adapter diagnostics collected by a platform monitor.
AfterWait func() []byte
}
type ProcessContinue struct {
ProcessID int
Input []byte
Interrupt bool
Wait time.Duration
}
type ProcessResult struct {
ProcessID int
CommandText string
RelativeCwd string
TTY bool
Output string
OutputTruncated bool
Exited bool
ExitCode int
Interrupted bool
Request Request
Enforcement Enforcement
Report AdapterReport
ReportErr error
Changes []Change
Metadata map[string]string
}
type ProcessSnapshot struct {
ID int
Command string
Cwd string
RelativeCwd string
StartedAt time.Time
LastUsedAt time.Time
TTY bool
Status string
ExitCode *int
RecentOutput string
OutputTruncated bool
}
func NewProcessManager(options ProcessManagerOptions) *ProcessManager {
retention := options.CompletedRetention
if retention == 0 {
retention = defaultCompletedRetention
}
maxProcesses := options.MaxProcesses
if maxProcesses == 0 {
maxProcesses = defaultMaxProcesses
}
return &ProcessManager{
nextID: 1000,
processes: make(map[int]*managedProcess),
completedRetention: retention,
maxProcesses: maxProcesses,
startTransport: startProcessTransport,
}
}
func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wait time.Duration) (ProcessResult, error) {
if manager == nil {
return ProcessResult{}, errors.New("execution process manager is not configured")
}
if err := input.Request.Validate(); err != nil {
return ProcessResult{}, fmt.Errorf("invalid execution request: %w", err)
}
if input.Prepared.Command == nil {
return ProcessResult{}, errors.New("prepared execution has no command")
}
command := input.Prepared.Command
buffer := newProcessOutputBuffer()
request := input.Request
observer := NewChangeObserver(request.WorkspaceRoots[0])
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
if err != nil {
if input.Prepared.Cleanup != nil {
input.Prepared.Cleanup()
}
return ProcessResult{}, err
}
if tty {
request.Mode = ModeInteractive
} else {
request.Mode = ModeCaptured
}
now := time.Now()
process := &managedProcess{
id: manager.allocateID(),
commandText: input.CommandText,
cwd: request.WorkingDirectory,
relativeCwd: input.RelativeCwd,
startedAt: now,
lastUsedAt: now,
tty: tty,
command: command,
request: request,
enforcement: input.Prepared.Enforcement,
report: input.Prepared.Report,
cleanup: input.Prepared.Cleanup,
stdin: stdin,
output: buffer,
reaped: make(chan struct{}),
done: make(chan struct{}),
kill: KillProcessTree,
metadata: cloneStringMap(input.Metadata),
}
manager.store(process)
manager.removeCompletedLater(process)
go func() {
waitErr := command.Wait()
close(process.reaped)
if input.AfterWait != nil {
if diagnostic := input.AfterWait(); len(diagnostic) > 0 {
_, _ = buffer.Write(diagnostic)
}
}
if transportCleanup != nil {
transportCleanup()
}
adapterReport, reportErr := AdapterReport{}, error(nil)
if process.report != nil {
adapterReport, reportErr = process.report()
}
changes := observer.Changes()
if process.cleanup != nil {
process.cleanup()
}
process.markDone(waitErr, commandExitCode(waitErr), adapterReport, reportErr, changes)
}()
result := process.collectResult(ctx, clampInitialProcessWait(wait), false)
if ctx != nil && ctx.Err() != nil && !result.Exited {
process.terminate()
more := process.collectResult(context.Background(), time.Second, true)
var mergeTruncated bool
result.Output, mergeTruncated = appendBoundedProcessOutputString(result.Output, more.Output)
result.OutputTruncated = result.OutputTruncated || more.OutputTruncated
result.OutputTruncated = result.OutputTruncated || mergeTruncated
result.Exited = more.Exited
result.ExitCode = more.ExitCode
result.Interrupted = result.Interrupted || more.Interrupted
result.Report = more.Report
result.ReportErr = more.ReportErr
result.Changes = more.Changes
}
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContinue) (ProcessResult, error) {
process, ok := manager.get(input.ProcessID)
if !ok {
return ProcessResult{}, ErrProcessNotFound
}
process.touch()
if input.Interrupt {
process.terminate()
} else if len(input.Input) > 0 {
if !process.tty || process.stdin == nil {
return ProcessResult{}, ErrProcessStdinDisabled
}
if _, err := process.stdin.Write(input.Input); err != nil && !process.doneClosed() {
return ProcessResult{}, err
}
}
result := process.collectResult(ctx, clampContinuationWait(input.Wait, len(input.Input) == 0), input.Interrupt)
if result.Exited {
manager.Remove(process.id)
}
return result, nil
}
func clampInitialProcessWait(wait time.Duration) time.Duration {
return min(wait, maxInteractiveYield)
}
func clampContinuationWait(wait time.Duration, emptyPoll bool) time.Duration {
if emptyPoll {
return min(wait, maxEmptyPollYield)
}
return min(wait, maxInteractiveYield)
}
func (manager *ProcessManager) List() []ProcessSnapshot {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
snapshots := make([]ProcessSnapshot, 0, len(processes))
for _, process := range processes {
snapshots = append(snapshots, process.snapshot())
}
sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].ID < snapshots[j].ID })
return snapshots
}
func (manager *ProcessManager) Snapshot(id int) (ProcessSnapshot, bool) {
process, ok := manager.get(id)
if !ok {
return ProcessSnapshot{}, false
}
return process.snapshot(), true
}
func (manager *ProcessManager) Stop(id int) bool {
process, ok := manager.get(id)
if !ok {
return false
}
process.terminate()
return true
}
func (manager *ProcessManager) StopAll() []int {
manager.mu.Lock()
processes := make([]*managedProcess, 0, len(manager.processes))
for _, process := range manager.processes {
if !process.doneClosed() {
processes = append(processes, process)
}
}
manager.mu.Unlock()
ids := make([]int, 0, len(processes))
for _, process := range processes {
process.terminate()
ids = append(ids, process.id)
}
waitForProcesses(processes, processStopTimeout)
sort.Ints(ids)
return ids
}
func (manager *ProcessManager) Remove(id int) {
manager.mu.Lock()
delete(manager.processes, id)
manager.mu.Unlock()
}
func (manager *ProcessManager) Len() int {
manager.mu.Lock()
defer manager.mu.Unlock()
return len(manager.processes)
}
func (manager *ProcessManager) allocateID() int {
manager.mu.Lock()
defer manager.mu.Unlock()
id := manager.nextID
manager.nextID++
return id
}
func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
manager.mu.Lock()
defer manager.mu.Unlock()
process, ok := manager.processes[id]
return process, ok
}
func (manager *ProcessManager) store(process *managedProcess) {
manager.mu.Lock()
var evicted *managedProcess
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
evicted = manager.processToPruneLocked()
if evicted != nil && evicted.doneClosed() {
delete(manager.processes, evicted.id)
evicted = nil
}
}
manager.processes[process.id] = process
manager.mu.Unlock()
if evicted != nil {
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
evicted.terminate()
}
}
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
var processes []*managedProcess
for _, process := range manager.processes {
processes = append(processes, process)
}
sort.Slice(processes, func(i, j int) bool { return processes[i].lastUsed().Before(processes[j].lastUsed()) })
for _, process := range processes {
if process.doneClosed() {
return process
}
}
if len(processes) > 8 {
return processes[0]
}
return nil
}
func (manager *ProcessManager) removeCompletedLater(process *managedProcess) {
go func() {
<-process.done
if manager.completedRetention > 0 {
timer := time.NewTimer(manager.completedRetention)
<-timer.C
}
manager.Remove(process.id)
}()
}
type managedProcess struct {
id int
commandText string
cwd string
relativeCwd string
startedAt time.Time
lastUsedAt time.Time
tty bool
command *exec.Cmd
request Request
enforcement Enforcement
report func() (AdapterReport, error)
cleanup func()
stdin io.WriteCloser
output *processOutputBuffer
reaped chan struct{}
doneOnce sync.Once
done chan struct{}
kill func(int) error
mu sync.Mutex
exitCode *int
waitErr error
resultReport AdapterReport
reportErr error
changes []Change
metadata map[string]string
}
func (process *managedProcess) markDone(err error, exitCode int, report AdapterReport, reportErr error, changes []Change) {
process.mu.Lock()
process.waitErr = err
process.exitCode = &exitCode
process.resultReport = report
process.reportErr = reportErr
process.changes = append([]Change(nil), changes...)
process.mu.Unlock()
process.doneOnce.Do(func() { close(process.done) })
}
func (process *managedProcess) collectResult(ctx context.Context, wait time.Duration, interrupted bool) ProcessResult {
output, truncated := process.collect(ctx, wait)
process.mu.Lock()
exitCode := 0
exited := process.exitCode != nil
if exited {
exitCode = *process.exitCode
}
result := ProcessResult{
ProcessID: process.id, CommandText: process.commandText, RelativeCwd: process.relativeCwd,
TTY: process.tty, Output: output, OutputTruncated: truncated, Exited: exited,
ExitCode: exitCode, Interrupted: interrupted, Request: process.request,
Enforcement: process.enforcement, Report: process.resultReport, ReportErr: process.reportErr,
Changes: append([]Change(nil), process.changes...), Metadata: cloneStringMap(process.metadata),
}
process.mu.Unlock()
return result
}
func (process *managedProcess) collect(ctx context.Context, wait time.Duration) (string, bool) {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var output []byte
truncated := false
finish := func() (string, bool) {
truncated = truncated || process.output.consumeTruncated()
return string(output), truncated
}
for {
if chunk := process.output.drain(); len(chunk) > 0 {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, chunk)
truncated = truncated || chunkTruncated
truncated = truncated || process.output.consumeTruncated()
if time.Now().After(deadline) {
return finish()
}
continue
}
if process.doneClosed() {
var chunkTruncated bool
output, chunkTruncated = appendBoundedProcessOutput(output, process.output.drain())
truncated = truncated || chunkTruncated
return finish()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
}
timer := time.NewTimer(remaining)
select {
case <-process.output.notify:
case <-process.done:
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return finish()
case <-timer.C:
return finish()
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
func appendBoundedProcessOutput(output []byte, chunk []byte) ([]byte, bool) {
if len(chunk) == 0 {
return output, false
}
if len(chunk) >= maxPendingOutputBytes {
bounded := make([]byte, maxPendingOutputBytes)
copy(bounded, chunk[len(chunk)-maxPendingOutputBytes:])
return bounded, len(output) > 0 || len(chunk) > maxPendingOutputBytes
}
if len(output)+len(chunk) <= maxPendingOutputBytes {
return append(output, chunk...), false
}
drop := len(output) + len(chunk) - maxPendingOutputBytes
copy(output, output[drop:])
output = output[:len(output)-drop]
return append(output, chunk...), true
}
func appendBoundedProcessOutputString(output string, chunk string) (string, bool) {
bounded, truncated := appendBoundedProcessOutput([]byte(output), []byte(chunk))
return string(bounded), truncated
}
func (process *managedProcess) doneClosed() bool {
select {
case <-process.done:
return true
default:
return false
}
}
func (process *managedProcess) touch() {
process.mu.Lock()
process.lastUsedAt = time.Now()
process.mu.Unlock()
}
func (process *managedProcess) lastUsed() time.Time {
process.mu.Lock()
defer process.mu.Unlock()
return process.lastUsedAt
}
func (process *managedProcess) terminate() {
if process.reapedClosed() || process.command.Process == nil {
return
}
kill := process.kill
if kill == nil {
kill = KillProcessTree
}
_ = kill(process.command.Process.Pid)
}
func (process *managedProcess) reapedClosed() bool {
if process.reaped == nil {
return false
}
select {
case <-process.reaped:
return true
default:
return false
}
}
func (process *managedProcess) snapshot() ProcessSnapshot {
process.mu.Lock()
defer process.mu.Unlock()
var exit *int
if process.exitCode != nil {
value := *process.exitCode
exit = &value
}
status := "running"
if exit != nil {
status = "exited"
}
return ProcessSnapshot{ID: process.id, Command: process.commandText, Cwd: process.cwd, RelativeCwd: process.relativeCwd,
StartedAt: process.startedAt, LastUsedAt: process.lastUsedAt, TTY: process.tty, Status: status,
ExitCode: exit, RecentOutput: process.output.recentString(), OutputTruncated: process.output.peekTruncated()}
}
func waitForProcesses(processes []*managedProcess, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for _, process := range processes {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
select {
case <-process.done:
case <-timer.C:
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
type processOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
}
func newProcessOutputBuffer() *processOutputBuffer {
return &processOutputBuffer{notify: make(chan struct{}, 1)}
}
func (buffer *processOutputBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, data...)
if len(buffer.data) > maxPendingOutputBytes {
buffer.data = buffer.data[len(buffer.data)-maxPendingOutputBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, data...)
if len(buffer.recent) > recentOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentOutputBytes:]
}
buffer.mu.Unlock()
select {
case buffer.notify <- struct{}{}:
default:
}
return len(data), nil
}
func (buffer *processOutputBuffer) drain() []byte {
buffer.mu.Lock()
defer buffer.mu.Unlock()
data := append([]byte(nil), buffer.data...)
buffer.data = nil
return data
}
func (buffer *processOutputBuffer) recentString() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return string(buffer.recent)
}
func (buffer *processOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
value := buffer.truncated
buffer.truncated = false
return value
}
func (buffer *processOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}
+275
View File
@@ -0,0 +1,275 @@
package execution
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func processManagerRequest(root string, command *exec.Cmd) Request {
return Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: command.Path, Args: command.Args[1:]},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
}
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{CompletedRetention: time.Second})
command := exec.Command("/bin/sh", "-c", "printf first; sleep 0.05; printf second")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "printf first; sleep 0.05; printf second"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: request, CommandText: "test",
}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
if started.Exited || started.ProcessID < 1000 {
t.Fatalf("initial result = %#v, want retained process", started)
}
continued, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Wait: time.Second})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !continued.Exited || continued.ProcessID != started.ProcessID {
t.Fatalf("continued result = %#v, want same completed process", continued)
}
combined := started.Output + continued.Output
if strings.Count(combined, "first") != 1 || strings.Count(combined, "second") != 1 {
t.Fatalf("output was lost or duplicated: %q", combined)
}
if manager.Len() != 0 {
t.Fatalf("completed process still retained: %d", manager.Len())
}
}
func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
request := Request{
Origin: OriginInteractiveCommand, Mode: ModeCaptured,
Command: Command{Name: "/bin/sh", Args: []string{"-c", "sleep 30"}},
WorkingDirectory: root, WorkspaceRoots: []string{root},
}
started, err := manager.Start(context.Background(), ProcessStart{Prepared: PreparedCommand{Command: command}, Request: request}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
stopped, err := manager.Continue(context.Background(), ProcessContinue{ProcessID: started.ProcessID, Interrupt: true, Wait: time.Second})
if err != nil {
t.Fatalf("Continue interrupt: %v", err)
}
if !stopped.Exited || !stopped.Interrupted {
t.Fatalf("interrupt result = %#v", stopped)
}
}
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
reaped := make(chan struct{})
close(reaped)
called := false
process := &managedProcess{
command: &exec.Cmd{Process: &os.Process{Pid: 4242}},
reaped: reaped,
kill: func(int) error { called = true; return nil },
}
process.terminate()
if called {
t.Fatal("terminate signaled an already-reaped process")
}
}
func TestProcessManagerCancellationReportsInterrupted(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
ctx, cancel := context.WithCancel(context.Background())
cancel()
result, err := manager.Start(ctx, ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !result.Exited || !result.Interrupted {
t.Fatalf("cancelled result = %#v, want exited and interrupted", result)
}
}
func TestProcessManagerObservesChangesMadeDuringTransportStart(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
target := filepath.Join(root, "created.txt")
manager := NewProcessManager(ProcessManagerOptions{})
manager.startTransport = func(command *exec.Cmd, output io.Writer, tty bool) (io.WriteCloser, bool, func(), error) {
if err := os.WriteFile(target, []byte("created"), 0o600); err != nil {
return nil, false, nil, err
}
return startProcessTransport(command, output, tty)
}
command := exec.Command("/bin/sh", "-c", "true")
result, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command},
Request: processManagerRequest(root, command),
}, time.Second)
if err != nil {
t.Fatalf("Start: %v", err)
}
if len(result.Changes) != 1 || result.Changes[0].Path != "created.txt" || result.Changes[0].Kind != ChangeCreated {
t.Fatalf("changes = %#v, want created.txt created", result.Changes)
}
}
func TestProcessWaitsClampToEffectiveBounds(t *testing.T) {
if got := clampInitialProcessWait(time.Minute); got != maxInteractiveYield {
t.Fatalf("initial upper clamp = %v, want %v", got, maxInteractiveYield)
}
if got := clampInitialProcessWait(time.Millisecond); got != time.Millisecond {
t.Fatalf("initial short wait = %v, want caller value", got)
}
if got := clampContinuationWait(time.Hour, true); got != maxEmptyPollYield {
t.Fatalf("empty poll upper clamp = %v, want %v", got, maxEmptyPollYield)
}
if got := clampContinuationWait(time.Hour, false); got != maxInteractiveYield {
t.Fatalf("stdin upper clamp = %v, want %v", got, maxInteractiveYield)
}
}
func TestProcessOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newProcessOutputBuffer()
chunk := []byte(strings.Repeat("x", 1024))
for i := 0; i < maxPendingOutputBytes/len(chunk)+10; i++ {
if _, err := buffer.Write(chunk); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxPendingOutputBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxPendingOutputBytes)
}
if got := buffer.drain(); !strings.HasSuffix(string(got), string(chunk)) {
t.Fatal("drained output should retain the newest bytes")
}
if !buffer.peekTruncated() || !buffer.consumeTruncated() || buffer.consumeTruncated() {
t.Fatal("truncation state was not preserved and consumed exactly once")
}
}
func TestManagedProcessCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
stop := make(chan struct{})
var writers sync.WaitGroup
for i := 0; i < 8; i++ {
writers.Add(1)
go func() {
defer writers.Done()
chunk := []byte(strings.Repeat("x", 256))
for {
select {
case <-stop:
return
default:
_, _ = process.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() { close(stop); writers.Wait() })
wait := 200 * time.Millisecond
started := time.Now()
_, _ = process.collect(context.Background(), wait)
if elapsed := time.Since(started); elapsed > 5*wait {
t.Fatalf("collect took %v under continuous output, want close to %v", elapsed, wait)
}
}
func TestManagedProcessCollectCapsCumulativeOutput(t *testing.T) {
process := &managedProcess{output: newProcessOutputBuffer(), done: make(chan struct{})}
chunk := []byte(strings.Repeat("x", maxPendingOutputBytes/2))
go func() {
for i := 0; i < 5; i++ {
_, _ = process.output.Write(chunk)
for {
process.output.mu.Lock()
drained := len(process.output.data) == 0
process.output.mu.Unlock()
if drained {
break
}
runtime.Gosched()
}
}
close(process.done)
}()
output, truncated := process.collect(context.Background(), time.Second)
if len(output) > maxPendingOutputBytes {
t.Fatalf("collected %d bytes, want <= %d", len(output), maxPendingOutputBytes)
}
if !truncated {
t.Fatal("cumulative output cap must report truncation")
}
}
func TestProcessPruningDoesNotRaceTouch(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
for id := 1000; id < 1012; id++ {
manager.processes[id] = &managedProcess{id: id, lastUsedAt: time.Now(), done: make(chan struct{})}
}
target := manager.processes[1000]
stop := make(chan struct{})
var writer sync.WaitGroup
writer.Add(1)
go func() {
defer writer.Done()
for {
select {
case <-stop:
return
default:
target.touch()
}
}
}()
for i := 0; i < 2000; i++ {
manager.mu.Lock()
_ = manager.processToPruneLocked()
manager.mu.Unlock()
}
close(stop)
writer.Wait()
}
+100
View File
@@ -0,0 +1,100 @@
//go:build !windows
package execution
import (
"errors"
"fmt"
"os/exec"
"syscall"
"time"
)
// ConfigureProcessGroup makes cmd the leader of a process group so lifecycle
// operations cover descendants instead of orphaning them.
func ConfigureProcessGroup(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// KillProcessTree immediately kills pid and, when it is a group leader, its
// descendant process group.
func KillProcessTree(pid int) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
if poll <= 0 {
poll = 50 * time.Millisecond
}
if grace < 0 {
grace = 0
}
deadline := time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if !alive() {
return nil
}
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
deadline = time.Now().Add(grace)
for time.Now().Before(deadline) {
if !alive() {
return nil
}
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
}
target := pid
if pgid, err := syscall.Getpgid(pid); err == nil {
if pgid == pid {
target = -pid
}
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
return target, nil
}
+37
View File
@@ -0,0 +1,37 @@
//go:build windows
package execution
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
)
func ConfigureProcessGroup(cmd *exec.Cmd) {}
func KillProcessTree(pid int) error {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
return process.Kill()
}
func TerminateProcessTree(pid int, _, _ time.Duration) error { return KillProcessTree(pid) }
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package execution
import (
"errors"
"io"
"os/exec"
)
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty transport is unavailable on this platform")
}
@@ -1,6 +1,6 @@
//go:build linux
package tools
package execution
import (
"errors"
@@ -14,41 +14,25 @@ import (
"golang.org/x/sys/unix"
)
func startPTYProcess(command *exec.Cmd, output *execOutputBuffer) (io.WriteCloser, func(), error) {
func startPTYProcess(command *exec.Cmd, output io.Writer) (io.WriteCloser, func(), error) {
master, slave, err := openPTY()
if err != nil {
return nil, nil, err
}
cleanup := func() {
_ = master.Close()
_ = slave.Close()
}
command.Stdin = slave
command.Stdout = slave
command.Stderr = slave
hardenPTYProcessLifetime(command, slave)
cleanup := func() { _ = master.Close(); _ = slave.Close() }
command.Stdin, command.Stdout, command.Stderr = slave, slave, slave
hardenPTYProcess(command)
if err := command.Start(); err != nil {
cleanup()
return nil, nil, err
}
_ = slave.Close()
copied := make(chan struct{})
go func() {
defer close(copied)
_, _ = io.Copy(output, master)
}()
go func() { defer close(copied); _, _ = io.Copy(output, master) }()
return master, func() {
// cleanup runs in the Wait goroutine AFTER command.Wait() returns, so the
// child has exited and its slave fds are closed — the master then EOFs once
// io.Copy drains the remaining PTY output into the buffer. Wait for that copy
// to finish BEFORE closing the master and letting the caller mark the session
// done + remove it; otherwise a command's final output chunk (e.g. a test
// runner's last PASS/FAIL line) can be lost on exit. Closing the master first
// would truncate the unread tail, so the join precedes the Close. Bounded so a
// stuck copy can't hang teardown. (AUDIT-M14)
select {
case <-copied:
case <-time.After(bashWaitDelay):
case <-time.After(processWaitDelay):
}
_ = master.Close()
}, nil
@@ -75,18 +59,17 @@ func openPTY() (*os.File, *os.File, error) {
_ = master.Close()
return nil, nil, err
}
slave := os.NewFile(uintptr(slaveFD), slaveName)
return master, slave, nil
return master, os.NewFile(uintptr(slaveFD), slaveName), nil
}
func hardenPTYProcessLifetime(command *exec.Cmd, slave *os.File) {
func hardenPTYProcess(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.Setsid = true
command.SysProcAttr.Setctty = true
command.SysProcAttr.Ctty = 0
command.WaitDelay = bashWaitDelay
command.WaitDelay = processWaitDelay
command.Cancel = func() error {
if command.Process == nil {
return nil
+25
View File
@@ -0,0 +1,25 @@
//go:build linux
package execution
import (
"os/exec"
"syscall"
"testing"
)
func TestPTYFallbackRestoresOriginalProcessAttributes(t *testing.T) {
original := &syscall.SysProcAttr{Setpgid: true}
command := exec.Command("/bin/true")
command.SysProcAttr = original
saved := cloneSysProcAttr(command.SysProcAttr)
hardenPTYProcess(command)
resetCommandAfterPTYFallback(command, saved)
if command.SysProcAttr == nil || !command.SysProcAttr.Setpgid {
t.Fatalf("fallback attributes = %#v, want original Setpgid", command.SysProcAttr)
}
if command.SysProcAttr.Setsid || command.SysProcAttr.Setctty {
t.Fatalf("fallback retained PTY attributes: %#v", command.SysProcAttr)
}
}
+221
View File
@@ -0,0 +1,221 @@
package execution
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
)
const maxCapturedStreamBytes = 1 << 20
// Preparer is the platform-adapter seam. It turns a platform-neutral request
// into one enforceable OS command while keeping native sandbox mechanics out of
// every caller.
type Preparer interface {
PrepareExecution(context.Context, Request) (PreparedCommand, error)
}
type PreparedCommand struct {
Command *exec.Cmd
Enforcement Enforcement
Report func() (AdapterReport, error)
Cleanup func()
}
type CapturedRequest struct {
Request Request
Stdin []byte
}
type CapturedResult struct {
Stdout string
Stderr string
Truncated bool
Outcome Outcome
Err error
}
// Runner is the deep execution module used by Zero-owned subprocess launchers.
// The preparer may be installed after tool registration, but execution fails
// closed until an adapter is present.
type Runner struct {
mu sync.RWMutex
preparer Preparer
}
func NewRunner(preparer Preparer) *Runner {
return &Runner{preparer: preparer}
}
func (runner *Runner) SetPreparer(preparer Preparer) {
if runner == nil {
return
}
runner.mu.Lock()
runner.preparer = preparer
runner.mu.Unlock()
}
func (runner *Runner) ExecuteCaptured(ctx context.Context, input CapturedRequest) CapturedResult {
if ctx == nil {
ctx = context.Background()
}
if err := input.Request.Validate(); err != nil {
return capturedSetupFailure("invalid execution request: "+err.Error(), err, Enforcement{})
}
if input.Request.Mode != ModeCaptured {
err := fmt.Errorf("captured execution requires mode %q", ModeCaptured)
return capturedSetupFailure(err.Error(), err, Enforcement{})
}
prepared, err := runner.Prepare(ctx, input.Request)
if err != nil {
return capturedSetupFailure("prepare execution: "+err.Error(), err, prepared.Enforcement)
}
if prepared.Cleanup != nil {
defer prepared.Cleanup()
}
if prepared.Command == nil {
err := errors.New("execution adapter returned no command")
return capturedSetupFailure(err.Error(), err, prepared.Enforcement)
}
if len(input.Stdin) > 0 {
prepared.Command.Stdin = bytes.NewReader(input.Stdin)
}
stdout := &capturedBuffer{limit: maxCapturedStreamBytes}
stderr := &capturedBuffer{limit: maxCapturedStreamBytes}
prepared.Command.Stdout = stdout
prepared.Command.Stderr = stderr
runErr := prepared.Command.Run()
report, reportErr := AdapterReport{}, error(nil)
if prepared.Report != nil {
report, reportErr = prepared.Report()
}
result := CapturedResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Truncated: stdout.Truncated() || stderr.Truncated(),
Err: runErr,
Outcome: Outcome{
Enforcement: prepared.Enforcement,
},
}
exitCode := commandExitCode(runErr)
result.Outcome.Exit = &Exit{Code: exitCode}
switch {
case reportErr != nil:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeSandboxSetupFailure
result.Err = reportErr
case report.Denial != nil:
denial := *report.Denial
result.Outcome.State = StateDenied
result.Outcome.Kind = OutcomeEnforcementDenied
result.Outcome.Denial = &denial
case errors.Is(ctx.Err(), context.DeadlineExceeded):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeTimedOut
case errors.Is(ctx.Err(), context.Canceled):
result.Outcome.State = StateCancelled
result.Outcome.Kind = OutcomeCancelled
case runErr == nil:
result.Outcome.State = StateCompleted
result.Outcome.Kind = OutcomeSuccess
case executableNotFound(runErr):
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeExecutableNotFound
default:
result.Outcome.State = StateFailed
result.Outcome.Kind = OutcomeApplicationFailure
}
return result
}
// Prepare evaluates and prepares a typed request for callers that require
// streaming pipes or protocol-specific lifecycle handling, such as stdio MCP.
// The returned cleanup remains mandatory; ordinary captured commands should use
// ExecuteCaptured so the Runner owns it automatically.
func (runner *Runner) Prepare(ctx context.Context, request Request) (PreparedCommand, error) {
if runner == nil {
return PreparedCommand{}, errors.New("execution runner is not configured")
}
if ctx == nil {
ctx = context.Background()
}
if err := request.Validate(); err != nil {
return PreparedCommand{}, err
}
runner.mu.RLock()
preparer := runner.preparer
runner.mu.RUnlock()
if preparer == nil {
return PreparedCommand{}, errors.New("execution adapter is not configured")
}
return preparer.PrepareExecution(ctx, request)
}
func capturedSetupFailure(message string, err error, enforcement Enforcement) CapturedResult {
return CapturedResult{
Stderr: message,
Err: err,
Outcome: Outcome{
State: StateFailed,
Kind: OutcomeSandboxSetupFailure,
Exit: &Exit{Code: -1},
Enforcement: enforcement,
},
}
}
func commandExitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
func executableNotFound(err error) bool {
var execErr *exec.Error
return errors.As(err, &execErr)
}
type capturedBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
limit int
truncated bool
}
func (buffer *capturedBuffer) Write(data []byte) (int, error) {
buffer.mu.Lock()
defer buffer.mu.Unlock()
remaining := buffer.limit - buffer.buffer.Len()
if remaining > 0 {
_, _ = buffer.buffer.Write(data[:min(len(data), remaining)])
}
if len(data) > remaining {
buffer.truncated = true
}
return len(data), nil
}
func (buffer *capturedBuffer) String() string {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.buffer.String()
}
func (buffer *capturedBuffer) Truncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
var _ io.Writer = (*capturedBuffer)(nil)
+77
View File
@@ -0,0 +1,77 @@
package execution
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
type capturedTestPreparer struct {
request Request
}
func (preparer *capturedTestPreparer) PrepareExecution(_ context.Context, request Request) (PreparedCommand, error) {
preparer.request = request
command := exec.Command(os.Args[0], "-test.run=^TestCapturedRunnerHelperProcess$")
command.Env = append(os.Environ(), "ZERO_CAPTURED_RUNNER_HELPER=1")
return PreparedCommand{
Command: command,
Enforcement: Enforcement{
Backend: "test-adapter",
Level: "native",
},
}, nil
}
func TestCapturedRunnerHelperProcess(t *testing.T) {
if os.Getenv("ZERO_CAPTURED_RUNNER_HELPER") != "1" {
return
}
fmt.Fprint(os.Stdout, "stdout")
fmt.Fprint(os.Stderr, "stderr")
os.Exit(7)
}
func TestRunnerExecutesCapturedRequestThroughAdapter(t *testing.T) {
preparer := &capturedTestPreparer{}
runner := NewRunner(preparer)
request := Request{
Origin: OriginHook,
Mode: ModeCaptured,
Command: Command{Name: "ignored-by-test-adapter"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
Approval: ApprovalContext{PolicyVersion: PolicyVersion},
}
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: request})
if preparer.request.Origin != OriginHook {
t.Fatalf("adapter origin = %q, want hook", preparer.request.Origin)
}
if result.Stdout != "stdout" || result.Stderr != "stderr" {
t.Fatalf("captured output = stdout %q stderr %q", result.Stdout, result.Stderr)
}
if result.Outcome.Kind != OutcomeApplicationFailure || result.Outcome.Exit == nil || result.Outcome.Exit.Code != 7 {
t.Fatalf("outcome = %#v, want application failure exit 7", result.Outcome)
}
if result.Outcome.Enforcement.Backend != "test-adapter" {
t.Fatalf("enforcement = %#v", result.Outcome.Enforcement)
}
}
func TestRunnerWithoutAdapterFailsClosed(t *testing.T) {
runner := NewRunner(nil)
result := runner.ExecuteCaptured(context.Background(), CapturedRequest{Request: Request{
Origin: OriginPlugin,
Mode: ModeCaptured,
Command: Command{Name: "true"},
WorkingDirectory: t.TempDir(),
WorkspaceRoots: []string{t.TempDir()},
}})
if result.Outcome.Kind != OutcomeSandboxSetupFailure || !strings.Contains(result.Stderr, "adapter") {
t.Fatalf("result = %#v, want fail-closed missing-adapter result", result)
}
}
+51
View File
@@ -0,0 +1,51 @@
package execution
import (
"io"
"os/exec"
"syscall"
"time"
)
const processWaitDelay = 2 * time.Second
type processTransportStarter func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error)
func startProcessTransport(command *exec.Cmd, output io.Writer, ttyRequested bool) (io.WriteCloser, bool, func(), error) {
if ttyRequested {
original := cloneSysProcAttr(command.SysProcAttr)
if stdin, cleanup, err := startPTYProcess(command, output); err == nil {
return stdin, true, cleanup, nil
}
resetCommandAfterPTYFallback(command, original)
}
stdin, err := command.StdinPipe()
if err != nil {
return nil, false, nil, err
}
command.Stdout = output
command.Stderr = output
ConfigureProcessGroup(command)
command.WaitDelay = processWaitDelay
if err := command.Start(); err != nil {
return nil, false, nil, err
}
return stdin, false, func() {}, nil
}
func cloneSysProcAttr(attributes *syscall.SysProcAttr) *syscall.SysProcAttr {
if attributes == nil {
return nil
}
cloned := *attributes
return &cloned
}
func resetCommandAfterPTYFallback(command *exec.Cmd, original *syscall.SysProcAttr) {
command.Stdin = nil
command.Stdout = nil
command.Stderr = nil
command.SysProcAttr = original
command.Cancel = nil
command.WaitDelay = 0
}
+51 -8
View File
@@ -9,6 +9,8 @@ import (
"os/exec"
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
@@ -51,13 +53,14 @@ type commandRunner func(ctx context.Context, command string, args []string, stdi
// DispatcherOptions configures a Dispatcher.
type DispatcherOptions struct {
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
now func() time.Time
run commandRunner
Config Config
Audit *AuditStore // optional; when set, every run is recorded
Cwd string // working directory for hook commands
Env []string // extra environment entries appended to the process env
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
Execution *execution.Runner // routes hook subprocesses through sandbox policy
now func() time.Time
run commandRunner
}
// Dispatcher selects and runs the hooks configured for a lifecycle event,
@@ -87,7 +90,11 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
run := options.run
if run == nil {
run = execCommandRunner
if options.Execution != nil {
run = executionCommandRunner(options.Execution)
} else {
run = execCommandRunner
}
}
return &Dispatcher{
config: options.Config,
@@ -100,6 +107,42 @@ func NewDispatcher(options DispatcherOptions) *Dispatcher {
}
}
func executionCommandRunner(runner *execution.Runner) commandRunner {
return func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
result := runner.ExecuteCaptured(ctx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginHook,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command, Args: append([]string(nil), args...), Env: append([]string(nil), env...)},
WorkingDirectory: cwd,
WorkspaceRoots: []string{cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
stderr := result.Stderr
if stderr == "" && result.Outcome.Denial != nil {
stderr = result.Outcome.Denial.Reason
}
commandErr := error(nil)
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound:
commandErr = result.Err
}
return commandResult{
ExitCode: exitCode,
Stdout: result.Stdout,
Stderr: stderr,
Err: commandErr,
TimedOut: result.Outcome.Kind == execution.OutcomeTimedOut,
}
}
}
// blocksOn reports whether a non-zero exit for this event should veto the action.
// Only beforeTool gates the tool; other events are observational.
func blocksOn(event Event) bool {
+31
View File
@@ -2,13 +2,25 @@ package hooks
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type hookExecutionPreparer struct {
request execution.Request
}
func (preparer *hookExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
func beforeToolConfig(hooks ...Definition) Config {
return Config{Enabled: true, Hooks: hooks}
}
@@ -186,6 +198,25 @@ func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
}
}
func TestDispatcherRoutesHookThroughTypedExecutionOrigin(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses /bin/sh")
}
preparer := &hookExecutionPreparer{}
dispatcher := NewDispatcher(DispatcherOptions{
Config: beforeToolConfig(Definition{ID: "typed", Event: EventBeforeTool, Command: "/bin/sh", Args: []string{"-c", "cat"}, Enabled: true}),
Cwd: t.TempDir(),
Execution: execution.NewRunner(preparer),
})
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", Payload: map[string]any{"ok": true}})
if outcome.Blocked || outcome.Ran != 1 {
t.Fatalf("dispatch outcome = %#v", outcome)
}
if preparer.request.Origin != execution.OriginHook || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
if result.Err == nil {
+56 -10
View File
@@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
type RemoteTool struct {
@@ -46,6 +48,7 @@ type Client struct {
mu sync.Mutex
closeMu sync.Mutex
nextID int
cleanup func()
// dispatchMu guards the response-dispatch state shared with the single
// reader goroutine. It is never held across a blocking read.
@@ -72,9 +75,18 @@ const (
)
func Connect(ctx context.Context, server Server) (ToolClient, error) {
return ConnectWithOptions(ctx, server, ConnectOptions{})
}
type ConnectOptions struct {
Execution *execution.Runner
WorkspaceRoot string
}
func ConnectWithOptions(ctx context.Context, server Server, options ConnectOptions) (ToolClient, error) {
switch server.Type {
case ServerTypeStdio:
return connectStdio(ctx, server)
return connectStdio(ctx, server, options)
case ServerTypeHTTP:
return connectNetwork(ctx, server)
case ServerTypeSSE:
@@ -120,9 +132,37 @@ func (b *boundedBuffer) String() string {
return b.buf.String()
}
func connectStdio(ctx context.Context, server Server) (*Client, error) {
cmd := exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
func connectStdio(ctx context.Context, server Server, options ConnectOptions) (*Client, error) {
var cmd *exec.Cmd
var cleanup func()
cleanupTransferred := false
defer func() {
if cleanup != nil && !cleanupTransferred {
cleanup()
}
}()
if options.Execution != nil {
workspaceRoot := strings.TrimSpace(options.WorkspaceRoot)
if workspaceRoot == "" {
return nil, fmt.Errorf("start MCP server %s: execution workspace root is required", server.Name)
}
prepared, err := options.Execution.Prepare(ctx, execution.Request{
Origin: execution.OriginMCPServer,
Mode: execution.ModeDurable,
Command: execution.Command{Name: server.Command, Args: append([]string(nil), server.Args...), Env: mergeProcessEnv(server.Env)},
WorkingDirectory: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
})
if err != nil {
return nil, fmt.Errorf("start MCP server %s: %w", server.Name, err)
}
cmd = prepared.Command
cleanup = prepared.Cleanup
} else {
cmd = exec.CommandContext(ctx, server.Command, server.Args...)
cmd.Env = mergeProcessEnv(server.Env)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("open MCP stdin for %s: %w", server.Name, err)
@@ -138,13 +178,15 @@ func connectStdio(ctx context.Context, server Server) (*Client, error) {
}
client := &Client{
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
server: server,
cmd: cmd,
stdin: stdin,
reader: newMessageReader(stdout),
writer: newMessageWriter(stdin),
nextID: 1,
cleanup: cleanup,
}
cleanupTransferred = true
if err := client.initialize(ctx); err != nil {
_ = client.Close()
message := strings.TrimSpace(stderr.String())
@@ -242,6 +284,10 @@ func (client *Client) Close() error {
}
}
}
if client.cleanup != nil {
client.cleanup()
client.cleanup = nil
}
return err
}
+42
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
@@ -16,8 +17,21 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
)
type mcpExecutionPreparer struct {
request execution.Request
}
func (preparer *mcpExecutionPreparer) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
command := exec.CommandContext(ctx, request.Command.Name, request.Command.Args...)
command.Dir = request.WorkingDirectory
command.Env = request.Command.Env
return execution.PreparedCommand{Command: command}, nil
}
func TestStdioClientListsAndCallsTools(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -65,6 +79,34 @@ func TestStdioClientListsAndCallsTools(t *testing.T) {
}
}
func TestStdioClientUsesTypedMCPExecutionOrigin(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
preparer := &mcpExecutionPreparer{}
workspace := t.TempDir()
client, err := ConnectWithOptions(ctx, Server{
Name: "docs",
Type: ServerTypeStdio,
Command: executable,
Args: []string{"-test.run=TestMCPStdioHelperProcess", "--"},
Env: map[string]string{"ZERO_MCP_STDIO_HELPER": "1"},
}, ConnectOptions{Execution: execution.NewRunner(preparer), WorkspaceRoot: workspace})
if err != nil {
t.Fatalf("ConnectWithOptions() error = %v", err)
}
defer client.Close()
if preparer.request.Origin != execution.OriginMCPServer || preparer.request.Mode != execution.ModeDurable {
t.Fatalf("execution request = %#v", preparer.request)
}
if preparer.request.WorkingDirectory != workspace {
t.Fatalf("working directory = %q, want %q", preparer.request.WorkingDirectory, workspace)
}
}
func TestStdioClientCloseAllowsConcurrentCallers(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -26,6 +27,8 @@ type RegisterOptions struct {
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
// defaultConnectTimeout.
ConnectTimeout time.Duration
Execution *execution.Runner
WorkspaceRoot string
}
// SkippedServer records an MCP server that was not registered because it could
@@ -80,7 +83,7 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
factory := options.ClientFactory
if factory == nil {
factory = func(ctx context.Context, server Server) (ToolClient, error) {
return Connect(ctx, server)
return ConnectWithOptions(ctx, server, ConnectOptions{Execution: options.Execution, WorkspaceRoot: options.WorkspaceRoot})
}
}
+6 -6
View File
@@ -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)
+25
View File
@@ -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
View File
@@ -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
}
+60
View File
@@ -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,
+5
View File
@@ -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.
+72 -17
View File
@@ -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)
}
+138
View File
@@ -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
+42 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/secrets"
@@ -113,6 +114,10 @@ type ActivateOptions struct {
// Env supplies the base environment for plugin tool commands. nil uses the
// current process environment (os.Environ()).
Env []string
// Execution routes plugin tool subprocesses through the shared sandbox and
// process execution seam. Production callers provide it before any tool can
// be invoked; tests may continue to inject runTool directly.
Execution *execution.Runner
runTool toolRunner
}
@@ -141,8 +146,14 @@ func Activate(registry *tools.Registry, loaded []LoadedPlugin, options ActivateO
if timeout <= 0 {
timeout = defaultPluginToolTimeout
}
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
if options.Execution != nil {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommandWithExecution(ctx, options.Execution, command, timeout)
}
} else {
runTool = func(ctx context.Context, command pluginCommand) commandOutput {
return execPluginCommand(ctx, command, timeout)
}
}
}
@@ -689,3 +700,32 @@ func execPluginCommand(ctx context.Context, command pluginCommand, timeout time.
output.Err = err
return output
}
func execPluginCommandWithExecution(ctx context.Context, runner *execution.Runner, command pluginCommand, timeout time.Duration) commandOutput {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := runner.ExecuteCaptured(runCtx, execution.CapturedRequest{
Request: execution.Request{
Origin: execution.OriginPlugin,
Mode: execution.ModeCaptured,
Command: execution.Command{Name: command.Command, Args: append([]string(nil), command.Args...), Env: append([]string(nil), command.Env...)},
WorkingDirectory: command.Cwd,
WorkspaceRoots: []string{command.Cwd},
Approval: execution.ApprovalContext{PolicyVersion: execution.PolicyVersion},
},
Stdin: command.Stdin,
})
exitCode := -1
if result.Outcome.Exit != nil {
exitCode = result.Outcome.Exit.Code
}
output := commandOutput{Stdout: result.Stdout, Stderr: result.Stderr, ExitCode: exitCode}
switch result.Outcome.Kind {
case execution.OutcomeSandboxSetupFailure, execution.OutcomeExecutableNotFound, execution.OutcomeTimedOut, execution.OutcomeCancelled:
output.Err = result.Err
}
if output.Stderr == "" && result.Outcome.Denial != nil {
output.Stderr = result.Outcome.Denial.Reason
}
return output
}
+27
View File
@@ -3,15 +3,27 @@ package plugins
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
)
type pluginExecutionPreparer struct {
request execution.Request
}
func (preparer *pluginExecutionPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
preparer.request = request
return execution.PreparedCommand{Command: exec.Command(request.Command.Name, request.Command.Args...)}, nil
}
// fakeToolRunner records the invocation and returns a canned result so tool
// activation can be exercised without spawning a real process.
type fakeToolRunner struct {
@@ -554,6 +566,21 @@ func TestActivateSkipsDisabledPlugin(t *testing.T) {
}
}
func TestPluginCommandUsesTypedPluginExecutionOrigin(t *testing.T) {
if testing.Short() {
t.Skip("spawns a subprocess")
}
preparer := &pluginExecutionPreparer{}
command := pluginCommand{Command: os.Args[0], Args: []string{"-test.run=^$"}, Cwd: t.TempDir()}
output := execPluginCommandWithExecution(context.Background(), execution.NewRunner(preparer), command, time.Second)
if output.Err != nil || output.ExitCode != 0 {
t.Fatalf("plugin execution output = %#v", output)
}
if preparer.request.Origin != execution.OriginPlugin || preparer.request.Mode != execution.ModeCaptured {
t.Fatalf("execution request = %#v", preparer.request)
}
}
func TestActivateIsDeterministic(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
@@ -1375,3 +1376,61 @@ func TestOpenAIRequestPromptCacheKey(t *testing.T) {
}
}
}
func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
tools := []zeroruntime.ToolDefinition{{
Name: "read_file",
Description: "Read a file.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
},
}}
firstMessages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "stable system prompt"},
{Role: zeroruntime.MessageRoleUser, Content: "first turn"},
}
secondMessages := append([]zeroruntime.Message(nil), firstMessages...)
secondMessages = append(secondMessages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "first response"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "second turn"},
)
marshalBody := func(messages []zeroruntime.Message) map[string]any {
t.Helper()
mapped := provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
Tools: tools,
PromptCacheKey: "session-stable-prefix",
})
data, marshalErr := json.Marshal(mapped)
if marshalErr != nil {
t.Fatalf("marshal request: %v", marshalErr)
}
var body map[string]any
if unmarshalErr := json.Unmarshal(data, &body); unmarshalErr != nil {
t.Fatalf("unmarshal request: %v", unmarshalErr)
}
return body
}
first := marshalBody(firstMessages)
second := marshalBody(secondMessages)
firstWireMessages := first["messages"].([]any)
secondWireMessages := second["messages"].([]any)
if !reflect.DeepEqual(secondWireMessages[:len(firstWireMessages)], firstWireMessages) {
t.Fatalf("first wire messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", firstWireMessages, secondWireMessages)
}
if !reflect.DeepEqual(first["tools"], second["tools"]) {
t.Fatalf("wire tool definitions drifted:\nfirst=%#v\nsecond=%#v", first["tools"], second["tools"])
}
if first["prompt_cache_key"] != second["prompt_cache_key"] || first["prompt_cache_key"] != "session-stable-prefix" {
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
}
}
+30 -10
View File
@@ -150,7 +150,7 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
case "go":
return firstSubcommand(words, nil) == "get"
case "git":
return firstSubcommand(words, nil) == "clone"
return gitUsesNetwork(words)
case "gh":
return ghUsesNetwork(words)
default:
@@ -159,9 +159,15 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
}
func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
if packageManagerOffline(words) {
return false
}
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "publish", "login":
case "install", "add", "ci", "create", "dlx", "publish", "unpublish",
"login", "logout", "adduser", "whoami", "ping", "audit", "outdated",
"update", "upgrade", "search", "view", "info", "show", "dist-tag",
"deprecate", "owner", "org", "team", "token", "profile", "access":
return true
case "start", "serve", "dev", "preview":
return true
@@ -169,19 +175,33 @@ func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
second := secondSubcommand(words)
return second == "start" || second == "serve" || second == "dev" || second == "preview"
case "exec":
for _, word := range words {
if word == "" || strings.HasPrefix(word, "-") || isNumericToken(word) {
continue
}
if word == "exec" || word == "x" || word == "dlx" {
continue
}
return localServerPrograms[word]
// Package-manager exec commands may resolve and download a missing
// package before launching it. An explicit offline flag keeps this path
// inside the isolated namespace; otherwise request egress up front.
return true
}
return false
}
func packageManagerOffline(words []string) bool {
for _, word := range words {
switch word {
case "--offline", "--no-network":
return true
}
}
return false
}
func gitUsesNetwork(words []string) bool {
switch firstSubcommand(words, nil) {
case "clone", "fetch", "pull", "push", "ls-remote", "archive":
return true
default:
return false
}
}
func npxUsesNetwork(_ []string) bool {
return true
}
+8
View File
@@ -49,6 +49,12 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
{name: "npm create", script: "npm create vite@latest .", network: true},
{name: "npm registry query", script: "npm view typescript version --fetch-retries=0", network: true},
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
@@ -56,6 +62,8 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
{name: "gh release download", script: "gh release download v1.0.0", network: true},
{name: "no network", script: "ls -la && echo done", network: false},
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
+34
View File
@@ -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()
+16
View File
@@ -80,6 +80,13 @@ func (engine *Engine) CanPersistGrants() bool {
return engine != nil && engine.store != nil
}
func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) {
if engine == nil || engine.store == nil {
return "", nil
}
return engine.store.ConsumeMigrationNotice()
}
func (engine *Engine) GrantCommandPrefixForSession(toolName string, prefix []string) {
if engine == nil || len(prefix) == 0 {
return
@@ -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
View File
@@ -12,10 +12,11 @@ import (
"sync"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/redaction"
)
const grantSchemaVersion = 2
const grantSchemaVersion = 3
type Grant struct {
ToolName string `json:"toolName"`
@@ -51,8 +52,18 @@ type GrantLookup struct {
type grantFile struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants map[string][]Grant `json:"grants"`
CommandPrefixes map[string][]CommandPrefixGrant `json:"commandPrefixes,omitempty"`
Migration *grantMigration `json:"migration,omitempty"`
}
type grantMigration struct {
FromSchemaVersion int `json:"fromSchemaVersion"`
BackupPath string `json:"backupPath"`
Migrated int `json:"migrated"`
Invalidated int `json:"invalidated"`
NoticePending bool `json:"noticePending"`
}
type GrantStore struct {
@@ -118,6 +129,31 @@ func (store *GrantStore) FilePath() string {
return store.filePath
}
// ConsumeMigrationNotice returns the legacy-grant migration summary once and
// atomically records it as seen. Frontends call this during startup so users are
// told when approvals were invalidated instead of encountering unexplained
// re-prompts. The original file remains available at the reported backup path.
func (store *GrantStore) ConsumeMigrationNotice() (string, error) {
if store == nil {
return "", nil
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return "", err
}
if state.Migration == nil || !state.Migration.NoticePending {
return "", nil
}
migration := *state.Migration
state.Migration.NoticePending = false
if err := store.writeState(state); err != nil {
return "", err
}
return fmt.Sprintf("sandbox grants updated for policy v%d: migrated %d, invalidated %d; backup: %s", execution.PolicyVersion, migration.Migrated, migration.Invalidated, migration.BackupPath), nil
}
func (store *GrantStore) Grant(input GrantInput) (Grant, error) {
grant, err := createGrant(input, store.now)
if err != nil {
@@ -518,25 +554,26 @@ func (store *GrantStore) readState() (grantFile, error) {
// current (v2) map[tool][]Grant shape.
var head struct {
SchemaVersion int `json:"schemaVersion"`
PolicyVersion int `json:"policyVersion"`
Grants json.RawMessage `json:"grants"`
CommandPrefixes json.RawMessage `json:"commandPrefixes"`
Migration *grantMigration `json:"migration"`
}
if err := json.Unmarshal(data, &head); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
if head.SchemaVersion == 1 || head.SchemaVersion == 2 {
return store.migrateLegacyState(data, head.SchemaVersion, head.Grants, head.CommandPrefixes)
}
if head.SchemaVersion != grantSchemaVersion {
return grantFile{}, fmt.Errorf("invalid sandbox grants file at %s: unsupported schemaVersion", store.filePath)
}
if head.PolicyVersion != execution.PolicyVersion {
return store.migrateChangedPolicy(data, head.PolicyVersion, head.Grants, head.CommandPrefixes)
}
buckets := map[string][]Grant{}
commandPrefixBuckets := map[string][]CommandPrefixGrant{}
switch head.SchemaVersion {
case 1:
legacy := map[string]Grant{}
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
case grantSchemaVersion:
if len(head.Grants) > 0 {
if err := json.Unmarshal(head.Grants, &buckets); err != nil {
@@ -581,7 +618,167 @@ func (store *GrantStore) readState() (grantFile, error) {
normalizedPrefixes[key] = append(normalizedPrefixes[key], ng)
}
}
return grantFile{SchemaVersion: grantSchemaVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes}, nil
return grantFile{SchemaVersion: grantSchemaVersion, PolicyVersion: execution.PolicyVersion, Grants: normalized, CommandPrefixes: normalizedPrefixes, Migration: head.Migration}, nil
}
func (store *GrantStore) migrateLegacyState(data []byte, schemaVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
if schemaVersion == 1 {
legacy := map[string]Grant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &legacy); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
for name, grant := range legacy {
buckets[name] = []Grant{grant}
}
} else if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
prefixes := map[string][]CommandPrefixGrant{}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeLegacyBackup(data, schemaVersion)
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || legacyShellGrant(normalized) {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for name, bucket := range prefixes {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredCommandPrefixGrant(key, grant)
if err != nil {
invalidated++
continue
}
state.CommandPrefixes[key] = append(state.CommandPrefixes[key], normalized)
migrated++
}
}
state.Migration = &grantMigration{FromSchemaVersion: schemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func (store *GrantStore) migrateChangedPolicy(data []byte, policyVersion int, grantsJSON json.RawMessage, prefixesJSON json.RawMessage) (grantFile, error) {
buckets := map[string][]Grant{}
prefixes := map[string][]CommandPrefixGrant{}
if len(grantsJSON) > 0 {
if err := json.Unmarshal(grantsJSON, &buckets); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
if len(prefixesJSON) > 0 {
if err := json.Unmarshal(prefixesJSON, &prefixes); err != nil {
return grantFile{}, store.invalidGrantFile(err)
}
}
backupPath, err := store.writeBackup(data, fmt.Sprintf("policy-v%d", policyVersion))
if err != nil {
return grantFile{}, err
}
state := emptyGrantState()
migrated, invalidated := 0, 0
for name, bucket := range buckets {
key := strings.TrimSpace(name)
if err := ValidateToolName(key); err != nil {
invalidated += len(bucket)
continue
}
for _, grant := range bucket {
normalized, err := normalizeStoredGrant(key, grant)
if err != nil || normalized.Decision != GrantDeny {
invalidated++
continue
}
state.Grants[key] = append(state.Grants[key], normalized)
migrated++
}
}
for _, bucket := range prefixes {
invalidated += len(bucket)
}
state.Migration = &grantMigration{FromSchemaVersion: grantSchemaVersion, BackupPath: backupPath, Migrated: migrated, Invalidated: invalidated, NoticePending: true}
if err := store.writeState(state); err != nil {
return grantFile{}, err
}
return state, nil
}
func legacyShellGrant(grant Grant) bool {
if grant.Decision == GrantDeny {
return false
}
switch grant.ToolName {
case "bash", "exec_command", "write_stdin":
return true
default:
return false
}
}
func (store *GrantStore) writeLegacyBackup(data []byte, schemaVersion int) (string, error) {
return store.writeBackup(data, fmt.Sprintf("v%d", schemaVersion))
}
func (store *GrantStore) writeBackup(data []byte, label string) (string, error) {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return "", err
}
base := fmt.Sprintf("%s.%s.backup", store.filePath, label)
for attempt := 0; ; attempt++ {
path := base
if attempt > 0 {
path = fmt.Sprintf("%s.%d", base, attempt)
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return "", err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}
}
func (store *GrantStore) invalidGrantFile(err error) error {
@@ -657,6 +854,7 @@ func normalizeStoredCommandPrefixGrant(name string, grant CommandPrefixGrant) (C
func emptyGrantState() grantFile {
return grantFile{
SchemaVersion: grantSchemaVersion,
PolicyVersion: execution.PolicyVersion,
Grants: map[string][]Grant{},
CommandPrefixes: map[string][]CommandPrefixGrant{},
}
+102 -11
View File
@@ -70,10 +70,11 @@ func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
}
}
func TestGrantStoreReadsV1GrantFile(t *testing.T) {
func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":1,"grants":{"bash":{"toolName":"bash","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`); err != nil {
original := `{"schemaVersion":1,"grants":{"write_file":{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z","reason":"legacy"}}}`
if err := writeText(path, original); err != nil {
t.Fatalf("write v1 grants: %v", err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path, Now: fixedSandboxTime("2026-06-05T15:00:00Z")})
@@ -85,19 +86,80 @@ func TestGrantStoreReadsV1GrantFile(t *testing.T) {
if err != nil {
t.Fatalf("List v1 grants returned error: %v", err)
}
if len(grants) != 1 || grants[0].ToolName != "bash" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
if len(grants) != 1 || grants[0].ToolName != "write_file" || grants[0].Decision != GrantAllow || grants[0].Reason != "legacy" {
t.Fatalf("unexpected v1 grants: %#v", grants)
}
if _, err := store.Grant(GrantInput{ToolName: "write_file", Decision: GrantAllow}); err != nil {
t.Fatalf("Grant after v1 read returned error: %v", err)
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 0") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
if again, err := store.ConsumeMigrationNotice(); err != nil || again != "" {
t.Fatalf("second migration notice = %q err=%v, want empty", again, err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rewritten grant file: %v", err)
}
if !strings.Contains(string(raw), `"schemaVersion": 2`) || !strings.Contains(string(raw), `"bash": [`) {
t.Fatalf("grant file was not rewritten as v2 buckets:\n%s", raw)
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
}
backup, err := os.ReadFile(path + ".v1.backup")
if err != nil || string(backup) != original {
t.Fatalf("migration backup = %q err=%v, want original", backup, err)
}
}
func TestGrantStoreInvalidatesLegacyShellAllowButKeepsSafePrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"exec_command":[{"toolName":"exec_command","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 0 {
t.Fatalf("legacy shell allows = %#v err=%v, want invalidated", grants, err)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil || len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("migrated prefixes = %#v err=%v", prefixes, err)
}
notice, err := store.ConsumeMigrationNotice()
if err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v", backup, err)
}
}
func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing.T) {
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
original := `{"schemaVersion":3,"policyVersion":0,"grants":{"write_file":[{"toolName":"write_file","decision":"allow","approvedAt":"2026-06-05T14:30:00Z"}],"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"exec_command":[{"toolName":"exec_command","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
grants, err := store.List()
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
t.Fatalf("migrated policy grants = %#v err=%v, want deny only", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("changed-policy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 2") {
t.Fatalf("notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".policy-v0.backup")
if err != nil || string(backup) != original {
t.Fatalf("policy backup = %q err=%v", backup, err)
}
}
@@ -207,7 +269,7 @@ func TestGrantStoreRejectsUnsafeInputsAndMalformedFiles(t *testing.T) {
}
}
func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
func TestGrantStoreInvalidatesUnsafeLegacyCommandPrefix(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
if err := writeText(path, `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["find"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`); err != nil {
@@ -217,8 +279,37 @@ func TestGrantStoreRejectsUnsafeStoredCommandPrefix(t *testing.T) {
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.ListCommandPrefixes(); err == nil || !strings.Contains(err.Error(), "invalid command prefix") {
t.Fatalf("expected invalid command prefix error, got %v", err)
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("unsafe legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 1") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
}
func TestGrantStoreInvalidatesMalformedLegacyToolKeys(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "sandbox-grants.json")
original := `{"schemaVersion":2,"grants":{"../escape":[{"toolName":"../escape","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bad name":[{"toolName":"bad name","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
if err := writeText(path, original); err != nil {
t.Fatal(err)
}
store, err := NewGrantStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatal(err)
}
if grants, err := store.List(); err != nil || len(grants) != 0 {
t.Fatalf("malformed legacy grants = %#v err=%v, want invalidated", grants, err)
}
if prefixes, err := store.ListCommandPrefixes(); err != nil || len(prefixes) != 0 {
t.Fatalf("malformed legacy prefixes = %#v err=%v, want invalidated", prefixes, err)
}
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
t.Fatalf("migration notice = %q err=%v", notice, err)
}
backup, err := os.ReadFile(path + ".v2.backup")
if err != nil || string(backup) != original {
t.Fatalf("backup = %q err=%v, want original", backup, err)
}
}
+51 -9
View File
@@ -26,6 +26,7 @@ type LinuxSandboxCommandArgsOptions struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -37,6 +38,7 @@ type LinuxSandboxHelperConfig struct {
ApplySeccompThenExec bool
BlockUnixSockets bool
NoProc bool
PolicyReportPath string
Command []string
}
@@ -51,6 +53,16 @@ type LinuxSandboxBwrapOptions struct {
HelperPath string
}
type linuxSandboxBwrapPlan struct {
Args []string
ProtectedCreateTargets []string
}
type linuxBwrapFilesystemPlan struct {
Args []string
ProtectedCreateTargets []string
}
var linuxSandboxHelperCommand = findLinuxSandboxHelperCommand
func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]string, error) {
@@ -86,6 +98,9 @@ func BuildLinuxSandboxCommandArgs(options LinuxSandboxCommandArgsOptions) ([]str
if options.NoProc {
args = append(args, "--no-proc")
}
if strings.TrimSpace(options.PolicyReportPath) != "" {
args = append(args, "--policy-report-path", options.PolicyReportPath)
}
args = append(args, "--")
args = append(args, options.Command...)
return args, nil
@@ -103,6 +118,7 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
flags.BoolVar(&config.ApplySeccompThenExec, "apply-seccomp-then-exec", false, "apply seccomp before exec")
flags.BoolVar(&config.BlockUnixSockets, "block-unix-sockets", false, "block AF_UNIX sockets before exec")
flags.BoolVar(&config.NoProc, "no-proc", false, "skip proc mount")
flags.StringVar(&config.PolicyReportPath, "policy-report-path", "", "structured policy report path")
if err := flags.Parse(args); err != nil {
return LinuxSandboxHelperConfig{}, err
}
@@ -122,6 +138,7 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
return LinuxSandboxHelperConfig{}, fmt.Errorf("invalid --permission-profile: %w", err)
}
config.Command = flags.Args()
config.PolicyReportPath = strings.TrimSpace(config.PolicyReportPath)
if len(config.Command) == 0 {
return LinuxSandboxHelperConfig{}, errors.New("missing command after --")
}
@@ -129,16 +146,24 @@ func ParseLinuxSandboxHelperArgs(args []string) (LinuxSandboxHelperConfig, error
}
func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, error) {
plan, err := buildLinuxSandboxBwrapPlan(options)
if err != nil {
return nil, err
}
return plan.Args, nil
}
func buildLinuxSandboxBwrapPlan(options LinuxSandboxBwrapOptions) (linuxSandboxBwrapPlan, error) {
config := options.Config
if config.ApplySeccompThenExec {
return nil, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
return linuxSandboxBwrapPlan{}, errors.New("inner seccomp stage cannot be wrapped by bubblewrap again")
}
if config.UseLandlock {
return nil, errors.New("linux landlock helper mode is not implemented yet")
return linuxSandboxBwrapPlan{}, errors.New("linux landlock helper mode is not implemented yet")
}
helperPath := strings.TrimSpace(options.HelperPath)
if helperPath == "" {
return nil, errors.New("linux sandbox helper path is required")
return linuxSandboxBwrapPlan{}, errors.New("linux sandbox helper path is required")
}
commandCWD := strings.TrimSpace(config.CommandCWD)
if commandCWD == "" {
@@ -154,13 +179,14 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
Command: config.Command,
})
if err != nil {
return nil, err
return linuxSandboxBwrapPlan{}, err
}
args := []string{
"--new-session",
"--die-with-parent",
}
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
filesystemPlan := buildLinuxBwrapFilesystemPlan(config.PermissionProfile)
args = append(args, filesystemPlan.Args...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
@@ -185,10 +211,17 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
}
args = append(args, "--", helperPath)
args = append(args, innerArgs...)
return args, nil
return linuxSandboxBwrapPlan{
Args: args,
ProtectedCreateTargets: filesystemPlan.ProtectedCreateTargets,
}, nil
}
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return buildLinuxBwrapFilesystemPlan(profile).Args
}
func buildLinuxBwrapFilesystemPlan(profile PermissionProfile) linuxBwrapFilesystemPlan {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
// Disabled filesystem policy means no write jail: expose the host root
@@ -200,10 +233,11 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
args = append(args, "--bind", root.Root, root.Root)
}
}
return args
return linuxBwrapFilesystemPlan{Args: args}
}
args := []string{}
protectedCreateTargets := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
@@ -231,7 +265,12 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
args = appendReadOnlyLinuxPathArgs(args, subpath)
}
for _, name := range root.ProtectedMetadataNames {
args = appendReadOnlyLinuxPathArgs(args, filepath.Join(root.Root, name))
path := filepath.Join(root.Root, name)
if pathExists(path) {
args = appendReadOnlyLinuxPathArgs(args, path)
} else {
protectedCreateTargets = append(protectedCreateTargets, path)
}
}
}
for _, path := range fs.DenyWrite {
@@ -240,7 +279,10 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
for _, path := range fs.DenyRead {
args = appendUnreadableLinuxPathArgs(args, path)
}
return args
return linuxBwrapFilesystemPlan{
Args: args,
ProtectedCreateTargets: dedupeStrings(protectedCreateTargets),
}
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
+14 -6
View File
@@ -11,8 +11,8 @@ import (
)
var (
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxIsolatedNetworkGuardFilter = ApplyLinuxIsolatedNetworkGuard
)
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
@@ -32,7 +32,7 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": resolve helper path: "+err.Error())
return 125
}
bwrapArgs, err := BuildLinuxSandboxBwrapArgs(LinuxSandboxBwrapOptions{
bwrapPlan, err := buildLinuxSandboxBwrapPlan(LinuxSandboxBwrapOptions{
Config: config,
HelperPath: helperPath,
})
@@ -45,7 +45,10 @@ func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": bubblewrap is not available: "+err.Error())
return 125
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapArgs...), os.Environ()); err != nil {
if len(bwrapPlan.ProtectedCreateTargets) > 0 {
return runLinuxSandboxWithProtectedCreateMonitor(bwrapPath, bwrapPlan, config.PolicyReportPath, stderr)
}
if err := syscall.Exec(bwrapPath, append([]string{"bwrap"}, bwrapPlan.Args...), os.Environ()); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec bubblewrap: "+err.Error())
return 126
}
@@ -69,9 +72,14 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
return 2
}
// The outer bubblewrap stage already enforces NetworkDeny with a private
// network namespace. Do not install the broad seccomp network filter here:
// it would also block AF_INET/AF_INET6 sockets inside that namespace and
// break isolated localhost test servers. The namespace has loopback only,
// so local bind/connect works while external egress remains unreachable.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
if err := applyLinuxNetworkDenyFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
if err := applyLinuxIsolatedNetworkGuardFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply isolated network guard: "+err.Error())
return 125
}
}
+15 -15
View File
@@ -8,18 +8,18 @@ import (
"testing"
)
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
func TestLinuxSandboxInnerStageReliesOnOuterNamespaceForNetworkDeny(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
originalUnixBlock := applyUnixSocketBlockFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
applyUnixSocketBlockFilter = originalUnixBlock
})
networkDenyCalls := 0
networkGuardCalls := 0
unixBlockCalls := 0
applyLinuxNetworkDenyFilter = func() error {
networkDenyCalls++
applyLinuxIsolatedNetworkGuardFilter = func() error {
networkGuardCalls++
return nil
}
applyUnixSocketBlockFilter = func() error {
@@ -35,24 +35,24 @@ func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 after Unix-socket filter; stderr=%s", code, stderr.String())
}
if networkDenyCalls != 1 {
t.Fatalf("network deny filter calls = %d, want 1", networkDenyCalls)
if networkGuardCalls != 1 {
t.Fatalf("isolated network guard calls = %d, want 1", networkGuardCalls)
}
if unixBlockCalls != 1 {
t.Fatalf("unix socket filter calls = %d, want 1", unixBlockCalls)
}
}
func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
func TestLinuxSandboxInnerStageSkipsIsolatedNetworkGuardWhenNetworkAllowed(t *testing.T) {
originalNetworkGuard := applyLinuxIsolatedNetworkGuardFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyLinuxIsolatedNetworkGuardFilter = originalNetworkGuard
})
applyLinuxNetworkDenyFilter = func() error {
return errors.New("network deny filter should not run")
applyLinuxIsolatedNetworkGuardFilter = func() error {
return errors.New("isolated network guard should not run")
}
var stderr bytes.Buffer
@@ -62,6 +62,6 @@ func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T)
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
t.Fatalf("exit code = %d, want lookup failure 127 without isolated network guard; stderr=%s", code, stderr.String())
}
}
+29
View File
@@ -236,6 +236,35 @@ func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
}
}
func TestLinuxBwrapFilesystemPlanPreservesMissingProtectedMetadata(t *testing.T) {
workspace := t.TempDir()
existing := filepath.Join(workspace, ".git")
if err := os.Mkdir(existing, 0o755); err != nil {
t.Fatalf("Mkdir existing metadata: %v", err)
}
missing := filepath.Join(workspace, ".zero")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{
Root: workspace,
ProtectedMetadataNames: []string{".git", ".zero"},
}},
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
plan := buildLinuxBwrapFilesystemPlan(profile)
assertArgsContainSequence(t, plan.Args, "--ro-bind", existing, existing)
if argsContainSequence(plan.Args, "--tmpfs", missing) || argsContainSequence(plan.Args, "--ro-bind", missing, missing) {
t.Fatalf("missing protected metadata must remain absent inside the sandbox: %#v", plan.Args)
}
if !reflect.DeepEqual(plan.ProtectedCreateTargets, []string{missing}) {
t.Fatalf("protected create targets = %#v, want %#v", plan.ProtectedCreateTargets, []string{missing})
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
+7
View File
@@ -228,6 +228,9 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
if request.ValidateExecution && preference != SandboxPreferenceForbid && backend.SupportLevel() != BackendSupportNative && policyHasExplicitDeny(policy) {
return SandboxExecutionRequest{}, errors.New("native sandbox unavailable: configured deny_read or deny_write rules cannot be enforced")
}
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
// (it applies WFP network filters + workspace ACLs and writes a marker).
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
@@ -283,6 +286,10 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
}, nil
}
func policyHasExplicitDeny(policy Policy) bool {
return len(normalizeProfilePaths(policy.DenyRead)) > 0 || len(normalizeProfilePaths(policy.DenyWrite)) > 0
}
func (manager SandboxManager) BuildCommandPlan(request SandboxManagerRequest) (CommandPlan, error) {
execRequest, err := manager.BuildExecutionRequest(request)
if err != nil {
@@ -0,0 +1,87 @@
package sandbox
import (
"path/filepath"
"strings"
"testing"
)
// The implementation mechanism differs by OS, but these observable policy
// semantics must remain stable across adapters.
func TestPlatformAdaptersShareExecutionContract(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
profile := PermissionProfileFromPolicy(root, policy, nil)
if !profile.RequiresPlatformSandbox() {
t.Fatal("baseline profile must require platform enforcement")
}
protected := strings.Join(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, "\x00")
for _, name := range []string{".zero", ".agents"} {
if !strings.Contains(protected, name) {
t.Fatalf("baseline profile missing protected metadata %q: %#v", name, profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
}
}
resolvedRoot := profile.FileSystem.WriteRoots[0].Root
for _, subpath := range []string{filepath.Join(resolvedRoot, ".git", "hooks"), filepath.Join(resolvedRoot, ".git", "config")} {
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ReadOnlySubpaths, subpath) {
t.Fatalf("baseline profile missing git metadata carveout %q: %#v", subpath, profile.FileSystem.WriteRoots[0].ReadOnlySubpaths)
}
}
t.Run("macOS native plan", func(t *testing.T) {
backend := Backend{Name: BackendMacOSSeatbelt, Available: true, Platform: "darwin", CommandWrapping: true, NativeIsolation: true, Executable: "/usr/bin/sandbox-exec"}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "darwin", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendMacOSSeatbelt || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("macOS request = %#v", request)
}
network := networkRuleForProfile(request.PermissionProfile.Network)
if network != "(deny network*)" {
t.Fatalf("macOS restricted network profile = %q, want strict deny", network)
}
})
t.Run("Windows native plan", func(t *testing.T) {
original := windowsSandboxInitialized
windowsSandboxInitialized = func() bool { return true }
t.Cleanup(func() { windowsSandboxInitialized = original })
backend := Backend{Name: BackendWindowsRestrictedToken, Available: true, Platform: "windows", CommandWrapping: true, NativeIsolation: true, Executable: `C:\zero\zero.exe`}
request, err := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend}).BuildExecutionRequest(SandboxManagerRequest{
WorkspaceRoot: root, Policy: policy, Profile: profile, Command: CommandSpec{Name: "cmd.exe", Args: WindowsShellArgs("echo ok"), Dir: root},
})
if err != nil {
t.Fatal(err)
}
if request.TargetBackend != BackendWindowsRestrictedToken || !request.CommandWrapped || request.EnforcementLevel != EnforcementNative {
t.Fatalf("Windows request = %#v", request)
}
if request.PermissionProfile.Network.Mode != NetworkDeny {
t.Fatalf("Windows native plan lost network restriction: %#v", request.PermissionProfile.Network)
}
})
}
func TestUnavailablePlatformAdaptersFailClosedOnlyForExplicitDenies(t *testing.T) {
root := t.TempDir()
for _, goos := range []string{"darwin", "windows"} {
t.Run(goos, func(t *testing.T) {
backend := unavailableBackend(goos, "native sandbox unavailable")
manager := NewSandboxManager(SandboxManagerOptions{GOOS: goos, Backend: backend})
baseline := DefaultPolicy()
request, err := manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: baseline, Command: CommandSpec{Name: "echo", Dir: root}})
if err != nil || request.EnforcementLevel != EnforcementDegraded || request.CommandWrapped {
t.Fatalf("baseline degraded request=%#v err=%v", request, err)
}
hardened := baseline
hardened.DenyRead = []string{"secrets"}
_, err = manager.BuildExecutionRequest(SandboxManagerRequest{WorkspaceRoot: root, Policy: hardened, Command: CommandSpec{Name: "echo", Dir: root}, ValidateExecution: true})
if err == nil {
t.Fatal("explicit deny on unavailable adapter must fail closed")
}
})
}
}
+1
View File
@@ -18,6 +18,7 @@ const (
type PermissionProfile struct {
FileSystem FileSystemPolicy `json:"fileSystem"`
Network NetworkPolicy `json:"network"`
Runtime *SandboxRuntime `json:"runtime,omitempty"`
}
type FileSystemPolicy struct {
+283
View File
@@ -0,0 +1,283 @@
//go:build linux
package sandbox
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/unix"
)
const protectedCreatePollInterval = 10 * time.Millisecond
// An absent protected path cannot be mounted read-only without making it
// appear inside the child. The monitor preserves workspace path fidelity and
// treats the kernel's create/move event as the denial signal. The path can
// briefly appear on the host before the command is killed and it is removed;
// parsing the event ensures a fast create+unlink is still reported.
type protectedCreateMonitor struct {
targets []string
stderr io.Writer
inotifyFD int
watches map[int]string
targetSet map[string]struct{}
stop chan struct{}
done chan struct{}
violation atomic.Bool
once sync.Once
path string
}
type synchronizedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (writer *synchronizedWriter) Write(data []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.writer.Write(data)
}
func runLinuxSandboxWithProtectedCreateMonitor(bwrapPath string, plan linuxSandboxBwrapPlan, reportPath string, stderr io.Writer) int {
if stderr == nil {
stderr = io.Discard
}
safeStderr := &synchronizedWriter{writer: stderr}
monitor, err := newProtectedCreateMonitor(plan.ProtectedCreateTargets, safeStderr)
if err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": prepare protected metadata monitor: "+err.Error())
return 125
}
command := exec.Command(bwrapPath, plan.Args...)
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = safeStderr
command.Env = os.Environ()
if err := command.Start(); err != nil {
monitor.close()
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": start bubblewrap: "+err.Error())
return 126
}
monitor.start(func() {
_ = command.Process.Kill()
})
waitErr := command.Wait()
violated, target := monitor.stopAndCleanup()
if violated {
report := execution.AdapterReport{Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: target},
Source: execution.DenialSourceConfiguredPolicy,
Reason: "protected workspace metadata cannot be created by a sandboxed command",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
}}
if err := writeLinuxExecutionReport(reportPath, report); err != nil {
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": write structured policy report: "+err.Error())
}
return 1
}
if waitErr == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
return exitErr.ExitCode()
}
fmt.Fprintln(safeStderr, LinuxSandboxHelperName+": wait for bubblewrap: "+waitErr.Error())
return 126
}
func newProtectedCreateMonitor(targets []string, stderr io.Writer) (*protectedCreateMonitor, error) {
monitor := &protectedCreateMonitor{
targets: dedupeStrings(targets),
stderr: stderr,
inotifyFD: -1,
stop: make(chan struct{}),
done: make(chan struct{}),
watches: make(map[int]string),
targetSet: make(map[string]struct{}),
}
for _, target := range monitor.targets {
target = filepath.Clean(target)
monitor.targetSet[target] = struct{}{}
if _, err := os.Lstat(target); err == nil {
return nil, fmt.Errorf("protected metadata path appeared before sandbox start: %s", target)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("inspect protected metadata path %s: %w", target, err)
}
}
monitor.inotifyFD, monitor.watches = openProtectedCreateInotify(monitor.targets)
return monitor, nil
}
func openProtectedCreateInotify(targets []string) (int, map[int]string) {
fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
return -1, nil
}
parents := make(map[string]struct{}, len(targets))
watches := make(map[int]string, len(targets))
for _, target := range targets {
parent := filepath.Dir(target)
if _, exists := parents[parent]; exists {
continue
}
parents[parent] = struct{}{}
watch, err := unix.InotifyAddWatch(fd, parent, unix.IN_CREATE|unix.IN_MOVED_TO|unix.IN_DELETE_SELF|unix.IN_MOVE_SELF)
if err != nil {
continue
}
watches[watch] = parent
}
if len(watches) == 0 {
_ = unix.Close(fd)
return -1, nil
}
return fd, watches
}
func (monitor *protectedCreateMonitor) start(onViolation func()) {
go func() {
defer close(monitor.done)
for {
monitor.scanAndRemove(onViolation)
monitor.wait(onViolation)
select {
case <-monitor.stop:
return
default:
}
}
}()
}
func (monitor *protectedCreateMonitor) wait(onViolation func()) {
if monitor.inotifyFD < 0 {
select {
case <-monitor.stop:
case <-time.After(protectedCreatePollInterval):
}
return
}
pollFD := []unix.PollFd{{Fd: int32(monitor.inotifyFD), Events: unix.POLLIN}}
_, _ = unix.Poll(pollFD, int(protectedCreatePollInterval/time.Millisecond))
if pollFD[0].Revents&unix.POLLIN != 0 {
monitor.drainInotifyEvents(onViolation)
}
}
func (monitor *protectedCreateMonitor) drainInotifyEvents(onViolation func()) {
var buffer [4096]byte
const eventHeaderSize = 16
for {
read, err := unix.Read(monitor.inotifyFD, buffer[:])
if err != nil {
return
}
for offset := 0; offset+eventHeaderSize <= read; {
watch := int(int32(binary.NativeEndian.Uint32(buffer[offset : offset+4])))
mask := binary.NativeEndian.Uint32(buffer[offset+4 : offset+8])
nameLength := int(binary.NativeEndian.Uint32(buffer[offset+12 : offset+16]))
next := offset + eventHeaderSize + nameLength
if nameLength < 0 || next > read {
break
}
name := strings.TrimRight(string(buffer[offset+eventHeaderSize:next]), "\x00")
if parent, ok := monitor.watches[watch]; ok && mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 && name != "" {
target := filepath.Clean(filepath.Join(parent, name))
if _, protected := monitor.targetSet[target]; protected {
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
offset = next
}
}
}
func (monitor *protectedCreateMonitor) scanAndRemove(onViolation func()) {
for _, target := range monitor.targets {
if _, err := os.Lstat(target); os.IsNotExist(err) {
continue
} else if err != nil {
continue
}
monitor.recordViolation(target, onViolation)
removeProtectedCreateTarget(target)
}
}
func (monitor *protectedCreateMonitor) recordViolation(target string, onViolation func()) {
monitor.once.Do(func() {
monitor.violation.Store(true)
monitor.path = target
fmt.Fprintln(monitor.stderr, "sandbox blocked creation of protected workspace metadata path "+target)
onViolation()
})
}
func removeProtectedCreateTarget(target string) bool {
for attempt := 0; attempt < 100; attempt++ {
_, err := os.Lstat(target)
if os.IsNotExist(err) {
return attempt > 0
}
if err != nil {
return false
}
err = os.RemoveAll(target)
if err == nil || os.IsNotExist(err) {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
func (monitor *protectedCreateMonitor) stopAndCleanup() (bool, string) {
close(monitor.stop)
<-monitor.done
monitor.scanAndRemove(func() {})
monitor.close()
return monitor.violation.Load(), monitor.path
}
func (monitor *protectedCreateMonitor) close() {
if monitor.inotifyFD >= 0 {
_ = unix.Close(monitor.inotifyFD)
monitor.inotifyFD = -1
}
}
func writeLinuxExecutionReport(path string, report execution.AdapterReport) error {
if path == "" {
return errors.New("policy report path is unavailable")
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
encoderErr := json.NewEncoder(file).Encode(report)
closeErr := file.Close()
if encoderErr != nil {
_ = os.Remove(path)
return encoderErr
}
return closeErr
}
@@ -0,0 +1,67 @@
//go:build linux
package sandbox
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestProtectedCreateMonitorStopsCommandAndRemovesTarget(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
started := time.Now()
code := runLinuxSandboxWithProtectedCreateMonitor("/bin/sh", linuxSandboxBwrapPlan{
Args: []string{"-c", "mkdir " + shellQuote(target) + "; while :; do :; done"},
ProtectedCreateTargets: []string{target},
}, "", &stderr)
if code != 1 {
t.Fatalf("exit code = %d, want policy failure 1; stderr=%s", code, stderr.String())
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("protected creation did not stop command promptly; elapsed=%s", elapsed)
}
if !strings.Contains(stderr.String(), "blocked creation of protected workspace metadata path") {
t.Fatalf("missing denial reason: %s", stderr.String())
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("protected target remained after denial: %v", err)
}
}
func TestProtectedCreateMonitorRecordsTransientCreateEvent(t *testing.T) {
target := filepath.Join(t.TempDir(), ".zero")
var stderr bytes.Buffer
monitor, err := newProtectedCreateMonitor([]string{target}, &stderr)
if err != nil {
t.Fatalf("newProtectedCreateMonitor: %v", err)
}
if monitor.inotifyFD < 0 {
monitor.close()
t.Skip("inotify is unavailable")
}
if err := os.Mkdir(target, 0o700); err != nil {
monitor.close()
t.Fatal(err)
}
if err := os.Remove(target); err != nil {
monitor.close()
t.Fatal(err)
}
triggered := make(chan struct{}, 1)
monitor.start(func() { triggered <- struct{}{} })
violated, gotTarget := monitor.stopAndCleanup()
if !violated || gotTarget != target {
t.Fatalf("transient creation = violated %t target %q, want true %q; stderr=%s", violated, gotTarget, target, stderr.String())
}
select {
case <-triggered:
default:
t.Fatal("transient create event did not invoke violation callback")
}
}
+32
View File
@@ -311,6 +311,38 @@ func (engine *Engine) GrantRequestPermissions(profile RequestPermissionProfile,
}
}
// CoversRequestPermissions reports whether the engine's current effective
// policy already includes every capability in profile. Callers use this before
// prompting for an explicit per-command permission request: asking for a
// capability that was granted for the session is not a new elevation.
func (engine *Engine) CoversRequestPermissions(profile RequestPermissionProfile) bool {
if engine == nil {
return false
}
policy := engine.effectivePolicy(engine.policy)
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled &&
NormalizeNetworkMode(policy.Network) != NetworkAllow {
return false
}
if profile.FileSystem == nil {
return true
}
workspaceRoot := engine.workspaceRoot
scope := engine.scopeFor(workspaceRoot)
enforceWorkspace := policy.EnforceWorkspace && workspaceRoot != ""
for _, path := range profile.FileSystem.Read {
if validatePathWithPolicy(scope, policy, SideEffectRead, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
for _, path := range profile.FileSystem.Write {
if validatePathWithPolicy(scope, policy, SideEffectWrite, enforceWorkspace, workspaceRoot, path) != nil {
return false
}
}
return true
}
func permissionRoot(path string) string {
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil && info.IsDir() {
@@ -140,6 +140,40 @@ func TestGrantRequestPermissionsSessionPersists(t *testing.T) {
}
}
func TestCoversRequestPermissionsReusesSessionNetworkCapability(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
if engine.CoversRequestPermissions(profile) {
t.Fatal("default network-deny policy must not cover external network access")
}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeSession)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
cleanup()
if !engine.CoversRequestPermissions(profile) {
t.Fatal("session network grant should cover an equivalent later request")
}
}
func TestCoversRequestPermissionsDoesNotOutliveTurnGrant(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
enabled := true
profile := RequestPermissionProfile{Network: &NetworkPermissions{Enabled: &enabled}}
cleanup, err := engine.GrantRequestPermissions(profile, PermissionGrantScopeTurn)
if err != nil {
t.Fatalf("GrantRequestPermissions: %v", err)
}
if !engine.CoversRequestPermissions(profile) {
t.Fatal("active turn grant should cover the requested network capability")
}
cleanup()
if engine.CoversRequestPermissions(profile) {
t.Fatal("cleaned-up turn grant must not cover later requests")
}
}
func TestPermissionProfileIncludesReadOnlyGrantAsReadRootOnly(t *testing.T) {
workspace := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
+109 -36
View File
@@ -2,6 +2,9 @@ package sandbox
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
@@ -11,6 +14,7 @@ import (
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -62,6 +66,10 @@ type CommandPlan struct {
// cleanup releases resources tied to the plan's lifetime. It is never
// serialized; callers invoke it via Cleanup() once the command has finished.
cleanup func()
// executionReportPath is an adapter-owned side channel outside the
// workspace. It carries structured policy facts; command output is never
// parsed as the control protocol.
executionReportPath string
}
// Cleanup releases any resources the plan holds. It is safe to call on a zero
@@ -72,6 +80,26 @@ func (plan CommandPlan) Cleanup() {
}
}
// ExecutionReport reads the structured platform-adapter report for a finished
// command. A command with no adapter report returns an empty report.
func (plan CommandPlan) ExecutionReport() (execution.AdapterReport, error) {
if strings.TrimSpace(plan.executionReportPath) == "" {
return execution.AdapterReport{}, nil
}
data, err := os.ReadFile(plan.executionReportPath)
if os.IsNotExist(err) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, fmt.Errorf("read sandbox execution report: %w", err)
}
var report execution.AdapterReport
if err := json.Unmarshal(data, &report); err != nil {
return execution.AdapterReport{}, fmt.Errorf("decode sandbox execution report: %w", err)
}
return report, nil
}
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
if ctx == nil {
ctx = context.Background()
@@ -86,6 +114,35 @@ func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*ex
return command, plan, nil
}
// PrepareExecution adapts the platform-neutral execution interface to the
// sandbox engine. Callers never need to construct native sandbox commands or
// interpret adapter reports themselves.
func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
if err := request.Validate(); err != nil {
return execution.PreparedCommand{}, err
}
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: request.Command.Name,
Args: append([]string(nil), request.Command.Args...),
Dir: request.WorkingDirectory,
Env: append([]string(nil), request.Command.Env...),
})
if err != nil {
return execution.PreparedCommand{}, err
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
},
Report: plan.ExecutionReport,
Cleanup: plan.Cleanup,
}, nil
}
// writeRoots returns the full ordered write-root list for command plans:
// the workspace root plus any granted extra roots. The single-root fallback
// only applies to engines built without a workspace root (NewEngine always
@@ -144,11 +201,20 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
preference = SandboxPreferenceForbid
}
profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope)
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
runtimeCleanup = cleanup
profile = permissionProfileWithRuntime(profile, runtimeState)
}
manager := NewSandboxManager(SandboxManagerOptions{
GOOS: backend.Platform,
Backend: backend,
})
return manager.BuildCommandPlan(SandboxManagerRequest{
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: workspaceRoot,
Command: spec,
Policy: policy,
@@ -157,6 +223,14 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
Preference: preference,
ValidateExecution: true,
})
if err != nil {
if runtimeCleanup != nil {
runtimeCleanup()
}
return CommandPlan{}, err
}
plan.cleanup = combineSandboxCleanups(plan.cleanup, runtimeCleanup)
return plan, nil
}
func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
@@ -201,38 +275,56 @@ func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy P
helper = resolved
}
command := append([]string{spec.Name}, spec.Args...)
reportPath, err := newLinuxExecutionReportPath()
if err != nil {
return CommandPlan{}, err
}
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: execRequest.WorkspaceRoot,
CommandCWD: spec.Dir,
PermissionProfile: execRequest.PermissionProfile,
BlockUnixSockets: policy.BlockUnixSockets,
PolicyReportPath: reportPath,
Command: command,
})
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, execRequest.PermissionProfile.Runtime)
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
}
plan := CommandPlan{
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
executionReportPath: reportPath,
cleanup: func() {
_ = os.Remove(reportPath)
},
}
return withSandboxExecutionMetadata(plan, execRequest), nil
}
func newLinuxExecutionReportPath() (string, error) {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return "", fmt.Errorf("generate sandbox execution report path: %w", err)
}
return filepath.Join("/tmp", "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil
}
func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequest) CommandPlan {
plan.Backend = request.Backend
plan.TargetBackend = request.TargetBackend
@@ -340,6 +432,7 @@ func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, prof
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, profile.Runtime)
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
@@ -382,17 +475,6 @@ func seatbeltCompatibilityPermissionProfile(writeRoots []string, policy Policy)
}
}
func existingBubblewrapMounts() []string {
candidates := []string{"/bin", "/usr", "/lib", "/lib64", "/sbin", "/etc"}
mounts := []string{}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
mounts = append(mounts, candidate)
}
}
return mounts
}
func sandboxEnvironment(policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommand(nil, policy, backend, workspaceRoot)
}
@@ -789,13 +871,6 @@ func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string {
return out
}
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
// for a single file. Empty when DenyWrite is unset.
func denyWriteRules(policy Policy) []string {
return denyWriteRulesFromPaths(resolvePolicyPaths(policy.DenyWrite))
}
func denyWriteRulesFromPaths(paths []string) []string {
return denySeatbeltPathRules("file-write*", paths)
}
@@ -823,16 +898,14 @@ func denySeatbeltPathRules(action string, paths []string) []string {
return out
}
// networkRuleFor returns the seatbelt network clause for a policy.
func networkRuleFor(policy Policy) string {
return networkRuleForProfile(NetworkPolicy{Mode: policy.Network})
}
func networkRuleForProfile(network NetworkPolicy) string {
switch network.Mode {
case NetworkAllow:
return "(allow network*)"
default:
// Seatbelt has no private network namespace. Its localhost filters can
// reach services on the host and host interfaces, so the restricted
// profile must deny the entire network surface.
return "(deny network*)"
}
}
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
@@ -44,6 +46,11 @@ func TestLinuxHelperRealSandboxSmoke(t *testing.T) {
Name: "/bin/sh",
Args: []string{"-c", strings.Join([]string{
"set -eu",
"test \"$HOME\" != \"$PWD\"",
"test ! -e .zero && test ! -e .agents",
"echo cache > \"$npm_config_cache/zero-runtime-probe\"",
"test ! -e .npm && test ! -e .cache",
"rm -f \"$npm_config_cache/zero-runtime-probe\"",
"echo ok > write-ok.txt",
"test \"$(cat write-ok.txt)\" = ok",
"echo tmp > /tmp/zero-sandbox-smoke",
@@ -167,6 +174,95 @@ func TestLinuxLandlockRealSandboxSmoke(t *testing.T) {
}
}
func TestLinuxHelperAllowsIsolatedLoopbackWithoutExternalEgress(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
python, err := exec.LookPath("python3")
if err != nil || python == "" {
t.Skip("python3 not found; skipping isolated loopback probe")
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
loopbackScript := strings.Join([]string{
"import socket",
"server = socket.socket()",
"server.bind(('127.0.0.1', 0))",
"server.listen()",
"client = socket.socket()",
"client.connect(('127.0.0.1', server.getsockname()[1]))",
"accepted, _ = server.accept()",
"client.send(b'ok')",
"assert accepted.recv(2) == b'ok'",
}, "; ")
output, runErr := runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", loopbackScript},
Dir: root,
})
if runErr != nil {
t.Fatalf("isolated loopback failed: %v\n%s", runErr, output)
}
output, runErr = runLinuxSandboxSmokeCommand(t, engine, CommandSpec{
Name: python,
Args: []string{"-c", "import socket; socket.create_connection(('1.1.1.1', 80), 1).close()"},
Dir: root,
})
if runErr == nil {
t.Fatalf("sandbox allowed external egress while isolated loopback was enabled: %s", output)
}
}
func TestLinuxHelperPreservesAbsentProtectedMetadata(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real sandbox smoke tests")
}
backend := SelectBackend(BackendOptions{})
if !backend.Available || backend.Name != BackendLinuxBwrap {
t.Skipf("Linux sandbox backend unavailable: %s", backend.Message)
}
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: backend})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: "/bin/sh",
Args: []string{"-c", "set -eu; test ! -e .zero; mkdir .zero"},
Dir: root,
})
if err != nil {
t.Fatalf("CommandContext: %v", err)
}
defer plan.Cleanup()
output, runErr := command.CombinedOutput()
if runErr == nil {
t.Fatalf("sandbox reported success after protected metadata creation; output=%s", output)
}
if !strings.Contains(string(output), "blocked creation of protected workspace metadata path") {
t.Fatalf("sandbox did not explain protected metadata denial: %v\n%s", runErr, output)
}
if _, err := os.Lstat(filepath.Join(root, ".zero")); !os.IsNotExist(err) {
t.Fatalf("protected metadata path remained after execution: %v", err)
}
report, err := plan.ExecutionReport()
if err != nil {
t.Fatalf("ExecutionReport: %v", err)
}
if report.Denial == nil || report.Denial.Capability.Kind != execution.CapabilityProtectedMetadata {
t.Fatalf("execution report denial = %#v, want protected metadata", report.Denial)
}
if report.Denial.Capability.Scope != filepath.Join(root, ".zero") {
t.Fatalf("denial scope = %q, want exact protected path", report.Denial.Capability.Scope)
}
}
func runLinuxSandboxSmokeCommand(t *testing.T, engine *Engine, spec CommandSpec) ([]byte, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+5
View File
@@ -399,6 +399,11 @@ func TestSeatbeltProfileConsumesPermissionProfile(t *testing.T) {
t.Fatalf("Seatbelt profile missing %q:\n%s", want, sbpl)
}
}
for _, forbidden := range []string{"network-bind", "network-inbound", `remote ip "localhost:*"`} {
if strings.Contains(sbpl, forbidden) {
t.Fatalf("restricted Seatbelt profile must not contain host-local rule %q:\n%s", forbidden, sbpl)
}
}
if strings.Contains(sbpl, "(allow file-read*)\n(allow file-write*)") {
t.Fatalf("restricted permission profile must not become full read/write:\n%s", sbpl)
}
+40
View File
@@ -0,0 +1,40 @@
package sandbox
import (
"fmt"
"sync"
)
const sandboxRuntimeLeaseSuffix = ".lease"
type sandboxRuntimeLease struct {
handle runtimeLeaseHandle
once sync.Once
}
func sandboxRuntimeLeasePath(root string) string {
return root + sandboxRuntimeLeaseSuffix
}
func acquireSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
handle, err := acquireSharedRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil {
return nil, fmt.Errorf("acquire sandbox runtime lease: %w", err)
}
return &sandboxRuntimeLease{handle: handle}, nil
}
func tryAcquireSandboxRuntimeCleanupLease(root string) (*sandboxRuntimeLease, bool, error) {
handle, inUse, err := tryAcquireExclusiveRuntimeLease(sandboxRuntimeLeasePath(root))
if err != nil || inUse {
return nil, inUse, err
}
return &sandboxRuntimeLease{handle: handle}, false, nil
}
func (lease *sandboxRuntimeLease) release() {
if lease == nil {
return
}
lease.once.Do(lease.handle.release)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !windows
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/unix"
)
type runtimeLeaseHandle struct {
file *os.File
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_SH); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return runtimeLeaseHandle{file: file}, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
_ = file.Close()
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return runtimeLeaseHandle{file: file}, false, nil
}
func (lease runtimeLeaseHandle) release() {
if lease.file == nil {
return
}
_ = unix.Flock(int(lease.file.Fd()), unix.LOCK_UN)
_ = lease.file.Close()
}
+53
View File
@@ -0,0 +1,53 @@
//go:build windows
package sandbox
import (
"errors"
"os"
"golang.org/x/sys/windows"
)
type runtimeLeaseHandle struct {
file *os.File
overlapped windows.Overlapped
}
func acquireSharedRuntimeLease(path string) (runtimeLeaseHandle, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, err
}
handle := runtimeLeaseHandle{file: file}
if err := windows.LockFileEx(windows.Handle(file.Fd()), 0, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
return runtimeLeaseHandle{}, err
}
return handle, nil
}
func tryAcquireExclusiveRuntimeLease(path string) (runtimeLeaseHandle, bool, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return runtimeLeaseHandle{}, false, err
}
handle := runtimeLeaseHandle{file: file}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &handle.overlapped); err != nil {
_ = file.Close()
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
return runtimeLeaseHandle{}, true, nil
}
return runtimeLeaseHandle{}, false, err
}
return handle, false, nil
}
func (lease runtimeLeaseHandle) release() {
if lease.file == nil {
return
}
_ = windows.UnlockFileEx(windows.Handle(lease.file.Fd()), 0, 1, 0, &lease.overlapped)
_ = lease.file.Close()
}
+242
View File
@@ -0,0 +1,242 @@
package sandbox
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
var sandboxUserCacheDir = os.UserCacheDir
var sandboxRuntimeNow = time.Now
const (
sandboxRuntimeMaxAge = 30 * 24 * time.Hour
sandboxRuntimeMaxRoots = 64
)
var fallbackSandboxRuntimes = struct {
sync.Mutex
roots map[string]string
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Home string `json:"home,omitempty"`
Cache string `json:"cache,omitempty"`
Config string `json:"config,omitempty"`
Data string `json:"data,omitempty"`
State string `json:"state,omitempty"`
Temp string `json:"temp,omitempty"`
}
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
workspaceRoot = filepath.Clean(strings.TrimSpace(workspaceRoot))
if workspaceRoot == "" || workspaceRoot == "." {
return SandboxRuntime{}, nil, errors.New("sandbox runtime requires a workspace root")
}
cacheRoot, err := sandboxUserCacheDir()
if err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("resolve user cache directory: %w", err)
}
cacheRoot = filepath.Clean(strings.TrimSpace(cacheRoot))
if cacheRoot == "" || cacheRoot == "." {
return SandboxRuntime{}, nil, errors.New("user cache directory is unavailable")
}
digest := sha256.Sum256([]byte(workspaceRoot))
root := filepath.Join(cacheRoot, "zero", "runtime", "v1", hex.EncodeToString(digest[:8]))
if pathWithinRoot(workspaceRoot, root) {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
lease, err := prepareSandboxRuntimeLease(root)
if err != nil {
root, err = fallbackSandboxRuntimeRoot(workspaceRoot)
if err != nil {
return SandboxRuntime{}, nil, err
}
lease, err = prepareSandboxRuntimeLease(root)
if err != nil {
return SandboxRuntime{}, nil, err
}
}
prepared := false
defer func() {
if !prepared {
lease.release()
}
}()
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
}
directories := []string{
runtimeState.Root,
runtimeState.Home,
runtimeState.Cache,
runtimeState.Config,
runtimeState.Data,
runtimeState.State,
runtimeState.Temp,
filepath.Join(runtimeState.Cache, "npm"),
filepath.Join(runtimeState.Cache, "yarn"),
filepath.Join(runtimeState.Cache, "corepack"),
filepath.Join(runtimeState.Cache, "pip"),
filepath.Join(runtimeState.Cache, "go-build"),
filepath.Join(runtimeState.Data, "go-mod"),
filepath.Join(runtimeState.Data, "cargo"),
}
for _, directory := range directories {
if err := os.MkdirAll(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("create sandbox runtime directory %s: %w", directory, err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("secure sandbox runtime directory %s: %w", directory, err)
}
}
now := sandboxRuntimeNow()
if err := os.Chtimes(runtimeState.Root, now, now); err != nil {
return SandboxRuntime{}, nil, fmt.Errorf("touch sandbox runtime root: %w", err)
}
cleanupSandboxRuntimeRoots(filepath.Dir(runtimeState.Root), runtimeState.Root, now)
prepared = true
return runtimeState, lease.release, nil
}
func prepareSandboxRuntimeLease(root string) (*sandboxRuntimeLease, error) {
if err := os.MkdirAll(filepath.Dir(root), 0o700); err != nil {
return nil, fmt.Errorf("create sandbox runtime parent: %w", err)
}
return acquireSandboxRuntimeLease(root)
}
// cleanupSandboxRuntimeRoots applies a conservative age/count policy. Cleanup
// is best-effort and never removes the runtime selected for the current
// command, so cache maintenance cannot turn a valid execution into a failure.
func cleanupSandboxRuntimeRoots(parent, current string, now time.Time) {
entries, err := os.ReadDir(parent)
if err != nil {
return
}
type candidate struct {
path string
modTime time.Time
}
var candidates []candidate
for _, entry := range entries {
path := filepath.Join(parent, entry.Name())
if !entry.IsDir() || filepath.Clean(path) == filepath.Clean(current) {
continue
}
info, infoErr := entry.Info()
if infoErr != nil {
continue
}
if now.Sub(info.ModTime()) > sandboxRuntimeMaxAge {
removeSandboxRuntimeRootIfUnused(path)
continue
}
candidates = append(candidates, candidate{path: path, modTime: info.ModTime()})
}
if len(candidates) < sandboxRuntimeMaxRoots {
return
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].modTime.Before(candidates[j].modTime) })
for len(candidates) >= sandboxRuntimeMaxRoots {
removeSandboxRuntimeRootIfUnused(candidates[0].path)
candidates = candidates[1:]
}
}
func removeSandboxRuntimeRootIfUnused(root string) {
lease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(root)
if err != nil || inUse {
return
}
defer lease.release()
_ = os.RemoveAll(root)
}
func combineSandboxCleanups(cleanups ...func()) func() {
var once sync.Once
return func() {
once.Do(func() {
for _, cleanup := range cleanups {
if cleanup != nil {
cleanup()
}
}
})
}
}
func fallbackSandboxRuntimeRoot(workspaceRoot string) (string, error) {
fallbackSandboxRuntimes.Lock()
defer fallbackSandboxRuntimes.Unlock()
if root := fallbackSandboxRuntimes.roots[workspaceRoot]; root != "" {
return root, nil
}
parent, err := os.MkdirTemp("", "zero-runtime-")
if err != nil {
return "", fmt.Errorf("create fallback sandbox runtime: %w", err)
}
root := filepath.Join(parent, "runtime")
if pathWithinRoot(workspaceRoot, root) {
_ = os.RemoveAll(parent)
return "", fmt.Errorf("fallback sandbox runtime root %q must be outside workspace %q", root, workspaceRoot)
}
fallbackSandboxRuntimes.roots[workspaceRoot] = root
return root, nil
}
func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []string {
if runtimeState == nil || strings.TrimSpace(runtimeState.Root) == "" {
return env
}
overrides := []string{
"HOME=" + runtimeState.Home,
"XDG_CACHE_HOME=" + runtimeState.Cache,
"XDG_CONFIG_HOME=" + runtimeState.Config,
"XDG_DATA_HOME=" + runtimeState.Data,
"XDG_STATE_HOME=" + runtimeState.State,
"TMPDIR=" + runtimeState.Temp,
"TMP=" + runtimeState.Temp,
"TEMP=" + runtimeState.Temp,
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
"GOCACHE=" + filepath.Join(runtimeState.Cache, "go-build"),
"GOMODCACHE=" + filepath.Join(runtimeState.Data, "go-mod"),
"CARGO_HOME=" + filepath.Join(runtimeState.Data, "cargo"),
}
return upsertEnvList(env, overrides...)
}
func permissionProfileWithRuntime(profile PermissionProfile, runtimeState SandboxRuntime) PermissionProfile {
profile.Runtime = &runtimeState
if profile.FileSystem.Kind != FileSystemRestricted || runtimeState.Root == "" {
return profile
}
for _, root := range profile.FileSystem.WriteRoots {
if filepath.Clean(root.Root) == filepath.Clean(runtimeState.Root) {
return profile
}
}
profile.FileSystem.WriteRoots = append(profile.FileSystem.WriteRoots, WritableRoot{Root: runtimeState.Root})
return profile
}
+210
View File
@@ -0,0 +1,210 @@
package sandbox
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
}
}
}
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
originalCache := sandboxUserCacheDir
originalNow := sandboxRuntimeNow
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
sandboxRuntimeNow = func() time.Time { return now }
t.Cleanup(func() {
sandboxUserCacheDir = originalCache
sandboxRuntimeNow = originalNow
})
parent := filepath.Join(cacheRoot, "zero", "runtime", "v1")
expired := filepath.Join(parent, "expired")
if err := os.MkdirAll(expired, 0o700); err != nil {
t.Fatal(err)
}
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
if err := os.Chtimes(expired, old, old); err != nil {
t.Fatal(err)
}
_, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
release()
if _, err := os.Stat(expired); !os.IsNotExist(err) {
t.Fatalf("expired runtime still exists: %v", err)
}
}
func TestPrepareSandboxRuntimeFallsBackWhenUserCacheIsInsideWorkspace(t *testing.T) {
workspace := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return filepath.Join(workspace, ".cache"), nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
defer release()
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("fallback runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
if filepath.Clean(filepath.Dir(runtimeState.Root)) == filepath.Clean(os.TempDir()) {
t.Fatalf("fallback runtime %q must use a private cleanup parent", runtimeState.Root)
}
}
func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
originalCache := sandboxUserCacheDir
originalNow := sandboxRuntimeNow
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
sandboxRuntimeNow = func() time.Time { return now }
t.Cleanup(func() {
sandboxUserCacheDir = originalCache
sandboxRuntimeNow = originalNow
})
runtimeState, release, err := prepareSandboxRuntime(workspace)
if err != nil {
t.Fatalf("prepareSandboxRuntime: %v", err)
}
old := now.Add(-sandboxRuntimeMaxAge - time.Hour)
if err := os.Chtimes(runtimeState.Root, old, old); err != nil {
release()
t.Fatal(err)
}
parent := filepath.Dir(runtimeState.Root)
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
if _, err := os.Stat(runtimeState.Root); err != nil {
release()
t.Fatalf("active runtime was removed: %v", err)
}
release()
cleanupSandboxRuntimeRoots(parent, filepath.Join(parent, "other"), now)
if _, err := os.Stat(runtimeState.Root); !os.IsNotExist(err) {
t.Fatalf("released expired runtime still exists: %v", err)
}
}
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"HOME=/workspace",
"XDG_CACHE_HOME=/host/cache",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"HOME": runtimeState.Home,
"XDG_CACHE_HOME": runtimeState.Cache,
"XDG_CONFIG_HOME": runtimeState.Config,
"XDG_DATA_HOME": runtimeState.Data,
"XDG_STATE_HOME": runtimeState.State,
"TMPDIR": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
}
}
if got := envListValue(env, "PATH", ""); got != "/usr/bin" {
t.Fatalf("PATH = %q, want preserved caller path", got)
}
}
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Backend: Backend{
Name: BackendLinuxBwrap,
Available: true,
Platform: "linux",
Executable: "/usr/bin/zero-linux-sandbox",
CommandWrapping: true,
NativeIsolation: true,
},
})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: workspace})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
defer plan.Cleanup()
if plan.PermissionProfile.Runtime == nil || plan.PermissionProfile.Runtime.Root == "" {
t.Fatal("command plan is missing managed runtime state")
}
if cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root); err != nil {
t.Fatalf("inspect active runtime lease: %v", err)
} else if cleanupLease != nil {
cleanupLease.release()
t.Fatal("command plan did not retain its runtime lease")
} else if !inUse {
t.Fatal("command plan runtime must be marked in use")
}
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
}
foundWriteRoot := false
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
if root.Root == plan.PermissionProfile.Runtime.Root {
foundWriteRoot = true
}
}
if !foundWriteRoot {
t.Fatalf("runtime root is not writable in profile: %#v", plan.PermissionProfile.FileSystem.WriteRoots)
}
plan.Cleanup()
cleanupLease, inUse, err := tryAcquireSandboxRuntimeCleanupLease(plan.PermissionProfile.Runtime.Root)
if err != nil || inUse || cleanupLease == nil {
t.Fatalf("runtime lease after plan cleanup = lease %v inUse %t err %v", cleanupLease, inUse, err)
}
cleanupLease.release()
}
+47
View File
@@ -82,6 +82,24 @@ var networkDenySyscallsAARCH64 = []uint32{
427, // io_uring_register
}
var isolatedNetworkGuardSyscallsX86_64 = []uint32{
101, // ptrace
310, // process_vm_readv
311, // process_vm_writev
425, // io_uring_setup
426, // io_uring_enter
427, // io_uring_register
}
var isolatedNetworkGuardSyscallsAARCH64 = []uint32{
117, // ptrace
270, // process_vm_readv
271, // process_vm_writev
425, // io_uring_setup
426, // io_uring_enter
427, // io_uring_register
}
// unixSocketBlockFilter builds a classic-BPF seccomp program that denies
// socket(2)/AF_UNIX with EPERM on x86-64 and arm64 and allows everything else.
// An unrecognized architecture is allowed (fail-open on arch is intentional: the
@@ -134,6 +152,35 @@ func networkDenySeccompFilter() []sockFilter {
return program
}
// isolatedNetworkGuardFilter preserves the non-network defense-in-depth rules
// from networkDenySeccompFilter without blocking sockets. The bubblewrap outer
// stage supplies network isolation with a private namespace, so socket, bind,
// and connect must remain available for localhost-only test servers.
func isolatedNetworkGuardFilter() []sockFilter {
x86Section := syscallDenySection(isolatedNetworkGuardSyscallsX86_64)
armSection := syscallDenySection(isolatedNetworkGuardSyscallsAARCH64)
program := []sockFilter{
{Code: bpfLDWABS, K: seccompOffsetArch},
{Code: bpfJEQK, K: auditArchX86_64, Jt: 0, Jf: uint8(len(x86Section))},
}
program = append(program, x86Section...)
program = append(program, sockFilter{Code: bpfJEQK, K: auditArchAARCH64, Jt: 0, Jf: uint8(len(armSection))})
program = append(program, armSection...)
program = append(program, sockFilter{Code: bpfRETK, K: seccompRetAllow})
return program
}
func syscallDenySection(deniedSyscalls []uint32) []sockFilter {
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
for _, nr := range deniedSyscalls {
section = append(section,
sockFilter{Code: bpfJEQK, K: nr, Jt: 0, Jf: 1},
sockFilter{Code: bpfRETK, K: seccompRetErrno | errnoEPERM},
)
}
return append(section, sockFilter{Code: bpfRETK, K: seccompRetAllow})
}
func networkDenySection(socketNr uint32, socketpairNr uint32, deniedSyscalls []uint32) []sockFilter {
section := []sockFilter{{Code: bpfLDWABS, K: seccompOffsetNr}}
for _, nr := range deniedSyscalls {
+4
View File
@@ -27,6 +27,10 @@ func ApplyLinuxNetworkDeny() error {
return applySeccompFilter(networkDenySeccompFilter(), "network deny")
}
func ApplyLinuxIsolatedNetworkGuard() error {
return applySeccompFilter(isolatedNetworkGuardFilter(), "isolated network guard")
}
func applySeccompFilter(filters []sockFilter, name string) error {
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return fmt.Errorf("seccomp: set no_new_privs: %w", err)
+8 -2
View File
@@ -8,8 +8,14 @@ import "errors"
// platforms, where seccomp BPF is unavailable.
var ErrSeccompUnsupported = errors.New("seccomp Unix-socket blocking is only supported on Linux")
// ApplyUnixSocketBlock is a no-op on non-Linux platforms.
// ApplyUnixSocketBlock is unsupported on non-Linux platforms and always
// returns ErrSeccompUnsupported.
func ApplyUnixSocketBlock() error { return ErrSeccompUnsupported }
// ApplyLinuxNetworkDeny is a no-op on non-Linux platforms.
// ApplyLinuxNetworkDeny is unsupported on non-Linux platforms and always
// returns ErrSeccompUnsupported.
func ApplyLinuxNetworkDeny() error { return ErrSeccompUnsupported }
// ApplyLinuxIsolatedNetworkGuard is unsupported on non-Linux platforms and
// always returns ErrSeccompUnsupported.
func ApplyLinuxIsolatedNetworkGuard() error { return ErrSeccompUnsupported }
+22
View File
@@ -62,6 +62,28 @@ func TestNetworkDenySeccompFilterStructure(t *testing.T) {
}
}
func TestIsolatedNetworkGuardPreservesProcessRestrictionsWithoutBlockingSockets(t *testing.T) {
prog := isolatedNetworkGuardFilter()
assertValidSockFilterProgram(t, prog)
for _, denied := range []uint32{101, 117, 310, 311, 270, 271, 425, 426, 427} {
if !hasInstruction(prog, bpfJEQK, denied) {
t.Fatalf("isolated network guard missing denied syscall %d", denied)
}
}
for _, socketSyscall := range []uint32{nrSocketX86_64, nrSocketAARCH64, 42, 49, 50, 200, 201, 203} {
if hasInstruction(prog, bpfJEQK, socketSyscall) {
t.Fatalf("isolated network guard blocks socket syscall %d needed by loopback", socketSyscall)
}
}
if !hasInstruction(prog, bpfRETK, seccompRetAllow) {
t.Fatal("isolated network guard has no allow return")
}
if !hasInstruction(prog, bpfRETK, seccompRetErrno|errnoEPERM) {
t.Fatal("isolated network guard has no EPERM block return")
}
}
func assertValidSockFilterProgram(t *testing.T, prog []sockFilter) {
t.Helper()
if len(prog) == 0 {
+1
View File
@@ -327,6 +327,7 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
return CommandPlan{}, err
}
childEnv := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendWindowsRestrictedToken, execRequest.WorkspaceRoot, spec.sensitiveEnvKeys)
childEnv = sandboxRuntimeEnvironment(childEnv, execRequest.PermissionProfile.Runtime)
// The unelevated enforcement tier maps to the runner's unelevated level: same
// restricted token, but the runner applies the workspace ACLs itself instead
// of requiring the elevated setup marker.
+4 -1
View File
@@ -89,7 +89,7 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
if resumeID != "" || options.ResumeLatest {
sessionID := resumeID
if sessionID == "" && options.ResumeLatest {
latest, err := store.Latest()
latest, err := store.LatestResumable()
if err != nil {
return PreparedExec{}, err
}
@@ -105,6 +105,9 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
if session == nil {
return PreparedExec{}, ExecError{"Zero session not found: " + sessionID}
}
if !IsResumableKind(session.SessionKind) {
return PreparedExec{}, ExecError{"Zero session is not resumable: " + sessionID}
}
contextEvents, err := readExecContextEvents(store, session.SessionID)
if err != nil {
return PreparedExec{}, err
+20 -10
View File
@@ -52,6 +52,7 @@ type SessionKind string
const (
SessionKindFork SessionKind = "fork"
SessionKindChild SessionKind = "child"
SessionKindSide SessionKind = "side"
SessionKindSpecDraft SessionKind = "spec-draft"
SessionKindSpecImpl SessionKind = "spec-impl"
)
@@ -125,11 +126,13 @@ type CreateInput struct {
}
type ForkInput struct {
SessionID string
Title string
Cwd string
ModelID string
Provider string
SessionID string
SessionKind SessionKind
Title string
Cwd string
ModelID string
Provider string
Tag string
}
type ChildInput struct {
@@ -354,10 +357,9 @@ func (store *Store) Latest() (*Metadata, error) {
}
// IsResumableKind reports whether a session kind represents a standalone,
// user-resumable conversation rather than an agent sub-run. Regular ("") and
// user fork sessions are resumable; child (specialist/sub-agent) and spec
// draft/impl sessions are not — each agent task and /spec run creates one, so
// listing them in the resume picker floods it with non-conversation entries.
// user-resumable conversation rather than a transient or agent-owned sub-run.
// Regular ("") and user fork sessions are resumable; side, child, and spec
// sessions are not, so they do not flood the resume picker.
func IsResumableKind(kind SessionKind) bool {
switch kind {
case "", SessionKindFork:
@@ -418,13 +420,21 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
if title == "" && parent.Title != "" {
title = parent.Title + " (fork)"
}
kind := input.SessionKind
if kind == "" {
kind = SessionKindFork
}
if kind != SessionKindFork && kind != SessionKindSide {
return Metadata{}, fmt.Errorf("invalid zero fork session kind %q", kind)
}
fork, err := store.Create(CreateInput{
SessionID: input.SessionID,
SessionKind: SessionKindFork,
SessionKind: kind,
Title: title,
Cwd: firstNonEmpty(input.Cwd, parent.Cwd),
ModelID: firstNonEmpty(input.ModelID, parent.ModelID),
Provider: firstNonEmpty(input.Provider, parent.Provider),
Tag: input.Tag,
ParentSessionID: parent.SessionID,
RootSessionID: firstNonEmpty(parent.RootSessionID, parent.SessionID),
ForkedFromEventID: last.ID,
+34 -1
View File
@@ -181,6 +181,32 @@ func TestStoreForkCopiesEventsAndLineage(t *testing.T) {
}
}
func TestStoreForkSupportsNonResumableSideSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
if err != nil {
t.Fatalf("Create parent: %v", err)
}
if _, err := store.AppendEvent(parent.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": "context"}}); err != nil {
t.Fatalf("AppendEvent: %v", err)
}
side, err := store.Fork(parent.SessionID, ForkInput{
SessionID: "side",
SessionKind: SessionKindSide,
Tag: "btw",
})
if err != nil {
t.Fatalf("Fork side: %v", err)
}
if side.SessionKind != SessionKindSide || side.Tag != "btw" || side.ParentSessionID != parent.SessionID {
t.Fatalf("side metadata = %#v", side)
}
if IsResumableKind(side.SessionKind) {
t.Fatal("side sessions must not appear as resumable conversations")
}
}
func TestStoreForkSkipsUsageEvents(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T11:00:00Z")})
parent, err := store.Create(CreateInput{SessionID: "parent", Title: "Parent"})
@@ -381,6 +407,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
mk(SessionKindSpecImpl, "spec-impl-1")
newestResumable := mk("", "conversation-2") // newest standalone conversation
mk(SessionKindChild, "child-2") // newer overall, but a sub-run
mk(SessionKindSide, "side-1") // newer overall, but non-resumable
resumable, err := store.ListResumable()
if err != nil {
@@ -404,7 +431,7 @@ func TestListAndLatestResumableExcludeSubRuns(t *testing.T) {
if latest != nil {
got = latest.SessionID
}
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip the newer child)", got, newestResumable.SessionID)
t.Fatalf("LatestResumable = %s, want newest resumable %s (must skip newer child and side sessions)", got, newestResumable.SessionID)
}
}
@@ -535,6 +562,9 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
if _, err := store.AppendEvent("latest", AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "previous answer"}}); err != nil {
t.Fatal(err)
}
if _, err := store.Create(CreateInput{SessionID: "newer-side", SessionKind: SessionKindSide}); err != nil {
t.Fatal(err)
}
prepared, err := PrepareExec(PrepareExecOptions{Store: store, ResumeLatest: true})
if err != nil {
@@ -546,6 +576,9 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
if got := FormatExecPrompt("continue", prepared); got == "continue" || !strings.Contains(got, "previous answer") {
t.Fatalf("expected session context in prompt, got %q", got)
}
if _, err := PrepareExec(PrepareExecOptions{Store: store, Resume: "newer-side"}); err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: newer-side") {
t.Fatalf("explicit side-session resume error = %v, want non-resumable rejection", err)
}
forked, err := PrepareExec(PrepareExecOptions{Store: store, Fork: "latest", SessionID: "forked"})
if err != nil {
+52 -18
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execution"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/secrets"
)
@@ -142,6 +143,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
}
defer plan.Cleanup()
addSandboxMeta(meta, plan)
executionRequest := execExecutionRequest(command, plan, absoluteCwd, false)
changeObserver := execution.NewChangeObserver(plan.WorkspaceRoot)
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
// can't grow Zero's memory before truncation: only the head+tail each stream
@@ -161,6 +164,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
monitor := zeroSandbox.StartDenialMonitor(context.Background(), plan.MonitorTag)
err = command.Run()
exitCode := commandExitCode(err)
adapterReport, reportErr := plan.ExecutionReport()
meta["exit_code"] = strconv.Itoa(exitCode)
stdoutText := stdout.retained()
stderrRetained := stderr.retained()
@@ -170,46 +174,68 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
return Result{
result := Result{
Status: StatusError,
Output: fmt.Sprintf("Error: Command timed out after %dms.", timeoutMS),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), true)
}
if err != nil {
if exitCode < 0 {
return Result{
result := Result{
Status: StatusError,
Output: "Error executing command: " + err.Error(),
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
return Result{
result := Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
if adapterReport.Denial != nil {
markStructuredSandboxDenial(meta, *adapterReport.Denial)
}
return Result{
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
result := Result{
Status: StatusOK,
Output: formatBashOutput(outText, errText, exitCode),
Truncated: truncated,
Meta: meta,
}
return withBashExecution(result, executionRequest, plan, exitCode, adapterReport, reportErr, changeObserver.Changes(), false)
}
func withBashExecution(result Result, request execution.Request, plan zeroSandbox.CommandPlan, exitCode int, report execution.AdapterReport, reportErr error, changes []execution.Change, timedOut bool) Result {
input := execToolResultInput{
exited: true,
exitCode: exitCode,
enforcement: executionEnforcement(plan),
request: request,
report: report,
reportErr: reportErr,
changes: changes,
}
outcome := execExecutionOutcome(input)
if timedOut && report.Denial == nil && reportErr == nil {
outcome.State = execution.StateFailed
outcome.Kind = execution.OutcomeTimedOut
}
result.ExecutionRequest = &request
result.ExecutionOutcome = &outcome
result.ChangedFiles = executionChangedFiles(changes)
result.ChangeSummaries = executionChangeSummaries(changes)
return result
}
func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) *zeroSandbox.Engine {
@@ -287,15 +313,23 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
return command, plan, err
}
directPolicy := zeroSandbox.DefaultPolicy()
directPolicy.Mode = zeroSandbox.ModeDisabled
directPolicy.Network = zeroSandbox.NetworkAllow
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
Name: zeroSandbox.BackendUnavailable,
Message: "sandbox engine not provided",
},
Wrapped: false,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
TargetBackend: zeroSandbox.BackendNone,
WorkspaceRoot: absoluteCwd,
Policy: directPolicy,
PermissionProfile: zeroSandbox.PermissionProfileFromPolicy(absoluteCwd, directPolicy, nil),
Wrapped: false,
EnforcementLevel: zeroSandbox.EnforcementDisabled,
Name: spec.Name,
Args: spec.Args,
Dir: spec.Dir,
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
+4 -11
View File
@@ -3,10 +3,10 @@
package tools
import (
"errors"
"os/exec"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/execution"
)
// bashWaitDelay bounds how long Wait blocks for the I/O pipes to drain after the
@@ -26,20 +26,13 @@ var bashWaitDelay = 2 * time.Second
// if a child still holds the I/O pipes after the group is killed, Wait gives up
// rather than blocking forever.
func hardenProcessLifetime(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.Setpgid = true
execution.ConfigureProcessGroup(command)
command.WaitDelay = bashWaitDelay
command.Cancel = func() error {
if command.Process == nil {
return nil
}
// Negative pid targets the process group led by the shell.
if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
return execution.KillProcessTree(command.Process.Pid)
}
}
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more