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
KRATOSandKRATOS ce4a996ffa fix(oauth): validate discovered endpoints before merge/use (#511) (#739)
Discovery metadata was merged without the https/loopback endpoint rule that
configured endpoints already pass, letting a malicious issuer downgrade the
authorization/token/device/registration endpoint after config validation.

- Add exported ValidateEndpointURL as the single endpoint-safety rule;
  validateTokenEndpoint now delegates to it.
- Backstop in BuildAuthorizationURL (the choke point both the provider and MCP
  flows build their browser URL through).
- Validate each discovered endpoint before merge in the provider resolveEndpoints
  and validate resolved MCP metadata in both branches; fail closed on insecure.

Co-authored-by: KRATOS <[email protected]>
2026-07-19 09:56:32 +05:30
Vasanth T 378d538e24 perf(agent): execution profiles with one-shot posture escalation (PR10b+PR10c) (#740)
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)

* fix(execprofile): harden profiles against review findings

- fast escalates on critical-risk mutations, not high: the sandbox marks
  every shell command high before command analysis, so a high trigger
  spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
  displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
  target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
  escalation turn target (mirroring exec's explicit --max-turns rule)
  and profile reverts use touched bits instead of value equality, so
  explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
  instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
  an unknown name previously produced an exit-0 report full of
  millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
  an inline-empty --exec-profile=; balanced trace label documented and
  pinned as deliberate attribution

* fix(execprofile): explicit effort choices disarm the escalation effort restore

An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.

* fix(execprofile): address review findings on the profile surfaces

- --use-spec projects away a profile's self-correct knob instead of
  silently arming an option the draft runner ignores (the explicit
  --self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
  boundary, so a direct caller's casing/whitespace cannot fork report
  labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
  ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
  kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
  stops filling low effort

* fix(tui): reconcile an active profile's effort fill on model switch

The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.

* fix(tui): void profile effort bookkeeping when the model-switch drop erases it

The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.

* fix(tui): only drop an explicit effort when the target ring is authoritative

A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
2026-07-19 00:53:17 +05:30
Vasanth T 015452c1c9 fix(perfbench): keep the stamped answer file out of negative oracle greps (#737)
* fix(perfbench): keep the stamped answer file out of negative oracle greps

* fix(perfbench): exclude stamped answer from positive oracle greps too
2026-07-18 22:54:02 +05:30
Vasanth T af875df587 perf(agent): posture-escalation signals and controller (PR10a) (#736)
* trace(perf): add posture escalation counter

* feat(agent): typed posture-escalation policy and tool-result risk field

* feat(agent): posture controller wiring and executed-risk stamping

* test(agent): cover posture controller, escalation act path, and risk stamping

* test(agent): pin zero risk on not-executed results

* fix(agent): wire uncertain-path escalation and validate risk thresholds
2026-07-18 22:32:02 +05:30
Vasanth T dbd9443014 fix(perfbench): absolutize the bench binary and make errored tasks first-class (#730)
* fix(perfbench): absolutize the bench binary and make errored tasks first-class

* fix(perfbench): state that errored tasks count as pass-rate failures

* fix(perfbench): align errored-task wording with the actual tier accounting

* chore(perfbench): never commit generated baseline reports
2026-07-18 21:39:52 +05:30
Anandan 0fae754460 docs: refresh repository agent guidelines (#734)
* docs: refresh repository agent guidelines

* docs: make validation guidance non-mutating
2026-07-18 21:37:36 +05:30
Vasanth T 2cc6f43bf7 docs(community): wire Discussions as the front door for questions and ideas (#732)
* docs(community): wire Discussions as the front door for questions and ideas

* docs(community): restore LF endings and make feature routing coherent
2026-07-18 21:34:58 +05:30
160 changed files with 10978 additions and 1964 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Gitlawb/zero/security/advisories/new
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
+13 -3
View File
@@ -1,12 +1,22 @@
name: Feature request
description: Propose a new capability or change
name: Feature request (previously discussed)
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Please do not begin implementation until the issue has been reviewed and marked with the
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
`issue-approved` label (see CONTRIBUTING.md).
- type: input
id: discussion
attributes:
label: Ideas discussion
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
validations:
required: true
- type: textarea
id: problem
attributes:
+3
View File
@@ -55,3 +55,6 @@ docs/superpowers/
zero-termux-arm64
zero-linux-sandbox
zero-seccomp
# Generated benchmark reports are evidence for one configuration, never tree state
internal/perfbench/reports/*.json
+26 -5
View File
@@ -8,6 +8,13 @@ stabilizes. Community participation is still welcome, especially through bug
reports, feature requests, reproduction details, logs, and discussion on existing
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
Please read this policy before opening a pull request. Pull requests that do not
follow it may be closed without review.
@@ -33,7 +40,11 @@ unless the issue has been explicitly approved for outside contribution.
There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Open a feature request describing the problem, use case, and expected behavior.
2. Propose a feature in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
3. Add useful context to an existing issue, such as logs, screenshots, examples,
affected versions, or reproduction cases.
@@ -46,7 +57,8 @@ All community pull requests require an approved parent issue.
Before starting implementation work:
1. Open an issue using the appropriate issue template.
1. For a bug, open an issue using the bug-report template. For a feature, start
in Discussions Ideas; the feature-request issue comes after the discussion.
2. Describe the bug, feature request, or functional change clearly.
3. Wait for the core team to review the issue.
4. Do not open a pull request unless the issue has the `issue-approved` label.
@@ -133,7 +145,12 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
When filing a feature request, please include:
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
include:
- The problem you are trying to solve.
- The behavior or functionality you are requesting.
@@ -156,5 +173,9 @@ contribution phase.
## Getting Help
If you are unsure whether a change is appropriate, open an issue first and ask.
Please keep discussion focused, specific, and respectful of maintainer time.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+29 -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
@@ -402,6 +412,24 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
- [Performance](docs/PERFORMANCE.md)
- [Agent evals](docs/AGENT_EVALS.md)
## Community
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
+36
View File
@@ -0,0 +1,36 @@
# Getting help with Zero
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
- `zero --version` output
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
script, source)
- the provider and model you are using (`zero providers list`)
- `zero doctor` output for setup problems
- the exact command you ran and the exact error text
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
(see SECURITY.md).
+17
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
@@ -92,3 +94,18 @@ func TestHelpText(t *testing.T) {
func emptyEnv(string) string {
return ""
}
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
var stderr bytes.Buffer
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
if code := turnExitCode(allErrored, &stderr); code != 1 {
t.Fatalf("exit=%d, want 1 when every task errored", code)
}
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
t.Fatalf("stderr missing explanation: %q", stderr.String())
}
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
if code := turnExitCode(partial, &stderr); code != 0 {
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
}
}
+42 -1
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
)
@@ -20,6 +21,7 @@ type turnOptions struct {
SuitePath string
Model string
Mode string
ExecProfile string
SelfCorrect bool
Binary string
Iterations int
@@ -64,6 +66,7 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
Model: options.Model,
Mode: options.Mode,
ExecProfile: options.ExecProfile,
SelfCorrect: options.SelfCorrect,
Version: options.Version,
Commit: options.Commit,
@@ -86,9 +89,23 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
return 0
return turnExitCode(result, stderr)
}
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
return turnExitCode(result, stderr)
}
// turnExitCode fails the command when every attempted task errored (no
// iteration produced an accepted benchmark sample): such a report measures
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
// path, failing harness step) pass as a clean baseline. Partial errors keep
// exit 0 — the summary surfaces them loudly and the surviving samples are
// still valid measurements.
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
return 1
}
return 0
}
@@ -123,6 +140,13 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
}
options.Mode = value
index = next
case "--exec-profile":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.ExecProfile = value
index = next
case "--binary":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
@@ -195,6 +219,20 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
return options, fmt.Errorf("--model is required (or pass --dry-run)")
}
// Validate the profile here, before anything spawns: an unknown name would
// otherwise make every child exit with a usage error in milliseconds, and
// those near-zero walls would be recorded as valid latency samples in an
// exit-0 report — exactly the misread-as-improvement trap the harness
// closed for spawn failures. Normalizing to the catalog name also keeps
// the stamped execProfile canonical (Lookup is case-insensitive), so two
// captures of the same posture always compare equal.
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
profile, ok := execprofile.Lookup(raw)
if !ok {
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
}
options.ExecProfile = profile.Name
}
return options, nil
}
@@ -225,6 +263,9 @@ func turnHelpText() string {
" --suite <path> Task set JSON file (required)",
" --model <model> Model to run (required unless --dry-run)",
" --mode <name> Exec mode preset to apply",
" --exec-profile <name>",
" Execution profile for every task (fast|balanced|thorough);",
" forwarded to zero exec and stamped into the result",
" --self-correct Enable the post-edit verify-and-correct loop",
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
" --iterations <n> Times to run each task (default: 1)",
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"strings"
"testing"
)
func TestParseTurnArgsExecProfile(t *testing.T) {
getenv := func(string) string { return "" }
options, err := parseTurnArgs([]string{
"--suite", "suite.json",
"--model", "m",
"--exec-profile", "fast",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs: %v", err)
}
if options.ExecProfile != "fast" {
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
}
// Inline form.
options, err = parseTurnArgs([]string{
"--suite=suite.json",
"--model=m",
"--exec-profile=thorough",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs inline: %v", err)
}
if options.ExecProfile != "thorough" {
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
}
}
// The bench validates the profile BEFORE spawning anything: an unknown name
// would otherwise make every child exit with a usage error in milliseconds
// and record those near-zero walls as valid latency samples in an exit-0
// report. Lookup normalization also keeps the stamped name canonical so two
// captures of the same posture compare equal.
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
getenv := func(string) string { return "" }
options, err := parseTurnArgs([]string{
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
}, getenv)
if err != nil {
t.Fatalf("parseTurnArgs: %v", err)
}
if options.ExecProfile != "fast" {
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
}
_, err = parseTurnArgs([]string{
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
}, getenv)
if err == nil {
t.Fatal("an unknown profile must fail parse, before anything spawns")
}
for _, name := range []string{"balanced", "fast", "thorough"} {
if !strings.Contains(err.Error(), name) {
t.Fatalf("error must list %q, got %q", name, err.Error())
}
}
}
+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{
+169 -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,46 @@ 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.
posture := newProfileController(options.Profile)
// applyPosture applies at most one posture escalation when an armed trigger
// has fired: raise the hoisted turn ceiling, restore effort and the
// completion gate, stamp the counter. Shared by the end-of-turn tail and
// the completion-gate uncertain path (which continues the loop before the
// tail runs). No-op forever after the first escalation and for unprofiled
// runs.
applyPosture := func() {
if target, fired := posture.maybeEscalate(); fired {
if target.MaxTurns > maxTurns {
maxTurns = target.MaxTurns
}
if target.ReasoningEffort != "" {
options.ReasoningEffort = target.ReasoningEffort
} else if target.RestoreDefaultEffort {
options.ReasoningEffort = ""
}
if target.RestoreCompletionGate {
options.RequireCompletionSignal = true
}
options.Trace.Counter(trace.CounterPostureEscalations, 1)
}
}
// Background post-edit diagnostics: files changed by mutating tools are
// checked off the tool-call critical path and any errors are appended as a
@@ -242,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,
@@ -545,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
@@ -554,6 +591,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, nil
case CompletionUncertain:
// Observe the uncertainty signal and apply any escalation NOW:
// this branch continues the turn loop before the end-of-turn
// act point, so deferring would skip escalation entirely.
posture.observeUncertain()
applyPosture()
switch evaluation.Action {
case completionActionContinue:
options.Trace.Counter(trace.CounterCompletionNudges, 1)
@@ -565,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
@@ -595,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
@@ -646,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)
}
@@ -692,6 +742,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
posture.observeToolOutcome(outcome, toolResult)
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
// the guard halts mid-turn so the calls after this one never run.
@@ -724,7 +775,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// the assistant's tool_results stay contiguous (a user message between
// tool_results breaks strict provider replay). nil SelfCorrect is a no-op.
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
if feedback, _ := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)); feedback != "" {
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,
Content: feedback,
@@ -817,6 +871,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Content: reminder,
})
}
// One-shot posture escalation: when an armed profile trigger fired this
// turn, restore the stricter knob values the profile displaced. Mutates
// only per-turn-read policy (the hoisted turn ceiling, reasoning effort,
// completion gate); never messages, model, or session. No-op forever
// after the first escalation and for every unprofiled run.
applyPosture()
}
if ctx.Err() != nil {
@@ -1110,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 {
@@ -1338,20 +1410,36 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
result = registry.RebudgetAfterHook(call.Name, args, result)
}
}
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
// observers can see the risk of an allowed mutation. Prefer the preflight
// decision's classification when the sandbox evaluated the call; otherwise
// run the same pure classifier the permission path uses. Denied/canceled
// results returned earlier keep the zero value.
executedRisk := sandbox.Risk{}
if preflightDecision != nil {
executedRisk = preflightDecision.Risk
} else if toolFound {
// Unknown-tool results fall through here with a nil tool; they carry a
// denial and keep the zero risk value.
executedRisk = sandbox.Classify(sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
}
// Secret scrubbing happens at the registry boundary (the single point both
// 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{
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
@@ -1471,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
@@ -1525,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"
}
@@ -1653,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"],
}
}
@@ -1923,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{
@@ -2495,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,
@@ -2508,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,
@@ -2517,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)
}
}
+118
View File
@@ -0,0 +1,118 @@
package agent
import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
// posture escalation per run for the armed execution profile. A nil policy (or
// a policy without Escalate) makes every method a no-op, so runs without a
// profile are byte-identical to before — the same opt-in convention as Trace
// and SelfCorrect.
//
// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning
// effort, completion gate) through the values the loop applies at its act
// point; it never injects messages, never touches the model or session, and
// never interacts with the model-initiated escalate_model path.
type profileController struct {
policy *PostureEscalation
escalated bool
uncertainSeen int
failureTripped bool
riskTripped bool
scTripped bool
uncertainTrip bool
}
// newProfileController is nil-safe: a nil ProfilePolicy (the default for every
// existing caller) or a nil Escalate yields a controller that observes and
// decides nothing.
func newProfileController(policy *ProfilePolicy) *profileController {
if policy == nil {
return &profileController{}
}
return &profileController{policy: policy.Escalate}
}
// observeToolOutcome watches two signals from an executed tool call: the
// repeated-failure guard's streak (reusing the guard's own counting, not a
// second failure model) and the sandbox risk classification of an executed
// result.
func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) {
if c.policy == nil || c.escalated {
return
}
if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak {
c.failureTripped = true
}
if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 &&
result.DenialReason == DenialNone &&
riskRank(result.Risk.Level) >= threshold {
// The call EXECUTED (was not denied): partial failures count too, since
// the mutation ran. An unrecognized threshold ranks 0 and disables the
// signal instead of matching everything.
c.riskTripped = true
}
}
// observeUncertain counts uncertain completion evaluations (continue nudges and
// semantic checks). Headless-only by construction: the completion gate never
// runs interactively.
func (c *profileController) observeUncertain() {
if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 {
return
}
c.uncertainSeen++
if c.uncertainSeen >= c.policy.OnCompletionUncertain {
c.uncertainTrip = true
}
}
// observeSelfCorrect watches the post-edit verification outcome. Any failing
// outcome (correcting, reported, aborted) is a signal; passed and disabled are
// not.
func (c *profileController) observeSelfCorrect(outcome Outcome) {
if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure {
return
}
switch outcome {
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
c.scTripped = true
}
}
// maybeEscalate reports the escalation target exactly once, the first time any
// armed trigger has fired. Subsequent calls return false for the rest of the
// run: escalation is one-shot and never de-escalates, so no cooldown state is
// needed.
func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
if c.policy == nil || c.escalated {
return PostureEscalation{}, false
}
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
return PostureEscalation{}, false
}
c.escalated = true
return *c.policy, true
}
// riskRank orders sandbox risk levels for threshold comparison. Unknown levels
// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an
// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the
// signal) so a typo in a profile can never make every result match.
func riskRank(level sandbox.RiskLevel) int {
switch level {
case sandbox.RiskLow:
return 1
case sandbox.RiskMedium:
return 2
case sandbox.RiskHigh:
return 3
case sandbox.RiskCritical:
return 4
default:
return 0
}
}
+345
View File
@@ -0,0 +1,345 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
for _, controller := range []*profileController{
newProfileController(nil),
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
} {
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
controller.observeUncertain()
controller.observeSelfCorrect(OutcomeAborted)
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("nil policy must never escalate")
}
}
}
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("streak below threshold must not escalate")
}
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
target, fired := controller.maybeEscalate()
if !fired || target.MaxTurns != 80 {
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
}
}
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
// Medium risk on an executed result: below threshold.
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("medium risk must not trip a high threshold")
}
// Critical risk on a DENIED result: never counts (the mutation never ran).
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("a denied result must not trip the risky-mutation signal")
}
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
// the mutation ran, so it counts.
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
}
}
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
}
}
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
controller.observeSelfCorrect(OutcomePassed)
controller.observeSelfCorrect(OutcomeDisabled)
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("passing/disabled outcomes must not escalate")
}
controller.observeSelfCorrect(OutcomeReported)
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("a failing verification outcome must escalate")
}
}
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
}
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("second uncertain evaluation must escalate")
}
}
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
if _, fired := controller.maybeEscalate(); !fired {
t.Fatal("expected first escalation")
}
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
controller.observeUncertain()
if _, fired := controller.maybeEscalate(); fired {
t.Fatal("escalation must be one-shot per run")
}
}
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
// point: the completion gate's continue branch escapes the end-of-turn tail,
// so escalation must apply before the continue. A cue turn under the gate
// counts as uncertain, escalates, and the very next request carries the target
// effort.
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
ReasoningEffort: "high",
OnCompletionUncertain: 1,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Done. All set." {
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
}
if provider.requests[1].ReasoningEffort != "high" {
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
t.Fatalf("posture_escalations = %d, want 1", got)
}
}
// A profile that filled the run's effort displaced the provider default ("");
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
// ReasoningEffort target cannot express (it means "leave untouched").
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
{Type: zeroruntime.StreamEventDone},
},
}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
ReasoningEffort: "low",
RequireCompletionSignal: true,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
RestoreDefaultEffort: true,
OnCompletionUncertain: 1,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "Done. All set." {
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
}
if provider.requests[0].ReasoningEffort != "low" {
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
}
if provider.requests[1].ReasoningEffort != "" {
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
}
}
// failingProfileTool always errors with the same signature so the repeated-
// failure guard builds a streak.
type failingProfileTool struct{}
func (failingProfileTool) Name() string { return "flaky_probe" }
func (failingProfileTool) Description() string { return "always fails identically (test)" }
func (failingProfileTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (failingProfileTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
}
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
// finishes on a turn the original ceiling would have cut off, with the effort
// target applied to the post-escalation request and the counter emitted.
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
toolTurn := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn,
toolTurn,
{
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
{Type: zeroruntime.StreamEventDone},
},
}}
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
MaxTurns: 2,
Trace: recorder,
Profile: &ProfilePolicy{
Name: "fast",
Escalate: &PostureEscalation{
MaxTurns: 4,
ReasoningEffort: "high",
OnToolFailureStreak: 2,
},
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "recovered after escalation" {
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
}
if len(provider.requests) != 3 {
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
}
if provider.requests[2].ReasoningEffort != "high" {
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
t.Fatalf("posture_escalations = %d, want 1", got)
}
}
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
// the loop level: the identical failing script without a profile ends at the
// original ceiling with the max-turns answer.
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
toolTurn := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) > 3 {
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
}
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
}
}
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
// stamp on the unsandboxed path: a read-only tool classifies low.
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(failingProfileTool{})
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "flaky_probe"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.Risk.Level == "" {
t.Fatal("executed result must carry a risk classification")
}
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
}
}
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
// or inventing a classification.
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
registry := tools.NewRegistry()
result, abortErr := executeToolCall(
context.Background(),
registry,
ToolCall{ID: "c1", Name: "no_such_tool"},
PermissionModeAuto,
Options{},
)
if abortErr != nil {
t.Fatal(abortErr)
}
if result.Status == tools.StatusOK {
t.Fatal("expected an error result for an unknown tool")
}
if result.Risk.Level != "" {
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
}
}
+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)
}
}
}
+65 -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,10 +72,19 @@ 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
// Risk is the sandbox risk classification of this call, stamped for EXECUTED
// results so run-policy observers (the execution-profile controller) can see
// the risk level of an allowed mutation. It mirrors the classification the
// permission path already computes; denied or canceled results keep the zero
// value. Pure observation: nothing about permissions or sandboxing changes.
Risk sandbox.Risk
// LoadedTools carries the deferred-tool names a tool_search call asked the
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
// ordinary tool result; only tool_search populates it.
@@ -98,6 +108,54 @@ const (
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
)
// ProfilePolicy is the loop-facing slice of a selected execution profile.
// The surface (exec flag, TUI command) resolves a named profile into this
// policy; the loop only ever sees the policy, never the catalog.
type ProfilePolicy struct {
// Name labels trace counters and diagnostics (e.g. "fast"). The trace
// recorder's profile label is set by the caller at recorder construction.
Name string
// Escalate, when non-nil, arms one-shot in-run posture escalation.
Escalate *PostureEscalation
}
// PostureEscalation describes a one-shot escalation to stricter knob values,
// applied mid-run when an armed trigger fires. Targets are the values the
// selected profile DISPLACED at run start (i.e. "restore the balanced
// posture"), so escalation can never introduce a value that was not already
// valid for this run and model. Zero-valued targets leave that knob untouched.
type PostureEscalation struct {
// MaxTurns raises the turn ceiling to this value when greater than the
// ceiling in effect. 0 leaves the ceiling untouched.
MaxTurns int
// ReasoningEffort replaces the run's effort when non-empty.
ReasoningEffort string
// RestoreDefaultEffort clears the run's effort override (back to the
// provider/model default) when true. It exists because the displaced value
// of a profile-filled effort is "" — which as a ReasoningEffort target
// means "leave untouched" — so restoring the default needs its own signal.
// Ignored when ReasoningEffort is non-empty.
RestoreDefaultEffort bool
// RestoreCompletionGate re-enables RequireCompletionSignal (headless
// completion semantics) when true.
RestoreCompletionGate bool
// Triggers. A zero value disables that signal entirely.
// OnToolFailureStreak fires when the repeated-failure guard observes a
// same-tool retriable-failure streak of at least this length.
OnToolFailureStreak int
// OnCompletionUncertain fires on the Nth uncertain completion evaluation
// (continue nudge or semantic check). Headless only: the completion gate
// never runs interactively.
OnCompletionUncertain int
// OnSelfCorrectFailure fires when a post-edit verification cycle reports a
// failing outcome (correcting, reported, or aborted).
OnSelfCorrectFailure bool
// OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk
// level at or above this threshold. Empty disables the signal.
OnRiskyMutation sandbox.RiskLevel
}
type PermissionRequest struct {
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
@@ -298,6 +356,12 @@ type Options struct {
// contract as ModelSwitcher: a returned error records a note and the run
// continues on the current model and session.
ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error)
// Profile, when set, arms the execution-profile posture controller for this
// run (auto-escalation to stricter knob values on failure/uncertainty/risky
// mutation signals). nil — the default everywhere today — leaves the loop
// byte-identical: no observation, no escalation, no counters. Same opt-in
// convention as Trace and SelfCorrect.
Profile *ProfilePolicy
// Trace, when set, records per-turn timing for the run: the loop stamps
// spans (prompt build, generation, tool execution, permission wait,
// compaction, provider connect) and counters (model requests, tool calls,
+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)
}
+28 -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,
@@ -1341,6 +1351,9 @@ Flags:
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--max-turns <number> Override the maximum agent loop turns
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
posture only (turn budget, effort, self-correction, escalation);
composes with --mode (which picks the model) and explicit flags win
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--enabled-tools <tools> Only expose these comma or space separated tools
--disabled-tools <tools> Hide these comma or space separated tools
+118 -31
View File
@@ -13,6 +13,8 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"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"
@@ -69,7 +71,14 @@ type execOptions struct {
// intentionally inert: nothing consumes it. Model selection is driven by
// --model / --mode instead. See writeExecHelp ("Accept legacy model profile
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
modelProfile string
modelProfile string
// execProfile selects a named execution profile (balanced, fast, thorough):
// a loop-posture bundle (turn budget, reasoning effort, self-correction,
// escalation triggers) applied AFTER --mode with the same
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
// Distinct from the mode preset also named "fast" (which picks a model) and
// from the legacy inert --profile above. See internal/execprofile.
execProfile string
reasoningEffort string
useSpec bool
specModel string
@@ -167,6 +176,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err := applyExecMode(&options); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
// A profile tunes loop posture the way a mode tunes model selection, and
// runs after it so the mode's fills count as "set" and win. MaxTurns is
// deferred to config resolution below, where the displaced resolved budget
// is known and becomes the escalation restore target.
execProfile, execProfileFilledEffort, err := applyExecProfile(&options)
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
if err != nil {
@@ -190,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.
@@ -222,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)
}
@@ -272,6 +287,31 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
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())
@@ -339,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
@@ -486,6 +504,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
reasoningEffort: forwardEffort,
specPermissionMode: permissionMode,
notifier: notifier,
// The profile displaced resolved.MaxTurns above, so the spec-draft
// run arms the same escalation policy as the main run (in practice
// only the failure-streak and risky-mutation triggers can fire
// here: the draft runs without the completion gate or a
// self-corrector).
profilePolicy: execProfile.Policy(displacedMaxTurns,
specProfileEffortFilled(execProfileFilledEffort, options.specReasoningEffort)),
})
}
@@ -528,7 +553,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
var traceRecorder *trace.Recorder
var traceSnapshot *trace.TurnTrace
if tracePath != "" {
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, "")
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, execProfile.Name)
defer func() {
if err := writeTraceSnapshot(traceSnapshot, tracePath, stderr); err != nil {
fmt.Fprintf(stderr, "[zero] failed to write trace: %s\n", err)
@@ -599,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,
@@ -627,6 +652,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -1133,6 +1159,67 @@ func applyExecMode(options *execOptions) error {
return nil
}
// applyExecProfile expands an --exec-profile selection onto the exec options.
// Like applyExecMode it only fills fields the caller left unset, and it runs
// AFTER the mode so a mode-filled field counts as set: precedence is explicit
// flag > --mode > --exec-profile. Only the options-level knobs are applied here
// (reasoning effort still flows through the model-supported gating downstream;
// self-correct is presence-only so a profile can only turn it on). MaxTurns is
// intentionally NOT applied here — the profile displaces the resolved budget
// after config resolution, where the displaced value is known and becomes the
// escalation restore target. An unknown profile is a usage error listing the
// valid names. Not to be confused with the legacy inert --profile flag.
// The second return reports whether the profile actually filled the reasoning
// effort (false when the user set one explicitly), so the escalation policy
// knows the displaced effort was the provider default and can restore it.
func applyExecProfile(options *execOptions) (execprofile.Profile, bool, error) {
name := strings.TrimSpace(options.execProfile)
if name == "" {
return execprofile.Profile{}, false, nil
}
profile, ok := execprofile.Lookup(name)
if !ok {
return execprofile.Profile{}, false, execUsageError{fmt.Sprintf("unknown execution profile %q. Valid profiles: %s.", options.execProfile, strings.Join(execprofile.Names(), ", "))}
}
effortFilled := false
if options.reasoningEffort == "" && profile.ReasoningEffort != "" {
options.reasoningEffort = profile.ReasoningEffort
effortFilled = true
}
// Spec-safe projection: the spec-draft path wires no self-corrector (an
// explicit --self-correct --use-spec is rejected at parse time, before
// profiles apply), so a profile's self-correct knob is meaningless there.
// Project it away rather than erroring: the user asked for a thorough
// DRAFT, and the profile's other knobs (budget, effort) apply to it fine.
if profile.SelfCorrect && !options.useSpec {
options.selfCorrect = true
}
return profile, effortFilled, nil
}
// specProfileEffortFilled reports whether the profile's effort fill actually
// governs the spec-draft run. An explicit --spec-reasoning-effort replaces the
// filled effort for the draft, so the escalation's effort restore must not arm
// there: escalation must never clear an effort the user pinned by hand.
func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool {
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
}
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
// The profile displaces the RESOLVED value, not the flag: a pinned budget
// always wins and the profile backs off entirely, while an env/config budget
// is the "balanced posture" a mid-run escalation can restore. pinnedMaxTurns
// is any caller-pinned budget — an explicit --max-turns flag OR a mode
// preset's fill (both land in options.maxTurns, and a mode outranks a profile
// just like the flag does). displaced is 0 when nothing was displaced, so an
// escalation leaves the ceiling untouched.
func applyProfileTurnBudget(profile execprofile.Profile, pinnedMaxTurns int, resolvedMaxTurns int) (effective int, displaced int) {
if profile.MaxTurns > 0 && pinnedMaxTurns == 0 {
return profile.MaxTurns, resolvedMaxTurns
}
return resolvedMaxTurns, 0
}
// resolveSelectedModel routes a user-supplied --model value through the model
// registry so that fuzzy aliases (e.g. "sonnet 4.5") resolve to canonical ids
// and deprecated models auto-redirect to their fallback. It returns the model id
+13
View File
@@ -147,6 +147,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--profile="):
options.modelProfile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
case arg == "--exec-profile":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.execProfile = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--exec-profile="):
value, err := requiredInlineFlagValue(arg, "--exec-profile")
if err != nil {
return options, false, err
}
options.execProfile = value
case arg == "-r" || arg == "--reasoning-effort":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
+224
View File
@@ -0,0 +1,224 @@
package cli
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/execprofile"
)
func TestApplyExecProfileFillsOnlyUnset(t *testing.T) {
// Unset effort: the profile fills it and reports the fill, so the
// escalation policy knows the displaced effort was the provider default.
options := execOptions{execProfile: "fast"}
profile, effortFilled, err := applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if profile.Name != "fast" {
t.Fatalf("profile.Name = %q, want fast", profile.Name)
}
if options.reasoningEffort != "low" {
t.Fatalf("reasoningEffort = %q, want the profile's low", options.reasoningEffort)
}
if !effortFilled {
t.Fatal("the profile filled the effort, so effortFilled must report it")
}
// Set effort (explicit flag or a mode's fill): the profile backs off.
options = execOptions{execProfile: "fast", reasoningEffort: "high"}
_, effortFilled, err = applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if options.reasoningEffort != "high" {
t.Fatalf("reasoningEffort = %q, an explicit value must win over the profile", options.reasoningEffort)
}
if effortFilled {
t.Fatal("the profile backed off, so effortFilled must be false")
}
}
func TestApplyExecProfileThoroughArmsSelfCorrect(t *testing.T) {
options := execOptions{execProfile: "thorough"}
if _, _, err := applyExecProfile(&options); err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if !options.selfCorrect {
t.Fatal("thorough must arm the post-edit self-correct loop")
}
if options.reasoningEffort != "high" {
t.Fatalf("reasoningEffort = %q, want high", options.reasoningEffort)
}
}
// The spec-draft path wires no self-corrector (explicit --self-correct
// --use-spec is rejected at parse time, before profiles apply), so a profile's
// self-correct knob is projected away under --use-spec instead of silently
// arming a knob the draft runner ignores. The other knobs still apply.
func TestApplyExecProfileSpecProjectsAwaySelfCorrect(t *testing.T) {
options := execOptions{execProfile: "thorough", useSpec: true}
if _, _, err := applyExecProfile(&options); err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if options.selfCorrect {
t.Fatal("thorough under --use-spec must not arm self-correct (the draft runner has none)")
}
if options.reasoningEffort != "high" {
t.Fatalf("reasoningEffort = %q, the profile's other knobs must still apply", options.reasoningEffort)
}
}
func TestApplyExecProfileUnknownIsUsageError(t *testing.T) {
options := execOptions{execProfile: "turbo"}
_, _, err := applyExecProfile(&options)
if err == nil {
t.Fatal("expected a usage error for an unknown profile")
}
if _, ok := err.(execUsageError); !ok {
t.Fatalf("expected execUsageError, got %T: %v", err, err)
}
for _, name := range execprofile.Names() {
if !strings.Contains(err.Error(), name) {
t.Fatalf("usage error must list %q, got %q", name, err.Error())
}
}
}
// Precedence: explicit flag > --mode > --exec-profile. The deep mode fills
// effort=high and max-turns=160; the fast profile must not override either,
// because a mode-filled field counts as set by the time the profile runs.
func TestExecProfilePrecedenceFlagOverModeOverProfile(t *testing.T) {
options := execOptions{mode: "deep", execProfile: "fast"}
if err := applyExecMode(&options); err != nil {
t.Fatalf("applyExecMode: %v", err)
}
profile, _, err := applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if options.reasoningEffort != "high" {
t.Fatalf("reasoningEffort = %q, the mode's high must win over the profile's low", options.reasoningEffort)
}
// The mode filled options.maxTurns, so the turn budget must not be displaced.
effective, displaced := applyProfileTurnBudget(profile, options.maxTurns, options.maxTurns)
if effective != 160 || displaced != 0 {
t.Fatalf("turn budget = (%d, displaced %d), the mode's 160 must win with nothing displaced", effective, displaced)
}
}
func TestApplyProfileTurnBudget(t *testing.T) {
fast, _ := execprofile.Lookup("fast")
balanced, _ := execprofile.Lookup("balanced")
// No explicit flag: the profile displaces the resolved budget.
if effective, displaced := applyProfileTurnBudget(fast, 0, 80); effective != 30 || displaced != 80 {
t.Fatalf("fast over resolved 80 = (%d, %d), want (30, 80)", effective, displaced)
}
// Explicit --max-turns pins the budget; the profile backs off entirely.
if effective, displaced := applyProfileTurnBudget(fast, 50, 50); effective != 50 || displaced != 0 {
t.Fatalf("fast with explicit 50 = (%d, %d), want (50, 0)", effective, displaced)
}
// Balanced never displaces anything.
if effective, displaced := applyProfileTurnBudget(balanced, 0, 80); effective != 80 || displaced != 0 {
t.Fatalf("balanced over resolved 80 = (%d, %d), want (80, 0)", effective, displaced)
}
}
// The no-regression invariant at the options level: selecting balanced leaves
// the options byte-identical to not selecting a profile at all, and produces
// no loop policy.
func TestExecProfileBalancedLeavesOptionsUntouched(t *testing.T) {
options := execOptions{execProfile: "balanced"}
reference := options
profile, _, err := applyExecProfile(&options)
if err != nil {
t.Fatalf("applyExecProfile: %v", err)
}
if !reflect.DeepEqual(options, reference) {
t.Fatalf("balanced changed the options: %+v vs %+v", options, reference)
}
if policy := profile.Policy(80, false); policy != nil {
t.Fatalf("balanced must produce a nil policy, got %+v", policy)
}
}
// --exec-profile= with an inline empty value must error like its two-token
// form and the sibling --mode=, not silently run the default posture (the
// classic shell hazard: an unset variable expanding into the inline form).
func TestParseExecProfileInlineEmptyIsError(t *testing.T) {
_, _, err := parseExecArgs([]string{"--exec-profile=", "hello"})
if err == nil || !strings.Contains(err.Error(), "--exec-profile requires a value") {
t.Fatalf("expected a required-value error, got %v", err)
}
}
// Full echo-provider run: the selected profile must be stamped into the
// per-turn trace so benchmark attribution can group runs by posture.
func TestRunExecTraceRecordsSelectedProfile(t *testing.T) {
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--exec-profile", "fast", "--trace", tracePath, "hello",
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
}
raw, err := os.ReadFile(tracePath)
if err != nil {
t.Fatalf("read trace: %v", err)
}
if !strings.Contains(string(raw), `"profile":"fast"`) {
t.Fatalf("trace must record the selected profile, got %s", raw)
}
}
// An explicit --spec-reasoning-effort governs the spec draft's effort, so the
// escalation effort-restore must not arm there even when the profile filled
// the (unused) main effort.
func TestSpecProfileEffortFilled(t *testing.T) {
if !specProfileEffortFilled(true, "") {
t.Fatal("no explicit spec effort: the profile's fill governs, restore must arm")
}
if specProfileEffortFilled(true, "high") {
t.Fatal("an explicit spec effort must disarm the effort restore")
}
if specProfileEffortFilled(false, "") {
t.Fatal("nothing filled, nothing to restore")
}
}
// The trace label deliberately records the SELECTED profile, balanced
// included, so captures are self-describing. Run behavior stays identical
// (balanced fills nothing and arms no policy); the label is the one intended
// difference in the opt-in trace artifact.
func TestRunExecTraceRecordsBalancedSelection(t *testing.T) {
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--exec-profile", "balanced", "--trace", tracePath, "hello",
})
if exitCode != exitSuccess {
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
}
raw, err := os.ReadFile(tracePath)
if err != nil {
t.Fatalf("read trace: %v", err)
}
if !strings.Contains(string(raw), `"profile":"balanced"`) {
t.Fatalf("trace must record the balanced selection, got %s", raw)
}
}
func TestRunExecUnknownExecProfileIsUsageError(t *testing.T) {
exitCode, _, stderr := runExecWithEcho(t, []string{
"exec", "--exec-profile", "turbo", "hello",
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
}
if !strings.Contains(stderr, "unknown execution profile") {
t.Fatalf("expected an unknown-profile usage error, got %q", stderr)
}
}
+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)
}
}
+8 -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"
@@ -46,6 +47,11 @@ type execSpecDraftRun struct {
reasoningEffort string
specPermissionMode agent.PermissionMode
notifier *notify.Notifier
// profilePolicy carries the selected execution profile's escalation policy
// (nil when none): the profile displaces resolved.MaxTurns before this
// path branches off, so the spec-draft run needs the same safety net as
// the main run.
profilePolicy *agent.ProfilePolicy
}
type execSpecDraftInfo struct {
@@ -109,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,
@@ -119,6 +125,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
ProviderName: run.resolved.Provider.Name,
Model: run.resolved.Provider.Model,
ReasoningEffort: run.reasoningEffort,
Profile: run.profilePolicy,
Cwd: run.workspaceRoot,
SystemPrompt: specmode.DraftSystemPrompt,
Images: run.images,
+4
View File
@@ -549,10 +549,14 @@ func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
}
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
// --profile (legacy, inert model profile) and --exec-profile (execution
// profile) are distinct flags and must coexist on one invocation.
exitCode, stdout, stderr := runExecWithEcho(t, []string{
"exec",
"--profile",
"fast",
"--exec-profile",
"balanced",
"--reasoning-effort",
"low",
"hello",
+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
+153
View File
@@ -0,0 +1,153 @@
// Package execprofile defines named execution profiles: bundles of loop-posture
// knobs (turn budget, reasoning effort, self-correction, escalation triggers)
// that tune how hard the agent loop tries, independent of which model runs.
// Profiles COMPOSE with --mode: a mode answers "what runs" (model, tools),
// a profile answers "how hard the loop tries". Selection precedence is
// explicit flag > --mode > profile, enforced by the callers applying profiles
// last with the same fill-only-if-unset rule modes use.
//
// The catalog is deliberately tiny and value-stable: balanced is the empty
// profile (selecting it leaves the run's options, budget, and loop behavior
// identical to an unflagged run — asserted by test; only the opt-in trace and
// bench artifacts record the selected profile name, by design, so captures are
// self-describing), fast trades turn budget and effort for latency with a
// one-shot escalation back to the displaced posture as the safety net, and
// thorough raises the budget and arms full self-correction.
package execprofile
import (
"sort"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sandbox"
)
// Profile is a named execution posture. Zero-valued fields inherit the run's
// existing value (flag, mode, config, or built-in default) — a profile only
// ever fills knobs the caller left unset, except MaxTurns which REPLACES the
// resolved budget (that displacement is what escalation restores).
type Profile struct {
// Name identifies the profile in traces, help text, and diagnostics.
Name string
// MaxTurns replaces the resolved per-run tool-turn budget when > 0 and the
// user did not pass an explicit --max-turns. The displaced resolved value
// becomes the escalation target (see Policy).
MaxTurns int
// ReasoningEffort fills the run's effort when the user left it unset. It
// still flows through the model-supported gating at the call site, so an
// unsupported level degrades exactly like an explicit flag would.
ReasoningEffort string
// SelfCorrect arms the post-edit verify-and-correct loop. Profiles can only
// turn it ON (the flag is presence-only, so false is indistinguishable from
// unset and must never override an explicit opt-in).
SelfCorrect bool
// Escalation triggers. All zero means the profile never escalates and
// Policy returns nil (the loop stays byte-identical to a nil-profile run).
// Targets are NOT part of the catalog: escalation restores the values the
// profile displaced at selection time, never invented ones, so targets are
// stamped by Policy from what the caller measured.
EscalateOnToolFailureStreak int
EscalateOnCompletionUncertain int
EscalateOnSelfCorrectFailure bool
EscalateOnRiskyMutation sandbox.RiskLevel
}
// The catalog. Values marked provisional are floors/ceilings derived from the
// Phase 0 baseline's read-class evidence (successful nav runs used single-digit
// turns; the mutating classes produced no successful samples to tune from) and
// are expected to be re-tuned from the post-oracle-fix re-capture. The
// escalation safety net is what makes shipping provisional Fast values safe:
// a run that hits trouble gets its displaced budget back mid-run.
var (
// Balanced is the default posture: empty on purpose. Selecting it changes
// nothing — the no-regression invariant of the whole feature.
Balanced = Profile{Name: "balanced"}
// Fast starts cheap (30 turns, low effort) and arms every escalation
// trigger so a struggling run restores its displaced posture: two
// same-tool retriable failures in a row, a second uncertain completion
// evaluation, a failing self-correction cycle, or a critical-risk
// mutation. The risk threshold is deliberately Critical, not High: the
// sandbox classifies EVERY shell command as at least high risk before any
// command analysis, so a High trigger would fire on the first `go test`
// of virtually any coding task and spend the one-shot on turn one.
// Critical marks the genuinely scary categories (destructive commands,
// piped installers, out-of-workspace writes, network mutations).
Fast = Profile{
Name: "fast",
MaxTurns: 30,
ReasoningEffort: "low",
EscalateOnToolFailureStreak: 2,
EscalateOnCompletionUncertain: 2,
EscalateOnSelfCorrectFailure: true,
EscalateOnRiskyMutation: sandbox.RiskCritical,
}
// Thorough doubles the default budget, asks for high effort, and arms the
// full post-edit verify-and-correct loop. Already the maximum posture, so
// it has nothing to escalate to.
Thorough = Profile{
Name: "thorough",
MaxTurns: 160,
ReasoningEffort: "high",
SelfCorrect: true,
}
)
var catalog = map[string]Profile{
Balanced.Name: Balanced,
Fast.Name: Fast,
Thorough.Name: Thorough,
}
// Lookup resolves a profile by name, case-insensitively and ignoring
// surrounding whitespace.
func Lookup(name string) (Profile, bool) {
profile, ok := catalog[strings.ToLower(strings.TrimSpace(name))]
return profile, ok
}
// Names returns the catalog's profile names, sorted, for usage errors and help.
func Names() []string {
names := make([]string, 0, len(catalog))
for name := range catalog {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Policy projects the profile into the loop-facing ProfilePolicy.
//
// displacedMaxTurns is the turn budget this profile displaced at selection time
// (what the run would have used without it) and becomes the escalation's
// restore target; pass 0 when the profile did not displace the budget (e.g.
// the user pinned --max-turns explicitly) so escalation leaves the ceiling
// untouched. effortFilled reports whether the profile actually filled the
// run's reasoning effort (it backs off when the user set one explicitly); the
// displaced effort is then "" (the provider default) by construction, which
// the escalation restores via RestoreDefaultEffort — a plain "" target would
// mean "leave untouched".
//
// Profiles with no armed triggers return nil: the loop treats a nil policy as
// "no profile" (no observation, no counters), which keeps balanced and
// thorough runs byte-identical to the same knob values set by hand.
func (p Profile) Policy(displacedMaxTurns int, effortFilled bool) *agent.ProfilePolicy {
if p.EscalateOnToolFailureStreak == 0 && p.EscalateOnCompletionUncertain == 0 &&
!p.EscalateOnSelfCorrectFailure && p.EscalateOnRiskyMutation == "" {
return nil
}
return &agent.ProfilePolicy{
Name: p.Name,
Escalate: &agent.PostureEscalation{
MaxTurns: displacedMaxTurns,
RestoreDefaultEffort: effortFilled,
OnToolFailureStreak: p.EscalateOnToolFailureStreak,
OnCompletionUncertain: p.EscalateOnCompletionUncertain,
OnSelfCorrectFailure: p.EscalateOnSelfCorrectFailure,
OnRiskyMutation: p.EscalateOnRiskyMutation,
},
}
}
+131
View File
@@ -0,0 +1,131 @@
package execprofile
import (
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
)
// The no-regression invariant of the whole feature: balanced must be the empty
// profile (nothing but a name), so selecting it cannot change a run.
func TestBalancedProfileIsEmpty(t *testing.T) {
profile, ok := Lookup("balanced")
if !ok {
t.Fatal("balanced must exist in the catalog")
}
if profile.Name != "balanced" {
t.Fatalf("Name = %q, want %q", profile.Name, "balanced")
}
profile.Name = ""
if !reflect.DeepEqual(profile, Profile{}) {
t.Fatalf("balanced must be zero-valued apart from its name, got %+v", profile)
}
if policy := Balanced.Policy(80, false); policy != nil {
t.Fatalf("balanced Policy must be nil (byte-identical loop), got %+v", policy)
}
}
func TestLookupIsCaseAndSpaceInsensitive(t *testing.T) {
for _, name := range []string{"fast", "Fast", " FAST "} {
if _, ok := Lookup(name); !ok {
t.Fatalf("Lookup(%q) should resolve", name)
}
}
if _, ok := Lookup("turbo"); ok {
t.Fatal("Lookup(turbo) should not resolve")
}
}
func TestNamesAreSorted(t *testing.T) {
want := []string{"balanced", "fast", "thorough"}
if got := Names(); !reflect.DeepEqual(got, want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
}
// Escalation targets must be the displaced values only — the catalog carries
// triggers, the call site supplies what was displaced, and nothing else may
// appear in the escalation.
func TestFastPolicyTargetsDisplacedValuesOnly(t *testing.T) {
policy := Fast.Policy(80, true)
if policy == nil {
t.Fatal("fast must arm an escalation policy")
}
if policy.Name != "fast" {
t.Fatalf("policy.Name = %q, want fast", policy.Name)
}
esc := policy.Escalate
if esc == nil {
t.Fatal("fast policy must carry an escalation")
}
if esc.MaxTurns != 80 {
t.Fatalf("Escalate.MaxTurns = %d, want the displaced 80", esc.MaxTurns)
}
if esc.ReasoningEffort != "" {
t.Fatalf("Escalate.ReasoningEffort = %q, want empty (displaced effort is the provider default by construction)", esc.ReasoningEffort)
}
if !esc.RestoreDefaultEffort {
t.Fatal("effort was profile-filled, so escalation must restore the provider default")
}
if esc.RestoreCompletionGate {
t.Fatal("fast does not touch the completion gate, so escalation must not either")
}
if esc.OnToolFailureStreak != Fast.EscalateOnToolFailureStreak ||
esc.OnCompletionUncertain != Fast.EscalateOnCompletionUncertain ||
esc.OnSelfCorrectFailure != Fast.EscalateOnSelfCorrectFailure ||
esc.OnRiskyMutation != Fast.EscalateOnRiskyMutation {
t.Fatalf("escalation triggers must mirror the catalog, got %+v", esc)
}
}
// The effort restore arms only when the profile actually filled the effort:
// with an explicit user effort the profile backed off, so escalation must not
// clear the user's choice.
func TestFastPolicyExplicitEffortIsNeverRestored(t *testing.T) {
policy := Fast.Policy(80, false)
if policy == nil || policy.Escalate == nil {
t.Fatal("fast must still arm its triggers")
}
if policy.Escalate.RestoreDefaultEffort {
t.Fatal("the profile did not fill the effort, so escalation must leave it alone")
}
}
// A profile that did not displace the budget (explicit --max-turns pinned it)
// must not let escalation move the ceiling at all.
func TestFastPolicyZeroDisplacedLeavesCeilingUntouched(t *testing.T) {
policy := Fast.Policy(0, false)
if policy == nil || policy.Escalate == nil {
t.Fatal("fast must still arm its triggers with a zero displaced budget")
}
if policy.Escalate.MaxTurns != 0 {
t.Fatalf("Escalate.MaxTurns = %d, want 0 (no displaced value to restore)", policy.Escalate.MaxTurns)
}
}
// Thorough is already the maximum posture: no triggers, so no policy — the
// loop must stay byte-identical to the same knobs set by hand.
func TestThoroughPolicyIsNil(t *testing.T) {
if policy := Thorough.Policy(80, true); policy != nil {
t.Fatalf("thorough Policy must be nil, got %+v", policy)
}
}
// Pin the catalog's provisional values so a retune is a deliberate,
// test-visible diff (these floors came from the Phase 0 baseline's read-class
// evidence and are expected to move after the post-oracle-fix re-capture).
func TestCatalogProvisionalValues(t *testing.T) {
if Fast.MaxTurns != 30 || Fast.ReasoningEffort != "low" || Fast.SelfCorrect {
t.Fatalf("fast knobs changed: %+v", Fast)
}
// Critical, not High: the sandbox classifies every shell command as at
// least high risk, so a High trigger would spend the one-shot escalation
// on the first benign `go test` of any coding task.
if Fast.EscalateOnRiskyMutation != sandbox.RiskCritical {
t.Fatalf("fast risky-mutation trigger = %q, want %q", Fast.EscalateOnRiskyMutation, sandbox.RiskCritical)
}
if Thorough.MaxTurns != 160 || Thorough.ReasoningEffort != "high" || !Thorough.SelfCorrect {
t.Fatalf("thorough knobs changed: %+v", Thorough)
}
}
+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()
+26
View File
@@ -133,6 +133,26 @@ func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseU
}, nil
}
// validateResolvedEndpoints applies the shared https/loopback endpoint rule to
// every non-empty endpoint in the resolved metadata — configured or discovered
// alike — so discovery metadata can never downgrade an MCP login to an insecure
// or attacker-controlled authorization, token, or registration endpoint.
func validateResolvedEndpoints(metadata authServerMetadata) error {
for _, ep := range []struct{ kind, url string }{
{"authorization", metadata.AuthorizationEndpoint},
{"token", metadata.TokenEndpoint},
{"registration", metadata.RegistrationEndpoint},
} {
if strings.TrimSpace(ep.url) == "" {
continue
}
if err := oauth.ValidateEndpointURL(ep.url); err != nil {
return fmt.Errorf("mcp oauth: %s endpoint: %w", ep.kind, err)
}
}
return nil
}
// resolveAuthorizationServer discovers metadata and applies explicit config
// overrides. Configured endpoints take precedence over discovered ones, and act
// as a fallback when discovery fails or omits a value.
@@ -148,6 +168,9 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
TokenEndpoint: strings.TrimSpace(cfg.TokenEndpoint),
RegistrationEndpoint: strings.TrimSpace(cfg.RegistrationEndpoint),
}
if err := validateResolvedEndpoints(metadata); err != nil {
return authServerMetadata{}, err
}
return metadata, nil
}
@@ -179,6 +202,9 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
if strings.TrimSpace(metadata.TokenEndpoint) == "" {
return authServerMetadata{}, errors.New("no token endpoint discovered or configured")
}
if err := validateResolvedEndpoints(metadata); err != nil {
return authServerMetadata{}, err
}
return metadata, nil
}
+16
View File
@@ -73,6 +73,22 @@ func TestResolveEndpointsFallsBackToConfig(t *testing.T) {
}
}
// Discovered MCP metadata that serves an insecure endpoint must be rejected
// before use, so discovery cannot downgrade the MCP login to an
// attacker-controlled origin (issue #511).
func TestResolveAuthorizationServerRejectsInsecureDiscovered(t *testing.T) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
defer server.Close()
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"issuer":"` + server.URL + `","token_endpoint":"` + server.URL + `/token","authorization_endpoint":"http://evil.example/authorize"}`))
})
_, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL, OAuthConfig{})
if err == nil || !strings.Contains(err.Error(), "authorization endpoint") {
t.Fatalf("err = %v, want insecure authorization endpoint rejection", err)
}
}
// roundTripperFunc adapts a function to http.RoundTripper so a test can
// observe every outbound request.
type roundTripperFunc func(*http.Request) (*http.Response, error)
+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}`))
+41 -8
View File
@@ -39,6 +39,13 @@ func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, ext
if endpoint == "" {
return "", errors.New("oauth: no authorization endpoint configured")
}
// Backstop: validate the authorization endpoint at the shared choke point
// both the provider and MCP flows build their browser URL through, so a
// discovery-downgraded endpoint can never open in the browser even if a
// merge site missed it.
if err := ValidateEndpointURL(endpoint); err != nil {
return "", err
}
parsed, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
@@ -83,13 +90,15 @@ func isReservedAuthParam(key string) bool {
}
}
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
// not https, unless it is a loopback host (mirrors oauth2-client.js
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
func validateTokenEndpoint(endpoint string) error {
// ValidateEndpointURL refuses an OAuth endpoint that is not https, unless it is
// a loopback host (mirrors oauth2-client.js validateTokenEndpointURL). Every
// credential-bearing endpoint — configured OR learned from discovery — must
// pass this single rule, so discovery metadata can never downgrade a login to
// cleartext or redirect it to an attacker-controlled http origin.
func ValidateEndpointURL(endpoint string) error {
parsed, err := url.Parse(trimmed(endpoint))
if err != nil || parsed.Host == "" {
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
return fmt.Errorf("oauth: invalid endpoint %q", endpoint)
}
if parsed.Scheme == "https" {
return nil
@@ -100,6 +109,30 @@ func validateTokenEndpoint(endpoint string) error {
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
}
// validateTokenEndpoint checks a token endpoint against the shared endpoint rule
// (kept as a named helper for the token-exchange and device call sites). This
// prevents leaking codes/tokens over cleartext.
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
@@ -160,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
}
+74
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"
)
@@ -112,6 +114,78 @@ func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
}
}
// The shared choke point both flows build their browser URL through must refuse
// an insecure authorization endpoint, so a discovery-downgraded endpoint can
// never open in the browser even if a merge site missed it (issue #511).
func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
_, err := BuildAuthorizationURL(
Config{AuthorizationEndpoint: "http://evil.example/authorize", ClientID: "c"},
PKCE{Method: MethodS256, Challenge: "chal"},
"state", "http://127.0.0.1/cb", nil,
)
if !errors.Is(err, ErrInsecureTokenEndpoint) {
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
}
}
// 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,
+16 -3
View File
@@ -218,13 +218,26 @@ func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, err
// may already be sufficient for the chosen flow. Only merge on success (and
// never return from the error branch, which would trip the nilerr linter).
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
if cfg.AuthorizationEndpoint == "" {
// A discovered endpoint must pass the same https/loopback rule as a
// configured one before it is merged, so discovery metadata can never
// silently downgrade the login to an insecure or attacker-controlled
// endpoint. Fail closed on a bad endpoint rather than merging it.
if cfg.AuthorizationEndpoint == "" && meta.AuthorizationEndpoint != "" {
if err := ValidateEndpointURL(meta.AuthorizationEndpoint); err != nil {
return cfg, fmt.Errorf("oauth: discovered authorization endpoint: %w", err)
}
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
}
if cfg.TokenEndpoint == "" {
if cfg.TokenEndpoint == "" && meta.TokenEndpoint != "" {
if err := ValidateEndpointURL(meta.TokenEndpoint); err != nil {
return cfg, fmt.Errorf("oauth: discovered token endpoint: %w", err)
}
cfg.TokenEndpoint = meta.TokenEndpoint
}
if cfg.DeviceAuthorizationEndpoint == "" {
if cfg.DeviceAuthorizationEndpoint == "" && meta.DeviceAuthorizationEndpoint != "" {
if err := ValidateEndpointURL(meta.DeviceAuthorizationEndpoint); err != nil {
return cfg, fmt.Errorf("oauth: discovered device authorization endpoint: %w", err)
}
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
}
}
+19
View File
@@ -2,6 +2,7 @@ package oauth
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -200,6 +201,24 @@ func TestManagerGetFreshDiscoversIssuerEndpoints(t *testing.T) {
}
}
// A discovery document that serves an insecure (non-https, non-loopback)
// endpoint must be rejected before it is merged, so discovery metadata cannot
// silently downgrade the login to an attacker-controlled origin (issue #511).
func TestResolveEndpointsRejectsInsecureDiscoveredEndpoint(t *testing.T) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
defer server.Close()
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
// Loopback issuer, but an attacker-controlled http authorization endpoint.
_, _ = io.WriteString(w, `{"issuer":"`+server.URL+`","token_endpoint":"`+server.URL+`/token","authorization_endpoint":"http://evil.example/authorize"}`)
})
m := managerFor(t, map[string]string{}, nil)
_, err := m.resolveEndpoints(context.Background(), Config{IssuerURL: server.URL})
if !errors.Is(err, ErrInsecureTokenEndpoint) {
t.Fatalf("resolveEndpoints err = %v, want ErrInsecureTokenEndpoint", err)
}
}
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
env := map[string]string{
+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.
+4 -4
View File
@@ -133,7 +133,7 @@
"verificationCommand": [
"bash",
"-c",
"go test ./... && ! grep -RIn 'MaxRetries =' ."
"go test ./... && ! grep --exclude=.zero-answer.txt -RIn 'MaxRetries =' ."
],
"oracleTest": "package edit\n\nvar _ = RetryLimit\n"
},
@@ -146,7 +146,7 @@
"verificationCommand": [
"bash",
"-c",
"go test ./... && ! grep -R receieves . && grep -R receives ."
"go test ./... && ! grep --exclude=.zero-answer.txt -R receieves . && grep --exclude=.zero-answer.txt -R receives ."
]
},
{
@@ -400,7 +400,7 @@
"verificationCommand": [
"bash",
"-c",
"go test ./... && ! grep -R 'package refactor' . && grep -R 'package zeroapp' ."
"go test ./... && ! grep --exclude=.zero-answer.txt -R 'package refactor' . && grep --exclude=.zero-answer.txt -R 'package zeroapp' ."
],
"oracleTest": "package zeroapp\n\nvar _ = Config{}\n"
},
@@ -426,7 +426,7 @@
"verificationCommand": [
"bash",
"-c",
"go test ./... && ! grep -R 'func Wrapper' ."
"go test ./... && ! grep --exclude=.zero-answer.txt -R 'func Wrapper' ."
],
"oracleTest": "package refactor\n\nimport \"testing\"\n\n// TestGreetWrapped proves greetWrapped still exists and behaves as the inlined\n// Wrapper did: deleting both Wrapper and greetWrapped (or stubbing greetWrapped\n// to return \"\") fails to compile or run, so the oracle can't be passed by\n// removing the target along with its caller. Paired with the ! grep 'func\n// Wrapper' verificationCommand that proves Wrapper itself is gone.\nfunc TestGreetWrapped(t *testing.T) {\n\tif got := greetWrapped(\"x\"); got != \"hello, x\" {\n\t\tt.Fatalf(\"greetWrapped(x) = %q, want %q\", got, \"hello, x\")\n\t}\n}\n"
},
+4
View File
@@ -85,6 +85,10 @@ type RunContext struct {
Model string
Mode string
SelfCorrect bool
// ExecProfile, when non-empty, is forwarded to the agent invocation as
// --exec-profile (turn benchmark runner; the task benchmark leaves it
// empty).
ExecProfile string
}
// TaskRunner runs a single benchmark task and returns its outcome.
+152 -20
View File
@@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/trace"
)
@@ -40,7 +41,25 @@ import (
// prevents a v2→v2 cross-version comparison from misreading the jump as a model
// improvement. New counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor
// 6), build 0, latency 14 (longproc 4 + longctx 4 + parallel 6).
const TurnSchemaVersion = 3
//
// v4 adds tasksErrored: the number of tasks whose every iteration produced no
// accepted benchmark sample. That covers pre-run failures (missing binary,
// spawn error, process crash) and harness errors after a successful agent run
// (e.g. oracle stamping failed) alike — in every case the iteration yields no
// usable measurement. Errored tasks were previously visible only in warnings,
// so a run where nothing was actually measured still printed a clean-looking
// 0% pass summary and exited 0. tasksErrored makes that state first-class:
// the summary surfaces it, and the turn command exits nonzero when every
// attempted task errored. Accounting: an errored oracle task stays in its
// tier denominator as a failure; an errored latency-only task counts in
// latencyOnlyTasks and, as always, in no pass rate.
//
// v5 is ADDITIVE ONLY: execProfile records the execution profile the run was
// benchmarked under (omitted when none was selected), so profile A/B captures
// are self-describing and two reports can't be compared without noticing they
// ran different postures. No existing field changed shape or meaning; a v4
// consumer reading a v5 report misses only the new field.
const TurnSchemaVersion = 5
// TurnRunner runs one benchmark task and reports its outcome plus the captured
// per-turn trace. A non-nil Err means the run failed to execute (process crash);
@@ -67,6 +86,10 @@ type TurnBenchConfig struct {
Model string
Mode string
SelfCorrect bool
// ExecProfile, when non-empty, runs every task under the named execution
// profile (zero exec --exec-profile) and stamps it into the result so the
// report is self-describing for profile A/B comparisons.
ExecProfile string
Version string
Commit string
// Iterations is how many times each task is run. The per-process `zero exec`
@@ -139,11 +162,13 @@ type TurnBenchResult struct {
Suite string `json:"suite"`
Model string `json:"model"`
Mode string `json:"mode,omitempty"`
ExecProfile string `json:"execProfile,omitempty"`
SelfCorrect bool `json:"selfCorrect"`
Version string `json:"version,omitempty"`
Commit string `json:"commit,omitempty"`
Date string `json:"date"`
TasksAttempted int `json:"tasksAttempted"`
TasksErrored int `json:"tasksErrored"`
TasksVerified int `json:"tasksVerified"`
TasksPassed int `json:"tasksPassed"`
LatencyOnlyTasks int `json:"latencyOnlyTasks"`
@@ -189,6 +214,19 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
if cfg.Runner == nil {
return TurnBenchResult{}, errors.New("turn benchmark requires a runner")
}
// Canonicalize the profile once at the boundary so every consumer — the
// child exec args, the stamped result, the summary — carries the same
// catalog name regardless of the caller's casing/whitespace, and a direct
// library caller cannot slip an unknown name into a report the way the CLI
// (which validates at parse time) cannot.
benchProfile := strings.TrimSpace(cfg.ExecProfile)
if benchProfile != "" {
profile, ok := execprofile.Lookup(benchProfile)
if !ok {
return TurnBenchResult{}, fmt.Errorf("unknown execution profile %q (valid: %s)", cfg.ExecProfile, strings.Join(execprofile.Names(), ", "))
}
benchProfile = profile.Name
}
iterations := cfg.Iterations
if iterations < 1 {
iterations = 1
@@ -197,7 +235,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
if now == nil {
now = time.Now
}
rc := RunContext{Model: cfg.Model, Mode: cfg.Mode, SelfCorrect: cfg.SelfCorrect}
rc := RunContext{Model: cfg.Model, Mode: cfg.Mode, SelfCorrect: cfg.SelfCorrect, ExecProfile: benchProfile}
perSpanSamples := map[string][]float64{}
classWalls := map[string][]float64{}
@@ -227,6 +265,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
Suite: strings.TrimSpace(set.ID),
Model: strings.TrimSpace(cfg.Model),
Mode: strings.TrimSpace(cfg.Mode),
ExecProfile: benchProfile,
SelfCorrect: cfg.SelfCorrect,
Version: strings.TrimSpace(cfg.Version),
Commit: strings.TrimSpace(cfg.Commit),
@@ -249,6 +288,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
// runner is cold-start, so a flaky pass on one iteration and a fail on
// another is a real regression signal, not noise to average away.
passedForTask := true
erroredIterations := 0
for iter := 0; iter < iterations; iter++ {
outcome := cfg.Runner(ctx, task, rc)
if outcome.TraceIssue != "" {
@@ -266,6 +306,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
Message: fmt.Sprintf("task %s: %v", task.ID, outcome.Err),
})
passedForTask = false
erroredIterations++
continue
}
if !outcome.Passed {
@@ -306,6 +347,14 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
// counters. A latency-only task (no verificationCommand) is never counted
// in any pass rate even when the runner reports Passed — an exit-0
// read-only run proves the turn ran, not that the answer was right.
if erroredIterations == iterations {
// Every iteration produced no accepted sample (spawn failure or a
// harness error after the run). The task still counts in its tier
// below — an oracle task as a failure, a latency-only task in no
// pass rate as always — but the errored state is first-class so a
// broken run can never print a clean-looking summary.
result.TasksErrored++
}
hasOracle := len(task.VerificationCommand) > 0
switch {
case !hasOracle:
@@ -456,9 +505,26 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
result.BuildPassedTasks, result.BuildCheckedTasks, result.BuildPassRate*100,
result.LatencyOnlyTasks, result.Iterations),
}
if result.TasksErrored > 0 {
lines = append(lines, fmt.Sprintf("ERRORED: %d task(s) produced no accepted benchmark sample (spawn/crash or harness error) — errored oracle tasks count as failures in their tier's pass rate; latency-only tasks stay out of pass rates as always", result.TasksErrored))
shown := 0
for _, warning := range result.Warnings {
if warning.Metric != "run" {
continue
}
lines = append(lines, " "+warning.Message)
shown++
if shown == 3 {
break
}
}
}
if result.Mode != "" {
lines = append(lines, "mode: "+result.Mode)
}
if result.ExecProfile != "" {
lines = append(lines, "exec-profile: "+result.ExecProfile)
}
if len(result.TopLatency) > 0 {
lines = append(lines, "top latency sources:")
for _, src := range result.TopLatency {
@@ -512,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 {
@@ -590,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
@@ -724,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)
}
@@ -734,6 +855,9 @@ func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArg
if rc.SelfCorrect {
args = append(args, "--self-correct")
}
if profile := strings.TrimSpace(rc.ExecProfile); profile != "" {
args = append(args, "--exec-profile", profile)
}
args = append(args, extraArgs...)
args = append(args, task.Prompt)
return args
@@ -747,7 +871,15 @@ func ResolveBinary(explicit string) (string, error) {
if _, err := os.Stat(v); err != nil {
return "", fmt.Errorf("trace binary not found: %w", err)
}
return v, nil
// Pin the explicit path to an absolute one: the turn runner sets each
// child's cmd.Dir to a per-task fixture copy, so a relative path
// (./zero, the Makefile default) that stats fine here would fail to
// spawn from inside every fixture dir, silently erroring all tasks.
absolute, err := filepath.Abs(v)
if err != nil {
return "", fmt.Errorf("resolve trace binary path: %w", err)
}
return absolute, nil
}
if path, err := exec.LookPath("zero"); err == nil {
return path, nil
+334 -8
View File
@@ -4,6 +4,8 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -530,14 +532,17 @@ func TestRunTurnBenchBuildOnlyMechanism(t *testing.T) {
}
}
// TestTurnSchemaVersion3 pins the schema bump. v3 records the tier
// reclassification (refactor structural-positive and nav answer-oracles moved
// into correctnessPassRate), so a v2->v3 cross-version comparison cannot
// silently misread the jump as a model improvement — exactly the misread the
// tier system exists to prevent.
func TestTurnSchemaVersion3(t *testing.T) {
if TurnSchemaVersion != 3 {
t.Fatalf("TurnSchemaVersion = %d, want 3", TurnSchemaVersion)
// TestTurnSchemaVersion pins the schema bump so a shape change is a conscious
// decision. v3 recorded the tier reclassification (refactor structural-positive
// and nav answer-oracles moved into correctnessPassRate). v4 adds tasksErrored:
// tasks whose every iteration died before the agent produced a run are now
// first-class in the report instead of visible only in warnings, so a
// spawn-broken run cannot print a clean-looking summary or exit 0. v5 is
// additive only: execProfile stamps the execution profile the run was
// benchmarked under so profile A/B reports are self-describing.
func TestTurnSchemaVersion(t *testing.T) {
if TurnSchemaVersion != 5 {
t.Fatalf("TurnSchemaVersion = %d, want 5", TurnSchemaVersion)
}
}
@@ -746,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
@@ -1067,3 +1158,238 @@ printf '%s\n' '{"type":"run_end","exitCode":0}'
`)
assertVerifyFailed(t, "nav-08 fmt-only-no-testing", outcome)
}
func TestResolveBinaryAbsolutizesExplicitPath(t *testing.T) {
dir := t.TempDir()
name := "zero-probe.exe"
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
resolved, err := ResolveBinary("./" + name)
if err != nil {
t.Fatalf("ResolveBinary: %v", err)
}
if !filepath.IsAbs(resolved) {
t.Fatalf("ResolveBinary returned relative path %q; the turn runner sets cmd.Dir per task, so a relative binary fails to spawn from every fixture copy", resolved)
}
}
func TestRunTurnBenchCountsErroredTasks(t *testing.T) {
set := TaskSet{
ID: "errored-suite",
Tasks: []BenchTask{
{ID: "t1", Class: "nav", Prompt: "p1", VerificationCommand: []string{"true"}},
{ID: "t2", Class: "longproc", Prompt: "p2"},
},
}
cfg := TurnBenchConfig{
Model: "fake-model",
Iterations: 1,
Runner: func(context.Context, BenchTask, RunContext) TurnTaskOutcome {
return TurnTaskOutcome{Err: errors.New("fork/exec ./zero: file does not exist")}
},
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
}
result, err := RunTurnBench(context.Background(), set, cfg)
if err != nil {
t.Fatalf("RunTurnBench: %v", err)
}
if result.TasksErrored != 2 {
t.Fatalf("TasksErrored=%d, want 2 (every iteration of both tasks errored)", result.TasksErrored)
}
if len(result.Warnings) != 2 {
t.Fatalf("warnings=%d, want 2 run warnings", len(result.Warnings))
}
summary := FormatTurnBenchSummary(result)
if !strings.Contains(summary, "ERRORED: 2 task(s)") {
t.Fatalf("summary does not surface the errored tasks:\n%s", summary)
}
if !strings.Contains(summary, "fork/exec ./zero") {
t.Fatalf("summary does not echo the underlying run error:\n%s", summary)
}
}
func TestRunTurnBenchPartialErrorStillCounts(t *testing.T) {
set := TaskSet{
ID: "partial-suite",
Tasks: []BenchTask{
{ID: "ok", Class: "nav", Prompt: "p", VerificationCommand: []string{"true"}},
{ID: "dead", Class: "nav", Prompt: "p", VerificationCommand: []string{"true"}},
},
}
canned := map[string]*trace.TurnTrace{"ok": cannedTrace(100, 10, 1000)}
inner := fakeTurnRunner(canned)
cfg := TurnBenchConfig{
Model: "fake-model",
Iterations: 1,
Runner: func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
if task.ID == "dead" {
return TurnTaskOutcome{Err: errors.New("spawn failed")}
}
return inner(ctx, task, rc)
},
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
}
result, err := RunTurnBench(context.Background(), set, cfg)
if err != nil {
t.Fatalf("RunTurnBench: %v", err)
}
if result.TasksErrored != 1 {
t.Fatalf("TasksErrored=%d, want 1", result.TasksErrored)
}
if result.TasksAttempted != 2 {
t.Fatalf("TasksAttempted=%d, want 2", result.TasksAttempted)
}
}
// The exec-profile passthrough: the profile must reach every task's zero exec
// invocation as --exec-profile (with the prompt staying last) and stay out of
// the args entirely when unset.
func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
task := BenchTask{ID: "t", Prompt: "do the thing"}
args := buildTurnExecArgs(task, RunContext{Model: "m", ExecProfile: "fast"}, "trace.ndjson", nil)
found := false
for i := 0; i < len(args)-1; i++ {
if args[i] == "--exec-profile" && args[i+1] == "fast" {
found = true
break
}
}
if !found {
t.Fatalf("args must carry --exec-profile fast, got %v", args)
}
if args[len(args)-1] != "do the thing" {
t.Fatalf("prompt must stay the last argument, got %v", args)
}
args = buildTurnExecArgs(task, RunContext{Model: "m"}, "trace.ndjson", nil)
for _, arg := range args {
if arg == "--exec-profile" {
t.Fatalf("no profile configured, but args carry --exec-profile: %v", args)
}
}
}
// 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
// label, and rejects unknown names so a direct library caller cannot slip an
// unvalidated profile into a report the way the CLI (parse-time check) cannot.
func TestRunTurnBenchStampsExecProfile(t *testing.T) {
set := TaskSet{
ID: "profile-suite",
Tasks: []BenchTask{{ID: "t1", Class: "longproc", Prompt: "p"}},
}
var gotProfile string
cfg := TurnBenchConfig{
Model: "fake-model",
ExecProfile: " FAST ",
Iterations: 1,
Runner: func(_ context.Context, _ BenchTask, rc RunContext) TurnTaskOutcome {
gotProfile = rc.ExecProfile
return TurnTaskOutcome{Passed: true, WallMs: 10, Trace: cannedTrace(10, 1, 100)}
},
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
}
result, err := RunTurnBench(context.Background(), set, cfg)
if err != nil {
t.Fatalf("RunTurnBench: %v", err)
}
if gotProfile != "fast" {
t.Fatalf("runner RunContext.ExecProfile = %q, want the canonical fast", gotProfile)
}
if result.ExecProfile != "fast" {
t.Fatalf("result.ExecProfile = %q, want the canonical fast", result.ExecProfile)
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
if !strings.Contains(string(raw), `"execProfile":"fast"`) {
t.Fatal("published JSON must carry execProfile")
}
}
func TestRunTurnBenchRejectsUnknownExecProfile(t *testing.T) {
set := TaskSet{
ID: "profile-suite",
Tasks: []BenchTask{{ID: "t1", Class: "longproc", Prompt: "p"}},
}
ran := false
cfg := TurnBenchConfig{
Model: "fake-model",
ExecProfile: "blanced",
Iterations: 1,
Runner: func(context.Context, BenchTask, RunContext) TurnTaskOutcome {
ran = true
return TurnTaskOutcome{Passed: true, WallMs: 10, Trace: cannedTrace(10, 1, 100)}
},
}
_, err := RunTurnBench(context.Background(), set, cfg)
if err == nil || !strings.Contains(err.Error(), "unknown execution profile") {
t.Fatalf("err = %v, want an unknown-profile rejection", err)
}
if ran {
t.Fatal("nothing may run under an unknown profile")
}
}
+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
}
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more