Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 a454931e66 fix(reasoning): address round-2 review — accessor deep-copy, max tier, provenance
- EffortControl/BudgetControl now return a cloned Control, so a caller holding a
  Capability that shares state with the catalog cannot mutate Min/Max pointers or
  the Values slice through an accessor result (CodeRabbit). Lookup already deep
  -copies; this closes the accessor path too.
- Add modelregistry.ReasoningEffortMax ("max") + accept it in ValidReasoningEffort,
  so the snapshot's "max" tier (e.g. gpt-5.1-codex-max, newer Anthropic) has a
  constant to map to. No curated model lists it yet.
- Snapshot provenance: add a _fetched date and a regeneration recipe (documented
  in catalog.go), so refreshes are reproducible/auditable.
- Document that Lookup takes the API model id (wire name), not the friendly
  registry id, and that the match is exact (no case-fold/prefix-strip).
- Record the source-of-truth intent in the package doc: this catalog is the
  intended authoritative source; modelregistry's name-pattern inference is demoted
  to a fallback when it is wired in (a later change). Rename the `cap` builtin
  shadow to `entry`.

Tests pin the accessor deep-copy isolation.
2026-06-28 14:09:09 +05:30
Vasanthdev2004 35145a519b fix(reasoning): address review — presence-aware budget bounds, deep-copy lookup, first-party-only snapshot
- Control.Min/Max are now *int so an explicit bound is distinct from an omitted
  one: Gemini's `min: 0` ("thinking can be disabled") and Anthropic's missing
  max (unbounded) are preserved instead of both collapsing to 0. Budget() becomes
  BudgetControl() returning the control so callers read the pointers.
- Catalog.Lookup returns a deep copy (clone) of the matched Capability, so a
  caller can no longer mutate the shared embedded catalog through the returned
  Controls / Values slices or budget pointers.
- Drop the google-vertex section from the snapshot: it re-hosts third-party MaaS
  models (Anthropic/Moonshot/OpenAI/DeepSeek/Qwen/Meta), violating first-party
  -only. google (AI Studio) already covers first-party Gemini, so the gemini
  provider kind resolves there. Snapshot 158 -> 123 models, 16.8 KB.

Tests pin the explicit min: 0 preservation, the nil max on budget-only Anthropic
models, and that a mutation of a Lookup result does not leak into the catalog.
2026-06-28 13:34:05 +05:30
Vasanthdev2004 1282a23ab1 feat(reasoning): typed per-provider reasoning capability from a models.dev catalog
Providers describe reasoning control with incompatible concepts — OpenAI a
discrete effort enum (split by minor version), Anthropic and Gemini 2.5 a
thinking-token budget, some models only a thinking on/off toggle — which a flat
effort string cannot represent and a model-name heuristic cannot detect.

Add internal/reasoning: a Capability type (a model's reasoning flag plus its
typed Controls — effort/budget_tokens/toggle) and a Catalog that looks capability
up by provider + api model id. The data is an embedded, trimmed snapshot of the
community capability database (first-party providers only; routers excluded since
they carry generic/stale option lists), so detection is offline and exact instead
of guessed.

The package depends only on the standard library, so the model registry and
provider adapters can consume it without an import cycle. This change is purely
additive — nothing is wired into the live resolution path yet.

Tests pin the per-version ground truth (gpt-5 minimal-not-none, gpt-5.1
none-not-minimal, gpt-5.1-codex-max xhigh, gpt-5-pro locked to high; Anthropic
budget-only legacy vs effort-only newer with the budget removed; Gemini 2.5
budget/toggle vs Gemini 3 effort) and that every reasoning model Zero ships today
is covered by the snapshot.
2026-06-28 13:22:55 +05:30
KRATOS 8aae0be6db feat(tui): compact one-line /model switch confirmation (#331)
The model-switch confirmation printed a 6-7 line block (Switched model. / model: /
provider: / api model: / effort: / saved:). Collapse it to a single summary line —
'<model> · <provider>[ · effort …][ · saved]' — keeping the deprecation/redirect
notice line when present. Tests updated to the new format.
2026-06-28 11:19:44 +05:30
KRATOS 8ea8831c30 fix(model): expose reasoning effort for GPT-5 / Codex / o-series (#330)
/effort showed no controls for ChatGPT (GPT-5) because those models aren't in the
curated registry and the OpenAI catalog entries carry no reasoning efforts, so
Registry.ReasoningEfforts returned nil even though GPT-5 supports reasoning_effort.

Add a name-based fallback: when a model isn't enumerated with efforts, infer them for
known reasoning families — GPT-5 / gpt-5.x Codex (minimal/low/medium/high) and the
o-series (low/medium/high). Non-reasoning models (GPT-4.1/4o, local models) stay empty,
so /effort still hides controls where they don't apply.
2026-06-28 09:52:45 +05:30
KRATOS a4b2e01fe6 feat: encrypted provider credentials + provider-aware /model (#329)
Encrypted credential storage
- internal/securefile: AES-256-GCM at-rest crypter (fail-closed, atomic, locked).
- internal/credstore: provider API-key store, keyring-first with an encrypted-file
  fallback; plaintext only via an explicit ZERO_CRED_STORAGE=file opt-out.
- Provider API keys no longer live in config.json: captured straight into the store
  from the wizard/CLI setup, and any existing inline plaintext key is migrated to the
  store on launch (fail-soft; stripped only after the store write succeeds).
- buildProvider and every model/provider switch reload the credential (stored key,
  env var, or OAuth bearer), so a stored-key provider always authenticates.
- `zero auth logout <provider>` now also removes the stored API key and its marker.

/model and /provider
- /model lists every authenticated provider (filtered to those with a usable
  credential), grouped per provider under section headers.
- Each provider's models are live-discovered (its real served models) rather than a
  static catalog; local providers show only the models actually pulled.
- Selecting a model from another provider switches provider and model together.
- /provider offers keep / replace / remove when a provider already has a stored key.

Gated throughout: go build, go vet ./..., go test ./... -race, lint.
2026-06-27 23:25:08 +05:30
KRATOS fb3277f46e feat(tui): tabbed ask_user questionnaire in the composer with suggested answers (#327)
* feat(tui): tabbed ask_user questionnaire rendered in the composer

Redesign the interactive ask_user prompt to match the requested mockup: when a
question is raised the composer text box becomes the questionnaire.

- Multi-question prompts render as a row of TABS (one per question + a trailing
  Confirm tab). Tab / Shift+Tab switch questions, answers are given in any order,
  and Enter on Confirm submits them all. A single-question prompt submits on
  answer (no Confirm step) and shows no tab row.
- Each option can carry a one-line DESCRIPTION (contract: per-option
  optionDescriptions, or option objects {label, description}); options render
  numbered with the description dimmer underneath and the recommended one tagged.
- Per-question optional `header` gives each tab a short title (falls back to a
  trimmed question).
- Rendered in the composer/footer region (footerView), replacing the text box
  while active, instead of a scrolling transcript card.
- Keys: Tab move · ↑↓ select · Enter confirm · Esc dismiss; a printable key in
  the picker drops into free-text; Esc from "type your own" returns to the picker;
  multi-select questions are free-text with the options shown as suggestions.
- Headless unaffected (zero exec never wires OnAskUser).

Removed the old single-question sequential state (cursor/typing/index,
renderFocusedAskUserPrompt, resolveAskUser). Tests rewritten for the tabbed flow
(picker default, type-your-own, multi-question tabs + Confirm, Esc dismiss,
descriptions render). make build / go vet / go test ./... -race / make lint green.

* fix(tui): ask_user questionnaire renders on the black terminal canvas

It was painting a gray panel background (styledBlockFill with zeroTheme.panel +
onPanel-wrapped foregrounds). Since the questionnaire replaces the composer, it
should match it: bare foregrounds on the terminal canvas (black) with the
composer's lineStrong border and no fill. The lime cursor/badge highlights and
the recommended tag stay.

* fix(tui): address PR review — schema/parser alignment, single-question Tab

- ask_user schema: options.items is declared as string, so advertise only the
  string form (descriptions go via the parallel optionDescriptions array). The
  parser stays object-tolerant defensively, but the schema no longer tells
  strict-schema providers to send objects it would reject.
- moveAskUserTab no-ops for single-question prompts (no tab strip / Confirm tab),
  so Tab/Shift+Tab can't move the prompt into the hidden Confirm state. Test added.
2026-06-27 17:41:48 +05:30
Vasanth T 95aaca6055 fix(tui): paint transcript selection once, not twice (#328)
Selecting text in the transcript lit up in two places: the real selection
plus a copy shifted right by the reading-column gutter (e.g. selecting "help"
also highlighted "today").

Root cause: assistant, user, and reasoning rows self-painted the selection
highlight in their render functions using the UNSHIFTED textStart, but the
selection points (anchor.x/cursor.x) are in the gutter-shifted screen
coordinate the mouse maps to. So the self-paint landed gutter cells off, and
finalizeTranscriptBodyRow then painted the highlight AGAIN at the correct
shifted position — two highlights, one gutter apart. The rendered/toolResult/
specialist rows were already converted to paint-once-at-finalize; these three
were left behind.

Fix: stop self-painting in renderSelectableUserRow, renderSelectableAssistantRow,
and renderSelectableReasoningBlock. They now return the canonical renderRow
output (already line-aligned with their selectable spans) and let
finalizeTranscriptBodyRow be the single painter, in the same shifted coordinate
the mouse uses. Removes the now-dead renderTranscriptSelectableText /
renderTranscriptSelectableMarkdownText helpers.

Regression test TestTranscriptSelectionPaintsHighlightOnceNotTwice selects a
sub-range of an assistant row and asserts the selection style is emitted exactly
once; it fails on the pre-fix code (painted 3x, including the gutter-shifted copy).
2026-06-27 17:29:38 +05:30
KRATOS 1d1fa7c514 feat(tui): ask_user suggested answers with a recommended default (#326)
* feat(tui): suggested answers with a recommended default for ask_user

When the agent asks a clarifying question mid-task, it can now offer 2-4
suggested answers and mark one recommended; the interactive TUI renders a
selector (arrow keys, lime cursor, "(recommended)" tag) with the cursor resting
on the recommended option, plus a trailing "✎ Type my own answer…" entry that
drops into the existing free-text composer. Whichever the user picks — a
suggested option's text or their typed answer — flows through the same answer
channel the agent loop already waits on.

Optional and backward-compatible: a question with no options renders the plain
free-text prompt exactly as before. Headless `zero exec` is unaffected (it never
wires OnAskUser; the non-interactive best-assumption fallback stands).

- contract: agent.AskUserQuestion / tools.AskUserQuestion gain Recommended;
  the tool schema advertises optional options + recommended; parser keeps
  recommended only when it resolves to one of the options (canonical text).
- system prompt: tells the model it MAY supply options + a recommended one.
- TUI: pendingAskUserPrompt gains cursor/typing; ask_user_prompt.go holds the
  selector logic mirroring permission_prompt.go; renderFocusedAskUserPrompt
  renders the picker or the free-text prompt.

Tests: selector defaults to recommended and returns its text; "type my own"
falls back to free-text and returns the typed text; no-options stays plain
free-text. make build / go vet / go test ./... -race / make lint green.

* fix(tui): ask_user — single input, Esc steps back from "type my own"

Two UX fixes from interactive testing:

- No double display: the focused ask card no longer echoes the in-progress
  free-text answer (it was shown both inside the card AND in the composer text
  box). The composer is now the single input; the card just shows the question
  and a hint. renderFocusedAskUserPrompt no longer takes the input string.

- Esc from "type my own" returns to the selector for that question instead of
  cancelling the whole questionnaire (escapeAskUser); a question with no options,
  or the selector itself, still cancels on Esc as before.

Tests: type-my-own asserts the card does not echo the typed text and the hint
flips to "type your own answer"; new test covers Esc returning to the selector
(no cancel, no answer delivered) then selecting a suggested option.

* fix(tui): ask_user review fixes — multi-select, selector typing, dead code

Address the three confirmed findings from the code review:

- multi-select no longer silently narrowed to one option: a single-pick
  selector cannot represent MultiSelect, so a multi-select question now renders
  as free-text with its options surfaced as suggestions; the user types one or
  more and the answer is returned verbatim (syncQuestionState; escapeAskUser and
  the renderer updated to match).

- typing in selector mode no longer discarded: a printable keystroke while the
  selector is showing flips into free-text and captures the key (model.go key
  capture), instead of accumulating in a hidden input that Enter then overrode
  with the highlighted option.

- removed dead submitAskUserAnswer (the Enter handler now routes through
  confirmAskUser; the orphan had no callers).

Tests: multi-select falls back to free-text and returns the typed answer;
a printable key in the selector switches to free-text and is captured. Full
gate green (build / vet / go test ./... -race / make lint).

* fix(tui): address PR review — element-wise option compare, clearer Esc copy

- ask_user_test: compare option slices with slices.Equal instead of
  strings.Join, so the parser test can't be fooled by ["A,B"] vs ["A","B"]
  or nil vs [""].
- ask_user prompt: the Esc hint said "skip", which reads like "skip this
  question" on a multi-question prompt even though Esc ends the rest of the
  questionnaire (remaining answered by best-assumption). Reworded to "skip the
  rest" in the selector and free-text hints; "type my own" keeps "go back".
2026-06-27 14:12:41 +05:30
Anandan b8b0bb7f65 Move deferred tool discovery into tool search (#324)
* Move deferred tool discovery into tool search

Keep deferred tool discovery in the tool_search definition instead of appending it as a per-turn user message, so models do not answer or acknowledge internal discovery metadata.

Preserve deferred loading behavior: hidden tools remain hidden until loaded, tool_search stays reachable for allowlisted deferred tools, and retry paths keep loaded/deferred state.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools -run 'Test.*Deferred|Test.*ToolSearch|TestRunLoadsDeferredToolThenAdvertisesNextTurn|TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestAllowlistedDeferredToolsKeepToolSearchReachable|TestDisabledToolSearchFallsBackToEager'

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools ./internal/cli

Tested: git diff --check

* Clarify deferred tool search selection
2026-06-27 13:02:09 +05:30
KRATOS f8ac5eceb0 fix(agent): gate headless run completion on honesty (no false-success on no-tool-call / self-reported-incomplete turns) (#325)
* fix(agent): don't end a run as success on a no-tool-call turn mid-task

A turn that produced text but no tool call was always accepted as the final answer, so the loop reported success even when the model stopped mid-task (e.g. ended on "...Let me check the SSH configuration:" with plan steps still pending).

Add an opt-in completion gate (Options.RequireCompletionSignal): when a turn has no tool call and work clearly remains -- pending update_plan items, or the message ends on a continuation cue -- re-prompt the model to continue instead of finalizing. Bounded by maxContinueNudges and still by MaxTurns/the deadline; once the budget is spent the run finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default off, so the interactive path is byte-identical.

Genuine single-turn completions (no pending plan, no cue) still finalize as success. Covered by internal/agent/completion_gate_test.go.

* feat(exec): report stalled headless runs as INCOMPLETE (exit 4)

Enable the agent completion gate for headless exec (RequireCompletionSignal) and map Result.Incomplete to run_end status "incomplete" with a new exit code 4, so a run that stalled mid-task (model stopped without a tool call while work remained, continue budget exhausted) is no longer reported as success. Interactive callers are unaffected.

* fix(agent): downgrade self-reported non-completion; advisory task-grounded acceptance

Reduce -- not eliminate -- false-success on headless runs. Two DETERMINISTIC, unit-tested gates (with the plan gate from the prior commit):

(a) self-report downgrade: if the final message admits the model guessed or could not meet the objective, finalize INCOMPLETE (exit 4), never success. Inability is matched by first-person STEMS generalized over verb/tense ("I cannot/can't/could not/am unable to/do not have/unable to ...") plus guess/fallback/uncertainty phrases, with a guard so success-y negations ("could not find any issues", "cannot reproduce") are not misread. (b7bc0b8's plan gate already forces INCOMPLETE on pending/in_progress update_plan items at termination.)

(b) task-grounded acceptance is ADVISORY, not a guarantee. When --self-correct is on it demands one bounded acceptance pass that re-derives the task's stated criterion and runs a concrete check, discouraging three false-success patterns (well-formed==correct, existing-tests-pass==objective-met, result==baseline-it-was-told-to-beat). But it is a prompt: a model that ignores it and confidently claims "PASS, all requirements met" still slips, because ZERO has no general oracle to verify correctness against a task's hidden criterion. Empirically (TB-2, qwen3-coder:480b) this reliably catches admissions and incomplete plans and REDUCES false-success, but a confident false PASS on a model-ceiling task is a residual, fundamental gap -- not a tuning miss.

Default off (RequireCompletionSignal); interactive callers are byte-identical. Covered by internal/agent/{acceptance_gate_test.go,completion_gate_test.go}.

* feat(exec): surface the INCOMPLETE reason in run_end and logs

When a headless run finalizes as INCOMPLETE, include Result.IncompleteReason in the session error event and a stderr warning so an honestly-incomplete run (e.g. "the final message admits the objective was not met") is debuggable rather than an opaque exit 4.

* fix(agent): harden completion gate per PR review

Addresses the correctness findings from the PR review.

self-report (#1): drop behavior-describing phrases ("fall back to",
"placeholder value", bare "best guess", "as a fallback", "without proper")
that also match legitimate final answers; keep first-person/uncertainty
admissions only, since these are matched without a context guard.

self-report (#5): scan every occurrence of each inability stem so an early
success-negation ("could not find any examples") no longer masks a later
genuine admission with the same stem ("could not implement it").

continuation cue (#6): require a trailing colon AND an action lead-in on the
final clause; stop flagging recommendations, plain summary colons, and
sign-offs. Still catches the mid-line "...Let me check the config:".

gate order (#8): check the self-report admission BEFORE pending-plan, so an
admitted-impossible task downgrades immediately with the accurate reason
instead of burning continue-nudges.

pending plan (#3): treat a pending/in_progress update_plan item as a
NUDGE-only weak signal -- it no longer forces INCOMPLETE on its own (a
completed run that left stale plan bookkeeping is trusted). Only a
continuation cue or a self-report admission finalizes INCOMPLETE.

max-turns (#4): a run cut off at the MaxTurns ceiling now finalizes
INCOMPLETE under the gate instead of being reported as success.

exec json/cron (#2, #7): for -o json, emit the terminal done with exit 4 on
an incomplete run (final() pre-emits a success done:0 for json that would
otherwise mask it); emit an error event -- not just a warning -- so the cron
failure extractor can recover the reason.

Deferred (noted on the PR): acceptance-only-when-mutated (#9, cost) and
reusing tools.normalizePlanStatus (#10, would widen scope to internal/tools).

Tests: add TestContinuationCueMatching and TestMaxTurnsCutoffIsIncompleteUnderGate,
extend TestSelfReportedIncompletionMatching with the #1/#5 cases, and replace
the in_progress=>incomplete test with TestPendingPlanAloneDoesNotForceIncomplete.
make build / go vet / make lint / go test ./... -race all green.
2026-06-27 12:31:41 +05:30
Anandan d29ab98a79 Improve sandbox permission handling (#323)
Retry shell commands outside the sandbox when sandboxed output only reflects the sandbox namespace, and keep network retries sandboxed when a network grant is enough.

Show user-facing permission reasons for escalated shell commands instead of exposing internal sandbox policy text.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui -run 'TestRunUsesEscalatedJustificationForPermissionPrompt|TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt|TestPermissionPromptMapsEscalatedSandboxReason|TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput'

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools

Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...
2026-06-26 16:21:06 +05:30
Anandan 80ab238361 Align sandbox command approval behavior (#322)
* Align shell approval sandbox execution

Make approved shell command-prefix executions use the escalated sandbox path when denied-read policy allows it, so remembered approvals do not still hit the restrictive sandbox.

Allow auto sandbox mode to degrade to a direct command plan when native command wrapping is unavailable, while strict sandbox require continues to fail closed.

Update regression tests for degraded execution, prefix-approved shell calls, and unavailable native sandbox metadata.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsWithDegradedUnavailableNativeSandbox|TestBashToolRunsWithDegradedUnavailableNativeSandbox|TestBashRequireEscalatedBypassesNativeSandboxAfterApproval|TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval'

Tested: GOCACHE=/tmp/zero-go-cache go test ./... -run '^$'

Tested: git diff --check

* Align sandbox command approval behavior

Evaluate reusable shell approvals across plain command segments so prefix grants can cover commands with safe read-only tails without relying on command-specific sandbox exceptions.

Keep process inspection commands sandboxed by default; unsandboxed host access continues to require explicit escalation or a narrow command-prefix approval.

Improve shell tool guidance around sandboxed additional permissions, full escalation, justifications, and narrow reusable prefixes.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/agent

Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...

* Normalize explicit sandbox backend platforms

Infer the platform from a caller-provided sandbox backend name before choosing the manager runtime platform. This keeps Linux, macOS, and Windows backend plans deterministic across host OSes when tests or callers provide a backend without an explicit Platform field.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools

Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./...
2026-06-26 13:20:07 +05:30
KRATOS 98bd1acbe6 ci(release): build all 5 platforms (Intel macOS + Linux arm64) (#321)
* ci(release): build all 5 platforms (add Intel macOS + Linux arm64 runners)

zero-release package builds + smoke-tests natively, so each target needs its own
runner. The matrix had only ubuntu-latest/macos-latest/windows-latest (linux-x64,
macos-arm64, windows-x64) — so install.sh/install.ps1 404 for Intel Macs
(macos-x64) and Linux arm64. Adds macos-13 (Intel) and ubuntu-24.04-arm so the
release covers all platforms the installers resolve.

* ci(release): use macos-15-intel for the Intel macOS leg

macos-13 was retired (June 2026) so the matrix leg would never schedule.
macos-15-intel is the current GitHub-hosted Intel x64 runner (supported through
Aug 2027). Addresses CodeRabbit review on #321.
2026-06-26 09:03:28 +05:30
KRATOS 2b6568f82b chore: launch-readiness — add MIT LICENSE + reconcile README (#320)
* docs: add MIT LICENSE

Adds the canonical MIT License (Copyright (c) 2026 Gitlawb) at the repo root,
resolving the launch blocker where the project was marketed as open source with
no license file (legally all-rights-reserved).

* docs(readme): declare MIT license + badge, link LICENSE

README's License section said 'being finalized'; now states MIT and links the
LICENSE file. Adds a license badge to the header row.
2026-06-26 08:35:31 +05:30
Anandan f1502f0bfe Add local control browser and terminal tools (#319)
* Add local control browser and terminal tools

Add configurable local browser, desktop, and terminal control helpers with packaged helper discovery for install and release flows.

Improve sandbox scope classification for local control commands and shared temporary paths.

Add structured browser actions, terminal session line input/key normalization/read support, and artifact capture for local-control outputs.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config -run 'Test.*LocalControl|TestTerminal|TestBrowser|TestDesktop|TestCapture'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config ./internal/agent

Tested: tuistory smoke launched bash, sent interactive input, waited for expected output, and captured a no-cursor snapshot

Tested: git diff --cached --check

* Fix npm wrapper helper manifest test on macOS

Canonicalize expected helper paths in the npm wrapper manifest test so macOS /var and /private/var temp path aliases compare correctly.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper

Tested: git diff --check

* Harden local control review fixes

Respect resolved local-control config before tool listing and filter validation while preserving stream-json usage error ordering.

Harden browser, desktop, and artifact tool validation; guard Linux-only app launch behavior; clear stale helper manifests from the npm wrapper.

Make helper packaging lockfile-based with npm ci and add installer/package tests for helper staging.

Tested: focused local-control/tool/CLI/release/install/sandbox suite

Tested: isolated full go test ./...

Tested: macOS and Windows compile-only checks for touched packages
2026-06-26 08:22:56 +05:30
KRATOS 0e7318ff72 feat(tui): clickable plan-step detail + plan/sidebar UX & rendering polish (#315)
* fix(tui): plan panel reflects live progress + prompt cadence

The plan panel could read as stuck mid-task even while work continued. Three
fixes:
- Header showed steps[0] forever, so the title never advanced as the plan
  progressed; show the current step (in_progress, else first incomplete).
- updateFromItems matched steps by content only, so an in-place reword reset a
  step's elapsed timer to zero; add positional carry-over when the step count is
  unchanged.
- Prompt: tell the model to mark each step completed + the next in_progress
  before starting it, so the plan tracks real progress instead of jumping at the
  end.

Regression tests included.

* feat(tui): click a plan step to see what was built for it

Each plan step now records the file mutations (write_file/edit_file/apply_patch)
made while it was in_progress, captured from tool-result rows. Clicking a step
row in the context sidebar drops a transcript card listing those changes — the
implementation for that step. Step->line mapping mirrors sidebarAgentSelectables'
offset accounting (one line per step); capture is keyed to the active step and
cleared per run. Read-only and additive. Tests cover the offset math + capture.

* feat(tui): plan-step detail shows diffs + commands

Extend the clickable plan-step detail: capture bash/exec_command runs in addition
to file mutations, store each tool's full output (the diff for edit/apply_patch,
stdout/stderr for commands), and render the card grouped into Changes and
Commands with a short truncated excerpt of each item's diff/output.

* fix(tui): plan-step detail toggles instead of stacking duplicates

Re-clicking a plan step now hides its card (a stable transcript id + drop-by-id),
and clicking a different step replaces it, so at most one detail card is shown.
Previously each click appended a fresh card and they piled up.

* feat(tui): elaborate plan-step detail by status (what we did / will do)

* feat(tui): plan-step card leads with a prose explanation (agent's own narration)

* feat(tui): write plan-step explanations with the model on click

Clicking a PLAN step now shows a fresh, plain-English write-up of that
step, generated on demand by the active model:

- past tense ('what we did') for completed/failed steps, future tense
  ('what we'll do') for pending/in_progress steps
- immediate feedback: the card drops in showing 'Writing explanation…'
  and updates in place (by stable row id) when the text returns
- cached per step (content+status) so re-clicking is instant with no
  second model call; re-clicking still toggles the card closed
- the prompt is built from already-captured local data (step text,
  status, notes, plus a compact digest of file edits, commands, and the
  agent's narration) and asks for short, non-technical prose
- reuses the TUI's existing provider via a one-shot StreamCompletion;
  no new client, no hardcoded provider. Falls back to the local summary
  when no provider is configured or the request fails.

* fix(tui): minimal plan-step card styling (dim border, status-tinted title)

* feat(tui): auto-hide context sidebar when it has no agents or plan

* feat(tui): widen chat reading column to fill wide terminals with breathing room

* fix(tui): toggle context sidebar silently (no transcript notice)

* fix(tui): mark plan complete when a finished task forgot the final update_plan

* fix(tui): remove the lime streaming-text glow (render responses in static ink)

* fix(tui): remove the shaded panel band behind tool-result cards

* fix(tui): Ctrl+B hides the plan entirely (no pinned-panel fallback)

* style(tui): gofmt-align model struct fields

* feat: narrate the build as a clean story (hide plumbing cards, live plan progress, narration prompt, write_file line counts)

* feat(tui): render agent narration bright (ink) so the build reads as a story

* feat(agent): demand an elaborate, structured closing summary (not a single line)

* feat(tui): mark agent narration with a leading bullet (selection-aligned)

* feat: post-turn recap line (※ recap:) with /config recaps on|off toggle

* feat(tui): show 'still generating…' during quiet stretches + count tool-call streaming

* feat(tui): ACTIVITY panel under PLAN + push the model to step the plan per file

* fix(tui): show the quiet 'still generating' hint only when the sidebar is hidden

* feat(tui): code preview in write/edit/apply cards (card-only, no model token cost)

* fix(tui): address CodeRabbit review — stale plan timestamps, stale explanation, Ctrl+B persistence, UTF-8 truncation

* fix: address CodeRabbit re-review round 2 (7 actionable + 2 nitpicks)

* chore: drop accidentally-committed WIP internal/cli/dryrun_test.go

It is an unrelated, pre-existing dry-run test that does not compile (references a
non-existent agent.Options.DryRun field, an unused import, and out-of-scope
sandbox symbols). It was swept into the prior commit by 'git add -A' and is not
part of this PR's scope.
2026-06-25 20:23:29 +05:30
Vasanth T ca41ac2b6c feat(agent): preamble before multi-step tasks for a readable chat (#317)
The prompt told the model "Use tools to act, not to narrate", which also
suppressed the brief upfront "here's my approach" the user relies on to follow
what the CLI is doing — so Zero jumped silently into tool calls. Keep the
per-call anti-spam, but lead a multi-step task with a one- or two-sentence
plain-language preamble. Prompt-only change; the TUI already commits streamed
pre-tool prose as an assistant row, so no render change is needed.
2026-06-25 19:32:41 +05:30
Vasanth T f9a26e7148 feat(tools): show red/green diff preview on write_file and edit_file (#318)
write_file/edit_file only reported "Created X (8462 bytes)" / "edited X",
so the user never saw WHAT changed — no code preview, no red/green. The
TUI already renders a colored diff card whenever a tool's output looks
like a unified diff (diffFirstToolBodyRenderer + looksLikeDiff), so the
fix is purely tool-side: emit a diff.

- edit_file appends a unified diff (old vs new) after its summary line →
  red/green change preview.
- write_file appends a unified diff of prior vs new content: a fresh
  create previews as all-green additions, an overwrite as red/green. It
  reads the prior bytes before writing only when the file existed.
- boundedUnifiedDiff (via the already-vendored go-udiff, now a direct
  require) caps the emitted diff at 48KB so a large generated file can't
  flood the transcript or balloon persisted session events; past the cap
  only the summary shows. The summary stays the first output line so any
  consumer that reads it (swarm/Task result bubbling) is unaffected.

Tests: edit emits @@/-/+ ; new-file emits additions-only; overwrite emits
red/green. Full tools/tui/agent/swarm suites green.
2026-06-25 19:24:27 +05:30
Vasanth T 01fed03a59 fix(tui): stop ANSI escape leaks + drop redundant update_plan cards (#316)
Two chat-clarity fixes surfaced by comparing Zero's output to a reference
agent on a "build a website" task.

1. ANSI escape garbage in the final answer. styleAssistantMarkdownLine
   treated already-styled input (syntax-highlighted code, headings, tables —
   which carry real ANSI) byte-by-byte as runes and re-wrapped the whole line
   in another base.Render. That doubled the SGR density and let a downstream
   width-truncation slice mid-escape, leaking "[38;2;…" / "[1;4;…" fragments
   as literal text next to code blocks. Now real escape sequences pass through
   verbatim (via the existing ansiSequenceEnd helper, like truncateStyledLine);
   the synthetic bold markers are still matched first so their style switch is
   kept. Only styled segments were affected — plain prose never had embedded
   escapes to split.

2. Redundant update_plan tool cards. The plan is already shown in the pinned
   plan panel AND the PLAN sidebar, so rendering each update_plan call as its
   own transcript card was pure duplication (it stacked 3x in the demo).
   Generalize the existing Task-card skip into toolCardSuppressedInTranscript
   and skip update_plan's call+result rows too; the plan-panel sync and session
   events are unchanged.

Tests: TestStyleAssistantMarkdownLinePassesAnsiVerbatim (fails before the fix),
TestToolCardSuppressedInTranscript.
2026-06-25 19:23:04 +05:30
Vasanth T 46c44a8753 feat(swarm): let swarm members build (write + sandboxed shell) (#313)
* feat(swarm): let swarm members build (write + sandboxed shell)

Swarm subagents were effectively read-only: a member runs headless at
"--auto low" (PermissionModeAuto), and ToolAdvertised strips every
prompt-requiring tool from the model's list — so write_file/edit_file/
apply_patch/bash were never handed to the member. It only saw read/grep/
glob/plan tools, so it could not build and the orchestrator did all the
writes itself.

Add PermissionModeMemberAuto: a headless mode that ADVERTISES the
in-workspace mutators (SideEffectWrite + SideEffectShell) on top of the
Auto set, while behaving exactly like Auto everywhere else. The sandbox
engine still decides at call time — in-workspace writes and sandbox-backed
shell auto-allow; out-of-workspace writes, network, and destructive
commands prompt and so are denied headless. member-auto normalizes to Auto
in the sandbox layer, so no sandbox authority is widened beyond what an
interactive auto agent already has.

Scope: swarm members only. The launcher sets MemberAutonomy; the executor
emits "--auto member" for a non-unsafe member (unsafe still -> high, plain
specialists still -> low). Task-tool specialists are unchanged.

Windows note: shell only auto-runs when the OS sandbox is active; without
`zero sandbox setup` a member writes files but cannot run builds.

* style: gofmt member_auto_test.go
2026-06-25 11:36:40 +05:30
Vasanth T 1c94168245 feat(tui): keep finished swarm members visible during the run (#312)
* feat(tui): keep finished swarm members visible during the run

Finished swarm members vanished from the AGENTS sidebar after a 1.5s
linger, so on a long turn (the orchestrator keeps working after the
subagents finish) they were gone before the user could inspect them —
the panel read "no agents spawned" mid-run and the clickable drill-in
had nothing to click.

Now a finished member stays in the panel (solid check, still clickable
to open its subchat) for as long as the run is in flight, and only fades
out and drops once the turn ends. Spawns are scoped to the active run
(row.runID matches activeRunID) so a previous turn's members do not
reappear when a later turn keeps members visible.

* fix(tui): scope swarm member status to the active run

Address review: swarmSpawnedAgents filtered spawn rows by activeRunID, but
swarmMemberStatus still read swarm_status/swarm_collect rows from any run. With
reused task ids, a stale prior-run status could mark a current member done and
wrongly fade/drop it. Scope swarmMemberStatus to the active run too, and tighten
the tests (non-zero run id; a stale same-id done status from a prior run).
2026-06-25 11:36:21 +05:30
Anandan 644f61fa58 Fix shared temp roots in sandbox scope (#314)
Treat platform temp directories as default writable roots so file tools, shell commands, and sandbox profiles share the same temp view.

On Linux, bind host temp roots instead of mounting an isolated tmpfs at /tmp so artifacts created under /tmp remain visible across tool and shell calls.

Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/cli ./internal/agent
2026-06-25 09:47:55 +05:30
Vasanth T 11926cc5d2 feat(tui): click a swarm member in the sidebar to open its subchat (#311)
* feat(tui): click a swarm member in the sidebar to open its subchat

Specialists were drillable (click the card → subchat shows the child
session), but swarm members shown in the AGENTS sidebar were not — even
though each member already runs as a `zero exec --init-session-id <sid>`
child that persists a durable session in the same store the TUI reads.
This wires that session through so a member row is clickable.

- swarm: Coordinator records the member's child session id on completion
  (CompleteWithSession / Task.SessionID); swarm_collect emits a
  task_id → session_id map in its Result.Meta.
- tui: the OnToolResult closure forwards that map via swarmSessionsMsg
  into m.swarmSessionMap; swarmAgent carries the session id; the AGENTS
  sidebar records a clickable hit per member whose session is known.
- tui: sidebarLineAtMouse maps a left-click in the sidebar column to a
  member row (screen-Y == sidebar line index; sidebar starts at
  chatColumnWidth + 3), and the existing specialist-card drill-in path
  (subchat.enter) is reused verbatim.

Members are clickable only once their session id is known (after
swarm_collect), so in-flight members simply aren't clickable — no dead
clicks. The subchat machinery, Result.Meta transport, and the click
handler are all reused; no new persistence or agent-loop changes.

* test(tui): assert sidebar member click drills into the member session

Address review: the routing test only checked the click was handled, so it
could pass even if the drill-in didn't enter the expected subchat. Seed a real
session in the store and assert subchat.active + childSessionID == the member's
session after the click.
2026-06-24 14:52:06 +05:30
Vasanth T 258f1a31c9 fix(swarm): make swarm_collect wait for members instead of polling (#310)
* fix(swarm): make swarm_collect wait for members instead of polling

Swarm spawn was fire-and-forget and the only way to learn a member had
finished was to call swarm_status/swarm_collect, which returned an instant
snapshot. So an orchestrator had to poll in a loop, flooding the chat with
repeated status cards and never getting a clean "done".

swarm_collect now BLOCKS until every member of the team reaches a terminal
state, then returns their results in one call:

- Coordinator gains a change-broadcast channel and WaitSettled(ctx, team),
  which blocks until the scope has no pending/running task (or ctx is done).
  Every state transition (register/setstatus/finish/reassign) wakes waiters.
- Swarm.CollectWait wraps it; the collect tool calls it bounded by the run
  context (user cancel) and a timeout (default 600s, cap 1800s, overridable
  via timeout_seconds), so a stuck member returns partial state instead of
  hanging forever.
- Tool descriptions now steer the model: spawn then collect once; status is a
  one-shot snapshot, not something to poll.

TUI: collapseRepeatedStatusCard drops the previous identical swarm
status/collect card when a check repeats verbatim back-to-back, so any
residual re-checks don't stack duplicate blocks in the chat.

* fix(swarm): address review on blocking collect (race + timeout overflow)

- WaitSettled: take the settled decision and the returned snapshot in the
  SAME locked pass. Previously the snapshot was re-read after releasing the
  RLock, so a concurrent Register/Reassign could make the call return a
  non-terminal task it never waited on — breaking the blocking contract.
  Removed the now-dead scopedList; shared sortTasksByCreation with List.
- collectWaitTimeout: clamp timeout_seconds in float space before converting
  to time.Duration. A very large value overflowed the int64-ns Duration and
  wrapped negative, making context.WithTimeout fire immediately instead of
  honoring the cap.
- test: release the gated member via defer in TestCollectBlocksUntilMembersFinish
  so a failing assertion can't leak the blocked goroutine; add a
  collectWaitTimeout clamp/overflow unit test.
2026-06-24 14:48:30 +05:30
Vasanth T 4a5a417156 fix(tui): keep running swarm subagents in the AGENTS sidebar (#309)
The sidebar cleared the whole swarm roster the moment ANY swarm_collect
result appeared, so calling swarm_collect mid-run (to poll for partial
results) made the AGENTS panel show "no agents spawned" even while every
subagent was still running.

Drop that collect-clears-everything heuristic and instead let the
member-status linger logic remove members only once they actually report
done/failed. swarm_collect results carry the same "[state]" roster as
swarm_status, so swarmMemberStatus now reads both — a mid-flight collect
refreshes the live states instead of wiping them. Each member also
surfaces a non-running state (pending/handoff/...) inline so the panel
reports real status, not just liveness.
2026-06-24 14:48:17 +05:30
Vasanth T 8acc1be7b2 feat(tui): live token counter on the working line (#308)
* feat(tui): live token counter on the working line

The working line ended with a static "↑ from-to/total" scroll indicator
(which transcript rows the line covered). It looked like a token counter
but never moved meaningfully, so a running turn felt frozen.

Replace it with a live "↑ <n> tok" estimate that climbs as the model
reasons and writes. turnStreamedRunes accumulates every streamed
reasoning+answer rune for the turn and survives the per-segment buffer
clears (a tool call wipes streamingText/Reasoning), so the count is
monotonic across a multi-tool turn and resets to zero on the next turn.
The figure is an estimate (~4 chars/token) because providers only report
exact usage when a step finishes; the authoritative totals stay in the
status line and sidebar.

Removes the now-unused phase_indicator.go (scroll indicator + helpers)
and its test, which were only referenced by the working line.

* fix(tui): keep the working-line token counter visible from turn start

The counter returned empty until the first streamed delta, so during the initial think phase the working line showed no counter at all - a regression from the old always-present row indicator. Show it for the whole turn starting at 0 and climbing.
2026-06-24 14:48:11 +05:30
Vasanth T 0b1fdef98e chore: scrub named other-CLI references from the codebase (#307)
* docs: drop other-agent names from Zero's description

AGENTS.MD described Zero as "in the same family as Claude Code, Codex,
and Droid". Because AGENTS.MD is injected into the model as project
context, the agent parroted that sentence verbatim when asked who it is,
naming other agents in its own identity.

Reword it to describe Zero on its own terms ("an open-source terminal
coding agent") with no comparison to other products.

* chore: scrub named other-agent references from the codebase

Follow-up to the AGENTS.MD wording fix: sweep the rest of the tree for
comments and docs that named specific other CLIs and replace them with
neutral phrasing ("comparable terminal agents" / generic positioning).

- internal/tui/rendering.go, model.go, session_controls.go (+ test):
  drop the named other-agent references from doc comments; the behaviour
  and the existing generic "reference agents/TUIs" phrasing are unchanged.
- docs/PRD.md: replace the named competitor in the target/vision with
  generic "multi-provider terminal agents".

The ChatGPT Codex provider integration keeps its name — that is a real
product Zero connects to, not a reference to another tool's source.
2026-06-24 11:47:09 +05:30
Vasanth T bc5c24ef87 fix(tui): stop showing the token count on both sides (#306)
When the sidebar is open it pins the token readout at its floor, but the
status line also rendered its own token usage segment, so the token figure
appeared twice (status line and sidebar).

The status line already drops the context-fill gauge while the sidebar is
open; do the same for usage. To avoid losing the session cost (which the
sidebar does not show), keep a cost-only segment in the status line when the
sidebar is active, falling back to empty until a priced record lands.
2026-06-24 11:45:53 +05:30
Vasanth T 215170b967 feat(tui): show provider on each model-picker row (#305)
The /model picker rendered a flat list of model names with no provider
context: modelPickerOverlay dropped the Group and provider dot that the
generic picker shows, so models from different providers were
indistinguishable.

Each model row now carries a right-aligned provider slug (anthropic,
deepseek, ollama, ...). The slug is stamped onto every item via
applyProviderPickerMeta from the catalog descriptor, and renderModelPickerRow
right-aligns it with the same gap math the generic picker uses, dropping it
on rows too narrow to keep a clear gap.
2026-06-24 11:22:04 +05:30
Anandan 212f1a0ff8 Fix token accounting and usage display (#304) 2026-06-24 08:18:34 +05:30
Anandan 6be50cd3a7 fix(sandbox): retry approved network shell commands (#301)
* fix(sandbox): retry approved network shell commands

* test(sandbox): fix portable smoke expectations

* fix(sandbox): restore Windows offline egress block
2026-06-23 17:44:46 +05:30
KRATOS c498a8b870 fix(tui): cap the expanded live reasoning to ~half screen (#303)
Clicking the live 'Thinking…' line expanded the streaming reasoning to its full
length, which filled the terminal as it grew and scrolled the clickable toggle
header off-screen — so it couldn't be collapsed again.

Cap an EXPANDED live reasoning block to ~half the screen height, keeping the
LATEST lines plus a faint '… N earlier lines · Ctrl+O for all' marker, so the
header stays on-screen and clickable. Applied identically to the display and
selectable render paths so the gutter highlighter stays line-aligned; committed
rows (expanded in the scrollable history) are uncapped. Regression test added.
2026-06-23 11:44:10 +05:30
KRATOS b776a9281f fix(sandbox): grant ancestor metadata so cd into the workspace works (#302)
* fix(sandbox): grant ancestor metadata so cd into the workspace works

macOS seatbelt granted file-read* on each read root via (subpath "<root>"),
which covers the root and its descendants but NOT its parent directories. An
absolute cd (and any path the kernel canonicalises) must stat each ancestor to
traverse to a deeply-nested workspace like /Users/me/Downloads/app — those
parents weren't granted, so seatbelt denied the chdir and surfaced it as the
misleading 'cd: <path>: Not a directory' (ENOTDIR). This blocked the agent from
cd-ing into its own workspace (e.g. 'cd /abs/workspace && go test').

Fix: grant file-read-metadata + file-test-existence on the path-ancestors of
each read root (metadata only — ancestor contents stay unreadable). Same pattern
already used for /opt/homebrew, /usr/local, /System/Volumes/Data/private.

Verified end-to-end: 'cd /abs/workspace && ...' now succeeds under the real
generated profile. go build/vet/test green.

* fix(sandbox): skip path-ancestors for filesystem roots (invalid SBPL)

Follow-up to the ancestor-metadata traversal fix. A read root of "/" has no
ancestors, and (path-ancestors "/") is invalid SBPL — sandbox-exec aborts with
exit 65 and the sandboxed command can't launch at all. This was reachable via the
compat builder (ReadRoots=["/"] flipped to restricted under EnforceWorkspace) and
any degenerate "/" workspace.

seatbeltAncestorMetadataRule now skips any root that is its own parent
(filepath.Dir(clean)==clean), neutralizing "/" and volume roots regardless of
entry point. Verified: the compat profile now compiles under sandbox-exec (exit
0). Found by adversarial verification of the prior commit.

* fix(sandbox): grant the CLT toolchain so git/clang/make work

On macOS, /usr/bin/git (and clang, make, etc.) are thin stubs that resolve the
real binary under the active developer dir — usually
/Library/Developer/CommandLineTools. The sandbox's platform read roots didn't
include it, so the stub couldn't reach the real tool and failed with the
misleading 'xcode-select: No developer tools were found' error. Add
/Library/Developer (read-only) to the platform read roots.

* fix(sandbox): allow reading the user's global git config

git reads identity/config from the user's global git config, but the sandbox
confines reads away from HOME, so it failed with 'unable to access ~/.gitconfig:
Operation not permitted'. Grant read-only on the git config FILES specifically
(~/.gitconfig and ~/.config/git/config) — not the ~/.config/git directory, which
can hold an XDG credential store — so git runs while credentials and the rest of
HOME stay unreadable. The path-ancestors traversal grant covers reaching them.

* fix(sandbox): let the agent terminate user-owned processes on request

The seatbelt profile only allowed signaling the agent's own process group
((target self) (target pgrp)), so a process started by a PREVIOUS session — e.g.
a dev server still listening on a port — was in a different group and could not
be killed. The agent would thrash through kill/pkill/osascript/sudo, all denied,
and never stop the process the user asked it to terminate.

Relax to (allow signal). The kernel still enforces UID ownership: a sandboxed
command runs as the user, so it can only signal the user's own processes, never
root's or another user's. Verified end-to-end: a sandboxed command now kills a
different-group user-owned process under the real generated profile.

* fix(sandbox): let the agent find processes + guide it to working tools

Pairs with the signal fix (8b5018d). Two more gaps blocked process management:
- process-info was scoped to (target same-sandbox), so the agent couldn't even
  inspect processes outside its own sandbox. Relaxed to (allow process-info*).
- `ps` is setuid root and macOS sandbox-exec refuses to exec setuid binaries
  (privilege-escalation guard) — unfixable via profile. `pgrep` needs a blocked
  system service. So the agent kept thrashing on ps/pgrep/os.kill.

Add macOS shell guidance pointing the model at the tools that DO work: lsof to
find a process, kill to stop it. With the signal grant, lsof+kill now manages
processes reliably (verified end-to-end).

* fix(sandbox): grant git config at the seatbelt rule, not the shared profile

The git-config read paths (~/.gitconfig, ~/.config/git/config) were added to the
cross-platform permissionProfileReadRoots, so they leaked HOME-dependent absolute
paths into the platform-agnostic 'sandbox policy --json' snapshot and broke the
golden test on every OS (machine-specific paths + Windows separators).

Move the grant to seatbeltReadRule (macOS only, where reads are actually
confined): the PermissionProfile stays platform-agnostic so the golden is stable,
and the seatbelt profile still grants the config files + their ancestor metadata
for traversal. Verified: golden test green, full 'go test ./...' green, and git
still reads ~/.gitconfig under the real generated profile.

* fix(sandbox): grant ancestor metadata for platform read roots too

Addresses CodeRabbit review on #302. seatbeltAncestorMetadataRule was passed only
fs.ReadRoots + gitConfig, on the assumption that platform read roots are top-level
or already covered. /Library/Developer (the CLT toolchain) breaks that: it needs
/Library stat-able as an ancestor, so a chdir-style traversal into it (cd
/Library/Developer/CommandLineTools) failed with ENOTDIR even though reads
succeeded (exec doesn't require ancestor-stat the way chdir does).

Include the platform read roots in ancestorRoots when IncludePlatformRoots is set.
Verified: cd into the CLT toolchain now succeeds under the real profile; golden +
sandbox tests green. Regression test added.
2026-06-23 11:12:11 +05:30
KRATOS 63c0e278ff feat(tui): chat + context sidebar redesign, AGENTS panel, animations (#300)
* feat(tui): chat + context sidebar redesign, AGENTS panel, animations, plan-timer fix

Two-column managed-mode layout: the existing chat (scroll engine, reduced width)
on the left + a new right context sidebar — AGENTS (spawned subagents: status
glyph, name, and a live working detail for running ones, with a running/total
count), the live PLAN, and a token readout. The sidebar is suppressed on the
home screen and under overlays/subchat; alt-screen managed mode, wide terminals.

Also: freeze the plan clock while the agent is idle (an in_progress step left
mid-plan when the agent yields stops ticking instead of counting forever), and a
clean-room animation port (phase scroll indicator + a cosine ripple working
line; the spinner was already animated, frozen under ZERO_REDUCED_MOTION).

Reuses the scroll/viewport engine; inline mode unchanged. New unit tests for the
sidebar, plan-clock freeze, and the pure animation helpers; go build/vet/test green.

* feat(swarm): live AGENTS sidebar + un-defer coordination tools

Sidebar AGENTS section now reflects the swarm/team accurately:
- name each member by a short 1-2 word task (not subagent-N)
- a mild, slow cool pulse on live member names (no per-letter flicker)
- members disappear when swarm_status reports them [done]/[failed]
- hide bogus "specialist not found" tool-misroute attempts
- keep the animation tick alive while sidebar agents exist

Plus the deeper fix so a coordinating model stops misrouting swarm calls
to the specialist tool ("specialist swarm_send not found"): the swarm
COORDINATION tools (swarm_send/status/inbox/collect) un-defer once a
swarm is active, while spawn/schedule/handoff stay discoverable via
tool_search. A new DeferralEligible() interface keeps all swarm tools
counting toward DeferThreshold even when exposed, so un-deferring can
never deactivate deferral / force-expose MCP tools in any config.

* feat(tui): polish pass — hierarchy, status line, motion, sidebar toggle

A grounded polish pass across the TUI (17 items):
- color: spread the gray tiers (AA-safe), lime working-ripple (no semantic
  green/amber), calm steady ask-label, dim completed plan-step bodies,
  bold-muted sidebar headers + stateful PLAN count
- status line: run-state chip (permission mode + effort) replaces the
  duplicated provider; composer rule is model-only; context-fill gauge down to
  the narrow tier + a sidebar context-% chip
- footer: persistent idle key-hint (managed mode) + a jump-to-bottom cue
- motion: pulsing streaming caret; running specialist spins; swarm/specialist
  agents linger with a fading ✓ before removal (no abrupt pop)
- layout: Ctrl+B toggles the sidebar; padded ' │ ' divider; sidebar gated to
  the medium tier so it never starves the chat
- home screen: cwd · branch · model orientation + example prompts
- cleanup: drop dead autoTag, fix 'six swarm tools' miscount

go build/vet/test green; -race clean; no hex outside theme.go.

* feat(tui): reading column for the chat transcript

Cap transcript body rows at a readable ~90-col measure and indent them by a
small left gutter, so on a wide terminal the chat text no longer runs
edge-to-edge. Only true transcript rows use the reading column; the frame,
composer, status line, sidebar, title bar, empty state, and prompts keep the
full chatColumnWidth. Streaming text shares the column so it doesn't snap on
finalize. Plus extra vertical spacing before headings and between paragraphs.

Implementation keeps the height cache valid (rows render at contentWidth, which
the width-keyed cache already captures) and the gutter is a horizontal post-pad;
each selectable line's textStart is shifted by the gutter so click-to-select and
the selection highlight stay aligned. Mouse card/toggle hit-testing is Y-based,
so it's unaffected. go build/vet/test green; -race clean.

* fix(tui): plain cancellation marker + copyable card/toggle rows

- 'Run cancelled.' renders as a plain amber '⊘ Run cancelled.' line instead of a
  heavy box (other system notices keep their box).
- Copy root cause: the app's text-selection skipped 'button' rows (collapsible
  toggles, specialist cards) — they carried no selectable text, so a drag-select
  omitted exactly that (dull) content. Fix: those rows now carry their plain text
  + start, so a selection dragged THROUGH them copies their content; a direct
  click still toggles/drills (resolved on press, before selection). The selection
  highlight no longer excludes them. Permission buttons stay excluded.
- Surface Ctrl+E in the idle hint — it releases the mouse for native terminal
  selection, which copies everything (the zero-risk full-copy path).

go build/vet/test green; -race clean.

* fix(tui): render all system notices as plain lines, not boxes

Every system notice (mouse-mode changes, mode set, run cancelled, etc.) now
renders as a plain marked line instead of a bordered box: a faint '·' marker +
muted text for info, amber '⊘' for a run cancellation. Multi-line notices keep
the marker on the first line and indent the rest. Command cards and error rows
keep their boxes. go build/vet/test green.

* fix(tui): separate think→act tool groups with a blank line

Reasoning headers (Thought for X) interleave the tool cards (tool → thought →
tool …), which broke the existing tool-card→tool-card gap rule, so consecutive
list_directory/read_file/update_plan groups packed into a dense wall. Now a
reasoning header that follows a tool card gets a blank line above it, so each
think→act group reads as a distinct block. go build/vet/test green.

* feat(tui): colour markdown emphasis + headings for readability

Assistant prose now reads as three tiers instead of one flat ink colour:
- body text stays ink
- bold / important words render in the brand accent (lime) so they pop
- markdown headings render accent + bold + underline as a clear top tier

go build/vet/test green.

* fix(tui): calmer prose colours + a working-phase label

- Revert the accent colour on bold/important words: emphasis is weight-only now
  (no colour), dark-mode-friendly.
- Headings drop the bright lime — distinguished by ink + bold + underline instead
  of a saturated accent colour.
- Working status line gains a phase label ('writing' while the answer streams,
  'thinking' otherwise) so a long, output-less step reads as live progress
  ('Working · thinking · 55s') rather than a frozen screen.

go build/vet/test green.

* fix(tui): paint selection highlight in the gutter-shifted coordinate

Mouse text-selection looked like it started in the wrong place: the highlight
landed a few cells right of where you clicked, so selecting/copying felt
impossible to aim.

Cause: the row renderers painted the selection highlight on the UNshifted,
unpadded line, then the body item applied the reading-column gutter (pad lines +
shift selectable textStart) afterward. But the stored selection points are in the
shifted/displayed coordinate (that's how the mouse maps them), so the highlight
was computed against unshifted textStart and landed gutter-cells off. (The copied
text was actually correct — extraction uses the shifted selectable — but the
visual selection was wrong, which is what made it un-aimable.)

Fix: stop highlighting inside the row renderers; add finalizeTranscriptBodyRow,
which pads + shifts THEN paints the highlight, so it's computed in the same
coordinate the mouse uses and lands exactly on the click. Also applies the
highlight uniformly to all rows + the interim block. Regression test added.
2026-06-23 10:51:47 +05:30
Anandan a2e0f3c1fe fix: update ChatGPT Codex OAuth request handling (#299) 2026-06-22 12:56:09 +05:30
234 changed files with 22179 additions and 1703 deletions

No files matched your search

+8 -3
View File
@@ -20,10 +20,15 @@ jobs:
strategy:
fail-fast: false
matrix:
# `zero-release package` builds + smoke-tests natively (host==target), so each
# target platform needs its own native runner. These five cover the platforms
# install.sh / install.ps1 / the npm wrapper resolve.
os:
- ubuntu-latest
- macos-latest
- windows-latest
- ubuntu-latest # linux-x64
- ubuntu-24.04-arm # linux-arm64
- macos-latest # macos-arm64 (Apple Silicon)
- macos-15-intel # macos-x64 (Intel)
- windows-latest # windows-x64
steps:
- name: Checkout
+1 -1
View File
@@ -6,7 +6,7 @@ alwaysApply: false
# Extending Zero
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gitlawb
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -1
View File
@@ -17,6 +17,7 @@
![providers](https://img.shields.io/badge/providers-25%2B-34E2EA)
![tests](https://img.shields.io/badge/test%20files-200%2B-43D17A)
![status](https://img.shields.io/badge/status-active%20development-E8B84B)
[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
</div>
@@ -354,7 +355,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go te
## License
License is being finalized; a `LICENSE` file will be added before the public release.
Zero is released under the [MIT License](LICENSE) — © 2026 Gitlawb.
---
+49
View File
@@ -9,8 +9,49 @@ function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
function helperShimNames(name, platform = process.platform) {
if (platform === 'win32') {
return [`${name}.cmd`, `${name}.exe`, name];
}
return [name];
}
function commandForShim(path, platform = process.platform) {
if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
return {
command: process.env.ComSpec || 'cmd.exe',
prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
};
}
return { command: path, prefixArgs: [] };
}
function resolveHelper(packageRoot, name) {
const binDir = join(packageRoot, 'node_modules', '.bin');
for (const shimName of helperShimNames(name)) {
const candidate = join(binDir, shimName);
if (!existsSync(candidate)) continue;
return {
...commandForShim(candidate),
pathPrepend: [binDir],
};
}
return null;
}
function localControlHelperManifest(packageRoot) {
const helpers = {};
for (const name of ['agent-browser', 'tuistory']) {
const helper = resolveHelper(packageRoot, name);
if (helper) helpers[name] = helper;
}
if (Object.keys(helpers).length === 0) return '';
return JSON.stringify({ version: 1, helpers });
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
if (!existsSync(nativePath)) {
console.error(
@@ -19,8 +60,16 @@ if (!existsSync(nativePath)) {
process.exit(1);
}
const env = { ...process.env };
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
delete env.ZERO_LOCAL_CONTROL_HELPERS;
}
const child = spawnSync(nativePath, process.argv.slice(2), {
stdio: 'inherit',
env,
});
if (child.error) {
+2 -2
View File
@@ -1,7 +1,7 @@
# Zero — Product Requirements Document (v1.0)
**Status:** DRAFT v1.0
**Target:** Compete with Claude Code + Cursor + OpenCode
**Target:** Compete with Claude Code + Cursor + other multi-provider terminal agents
**Timeline:** 6-12 months
**Stack:** Go native core + npm distribution wrapper
@@ -14,7 +14,7 @@
It combines the best of:
- **Claude Code** — terminal-native agentic coding
- **Cursor** — IDE integration + smart editing
- **OpenCode** — multi-provider + headless
- **Multi-provider terminal agents** — provider-agnostic + headless
**One Go-native agent core. Three surfaces. Zero lock-in.**
+1 -1
View File
@@ -170,7 +170,7 @@ preset (no API key — the proxy authenticates):
"name": "chatgpt",
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
"model": "gpt-5" // whatever model your proxy serves
"model": "gpt-5.5" // whatever model your proxy serves
}
]
}
+1
View File
@@ -10,6 +10,7 @@ require (
charm.land/lipgloss/v2 v2.0.4
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
+228
View File
@@ -0,0 +1,228 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
t.Helper()
// checker/verifier nil → no LSP / project-test pass runs; we only need a
// non-nil SelfCorrector to enable the task-grounded acceptance gate.
return NewSelfCorrector(t.TempDir(), nil, nil, SelfCorrectConfig{Enabled: true})
}
// BUG #2(a): a final message that admits the model guessed / could not meet the
// objective must be reported as INCOMPLETE, never success — and immediately
// (an admitted guess is not worth re-prompting).
func TestAcceptanceSelfReportAdmissionIsIncomplete(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("I couldn't analyze the board image, so I wrote the common opening move e2e4 as my best guess."),
}}
result, err := Run(context.Background(), "write the best chess move", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("admitted guess must be Incomplete, got success; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, "objective was not met") {
t.Fatalf("IncompleteReason = %q, want it to cite the admission", result.IncompleteReason)
}
if len(provider.requests) != 1 {
t.Fatalf("an admission should downgrade immediately (1 turn), got %d", len(provider.requests))
}
}
// BUG #2(a): the inability detector must generalize over the verb the model uses
// (so re-phrasing can't defeat it) while NOT misreading success-y negations
// ("could not find any issues") as admissions.
func TestSelfReportedIncompletionMatching(t *testing.T) {
cases := []struct {
text string
admit bool
}{
// real chess re-run wording that slipped a fixed phrase list:
{"…which I cannot do without proper image analysis capabilities.", true},
{"I cannot visually analyze the chess board image.", true},
{"I could not recover the original values, so this is my best guess.", true},
{"I was unable to complete the optimization.", true},
{"The result may not be correct.", true},
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
// later genuine admission with the same stem (review #5):
{"I could not find any examples in the codebase, so I could not implement the feature.", true},
// genuine successes that must NOT be flagged:
{"I implemented the function and all tests pass.", false},
{"I verified the fix; I could not find any remaining issues.", false},
{"I cannot reproduce the bug, so the fix holds.", false},
{"Done — the output matches the required format and values.", false},
// behavior descriptions that must NOT be flagged — these phrases describe
// implemented behavior, not an admission (review #1, removed from the list):
{"The parser will fall back to UTF-8 when no encoding is set.", false},
{"I replaced the placeholder value with the computed key.", false},
{"The retry uses exponential backoff as a fallback.", false},
{"Without proper error handling the function would crash, so I added it.", false},
}
for _, c := range cases {
got := selfReportedIncompletion(c.text) != ""
if got != c.admit {
t.Errorf("selfReportedIncompletion(%q) admitted=%v, want %v", c.text, got, c.admit)
}
}
}
// BUG #2(a): a "cannot analyze / cannot determine" admission (present tense) must
// be caught even when the model also stamps a bogus "PASS" based on a shape check.
// Mirrors the chess-best-move final ("I cannot analyze the position… PASS: I wrote
// the move and verified by reading it back").
func TestAcceptanceCannotPhrasingIsIncomplete(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
}}
result, err := Run(context.Background(), "write the best move", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q", result.FinalAnswer)
}
}
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
// The gate nudges, but if the model keeps claiming completion with no continuation
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
func TestPendingPlanAloneDoesNotForceIncomplete(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
done := "All set." // confident; no cue, no admission — only the plan is stale
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("completed", "in_progress"),
textTurn(done), textTurn(done), textTurn(done), textTurn(done), textTurn(done),
}}
result, err := Run(context.Background(), "do the multi-step task", provider, Options{
Registry: registry,
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("pending-plan alone must NOT force Incomplete (stale bookkeeping != unfinished); reason=%q", result.IncompleteReason)
}
if result.FinalAnswer != done {
t.Fatalf("final answer = %q, want the completion claim %q", result.FinalAnswer, done)
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected at least one continue-nudge before accepting completion")
}
}
// BUG #2(b): when self-correct is on, the run must pass a one-time task-grounded
// acceptance check before success. A model that, when challenged, confirms its
// work meets the criterion (no admission) then finalizes as success — no regression.
func TestAcceptanceGroundedCompletionSucceeds(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("I implemented the function."),
textTurn("PASS. I ran the verification and it passes; the output meets the task requirement."),
}}
result, err := Run(context.Background(), "implement the function", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
SelfCorrect: enabledSelfCorrector(t),
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("a verified completion must succeed, got Incomplete (%q)", result.IncompleteReason)
}
if result.FinalAnswer != "PASS. I ran the verification and it passes; the output meets the task requirement." {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
// Exactly one acceptance pass: the first "done" turn was challenged, the second confirmed.
if len(provider.requests) != 2 {
t.Fatalf("expected 2 turns (1 acceptance challenge + 1 confirmation), got %d", len(provider.requests))
}
if !someRequestContains(provider.requests, acceptanceNudgeMarker) {
t.Fatalf("expected the task-grounded acceptance check to be demanded")
}
}
// With the gate OFF (interactive/TUI default), even an admission is accepted as the
// final answer exactly as before — proving no behavior change for non-headless callers.
func TestAcceptanceGateOffLeavesAdmissionAsSuccess(t *testing.T) {
admission := "I couldn't determine the value, so this is a guess."
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn(admission),
}}
result, err := Run(context.Background(), "do a thing", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
// RequireCompletionSignal false, SelfCorrect nil — the legacy path.
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != admission {
t.Fatalf("final answer = %q, want the text verbatim (legacy behavior)", result.FinalAnswer)
}
if len(provider.requests) != 1 {
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
}
}
// The acceptance check is scoped to runs with self-correct on: a headless run
// WITHOUT a SelfCorrector accepts a confident completion immediately (the (b) gate
// is inert), so enabling RequireCompletionSignal alone adds no acceptance turn.
func TestAcceptanceGateRequiresSelfCorrect(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("Done. Implemented and it works."),
}}
result, err := Run(context.Background(), "implement it", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
// SelfCorrect deliberately nil → acceptance gate (b) must not fire.
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("confident completion without self-correct must succeed; reason=%q", result.IncompleteReason)
}
if len(provider.requests) != 1 {
t.Fatalf("acceptance gate must not fire without self-correct; got %d turns", len(provider.requests))
}
if someRequestContains(provider.requests, acceptanceNudgeMarker) {
t.Fatalf("acceptance nudge must not be injected when self-correct is off")
}
}
+358 -22
View File
@@ -1,9 +1,11 @@
package agent
import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
@@ -15,43 +17,106 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
if !ok {
return nil
}
tokens, ok := safeShellCommandTokens(command)
segments, ok := safeShellCommandSegments(command)
if !ok {
return nil
}
if requested, ok := requestedPrefixRule(args); ok {
if safeRequestedPrefix(requested, tokens) {
if safeRequestedPrefixForSegments(requested, segments) {
return requested
}
return nil
}
if !sandbox.ValidCommandPrefix(tokens) {
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
}
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
}
return append([]string(nil), tokens...)
return append([]string(nil), segments[0]...)
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
}
if shellCommandAdditionalPermissionsRequested(args) {
return sandbox.CommandPrefixGrant{}, false, false
}
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
if !ok {
return sandbox.CommandPrefixGrant{}, false, false
}
tokens, ok := safeShellCommandTokens(command)
segments, ok := safeShellCommandSegments(command)
if !ok {
return sandbox.CommandPrefixGrant{}, false, false
}
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
return grant, true, false
var matched sandbox.CommandPrefixGrant
matchedAny := false
matchedSession := false
for _, tokens := range segments {
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
if !matchedAny {
matched = grant
}
matchedAny = true
continue
}
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
if !matchedAny {
matched = grant
}
matchedAny = true
matchedSession = true
continue
}
if knownSafeCommandSegment(tokens) {
continue
}
return sandbox.CommandPrefixGrant{}, false, false
}
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
return grant, true, true
if matchedAny {
return matched, true, matchedSession
}
return sandbox.CommandPrefixGrant{}, false, false
}
func shellExecutionArgsForApproval(toolName string, args map[string]any, action PermissionDecisionAction, options Options) map[string]any {
if !isShellCommandTool(toolName) || !shellPrefixApprovalBypassesSandbox(action) {
return args
}
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
return args
}
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
return args
}
planned := cloneArgs(args)
if planned == nil {
planned = map[string]any{}
}
planned["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
return planned
}
func shellPrefixApprovalBypassesSandbox(action PermissionDecisionAction) bool {
return action == PermissionDecisionAllowPrefix || action == PermissionDecisionAlwaysAllowPrefix
}
func shellCommandRequiresEscalated(args map[string]any) bool {
raw, ok := args["sandbox_permissions"]
if !ok || raw == nil {
return false
}
value, ok := raw.(string)
if !ok {
value = fmt.Sprint(raw)
}
return strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated)
}
func isShellCommandTool(toolName string) bool {
return toolName == "bash" || toolName == "exec_command"
}
@@ -104,32 +169,92 @@ func cleanPrefixRule(prefix []string) []string {
}
func safeRequestedPrefix(prefix []string, command []string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
}
for index := range prefix {
if prefix[index] != command[index] {
matched := false
for _, command := range segments {
if len(prefix) > len(command) {
if knownSafeCommandSegment(command) {
continue
}
return false
}
if hasStringPrefix(command, prefix) {
matched = true
continue
}
if knownSafeCommandSegment(command) {
continue
}
return false
}
return true
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
return nil, false
}
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
if err != nil || len(file.Stmts) != 1 {
if err != nil || len(file.Stmts) == 0 {
return nil, false
}
stmt := file.Stmts[0]
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
segments := make([][]string, 0, len(file.Stmts))
for _, stmt := range file.Stmts {
if !collectSafeShellStatement(stmt, &segments) {
return nil, false
}
}
if len(segments) == 0 {
return nil, false
}
call, ok := stmt.Cmd.(*syntax.CallExpr)
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
return segments, true
}
func collectSafeShellStatement(stmt *syntax.Stmt, segments *[][]string) bool {
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
return false
}
return collectSafeShellCommand(stmt.Cmd, segments)
}
func collectSafeShellCommand(cmd syntax.Command, segments *[][]string) bool {
switch node := cmd.(type) {
case *syntax.CallExpr:
tokens, ok := literalCallTokens(node)
if !ok {
return false
}
*segments = append(*segments, tokens)
return true
case *syntax.BinaryCmd:
switch node.Op {
case syntax.AndStmt, syntax.OrStmt, syntax.Pipe:
default:
return false
}
return collectSafeShellStatement(node.X, segments) && collectSafeShellStatement(node.Y, segments)
default:
return false
}
}
func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
if call == nil || len(call.Assigns) > 0 || len(call.Args) == 0 {
return nil, false
}
tokens := make([]string, 0, len(call.Args))
@@ -138,17 +263,228 @@ func safeShellCommandTokens(command string) ([]string, bool) {
return nil, false
}
lit, ok := word.Parts[0].(*syntax.Lit)
if !ok {
if !ok || strings.ContainsAny(lit.Value, "*?[]{}") {
return nil, false
}
tokens = append(tokens, lit.Value)
}
if !sandbox.ValidCommandPrefix(tokens) {
return nil, false
}
return tokens, true
}
func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
switch commandName(command[0]) {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
return true
case "base64":
return safeBase64Command(command[1:])
case "find":
return safeFindCommand(command[1:])
case "rg":
return safeRipgrepCommand(command[1:])
case "sed":
return safeSedCommand(command[1:])
case "git":
return safeGitCommand(command)
default:
return false
}
}
func safeBase64Command(args []string) bool {
for _, arg := range args {
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || (strings.HasPrefix(arg, "-o") && arg != "-o") {
return false
}
}
return true
}
func safeFindCommand(args []string) bool {
unsafe := map[string]bool{
"-exec": true, "-execdir": true, "-ok": true, "-okdir": true,
"-delete": true,
"-fls": true, "-fprint": true, "-fprint0": true, "-fprintf": true,
}
for _, arg := range args {
if unsafe[arg] {
return false
}
}
return true
}
func safeRipgrepCommand(args []string) bool {
for _, arg := range args {
if arg == "--search-zip" || arg == "-z" || arg == "--pre" || arg == "--hostname-bin" ||
strings.HasPrefix(arg, "--pre=") || strings.HasPrefix(arg, "--hostname-bin=") {
return false
}
}
return true
}
func safeSedCommand(args []string) bool {
if len(args) < 2 || len(args) > 3 || args[0] != "-n" {
return false
}
return validSedPrintArg(args[1])
}
func validSedPrintArg(arg string) bool {
if !strings.HasSuffix(arg, "p") {
return false
}
body := strings.TrimSuffix(arg, "p")
if body == "" {
return false
}
for _, part := range strings.Split(body, ",") {
if part == "" {
return false
}
for _, r := range part {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
func safeGitCommand(command []string) bool {
subIndex, subcommand, ok := gitSubcommand(command)
if !ok {
return false
}
if gitHasUnsafeGlobalOption(command[1:subIndex]) {
return false
}
args := command[subIndex+1:]
switch subcommand {
case "status", "log", "diff", "show":
return gitArgsReadOnly(args)
case "branch":
return gitArgsReadOnly(args) && gitBranchReadOnly(args)
default:
return false
}
}
func gitSubcommand(command []string) (int, string, bool) {
for index := 1; index < len(command); index++ {
arg := command[index]
if gitOptionConsumesValue(arg) {
index++
continue
}
if gitOptionHasInlineValue(arg) || arg == "--" || strings.HasPrefix(arg, "-") {
continue
}
switch arg {
case "status", "log", "diff", "show", "branch":
return index, arg, true
default:
return 0, "", false
}
}
return 0, "", false
}
func gitOptionConsumesValue(arg string) bool {
switch arg {
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
return true
default:
return false
}
}
func gitOptionHasInlineValue(arg string) bool {
return strings.HasPrefix(arg, "--config-env=") ||
strings.HasPrefix(arg, "--exec-path=") ||
strings.HasPrefix(arg, "--git-dir=") ||
strings.HasPrefix(arg, "--namespace=") ||
strings.HasPrefix(arg, "--super-prefix=") ||
strings.HasPrefix(arg, "--work-tree=") ||
((strings.HasPrefix(arg, "-C") || strings.HasPrefix(arg, "-c")) && len(arg) > 2)
}
func gitHasUnsafeGlobalOption(args []string) bool {
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case strings.HasPrefix(arg, "--upload-pack"):
return true
case arg == "-C" || strings.HasPrefix(arg, "-C"):
return true
case gitOptionConsumesValue(arg):
index++
}
}
return false
}
func gitArgsReadOnly(args []string) bool {
for _, arg := range args {
switch {
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--exec="):
return false
case arg == "--output", arg == "--exec":
return false
}
}
return true
}
func gitBranchReadOnly(args []string) bool {
if len(args) == 0 {
return true
}
for _, arg := range args {
if strings.HasPrefix(arg, "--format=") {
continue
}
switch arg {
case "--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose":
continue
default:
return false
}
}
return true
}
func commandName(raw string) string {
name := strings.TrimSpace(raw)
if index := strings.LastIndexAny(name, `/\`); index >= 0 {
name = name[index+1:]
}
name = strings.ToLower(name)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
}
func hasStringPrefix(values []string, prefix []string) bool {
if len(prefix) == 0 || len(prefix) > len(values) {
return false
}
for index := range prefix {
if values[index] != prefix[index] {
return false
}
}
return true
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
+53 -2
View File
@@ -1,6 +1,10 @@
package agent
import "testing"
import (
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
@@ -29,6 +33,35 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
}
}
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
"prefix_rule": []any{"git", "status"},
})
want := []string{"git", "status"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "ps aux && npm install",
"prefix_rule": []any{"ps", "aux"},
})
if got != nil {
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short",
@@ -41,7 +74,6 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"echo hi && echo bye",
"cat < in > out",
"FOO=bar go test",
"echo $(whoami)",
@@ -85,6 +117,25 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
}
}
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
}
}
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "find . -type f",
+205
View File
@@ -0,0 +1,205 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
// turns contains substr — used to assert a continue nudge was actually injected.
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
for _, req := range requests {
for _, msg := range req.Messages {
if strings.Contains(msg.Content, substr) {
return true
}
}
}
return false
}
// planTurn is a turn that calls update_plan with the given item statuses (reusing
// the package's shared toolTurn helper).
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
items := make([]string, len(statuses))
for i, s := range statuses {
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
}
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
}
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
// Mirrors the git-multibranch failure: plan with pending steps, then the model
// keeps emitting "…Let me check the SSH configuration:" without acting.
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("completed", "pending", "pending"),
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
}}
result, err := Run(context.Background(), "set up a git server", provider, Options{
Registry: registry,
MaxTurns: 10,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
}
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
// Critically it did NOT stop at the first text turn (request 2) as success.
if len(provider.requests) != 1+maxContinueNudges+1 {
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
}
}
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
// still finalize as success — the gate must not break short/read-only tasks.
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("The file contains 42 lines."),
}}
result, err := Run(context.Background(), "count the lines", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != "The file contains 42 lines." {
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
}
if len(provider.requests) != 1 {
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
}
}
// A continuation-cue turn triggers a re-prompt, but once the model actually
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
// nudge gives the model a path to a legitimate completion.
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn("Let me read the file:"),
textTurn("Done. The file has 42 lines."),
}}
result, err := Run(context.Background(), "count the lines", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != "Done. The file has 42 lines." {
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge after the continuation-cue turn")
}
}
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
// accepted as the final answer exactly as before — guaranteeing no behavior
// change for non-headless callers.
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
cue := "Let me check the config:"
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
textTurn(cue),
}}
result, err := Run(context.Background(), "do a thing", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 10,
// RequireCompletionSignal deliberately left false.
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
}
if result.FinalAnswer != cue {
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
}
if len(provider.requests) != 1 {
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
}
}
// review #6: the continuation-cue detector must catch a mid-line action announcement
// that stops on a colon (the git-multibranch case), without flagging genuine closers
// — a recommendation, a plain summary colon, or a sign-off.
func TestContinuationCueMatching(t *testing.T) {
cases := []struct {
text string
cue bool
}{
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
{"Let me read the file:", true},
{"Now I'll run the tests:", true},
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
{"Here is the summary:", false}, // summary colon, no action lead-in
{"Let me know if you need anything:", false}, // sign-off
{"The function is implemented and all tests pass.", false},
}
for _, c := range cases {
if got := endsWithContinuationCue(c.text); got != c.cue {
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
}
}
}
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
// never reaches the no-tool-call gate) was reported as success. Under the headless
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolEvery, toolEvery, toolEvery, toolEvery,
}}
result, err := Run(context.Background(), "keep working", provider, Options{
Registry: registry,
MaxTurns: 2,
RequireCompletionSignal: true,
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
}
if !strings.Contains(result.IncompleteReason, "max-turns") {
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
}
}
+8
View File
@@ -81,3 +81,11 @@ Otherwise, confirm before executing.
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
## Sandbox Permission Requests
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
+102 -70
View File
@@ -129,6 +129,24 @@ func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
func toolDefinitionByName(definitions []zeroruntime.ToolDefinition, name string) *zeroruntime.ToolDefinition {
for i := range definitions {
if definitions[i].Name == name {
return &definitions[i]
}
}
return nil
}
func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.CompletionRequest) {
t.Helper()
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, "Deferred tools:") {
t.Fatalf("deferred discovery must not be appended as a user message: %q", message.Content)
}
}
}
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -219,8 +237,8 @@ func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
// eligible total. With N deferred registered and threshold == N, disabling one
// drops the surviving eligible count to N-1 < threshold, so the partition takes
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
// the disabled one filtered out entirely).
// the INACTIVE path (empty discovery text, all VISIBLE tools exposed with full
// schemas, the disabled one filtered out entirely).
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
@@ -246,9 +264,9 @@ func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T)
}
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
// appear in the reminder NOR be exposed — it is filtered out before the partition
// influence discovery NOR be exposed — it is filtered out before the partition
// even considers it.
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
@@ -256,25 +274,31 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *t
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{
DeferThreshold: 2,
DisabledTools: []string{"mcp__srv__beta"},
}, map[string]bool{})
if reminder == "" {
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
if discovery == "" {
t.Fatalf("expected active path with discovery text for the unloaded tools")
}
if strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
if strings.Contains(discovery, "mcp__srv__beta") {
t.Fatalf("disabled deferred tool must not appear in discovery text, got %q", discovery)
}
for _, def := range exposed {
if def.Name == "mcp__srv__beta" {
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
}
}
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("tool_search must be exposed on active path, got %#v", exposed)
}
if search.Description != discovery {
t.Fatalf("tool_search description must carry discovery text\n got: %q\nwant: %q", search.Description, discovery)
}
if !strings.Contains(discovery, "mcp") {
t.Fatalf("discovery text must list the surviving deferred source, got %q", discovery)
}
}
@@ -289,7 +313,7 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
loaded := map[string]bool{"mcp__srv__alpha": true}
// 2 eligible deferred tools, threshold 2 => active.
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
exposedNames := map[string]bool{}
for _, def := range exposed {
@@ -307,14 +331,21 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
if exposedNames["mcp__srv__beta"] {
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
}
if reminder == "" {
t.Fatalf("expected a non-empty reminder for the hidden tool")
if discovery == "" {
t.Fatalf("expected non-empty discovery text for the hidden tool")
}
if !strings.Contains(reminder, "mcp__srv__beta") {
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
if strings.Contains(reminder, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
}
if strings.Contains(search.Description, "mcp__srv__alpha") {
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
}
if !strings.Contains(search.Description, "mcp__srv__beta") {
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
}
}
@@ -337,9 +368,9 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
}
// BuildDeferredReminder returns "" for no hidden lines.
// No hidden tools means no dynamic discovery text is needed.
if reminder != "" {
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
}
}
@@ -376,7 +407,8 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
t.Fatalf("expected two turns, got %d", len(provider.requests))
}
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
// Turn 1: alpha is hidden (not advertised), and discovery lives on the
// tool_search definition rather than a trailing user message.
turn1Tools := map[string]bool{}
for _, def := range provider.requests[0].Tools {
turn1Tools[def.Name] = true
@@ -384,9 +416,13 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
if turn1Tools["mcp__srv__alpha"] {
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
}
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
assertNoDeferredDiscoveryMessage(t, provider.requests[0])
search1 := toolDefinitionByName(provider.requests[0].Tools, tools.ToolSearchToolName)
if search1 == nil {
t.Fatalf("turn 1 must expose tool_search, got %#v", provider.requests[0].Tools)
}
if !strings.Contains(search1.Description, "Tool discovery") || !strings.Contains(search1.Description, "mcp") {
t.Fatalf("tool_search description must carry deferred discovery, got %q", search1.Description)
}
// Turn 2: alpha is now loaded and advertised with a full schema.
@@ -401,20 +437,20 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
}
// The reminder must NOT persist into the returned message history.
// The discovery text must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
t.Fatalf("deferred discovery must not be persisted in result.Messages")
}
}
}
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery drives a mid-run
// context-limit error that triggers reactive compaction+retry while deferral is
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
// degraded to the empty-loaded/no-reminder state: it must still advertise the
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
// degraded to the empty-loaded/no-discovery state: it must still advertise the
// loaded tool's FULL schema and keep tool_search discovery for hidden tools.
func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
registry := tools.NewRegistry()
// load_signal asks the loop to load mcp__srv__alpha next turn.
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
@@ -492,24 +528,20 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
// The retry must carry the deferred-tools reminder as its trailing user
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
assertNoDeferredDiscoveryMessage(t, retry)
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
}
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
}
// The reminder must NOT persist into the returned message history, even on
// The discovery text must NOT persist into the returned message history, even on
// the reactive-retry path.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
t.Fatalf("deferred discovery must not be persisted in result.Messages")
}
}
}
@@ -544,15 +576,15 @@ func (provider *connectErrorProvider) StreamCompletion(_ context.Context, reques
return ch, nil
}
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery mirrors
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery but drives the
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
// context-limit error (rather than a mid-stream StreamEventError). With deferral
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
// advertise the loaded tool's FULL schema AND carry tool_search discovery —
// i.e. the first reactive block reuses exposed, not the empty-loaded
// partition.
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
@@ -625,22 +657,19 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testin
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
}
// The retry must carry the deferred-tools reminder as its trailing user message.
last := retry.Messages[len(retry.Messages)-1]
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
assertNoDeferredDiscoveryMessage(t, retry)
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
if search == nil {
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
}
if !strings.Contains(last.Content, "mcp__srv__beta") {
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
}
if strings.Contains(last.Content, "mcp__srv__alpha") {
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
}
// The reminder must NOT persist into the returned message history.
// The discovery text must NOT persist into the returned message history.
for _, m := range result.Messages {
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
t.Fatalf("deferred discovery must not be persisted in result.Messages")
}
}
}
@@ -649,8 +678,7 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testin
// regression: N deferred tools are registered alongside tool_search, the operator
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
// the reminder never points the model at an unreachable tool.
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED.
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
registry := tools.NewRegistry()
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
@@ -667,14 +695,14 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
EnabledTools: append([]string{}, deferredNames...),
}
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
exposed, discovery := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
// Deferral active => non-empty reminder advertising the hidden deferred tools.
if reminder == "" {
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
// Deferral active => non-empty tool_search discovery for hidden deferred tools.
if discovery == "" {
t.Fatalf("expected deferral active (non-empty discovery) at threshold N, got empty")
}
if !strings.Contains(reminder, "tool_search") {
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
if !strings.Contains(discovery, "tool_search") {
t.Fatalf("discovery must instruct the model to call tool_search, got %q", discovery)
}
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
@@ -685,6 +713,10 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
if !exposedNames["tool_search"] {
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
}
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
if search == nil || search.Description != discovery {
t.Fatalf("tool_search description must carry discovery text, got %#v discovery=%q", search, discovery)
}
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
for _, name := range deferredNames {
if exposedNames[name] {
+219
View File
@@ -1,6 +1,7 @@
package agent
import (
"encoding/json"
"strconv"
"strings"
@@ -44,8 +45,192 @@ const (
// with the same error, so NO model (weak or strong) burns turns looping on a
// bad call.
toolFailureStopAt = 4
// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
// tool call while work clearly remained. Once spent, the run finalizes as
// INCOMPLETE rather than nudging forever (and it is still bounded by maxTurns
// and the run deadline).
maxContinueNudges = 3
)
// continueNudgeMarker is a stable substring for tests.
const continueNudgeMarker = "the task is not finished"
// continueNudge tells a model that stopped without a tool call — while work
// clearly remained — to keep going, or to mark the plan complete and summarize if
// it is genuinely done. The second path gives it a clean route to a legitimate
// completion (a finished plan + no continuation cue then exits as success).
func continueNudge(reason string) string {
return "You stopped without calling a tool, but " + continueNudgeMarker + " (" + reason + "). " +
"Do not stop here: take the next concrete action with a tool now. " +
"If you are genuinely finished, first mark the plan complete with update_plan, then give your final summary."
}
// endsWithContinuationCue reports whether an assistant message ends mid-thought —
// the model announcing its OWN next action and then stopping on a colon
// ("…Let me check the SSH configuration:") rather than concluding. It requires
// BOTH a trailing colon AND an action lead-in on the last line, so genuine closers
// are NOT flagged: a forward-looking recommendation ("Next, I suggest reviewing
// the changes."), a sign-off ("Let me know if you need anything"), or a summary
// that merely ends in a colon ("Here is the summary:"). A bare trailing colon alone
// is too common in legitimate final answers to use as a signal.
func endsWithContinuationCue(text string) bool {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return false
}
lines := strings.Split(trimmed, "\n")
last := ""
for i := len(lines) - 1; i >= 0; i-- {
if s := strings.TrimSpace(lines[i]); s != "" {
last = strings.ToLower(s)
break
}
}
if !strings.HasSuffix(last, ":") {
return false
}
// Inspect the final clause (after the last sentence break) so a mid-line action
// announcement is caught ("…configure the server. Let me check the config:")
// while a plain summary colon ("Here is the summary:") or a recommendation is not.
clause := last
if idx := strings.LastIndex(last, ". "); idx >= 0 {
clause = strings.TrimSpace(last[idx+2:])
}
if strings.HasPrefix(clause, "let me know") { // sign-off, not a mid-step action
return false
}
for _, cue := range []string{
"let me ", "let's ", "now i'll ", "now i will ", "now let me ", "let me now ",
"i'll now ", "i will now ", "next i'll ", "next, i'll ", "first, i'll ", "first let me ",
} {
if strings.HasPrefix(clause, cue) {
return true
}
}
return false
}
// planStatusRemaining reports whether a raw update_plan status string represents
// unfinished work. Anything not clearly completed/failed (incl. empty/unknown,
// 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":
return false
default:
return true
}
}
// acceptanceNudgeMarker is a stable substring for tests; it is embedded verbatim
// in acceptanceVerificationNudge.
const acceptanceNudgeMarker = "verify your work against the task's stated success criterion"
// selfReportPhrases are high-signal admissions of guessing/fabrication or stated
// uncertainty about the produced result. They are matched by plain substring with
// NO context guard, so every entry must be FIRST-PERSON or unambiguously about the
// model's own output — NOT a phrase that also describes implemented behavior.
// (Earlier versions included "fall back to" / "placeholder value" / "as a
// fallback" / bare "best guess" / "without proper", which match legitimate final
// answers like "the parser will fall back to UTF-8" or "I replaced the placeholder
// value", wrongly downgrading completed runs — so those are removed. Admissions of
// INABILITY are handled separately by inabilityStems, which carry a guard.)
var selfReportPhrases = []string{
// first-person admissions of guessing / fabrication / assumption
"i guessed", "my best guess", "i'm guessing", "this is a guess", "just a guess",
"i made it up", "i fabricated", "i assumed a", "i had to assume",
// first-person lack of capability (inability stems also cover cannot/could-not)
"i do not have the ability", "i don't have the ability", "i lack the ability",
"no way for me to", "not possible for me to",
// stated uncertainty about the correctness of the produced result
"may not be correct", "might not be correct", "may be incorrect", "might be incorrect",
"this may not work", "this might not work", "not fully functional", "not fully working",
}
// inabilityStems are first-person "I cannot / can't / could not / am unable to /
// do not have" stems. Matching the STEM (not a fixed verb) generalizes over
// whatever action the model claims it could not perform — "analyze", "determine",
// "do", "complete", "verify", "see", etc. — so the detector is not defeated by
// re-phrasing (the chess case slipped a fixed "cannot analyze" list by writing
// "…which I cannot do without proper image analysis capabilities").
var inabilityStems = []string{
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
"i was not able to", "i do not have", "i don't have", "unable to ",
"without being able to",
}
// successNegationTails are negated phrasings that indicate SUCCESS, not an
// admission ("I could not find any remaining issues", "I cannot reproduce the
// bug"). When an inability stem is immediately followed by one of these, it is not
// treated as an admission, so a clean result is not misreported as INCOMPLETE.
var successNegationTails = []string{
"find any", "found any", "find a ", "see any", "detect any", "identify any",
"reproduce", "spot any", "locate any",
}
// selfReportedIncompletion returns a short reason when the model's final text
// admits it guessed or could not meet the objective, else "". Case-insensitive.
func selfReportedIncompletion(text string) string {
lower := strings.ToLower(text)
for _, phrase := range selfReportPhrases {
if strings.Contains(lower, phrase) {
return selfReportReason(phrase)
}
}
for _, stem := range inabilityStems {
// Scan EVERY occurrence of the stem, not just the first: an earlier
// success-negation use ("I could not find any examples, so I could not
// implement it") must not mask a later genuine admission with the same stem.
for start := 0; ; {
rel := strings.Index(lower[start:], stem)
if rel < 0 {
break
}
abs := start + rel
tail := strings.TrimSpace(lower[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(stem) + " …")
}
start = abs + len(stem)
}
}
return ""
}
func selfReportReason(marker string) string {
return `the final message admits the objective was not met ("` + marker + `")`
}
func hasAnyPrefix(s string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
// acceptanceVerificationNudge forces a TASK-GROUNDED acceptance check before a run
// may finalize as success. It explicitly rejects the three false-success patterns
// 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 + " — " +
"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: " +
"execute the program or tests that demonstrate the required behavior, or directly probe the specific " +
"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."
}
// toolFailureHintMarker is a stable substring for tests.
const toolFailureHintMarker = "kept failing with the same error"
@@ -177,6 +362,10 @@ type guardState struct {
staleReminderSent bool
toolOnlyTurns int
toolOnlyReminderSent bool
// planItemsPending is the number of remaining (pending/in_progress) items in
// the most recent update_plan call, so the headless completion gate can tell
// whether work is unfinished when the model stops without a tool call.
planItemsPending int
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
// name, so the loop can hint then halt instead of looping forever.
toolFailures map[string]*toolFailureRecord
@@ -245,6 +434,9 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
state.toolCallsSincePlanUpdate = 0
// A fresh plan update opens a new stale interval.
state.staleReminderSent = false
// Record how many items remain so the completion gate knows whether
// work is unfinished if the model later stops without a tool call.
state.observePlanUpdate(call.Arguments)
} else {
state.toolCallsSincePlanUpdate++
}
@@ -253,6 +445,33 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
return state.emptyTurns >= maxEmptyTurns
}
// pendingPlanItems reports whether the most recent update_plan call still has
// unfinished (pending/in_progress) items. False when no plan was ever recorded.
func (state *guardState) pendingPlanItems() bool {
return state.planItemsPending > 0
}
// observePlanUpdate parses an update_plan call's raw arguments and records how
// 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 {
return
}
pending := 0
for _, item := range parsed.Plan {
if planStatusRemaining(item.Status) {
pending++
}
}
state.planItemsPending = pending
}
func (state *guardState) progressReminder() string {
if state.toolOnlyReminderSent || state.toolOnlyTurns < toolOnlyProgressReminderAt {
return ""
+549 -60
View File
@@ -130,6 +130,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
loaded := map[string]bool{}
// continueNudges counts how many times the headless completion gate
// (Options.RequireCompletionSignal) has re-prompted a no-tool-call turn that
// stopped with work still unfinished. Bounded by maxContinueNudges.
continueNudges := 0
// acceptanceRequested records that the one-time task-grounded acceptance check
// has already been demanded this run, so it fires at most once.
acceptanceRequested := false
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -138,7 +146,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
// the messages, so computing it before compaction is safe.
exposed, reminder := partitionTools(registry, permissionMode, options, loaded)
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
@@ -149,15 +157,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
if reminder != "" {
// Append to the per-turn request copy only — NEVER to persistent
// messages — so the reminder refreshes each turn and never accumulates
// in the saved transcript.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Report the per-category context budget for this turn so a surface can
// show utilization. Opt-in: a no-op when OnContext is unset.
@@ -184,24 +183,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, retryErr
}
// Rebuild from the compacted messages but reuse the SAME active-mode
// partition (exposed) and reminder computed for this turn: they depend
// partition computed for this turn: it depends
// on registry+loaded, not on the messages, so they stay valid after
// compaction. Using the bare toolDefinitions here would route through an
// empty-loaded partition, re-hiding every already-loaded deferred tool
// and dropping the reminder when deferral is active.
// empty-loaded partition, re-hiding every already-loaded deferred tool.
request = zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
request.Messages = append(request.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Pre-content connect after a context-limit compaction: route through the
// reconnect helper so a transient upstream hiccup here doesn't fail the
// whole run and re-burn every token (AUDIT-L1).
@@ -235,24 +225,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) and reminder from this
// turn rather than the bare toolDefinitions: exposed/reminder depend on
// Reuse the SAME active-mode partition (exposed) from this
// turn rather than the bare toolDefinitions: exposed depends on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool and drop the reminder when deferral is active.
// already-loaded deferred tool.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
if reminder != "" {
// Append to the per-turn retry copy only — NEVER to persistent
// messages — matching the main path's reminder-not-persisted invariant.
retryRequest.Messages = append(retryRequest.Messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: reminder,
})
}
// Pre-content reconnect of a mid-stream disconnect: route the connect
// through the reconnect helper too (AUDIT-L1).
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
@@ -327,6 +309,72 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
continue
}
// Completion gate (headless): a turn with text but no tool call is the
// model's final answer ONLY when the work is actually done. Default off
// (RequireCompletionSignal), so interactive runs stay byte-identical.
if options.RequireCompletionSignal {
// (1) Self-report downgrade (strongest, unambiguous): the model's own
// final message admits it guessed / could not meet the objective. Checked
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
// continue-nudges) and reports the accurate reason.
if reason := selfReportedIncompletion(collected.Text); reason != "" {
result.Incomplete = true
result.IncompleteReason = reason
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
}
// (2) The model stopped without a tool call while work may be unfinished:
// - a continuation cue ("…Let me check the config:") is an unambiguous
// mid-step stop;
// - pending update_plan items are a WEAK, ambiguous signal (the model may
// have finished without re-marking the last step).
// Nudge to continue (bounded). After the budget: a persisted continuation
// cue finalizes INCOMPLETE; pending-plan WITHOUT a cue does NOT (that would
// false-fail a completed run with stale bookkeeping) — fall through to the
// acceptance check / success.
cue := endsWithContinuationCue(collected.Text)
planPending := guards.pendingPlanItems()
if cue || planPending {
if continueNudges < maxContinueNudges {
continueNudges++
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: continueNudge(reason),
})
continue
}
if cue {
result.Incomplete = true
result.IncompleteReason = "your message ended mid-step"
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
}
// pending-plan only, budget spent: trust the model's completion claim
// over stale plan bookkeeping; fall through.
}
// (3) Task-grounded acceptance: before accepting a "done" turn as success,
// require ONE acceptance check grounded in the task's stated criterion
// (only when self-correct is on). Rejects "well-formed == correct",
// "existing-tests-pass == objective met", and "result == baseline" false
// successes. Bounded to a single pass; a genuine post-check completion
// (no admission, no cue) then finalizes as success on the next turn.
if options.SelfCorrect != nil && !acceptanceRequested {
acceptanceRequested = true
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: acceptanceVerificationNudge(),
})
continue
}
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -502,15 +550,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// Reaching here means the loop hit the maxTurns ceiling — the agent was cut off
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the max-turns limit without completing"
}
return result, nil
}
result.FinalAnswer = maxTurnsAnswer
result.Messages = copyMessages(messages)
if options.RequireCompletionSignal {
result.Incomplete = true
result.IncompleteReason = "reached the max-turns limit without a final answer"
}
return result, nil
}
@@ -595,7 +655,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
// reminder points the model at a tool the dispatch gate rejects (an inescapable
// discovery tool exposed to the model rejects at dispatch time (an inescapable
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
// tool_search is still honored (only the allowlist is exempted, not the denylist).
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
@@ -672,7 +732,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
preflightDecision = &decision
}
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, permissionGranted, preflightDecision) {
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, args, permissionGranted, preflightDecision) {
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
if !ok {
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
@@ -696,6 +756,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return deniedPermissionResult(call, reason, requestEvent), nil
}
permissionCleanups = append(permissionCleanups, cleanup)
cleanup, err = grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, options)
if err != nil {
reason := "failed to grant additional permissions: " + err.Error()
emitDeniedPermission(options, call, requestEvent, reason)
return deniedPermissionResult(call, reason, requestEvent), nil
}
permissionCleanups = append(permissionCleanups, cleanup)
cleanup, err = grantFilesystemForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
if err != nil {
reason := "failed to grant filesystem permission: " + err.Error()
@@ -706,7 +773,15 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
case PermissionDecisionAllowForSession:
permissionGranted = true
requestEvent.DecisionAction = decision.Action
if networkSandboxPrompt(requestEvent) {
if shellCommandAdditionalPermissionsRequested(args) {
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeSession, options)
if err != nil {
reason := "failed to grant session additional permissions: " + err.Error()
emitDeniedPermission(options, call, requestEvent, reason)
return deniedPermissionResult(call, reason, requestEvent), nil
}
permissionCleanups = append(permissionCleanups, cleanup)
} else if networkSandboxPrompt(requestEvent) {
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeSession, options)
if err != nil {
reason := "failed to grant session network permission: " + err.Error()
@@ -800,6 +875,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
return blockedByHookResult(call, outcome), nil
}
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
// Task tool: wire progress callback so the TUI sees live tool-call events
// from the specialist child process.
@@ -834,6 +910,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// The sandbox decision (if any) is returned synchronously on the Result and
// used here for permission event building.
})
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
if directResult != nil {
return *directResult, abortErr
}
result = retryResult
permissionGranted = true
decisionAction = action
decisionReason = reason
if len(prefix) > 0 {
decisionCommandPrefix = append([]string(nil), prefix...)
}
}
sandboxDecision := result.SandboxDecision
if toolFound && options.OnPermission != nil {
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
@@ -879,6 +967,279 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, []string, error) {
if retryResult, directResult, retried, action, reason, abortErr := maybeRetryWithNetworkAfterSandboxDenial(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
return retryResult, directResult, retried, action, reason, nil, abortErr
}
if !sandboxRestrictedShellRetryCandidate(call, args, result, options) {
return result, nil, false, "", "", nil, nil
}
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
}
decision.Action = normalizePermissionDecisionAction(decision.Action)
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = request.Reason
}
switch decision.Action {
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix:
prefix := []string(nil)
if decision.Action == PermissionDecisionAllowPrefix || decision.Action == PermissionDecisionAlwaysAllowPrefix {
if len(request.CommandPrefix) == 0 {
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil, nil
}
prefix = append([]string(nil), request.CommandPrefix...)
if options.Sandbox != nil {
if decision.Action == PermissionDecisionAlwaysAllowPrefix {
if grant, err := persistCommandPrefixGrant(call.Name, prefix, reason, options); err == nil {
prefix = append([]string(nil), grant.Prefix...)
}
} else {
options.Sandbox.GrantCommandPrefixForSession(call.Name, prefix)
}
}
}
retryArgs := unsandboxedRetryArgs(args)
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
return retry, nil, true, decision.Action, reason, prefix, nil
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil, nil
}
}
func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, error) {
if !sandboxDeniedNetworkRetryCandidate(call, args, result, options) {
return result, nil, false, "", "", nil
}
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
request := permissionRequestFromEvent(requestEvent, args, options)
if permissionMode == PermissionModeUnsafe {
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
}
decision.Action = normalizePermissionDecisionAction(decision.Action)
reason := strings.TrimSpace(decision.Reason)
if reason == "" {
reason = request.Reason
}
switch decision.Action {
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession:
scope := sandbox.PermissionGrantScopeTurn
if decision.Action == PermissionDecisionAllowForSession {
scope = sandbox.PermissionGrantScopeSession
}
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, scope, options)
if err != nil {
reason := "failed to grant network permission: " + err.Error()
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil
}
if cleanup != nil {
defer cleanup()
}
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
return retry, nil, true, decision.Action, reason, nil
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, reason)
canceled := canceledPermissionResult(call, reason, requestEvent)
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, reason)
denied := deniedPermissionResult(call, reason, requestEvent)
return result, &denied, true, decision.Action, reason, nil
}
}
func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
return false
}
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
}
return true
}
func sandboxDeniedNetworkRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
risk := sandbox.Classify(sandbox.Request{
ToolName: call.Name,
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
Permission: sandbox.Permission(tool.Safety().Permission),
PermissionGranted: false,
PermissionMode: sandbox.PermissionMode(permissionMode),
Args: args,
Reason: sandbox.ReasonNetworkBlocked,
})
risk = ensureRiskCategory(risk, "network", sandbox.RiskCritical)
reason := sandbox.ReasonNetworkBlocked
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
reason += " (" + keyword + ")"
}
return PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
Action: PermissionActionPrompt,
Permission: string(tools.PermissionPrompt),
PermissionMode: permissionMode,
Autonomy: options.Autonomy,
SideEffect: string(tools.SideEffectShell),
Reason: sandbox.ReasonNetworkBlocked,
DecisionReason: reason,
Scope: permissionScope(call.Name, args),
Risk: risk,
CommandPrefix: proposedCommandPrefix(call.Name, args),
}
}
func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
return false
}
if !options.Sandbox.UnsandboxedExecutionAllowed() {
return false
}
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
return false
}
return sandboxDeniedShellResult(result) || sandboxNamespaceLimitedShellResult(result)
}
func sandboxDeniedShellResult(result tools.Result) bool {
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
func sandboxNamespaceLimitedShellResult(result tools.Result) bool {
if result.Status != tools.StatusOK {
return false
}
if result.Meta["sandbox_wrapped"] != "true" {
return false
}
if result.Meta["sandbox_backend"] != string(sandbox.BackendLinuxBwrap) && result.Meta["sandbox_target_backend"] != string(sandbox.BackendLinuxBwrap) {
return false
}
output := strings.ToLower(result.Output)
return strings.Contains(output, "bwrap ") &&
strings.Contains(output, "--unshare-pid") &&
strings.Contains(output, "-- /bin/sh -c")
}
func sandboxRestrictionRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
reason := sandboxRestrictionRetryReason(result)
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
reason += " (" + keyword + ")"
}
risk := sandbox.Classify(sandbox.Request{
ToolName: call.Name,
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
Permission: sandbox.Permission(tool.Safety().Permission),
PermissionGranted: false,
PermissionMode: sandbox.PermissionMode(permissionMode),
Args: args,
Reason: reason,
})
return PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
Action: PermissionActionPrompt,
Permission: string(tools.PermissionPrompt),
PermissionMode: permissionMode,
Autonomy: options.Autonomy,
SideEffect: string(tools.SideEffectShell),
Reason: reason,
Scope: permissionScope(call.Name, args),
Risk: risk,
CommandPrefix: proposedCommandPrefix(call.Name, args),
}
}
func sandboxRestrictionRetryReason(result tools.Result) string {
if sandboxDeniedShellResult(result) {
if reason := strings.TrimSpace(result.Meta[tools.SandboxDenialReasonMeta]); reason != "" {
return reason
}
return "sandbox blocked command execution"
}
if sandboxNamespaceLimitedShellResult(result) {
return sandboxNamespaceLimitedReason
}
return "sandbox blocked command execution"
}
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
func unsandboxedRetryArgs(args map[string]any) map[string]any {
retryArgs := cloneArgs(args)
if retryArgs == nil {
retryArgs = map[string]any{}
}
retryArgs["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
return retryArgs
}
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
PermissionGranted: true,
PermissionMode: string(permissionMode),
Autonomy: options.Autonomy,
Sandbox: options.Sandbox,
ToolCallID: toolCallID,
SessionID: options.SessionID,
Model: options.Model,
ReasoningEffort: options.ReasoningEffort,
Depth: options.Depth,
Cwd: options.Cwd,
FileTracker: options.FileTracker,
EnabledTools: options.EnabledTools,
DisabledTools: options.DisabledTools,
Progress: progressCallback,
})
}
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
output, outputRedacted := scrubInterceptedOutput(result.Output)
display := result.Display
@@ -1142,9 +1503,12 @@ func toAgentAskUserQuestions(questions []tools.AskUserQuestion) []AskUserQuestio
converted := make([]AskUserQuestion, len(questions))
for index, question := range questions {
converted[index] = AskUserQuestion{
Question: question.Question,
Options: append([]string{}, question.Options...),
MultiSelect: question.MultiSelect,
Question: question.Question,
Header: question.Header,
Options: append([]string{}, question.Options...),
OptionDescriptions: append([]string{}, question.OptionDescriptions...),
Recommended: question.Recommended,
MultiSelect: question.MultiSelect,
}
}
return converted
@@ -1175,13 +1539,16 @@ func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission
return tool.Safety().Permission
}
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
func shouldRequestPermission(tool tools.Tool, args map[string]any, permissionGranted bool, decision *sandbox.Decision) bool {
if decision != nil && decision.Action == sandbox.ActionPrompt {
return true
}
if tool.Safety().Permission != tools.PermissionPrompt {
return false
}
if !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
return true
}
if decision != nil {
if sandboxDecisionRequiresExplicitPermission(decision) {
return true
@@ -1666,6 +2033,12 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
return PermissionEvent{}, false
}
scopeText := permissionScope(call.Name, args)
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
scopeText = requestPermissionsScope(profile)
}
reason = userFacingPermissionReason(call.Name, args, reason)
return PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
@@ -1677,7 +2050,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
Autonomy: options.Autonomy,
SideEffect: string(safety.SideEffect),
Reason: reason,
Scope: permissionScope(call.Name, args),
Scope: scopeText,
Risk: risk,
Block: block,
GrantMatched: grantMatched,
@@ -1686,6 +2059,20 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}, true
}
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
reason = strings.TrimSpace(reason)
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
return reason
}
if justification, ok := firstStringArg(args, "justification"); ok {
return justification
}
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
return "This command needs to run outside the sandbox."
}
return reason
}
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
if grant == nil {
return ""
@@ -1721,24 +2108,25 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
GrantMatched: event.GrantMatched,
Grant: event.Grant,
CommandPrefix: append([]string(nil), event.CommandPrefix...),
AvailableDecisions: availablePermissionDecisions(event, options),
AvailableDecisions: availablePermissionDecisions(event, args, options),
}
}
func availablePermissionDecisions(event PermissionEvent, options Options) []PermissionDecisionAction {
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
if event.Action != PermissionActionPrompt {
return nil
}
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
inlineAdditionalPermissions := shellCommandAdditionalPermissionsRequested(args)
if options.Sandbox != nil {
decisions = append(decisions, PermissionDecisionAllowForSession)
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) {
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) && !inlineAdditionalPermissions {
decisions = append(decisions, PermissionDecisionAllowPrefix)
if options.Sandbox.CanPersistGrants() {
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
}
}
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) {
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
decisions = append(decisions, PermissionDecisionAlwaysAllow)
}
}
@@ -1759,6 +2147,74 @@ func networkSandboxPrompt(event PermissionEvent) bool {
sandbox.HasRiskCategory(event.Risk, "network")
}
func ensureRiskCategory(risk sandbox.Risk, category string, level sandbox.RiskLevel) sandbox.Risk {
if !sandbox.HasRiskCategory(risk, category) {
risk.Categories = append(risk.Categories, category)
sort.Strings(risk.Categories)
}
risk.Level = level
if len(risk.Categories) > 0 {
risk.Reason = fmt.Sprintf("%s risk: %s", risk.Level, strings.Join(risk.Categories, ", "))
}
return risk
}
func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
raw, ok := args["sandbox_permissions"]
if !ok || raw == nil {
return false
}
value, ok := raw.(string)
if !ok {
value = fmt.Sprint(raw)
}
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
}
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
if !shellCommandAdditionalPermissionsRequested(args) {
if _, exists := args["additional_permissions"]; exists {
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
}
return sandbox.RequestPermissionProfile{}, false, nil
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
}
data, err := json.Marshal(raw)
if err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
var profile sandbox.RequestPermissionProfile
if err := json.Unmarshal(data, &profile); err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
if err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
return sandbox.RequestPermissionProfile{}, true, err
}
return grantProfile, true, nil
}
func grantInlineAdditionalPermissions(args map[string]any, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd)
if err != nil || !ok {
return nil, err
}
if options.Sandbox == nil {
return nil, errors.New("sandbox engine is not configured")
}
return options.Sandbox.GrantRequestPermissions(profile, scope)
}
func grantNetworkForSandboxPrompt(event PermissionEvent, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
if !networkSandboxPrompt(event) || options.Sandbox == nil {
return nil, nil
@@ -1799,6 +2255,8 @@ func permissionSupportsPersistentDecision(toolName string) bool {
switch toolName {
case "bash", "exec_command", "write_stdin", "apply_patch":
return false
case "browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "browser_action", "desktop_windows", "desktop_snapshot", "desktop_action", "terminal_session", "capture_artifact":
return false
default:
return true
}
@@ -1826,15 +2284,15 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
}
}
// partitionTools builds the per-turn advertised tool list and an optional
// deferred-tools reminder. INACTIVE (DeferThreshold <= 0 or the eligible count is
// partitionTools builds the per-turn advertised tool list and optional
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
// (dropped so it is never advertised when it cannot help), and the reminder is
// (dropped so it is never advertised when it cannot help), and the discovery text is
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
// tool is exposed only when loaded[name]; otherwise it is hidden and its compact
// line goes into the reminder. Non-deferred tools (including tool_search) are
// always exposed. The exposed slice is alpha-sorted by name, matching the legacy
// order so the inactive path is stable.
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
// through tool_search. Non-deferred tools (including tool_search) are always
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
registeredTools := registry.All()
@@ -1845,7 +2303,12 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
continue
}
visible = append(visible, tool)
if tools.IsDeferred(tool) {
// Count by deferral-ELIGIBILITY, not current deferred state: a tool that
// un-defers at runtime (e.g. swarm coordination tools once a swarm is
// active) still counts, so it can't drop `eligible` below the threshold
// and force-expose every other deferred tool. Whether a tool is actually
// hidden is decided by IsDeferred in the exposure loop below.
if tools.IsDeferralEligible(tool) {
eligible++
}
}
@@ -1867,7 +2330,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenLines []string
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
deferred := tools.IsDeferred(tool)
@@ -1888,7 +2351,7 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
// Active path.
if deferred && !loaded[name] {
hiddenLines = append(hiddenLines, tools.DeferredLine(tool))
hiddenTools = append(hiddenTools, tool)
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
@@ -1905,23 +2368,34 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: loader.Description(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
reminder := ""
if active {
reminder = tools.BuildDeferredReminder(hiddenLines)
}
return definitions, reminder
return definitions, discovery
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
@@ -2015,6 +2489,21 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
}
if permissionMode == PermissionModeMemberAuto {
// Like Auto, plus the in-workspace mutators a headless member needs to
// build. The sandbox engine still decides at call time: in-workspace writes
// and sandbox-backed shell auto-allow, while out-of-workspace writes,
// network, and destructive commands prompt → denied headless. So this
// advertises capability without widening sandbox authority.
if tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto {
return true
}
switch tool.Safety().SideEffect {
case tools.SideEffectWrite, tools.SideEffectShell:
return true
}
return false
}
return true
}
+630 -9
View File
@@ -37,6 +37,365 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
type sandboxDeniedRetryTool struct {
calls []map[string]any
}
func (tool *sandboxDeniedRetryTool) Name() string { return "bash" }
func (tool *sandboxDeniedRetryTool) Description() string { return "test shell tool" }
func (tool *sandboxDeniedRetryTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
"sandbox_permissions": {Type: "string"},
"justification": {Type: "string"},
},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *sandboxDeniedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
return tools.Result{Status: tools.StatusOK, Output: "installed"}
}
return tools.Result{
Status: tools.StatusError,
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
Meta: map[string]string{
"exit_code": "1",
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
tools.SandboxDenialKeywordMeta: "read-only file system",
},
}
}
type sandboxDeniedExecCommandRetryTool struct {
sandboxDeniedRetryTool
}
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
type sandboxNetworkDeniedRetryTool struct {
calls []map[string]any
}
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
func (tool *sandboxNetworkDeniedRetryTool) Description() string { return "test shell tool" }
func (tool *sandboxNetworkDeniedRetryTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *sandboxNetworkDeniedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
}
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
if shellNetworkAllowed(ctx, options.Sandbox, args) {
return tools.Result{Status: tools.StatusOK, Output: "server started"}
}
return tools.Result{
Status: tools.StatusOK,
Output: "Cannot open a network socket.",
Meta: map[string]string{
"exit_code": "0",
tools.SandboxLikelyDeniedMeta: "true",
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
tools.SandboxDenialKeywordMeta: "cannot open a network socket",
},
}
}
func shellNetworkAllowed(ctx context.Context, engine *sandbox.Engine, args map[string]any) bool {
if engine == nil {
return false
}
decision := engine.Evaluate(ctx, sandbox.Request{
ToolName: "bash",
SideEffect: sandbox.SideEffectShell,
Permission: sandbox.PermissionPrompt,
PermissionGranted: true,
PermissionMode: sandbox.PermissionModeAsk,
Args: map[string]any{"command": "curl https://example.com"},
})
return decision.Action == sandbox.ActionAllow
}
func agentNativeBackendStub() sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendLinuxBwrap,
Available: true,
Executable: "/nonexistent/zero-linux-sandbox-stub",
CommandWrapping: true,
NativeIsolation: true,
}
}
type sandboxNamespaceLimitedRetryTool struct {
calls []map[string]any
}
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
func (tool *sandboxNamespaceLimitedRetryTool) Description() string { return "test shell tool" }
func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
"sandbox_permissions": {Type: "string"},
},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
}
return tools.Result{
Status: tools.StatusOK,
Output: "stdout:\nUSER PID COMMAND\nanaxy 1 bwrap --new-session --die-with-parent --unshare-user --unshare-pid --unshare-net --proc /proc -- /bin/sh -c ps aux\nanaxy 2 ps aux",
Meta: map[string]string{
"sandbox_backend": string(sandbox.BackendLinuxBwrap),
"sandbox_target_backend": string(sandbox.BackendLinuxBwrap),
"sandbox_wrapped": "true",
},
}
}
func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxNamespaceLimitedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var events []PermissionEvent
result, err := Run(context.Background(), "check running applications", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "inspect host processes"}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
}
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
}
if len(requests) != 1 {
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
}
if !strings.Contains(requests[0].Reason, "sandbox PID namespace") {
t.Fatalf("permission reason = %q, want sandbox namespace reason", requests[0].Reason)
}
if !equalStringSlices(requests[0].CommandPrefix, []string{"ps", "aux"}) {
t.Fatalf("request command prefix = %#v, want ps aux", requests[0].CommandPrefix)
}
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllowPrefix {
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "firefox") || strings.Contains(last.Content, "bwrap --new-session") {
t.Fatalf("tool result sent to model = %q, want unsandboxed retry output only", last.Content)
}
}
func TestRunUsesEscalatedJustificationForPermissionPrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(&sandboxDeniedRetryTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated","justification":"Need host process visibility."}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requestReasons []string
_, err := Run(context.Background(), "check host processes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requestReasons = append(requestReasons, request.Reason)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if len(requestReasons) != 1 {
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
}
if requestReasons[0] != "Need host process visibility." {
t.Fatalf("permission reason = %q, want model justification", requestReasons[0])
}
}
func TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(&sandboxDeniedExecCommandRetryTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "exec_command"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requestReasons []string
_, err := Run(context.Background(), "check host processes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requestReasons = append(requestReasons, request.Reason)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if len(requestReasons) != 1 {
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
}
if requestReasons[0] != "This command needs to run outside the sandbox." {
t.Fatalf("permission reason = %q, want user-facing fallback", requestReasons[0])
}
if strings.Contains(requestReasons[0], sandbox.ReasonEscalatedSandboxRequired) {
t.Fatalf("permission reason leaked internal sandbox copy: %q", requestReasons[0])
}
}
func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxNetworkDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"node server.js"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "start server", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "approve network"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want denied attempt plus network-granted retry", retryTool.calls)
}
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
}
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("permission requests = %#v, want one network approval", requests)
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "server started") {
t.Fatalf("tool result sent to model = %q, want network retry output", last.Content)
}
}
func TestIsRetriableToolError(t *testing.T) {
cases := []struct {
name string
@@ -64,6 +423,145 @@ func TestIsRetriableToolError(t *testing.T) {
}
}
func TestRunRetriesShellUnsandboxedAfterSandboxDeniedExit(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"npm install --save http-server"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var events []PermissionEvent
result, err := Run(context.Background(), "install package", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "retry outside sandbox"}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
}
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
}
if len(requests) != 2 {
t.Fatalf("permission requests = %#v, want network approval plus retry approval", requests)
}
if requests[0].Reason != sandbox.ReasonNetworkBlocked {
t.Fatalf("first request reason = %q, want network approval", requests[0].Reason)
}
if !strings.Contains(requests[1].Reason, "sandbox blocked command execution") || !strings.Contains(requests[1].Reason, "read-only file system") {
t.Fatalf("retry request reason = %q", requests[1].Reason)
}
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllow {
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "installed") {
t.Fatalf("tool result sent to model = %q, want retry output", last.Content)
}
}
func TestRunDoesNotRetryUnsandboxedWhenDeniedReadsActive(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"touch cache-file"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var events []PermissionEvent
policy := sandbox.DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
result, err := Run(context.Background(), "touch cache", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: policy,
Backend: agentNativeBackendStub(),
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "unexpected"}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(retryTool.calls) != 1 {
t.Fatalf("tool calls = %#v, want only the original sandboxed attempt", retryTool.calls)
}
if len(requests) != 0 {
t.Fatalf("permission requests = %#v, want no unsandboxed retry request", requests)
}
for _, event := range events {
if strings.Contains(event.Reason, "sandbox blocked command execution") {
t.Fatalf("permission events = %#v, want no unsandboxed retry event", events)
}
}
if len(provider.requests) < 2 {
t.Fatalf("provider requests = %d, want tool result sent back to model", len(provider.requests))
}
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(last.Content, "Read-only file system") {
t.Fatalf("tool result sent to model = %q, want original sandbox denial", last.Content)
}
}
func TestRunReturnsProviderText(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
@@ -1025,7 +1523,7 @@ func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
got := availablePermissionDecisions(PermissionEvent{
ToolName: tc.tool,
Action: PermissionActionPrompt,
}, options)
}, nil, options)
if !equalPermissionDecisions(got, tc.want) {
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
}
@@ -1260,6 +1758,129 @@ func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
}
}
func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo prefix-ok"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo prefix-ok again"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "run twice", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected only first command to request prefix approval, got %#v", requests)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
}
for index, call := range retryTool.calls {
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("call %d args = %#v, want prefix-approved require_escalated execution", index, call)
}
}
}
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "inspect processes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: "medium",
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
if !equalStringSlices(request.CommandPrefix, []string{"ps", "aux"}) {
t.Fatalf("request command prefix = %#v, want ps aux", request.CommandPrefix)
}
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected only first command to request approval, got %#v", requests)
}
if len(retryTool.calls) != 2 {
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
}
for index, call := range retryTool.calls {
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
t.Fatalf("call %d args = %#v, want require_escalated execution", index, call)
}
}
}
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
root := t.TempDir()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
@@ -1494,8 +2115,8 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
if !strings.Contains(lastMessage.Content, "fake curl https://example.com") {
t.Fatalf("expected approved network command output after degraded execution, got %q", lastMessage.Content)
}
}
@@ -1507,7 +2128,7 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && echo bye","prefix_rule":["echo"]}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && npm install","prefix_rule":["echo"]}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -1603,8 +2224,8 @@ func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
}
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
if !strings.Contains(lastMessage.Content, "rm -rf /") {
t.Fatalf("expected approved shell command output after degraded execution, got %q", lastMessage.Content)
}
}
@@ -1775,7 +2396,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
@@ -1837,7 +2458,7 @@ func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
@@ -1907,7 +2528,7 @@ func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
+51
View File
@@ -0,0 +1,51 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
type safetyTool struct {
name string
safety tools.Safety
}
func (t safetyTool) Name() string { return t.name }
func (t safetyTool) Description() string { return "test tool" }
func (t safetyTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t safetyTool) Safety() tools.Safety { return t.safety }
func (t safetyTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// member-auto advertises the in-workspace mutators a headless member needs to
// build (write/edit + shell) that plain Auto hides, but NOT network or denied
// tools — the sandbox still gates the advertised ones at call time.
func TestToolAdvertisedMemberAuto(t *testing.T) {
write := safetyTool{name: "write_file", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt}}
shell := safetyTool{name: "bash", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt}}
read := safetyTool{name: "read_file", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}}
network := safetyTool{name: "net_tool", safety: tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt}}
denied := safetyTool{name: "blocked", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny}}
// Plain Auto hides prompt-requiring mutators (the read-only member problem).
if ToolAdvertised(write, PermissionModeAuto) || ToolAdvertised(shell, PermissionModeAuto) {
t.Fatal("Auto must NOT advertise write/shell prompt tools")
}
for _, tool := range []tools.Tool{write, shell, read} {
if !ToolAdvertised(tool, PermissionModeMemberAuto) {
t.Fatalf("member-auto must advertise %q", tool.Name())
}
}
if ToolAdvertised(network, PermissionModeMemberAuto) {
t.Fatal("member-auto must NOT advertise a network prompt tool")
}
if ToolAdvertised(denied, PermissionModeMemberAuto) {
t.Fatal("member-auto must NOT advertise a denied tool")
}
}
+66 -1
View File
@@ -15,7 +15,7 @@ import (
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
workspace := t.TempDir()
outside := t.TempDir()
outside := tempDirOutsideDefaultTemp(t)
target := filepath.Join(outside, "vegetables.txt")
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
@@ -120,6 +120,71 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
}
}
func tempDirOutsideDefaultTemp(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
if err != nil {
t.Fatalf("MkdirTemp outside default temp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatalf("Abs(%q): %v", dir, err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return filepath.Clean(abs)
}
func TestInlineAdditionalPermissionsGrantAllowsNetworkCommand(t *testing.T) {
workspace := t.TempDir()
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspace,
Policy: sandbox.DefaultPolicy(),
})
args := map[string]any{
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
"additional_permissions": map[string]any{
"network": map[string]any{"enabled": true},
},
}
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, Options{
Sandbox: engine,
Cwd: workspace,
})
if err != nil {
t.Fatalf("grantInlineAdditionalPermissions: %v", err)
}
defer cleanup()
decision := engine.Evaluate(context.Background(), sandbox.Request{
WorkspaceRoot: workspace,
ToolName: "exec_command",
SideEffect: sandbox.SideEffectShell,
Permission: sandbox.PermissionPrompt,
PermissionGranted: true,
PermissionMode: sandbox.PermissionModeAsk,
Args: map[string]any{"cmd": "python3 -m http.server 8080 --bind 127.0.0.1"},
})
if decision.Action != sandbox.ActionAllow {
t.Fatalf("inline network grant should allow shell network command, got %#v", decision)
}
}
func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
args := map[string]any{
"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(tools.NewBashTool(t.TempDir()), args, false, decision) {
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
}
}
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
workspace := t.TempDir()
registry := tools.NewRegistry()
+30 -6
View File
@@ -16,7 +16,12 @@ work.
implementation, verification, and a clear summary.
- Only stop to ask the user (via the ask_user tool) when you are genuinely
blocked on a decision that is theirs to make and that you cannot resolve from
the code, the request, or sensible defaults.
the code, the request, or sensible defaults. When the answer is likely one of a
small set, include 2-4 suggested `options` and mark the best as `recommended`
(it must be one of the options) so the user can pick quickly; give each option a
short `optionDescriptions` line when a one-word label needs context, and a short
`header` (2-3 words) per question as its tab label when you ask several. Omit
options for genuinely open-ended questions.
## Workflow: plan then act
@@ -26,15 +31,29 @@ work.
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
keep it current as you go. Skip the plan for trivial one-step tasks.
keep it live. The plan bar is the user's progress signal — call update_plan
after EACH concrete unit of work (every file written, every command run), not
just at coarse milestones: mark the finished item completed and the next one
in_progress before you start it. A plan stuck at 0/N while files are landing is
a bug, not economy — these calls are cheap, expected, and the update_plan cards
are hidden from the transcript, so frequent updates cost the user nothing and
never clutter the conversation. Keep at most one item in_progress, and never
batch the updates to the end of the turn. Skip the plan for trivial one-step
tasks.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** Give a concise final response: what changed, why, which files
matter, and what validation ran. State plainly what was not verified.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
## Editing discipline
@@ -63,8 +82,13 @@ work.
## Tool use
- Use tools to act, not to narrate. Do not announce each call; just do the work
and explain the outcome.
- Lead a multi-step task with a one- or two-sentence plain-language preamble on
your approach, so the user can follow what you're about to do. Then keep a brief
running account: drop a short, single-line note before each SIGNIFICANT step
(e.g. "Now the stylesheet.", "Let me wire up the cart and filters.") and explain
the outcome. Use tools to act, not to narrate — don't announce every individual
call or read; narrate only the steps a person would care about, and skip
narration entirely for trivial one-step tasks.
- Run independent, read-only lookups together when you can, rather than one at a
time, to move faster.
- exec_command is for commands that have no native tool (build, test, git,
+14 -1
View File
@@ -23,7 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"final response",
"how each requirement is met",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -31,6 +31,19 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
}
}
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
for _, want := range []string{
"sandboxed shell command returns only sandbox-local state",
"sandbox_permissions: \"require_escalated\"",
"prefix_rule",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected sandbox escalation guidance %q, got:\n%s", want, buildSystemPrompt(Options{}))
}
}
}
func TestBuildSystemPromptIncludesWorkspaceSeedFromCwd(t *testing.T) {
cwd := t.TempDir()
writeSystemPromptTestFile(t, cwd, "go.mod", "module example.test/zero\n")
+43 -4
View File
@@ -24,6 +24,15 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
// call time — in-workspace writes and sandbox-backed shell auto-allow, but
// out-of-workspace writes, network, and destructive commands still prompt (and
// a headless member has no approver, so they are denied). It normalizes to Auto
// everywhere except ToolAdvertised, so authority is never widened beyond what an
// interactive auto agent already has inside the sandbox.
PermissionModeMemberAuto PermissionMode = "member-auto"
)
type StopReason string
@@ -129,11 +138,19 @@ type PermissionEvent struct {
CommandPrefix []string `json:"commandPrefix,omitempty"`
}
// AskUserQuestion is one clarifying question the agent wants answered.
// AskUserQuestion is one clarifying question the agent wants answered. Options are
// optional suggested answers an interactive front-end can render as a picker;
// Recommended (when set) is the suggested default — it should match one of Options.
// Header is an optional short tab title for a multi-question prompt (falls back to
// the question text). OptionDescriptions, when present, holds a one-line description
// per option aligned by index to Options (empty string = no description).
type AskUserQuestion struct {
Question string `json:"question"`
Options []string `json:"options,omitempty"`
MultiSelect bool `json:"multiSelect,omitempty"`
Question string `json:"question"`
Header string `json:"header,omitempty"`
Options []string `json:"options,omitempty"`
OptionDescriptions []string `json:"optionDescriptions,omitempty"`
Recommended string `json:"recommended,omitempty"`
MultiSelect bool `json:"multiSelect,omitempty"`
}
// AskUserRequest is handed to OnAskUser when the model invokes the ask_user tool.
@@ -253,6 +270,17 @@ type Options struct {
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
// work clearly remains — pending update_plan items, or a message that ends on a
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
RequireCompletionSignal bool
runPermissions *permissionRunState
}
@@ -266,6 +294,17 @@ type Result struct {
// hit the token cap, FinishReasonContentFilter when it was filtered. Empty for
// a normal completion.
FinishReason string
// Incomplete reports that a headless run (RequireCompletionSignal) stopped with
// work clearly unfinished: the model ended a turn with no tool call while plan
// items were pending or the message ended mid-step, the model admitted it
// guessed / could not meet the objective, and/or it failed a task-grounded
// acceptance check. Callers map it to a non-success terminal status / exit
// code. False for every normal completion.
Incomplete bool
// IncompleteReason is a short, model-derived explanation of why the run was
// marked Incomplete (e.g. "pending plan items remain"). Empty when Incomplete
// is false. Surfaced in logs / run_end so an abandoned run is debuggable.
IncompleteReason string
}
// Truncated reports whether the final response ended abnormally (cut off at the
+4 -2
View File
@@ -52,11 +52,13 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
if err != nil {
return nil, nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope, false)
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
if err != nil {
return nil, nil, err
}
return newCoreRegistryScoped(workspaceRoot, scope), engine, nil
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
return registry, engine, nil
},
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
Store: deps.newSessionStore(),
+84 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
@@ -513,6 +514,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if err != nil {
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
}
// Fail-soft, one-time: move any inline plaintext API keys in config.json into
// the encrypted credential store (interactive runs only; headless exec keeps
// its existing behavior). Non-fatal — a missing keyring or write error leaves
// the inline key in place and this run still uses the already-resolved key.
if store, storeErr := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); storeErr == nil {
_, _ = config.MigratePlaintextProviderKeys(userConfigPath, store)
}
doctorUserConfigPath := ""
projectConfigPath := ""
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
@@ -553,6 +561,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
@@ -638,7 +647,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ProviderName: resolved.Provider.Name,
ModelName: resolved.Provider.Model,
ProviderProfile: resolved.Provider,
SavedProviders: usableSavedProviders(resolved.Providers),
FavoriteModels: resolved.Preferences.FavoriteModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
@@ -712,7 +723,16 @@ func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Pr
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
return deps.newProvider(resolved.Provider)
// Load the API key from the encrypted credential store when it isn't already
// resolved from inline config or an env var. This keeps the resolver pure (no
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
profile := resolved.Provider
if path, perr := deps.userConfigPath(); perr == nil {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
return deps.newProvider(profile)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
@@ -724,9 +744,71 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
registry.Register(tool)
}
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{})
return registry
}
func registerLocalControlTools(registry *tools.Registry, workspaceRoot string, cfg config.LocalControlConfig) {
if registry == nil {
return
}
browserOptions := localBrowserOptionsFromConfig(cfg)
desktopOptions := localDesktopOptionsFromConfig(cfg)
terminalOptions := localTerminalOptionsFromConfig(cfg)
for _, tool := range tools.NewLocalBrowserTools(browserOptions) {
registry.Register(tool)
}
for _, tool := range tools.NewLocalDesktopTools(desktopOptions) {
registry.Register(tool)
}
for _, tool := range tools.NewLocalTerminalTools(terminalOptions) {
registry.Register(tool)
}
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
Browser: browserOptions,
Desktop: desktopOptions,
Terminal: terminalOptions,
ArtifactsDir: localArtifactsDirFromConfig(workspaceRoot, cfg),
}) {
registry.Register(tool)
}
}
func localBrowserOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.BrowserOptions {
return localcontrol.BrowserOptions{
Enabled: cfg.BrowserEnabled(),
Driver: cfg.Browser.Driver,
HelperPath: cfg.Browser.HelperPath,
}
}
func localDesktopOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.DesktopOptions {
return localcontrol.DesktopOptions{
Enabled: cfg.DesktopEnabled(),
Driver: cfg.Desktop.Driver,
HelperPath: cfg.Desktop.HelperPath,
}
}
func localTerminalOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.TerminalOptions {
return localcontrol.TerminalOptions{
Enabled: cfg.TerminalEnabled(),
Driver: cfg.Terminal.Driver,
HelperPath: cfg.Terminal.HelperPath,
}
}
func localArtifactsDirFromConfig(workspaceRoot string, cfg config.LocalControlConfig) string {
dir := strings.TrimSpace(cfg.ArtifactsDir)
if dir == "" {
dir = filepath.Join(".zero", "artifacts")
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(workspaceRoot, dir)
}
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
// both, closeSpecialistRuntime tears down both.
@@ -986,7 +1068,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
// setup result is not env-resolved, so checking APIKey alone would wrongly flag every
// env-var-based provider as keyless.
func profileHasCredential(profile config.ProviderProfile) bool {
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
if strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != "" {
return true
}
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" {
+7 -6
View File
@@ -617,9 +617,6 @@ func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
}
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
if len(roots) != 2 {
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
}
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
// so compare against the resolved extra dir.
resolvedExtra, err := filepath.EvalSymlinks(extra)
@@ -679,10 +676,14 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
}
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
if len(roots) != 2 {
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
found := false
for _, root := range roots {
if root == resolvedExtra {
found = true
break
}
}
if roots[1] != resolvedExtra {
if !found {
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
}
})
+16 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
@@ -137,7 +138,7 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5\n"); err != nil {
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
}
return exitSuccess
@@ -336,6 +337,19 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
// Also drop any stored API key and its marker so `auth logout` clears the whole
// credential (OAuth token AND key), not just the OAuth side. Surface deletion
// failures rather than reporting success while a credential remains.
keyRemoved, keyErr := config.ForgetProviderKey(provider)
if keyErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash)
}
if configPath, perr := deps.userConfigPath(); perr == nil {
if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
}
}
removed = removed || keyRemoved
if parsed.json {
payload := struct {
Provider string `json:"provider"`
@@ -346,7 +360,7 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
}
return exitSuccess
}
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
msg := fmt.Sprintf("No stored credential for %s.\n", provider)
if removed {
msg = fmt.Sprintf("Logged out of %s.\n", provider)
}
+1 -1
View File
@@ -72,7 +72,7 @@ func TestRunAuthLogoutNothing(t *testing.T) {
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
if !strings.Contains(stdout.String(), "No stored credential for demo") {
t.Fatalf("logout output = %q", stdout.String())
}
}
+4 -5
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
@@ -128,8 +127,8 @@ func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
// independent of the deferral threshold: --list-tools short-circuits and returns
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
// so tool_search is never registered on this path. The threshold gate itself is
// before registerToolSearchIfEligible runs (see exec.go), so tool_search is
// never registered on this path. The threshold gate itself is
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
@@ -141,7 +140,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
@@ -176,7 +175,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
+103 -126
View File
@@ -32,6 +32,12 @@ const (
exitCrash = 1
exitUsage = 2
exitProvider = 3
// exitIncomplete marks a headless run that stopped with work clearly
// unfinished — the completion gate exhausted its continue budget on a
// no-tool-call turn while plan items were pending or the model ended mid-step.
// Distinct from success so callers/benchmarks don't treat an abandoned run as
// finished.
exitIncomplete = 4
// exitInterrupted is the conventional shell exit code for a process stopped by
// SIGINT (128 + signal number 2).
exitInterrupted = 130
@@ -105,16 +111,6 @@ type execOptions struct {
// additional write roots for this run. Unioned with
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
addDirs []string
// sandboxMode is the raw --sandbox flag value ("" = default enforce, "off"/
// "disabled" = container-trusted). Resolved (with the ZERO_SANDBOX env
// fallback) into a disable decision in runExec. Off by default; the disabled
// path is an EXPLICIT, loudly-warned opt-in meant ONLY for use inside an
// already-isolated container (CI / benchmark).
sandboxMode string
// deadlineRaw is the raw --deadline flag value (a Go duration like "900s").
// Resolved (with the ZERO_DEADLINE env fallback) into a wall-clock budget for
// the run. Empty = no deadline (current behavior).
deadlineRaw string
}
type execUsageError struct {
@@ -210,6 +206,44 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
// Build the model registry once and reuse it across every model-aware
// lookup in this exec run (model resolution, reasoning-effort advisory, and
// context-window sizing). DefaultRegistry builds the full catalog and
// compiles its match patterns, so rebuilding it per lookup is wasteful.
// modelRegistry is the zero Registry when the catalog fails to build; the
// helpers below degrade to safe no-op behavior in that case.
modelRegistry, _ := modelregistry.DefaultRegistry()
overrides := config.Overrides{}
modelOverride := options.model
if options.useSpec && options.specModel != "" {
modelOverride = options.specModel
}
if modelOverride != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
overrides.Provider.Model = resolvedModel
if notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
if options.maxTurns > 0 {
overrides.MaxTurns = options.maxTurns
}
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
if err != nil {
if !options.listTools {
if preflightErr := preflightExecSession(options); preflightErr != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, preflightErr.Error())
}
if _, _, promptErr := resolveExecPrompt(options, workspaceRoot, deps.stdin); promptErr != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, promptErr.Error())
}
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
if err := validateExecToolFilters(options, registry); err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
@@ -238,35 +272,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
sessionTitle := execSessionTitle(options, prompt)
// Build the model registry once and reuse it across every model-aware
// lookup in this exec run (model resolution, reasoning-effort advisory, and
// context-window sizing). DefaultRegistry builds the full catalog and
// compiles its match patterns, so rebuilding it per lookup is wasteful.
// modelRegistry is the zero Registry when the catalog fails to build; the
// helpers below degrade to safe no-op behavior in that case.
modelRegistry, _ := modelregistry.DefaultRegistry()
overrides := config.Overrides{}
modelOverride := options.model
if options.useSpec && options.specModel != "" {
modelOverride = options.specModel
}
if modelOverride != "" {
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
overrides.Provider.Model = resolvedModel
if notice != "" {
if _, err := fmt.Fprintln(stderr, notice); err != nil {
return exitCrash
}
}
}
if options.maxTurns > 0 {
overrides.MaxTurns = options.maxTurns
}
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
if !config.HasProviderProfile(resolved.Provider) {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Run `zero setup` (guided), `zero auth` (OAuth providers), set a provider API key env var (e.g. OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY), or add .zero/config.json.")
}
@@ -323,8 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
registry.Register(tool)
}
sandboxOff := resolveSandboxOff(options.sandboxMode)
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope, sandboxOff)
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
}
@@ -469,12 +473,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitCrash
}
}
if sandboxOff {
writer.warning("Sandbox disabled (container-trusted mode): filesystem and network are unrestricted. Use only inside an isolated container.")
if writer.err != nil {
return exitCrash
}
}
sessionRecorder := execSessionRecorder{prepared: preparedSession}
// Surface a best-effort session-recording failure once, on every exit path.
@@ -492,15 +490,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// cleanly (the loop honors context cancellation) instead of being killed.
runCtx, stopSignals := signalContext()
defer stopSignals()
// --deadline / ZERO_DEADLINE bound the run by wall-clock time (Terminal-Bench
// budgets by time, not turns). When the deadline fires, runCtx is cancelled,
// the loop stops, and the run_end below reports status "deadline" with the
// trajectory already flushed. Empty = no deadline (unchanged behavior).
if runDeadline := resolveDeadline(options.deadlineRaw); runDeadline > 0 {
var cancelDeadline context.CancelFunc
runCtx, cancelDeadline = context.WithTimeout(runCtx, runDeadline)
defer cancelDeadline()
}
// --self-correct opts the run into the post-edit verify-and-correct loop. Off
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
@@ -530,12 +519,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
Sandbox: sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
// 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
// if the model keeps stalling. The interactive TUI leaves this off.
RequireCompletionSignal: true,
Sandbox: sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
@@ -591,18 +585,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
// Distinguish a wall-clock --deadline stop (status "deadline") from a
// Ctrl+C / SIGTERM interrupt; both are clean shutdowns, not errors, and
// the trajectory is flushed before exit either way.
cancelStatus, cancelMsg := "interrupted", "run cancelled by signal"
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
cancelStatus, cancelMsg = "deadline", "run stopped: wall-clock deadline reached"
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": cancelStatus})
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
switch options.outputFormat {
case execOutputStreamJSON:
writer.errorEvent(cancelStatus, cancelMsg, false)
writer.runEnd(cancelStatus, exitInterrupted)
writer.errorEvent("interrupted", "run cancelled by signal", false)
writer.runEnd("interrupted", exitInterrupted)
if writer.err != nil {
return exitCrash
}
@@ -646,6 +633,43 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if notice := result.TruncationNotice(); notice != "" {
writer.warning(notice)
}
// A headless run the completion gate marked INCOMPLETE (no-tool-call stall,
// self-reported non-completion, or max-turns cutoff) must NOT be reported as a
// success: exit 4 AND a machine-readable terminal event saying so. Handled per
// output format BEFORE writer.final(), because for -o json final() emits a
// {"type":"done","exit_code":0} that would otherwise mask the incomplete exit.
// An `error` event (not just a warning) is emitted so log/cron consumers that
// scan for type=="error" can recover the reason.
if result.Incomplete {
reason := result.IncompleteReason
if reason == "" {
reason = "run stopped with work unfinished"
}
sessionRecorder.append(sessions.EventError, map[string]any{"message": "incomplete: " + reason})
switch options.outputFormat {
case execOutputStreamJSON:
writer.final(result.FinalAnswer)
writer.errorEvent("incomplete", reason, false)
writer.runEnd("incomplete", exitIncomplete)
if writer.err != nil {
return exitCrash
}
case execOutputJSON:
if writeErr := writeJSONLine(stdout, map[string]any{"type": "final", "text": result.FinalAnswer}); writeErr != nil {
return exitCrash
}
if writeErr := writeJSONLine(stdout, map[string]any{"type": "error", "code": "incomplete", "message": reason}); writeErr != nil {
return exitCrash
}
if writeErr := writeJSONLine(stdout, map[string]any{"type": "done", "exit_code": exitIncomplete}); writeErr != nil {
return exitCrash
}
default:
writer.final(result.FinalAnswer)
fmt.Fprintln(stderr, "Run incomplete (not reported as success): "+reason)
}
return exitIncomplete
}
writer.final(result.FinalAnswer)
writer.runEnd("success", exitSuccess)
if writer.err != nil {
@@ -692,7 +716,11 @@ func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
count := 0
for _, tool := range registry.All() {
if !tools.IsDeferred(tool) {
// Count by deferral-eligibility (not current deferred state) to match
// partitionTools' active-gate: a tool that un-defers at runtime still
// participates in the threshold. At registration time the swarm is empty
// so this equals IsDeferred, but it keeps the two count sites consistent.
if !tools.IsDeferralEligible(tool) {
continue
}
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
@@ -721,63 +749,12 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
registry.Register(tools.NewToolSearchTool(registry))
}
// resolveSandboxOff decides whether to disable confinement for an exec run.
// Precedence: the --sandbox flag value wins; otherwise the ZERO_SANDBOX env var.
// Recognized "off" values: off / disabled / none / 0 (case-insensitive). Anything
// else, including empty, keeps the default enforcing sandbox.
func resolveSandboxOff(flagValue string) bool {
value := strings.TrimSpace(flagValue)
if value == "" {
value = strings.TrimSpace(os.Getenv("ZERO_SANDBOX"))
}
switch strings.ToLower(value) {
case "off", "disabled", "none", "0":
return true
default:
return false
}
}
// resolveDeadline parses the run's wall-clock budget. Precedence: the --deadline
// flag wins; otherwise the ZERO_DEADLINE env var. Empty = no deadline. An
// unparseable or non-positive value is treated as no deadline.
func resolveDeadline(flagValue string) time.Duration {
value := strings.TrimSpace(flagValue)
if value == "" {
value = strings.TrimSpace(os.Getenv("ZERO_DEADLINE"))
}
if value == "" {
return 0
}
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return 0
}
return d
}
// execSandboxPolicy builds the sandbox policy for an exec run. By default it is
// the configured, enforcing policy (DefaultPolicy + config overlay). With
// disableSandbox it becomes container-trusted: Mode=disabled, Network=allow,
// EnforceWorkspace=false — flipping all three gates so both the in-process
// Evaluate path and the OS wrapping are permissive. Pure, so the default-unchanged
// and off-mode behavior are unit-tested directly.
func execSandboxPolicy(cfg config.SandboxConfig, disableSandbox bool) sandbox.Policy {
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
if disableSandbox {
policy.Mode = sandbox.ModeDisabled
policy.Network = sandbox.NetworkAllow
policy.EnforceWorkspace = false
}
return policy
}
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope, disableSandbox bool) (*sandbox.Engine, error) {
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope) (*sandbox.Engine, error) {
store, err := deps.newSandboxStore()
if err != nil {
return nil, err
}
policy := execSandboxPolicy(resolved.Sandbox, disableSandbox)
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
return sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
-18
View File
@@ -52,24 +52,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
index = next
case strings.HasPrefix(arg, "--auto="):
options.autonomy = strings.TrimSpace(strings.TrimPrefix(arg, "--auto="))
case arg == "--sandbox":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.sandboxMode = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--sandbox="):
options.sandboxMode = strings.TrimSpace(strings.TrimPrefix(arg, "--sandbox="))
case arg == "--deadline":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.deadlineRaw = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--deadline="):
options.deadlineRaw = strings.TrimSpace(strings.TrimPrefix(arg, "--deadline="))
case arg == "--enabled-tools":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
+27 -8
View File
@@ -51,13 +51,18 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
providerBuilt := false
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "read_file,grep"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
},
})
@@ -67,6 +72,9 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
}
for _, want := range []string{"Tools visible to model", "read_file", "grep"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("expected tool list to contain %q, got %q", want, stdout.String())
@@ -81,13 +89,18 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
providerBuilt := false
exitCode := runWithDeps([]string{"exec", "--list-tools", "--output-format", "stream-json", "--enabled-tools", "read_file"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
},
})
@@ -97,6 +110,9 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
}
events := decodeJSONLines(t, stdout.String())
if len(events) != 3 {
t.Fatalf("expected run_start, final, run_end events, got %#v", events)
@@ -115,11 +131,11 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
}
}
func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
providerResolved := false
providerBuilt := false
closed := false
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
@@ -127,8 +143,11 @@ func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
providerResolved = true
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
@@ -150,8 +169,8 @@ func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if providerResolved {
t.Fatal("provider config should not be resolved for --list-tools")
if providerBuilt {
t.Fatal("provider should not be constructed for --list-tools")
}
if !closed {
t.Fatal("MCP runtime was not closed after --list-tools")
-112
View File
@@ -1,112 +0,0 @@
package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestExecSandboxPolicyDefaultUnchanged is the load-bearing guard for real users:
// WITHOUT --sandbox off / ZERO_SANDBOX, the exec policy MUST be the default
// enforcing one (workspace-confined, network denied). If this ever changes, the
// container-trusted off-switch has leaked into the default.
func TestExecSandboxPolicyDefaultUnchanged(t *testing.T) {
policy := execSandboxPolicy(config.SandboxConfig{}, false)
if policy.Mode != sandbox.ModeEnforce {
t.Errorf("Mode = %q, want %q (default must not change)", policy.Mode, sandbox.ModeEnforce)
}
if policy.Network != sandbox.NetworkDeny {
t.Errorf("Network = %q, want %q (default must not change)", policy.Network, sandbox.NetworkDeny)
}
if !policy.EnforceWorkspace {
t.Error("EnforceWorkspace = false, want true (default must not change)")
}
}
// TestExecSandboxPolicyOff: with the off-switch, all three confinement gates drop
// so writes-anywhere and network both work inside an isolated container.
func TestExecSandboxPolicyOff(t *testing.T) {
policy := execSandboxPolicy(config.SandboxConfig{}, true)
if policy.Mode != sandbox.ModeDisabled {
t.Errorf("Mode = %q, want %q", policy.Mode, sandbox.ModeDisabled)
}
if policy.Network != sandbox.NetworkAllow {
t.Errorf("Network = %q, want %q", policy.Network, sandbox.NetworkAllow)
}
if policy.EnforceWorkspace {
t.Error("EnforceWorkspace = true, want false")
}
}
func TestResolveSandboxOff(t *testing.T) {
cases := []struct {
flag, env string
want bool
}{
{"", "", false},
{"off", "", true},
{"disabled", "", true},
{"none", "", true},
{"0", "", true},
{"OFF", "", true}, // case-insensitive
{"enforce", "", false},
{"", "off", true}, // env fallback
{"", "disabled", true},
{"", "1", false}, // unrecognized env value
{"enforce", "off", false}, // flag wins over env
{"off", "enforce", true}, // flag wins over env
}
for _, c := range cases {
t.Setenv("ZERO_SANDBOX", c.env)
if got := resolveSandboxOff(c.flag); got != c.want {
t.Errorf("resolveSandboxOff(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
}
}
}
func TestResolveDeadline(t *testing.T) {
cases := []struct {
flag, env string
want time.Duration
}{
{"", "", 0},
{"900s", "", 15 * time.Minute},
{"15m", "", 15 * time.Minute},
{"", "300s", 5 * time.Minute}, // env fallback
{"60s", "300s", time.Minute}, // flag wins over env
{"bogus", "", 0}, // unparseable -> no deadline
{"-5s", "", 0}, // non-positive -> no deadline
{"0s", "", 0}, // zero -> no deadline
}
for _, c := range cases {
t.Setenv("ZERO_DEADLINE", c.env)
if got := resolveDeadline(c.flag); got != c.want {
t.Errorf("resolveDeadline(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
}
}
}
// TestParseExecArgsSandboxAndDeadline: both --flag value and --flag=value spellings
// thread through to the options.
func TestParseExecArgsSandboxAndDeadline(t *testing.T) {
opts, _, err := parseExecArgs([]string{"--sandbox", "off", "--deadline", "900s", "do the task"})
if err != nil {
t.Fatalf("parseExecArgs: %v", err)
}
if opts.sandboxMode != "off" {
t.Errorf("sandboxMode = %q, want off", opts.sandboxMode)
}
if opts.deadlineRaw != "900s" {
t.Errorf("deadlineRaw = %q, want 900s", opts.deadlineRaw)
}
opts2, _, err := parseExecArgs([]string{"--sandbox=disabled", "--deadline=15m", "x"})
if err != nil {
t.Fatalf("parseExecArgs inline: %v", err)
}
if opts2.sandboxMode != "disabled" || opts2.deadlineRaw != "15m" {
t.Errorf("inline parse: sandboxMode=%q deadlineRaw=%q, want disabled/15m", opts2.sandboxMode, opts2.deadlineRaw)
}
}
+20 -3
View File
@@ -44,7 +44,7 @@ func TestRunAddDirDispatchForwardsGrantIntoExecScope(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
extra := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
target := filepath.Join(extra, "granted.txt")
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, tc.args(extra), target)
@@ -117,7 +117,7 @@ func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target str
// prove both the overwrite and the scoped enforcement it ships.
func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
root := t.TempDir()
extra := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
inside := filepath.Join(extra, "inside.txt")
registry := newCoreRegistry(root)
@@ -160,7 +160,7 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
}
// A path outside every scope root must still be denied after re-registration.
outside := filepath.Join(t.TempDir(), "outside.txt")
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
deniedOutside := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": outside,
"content": "never",
@@ -175,3 +175,20 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
}
}
func tempDirOutsideDefaultTemp(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
if err != nil {
t.Fatalf("MkdirTemp outside default temp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatalf("Abs(%q): %v", dir, err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return filepath.Clean(abs)
}
+45 -2
View File
@@ -465,7 +465,7 @@ func TestRunExecModeModelSurfacesDeprecationNoticeViaSharedPath(t *testing.T) {
func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
// applyExecMode now runs before tool-filter validation and the --list-tools
// branch, so a --mode preset is expanded for --list-tools. Combining a mode
// with --list-tools must still succeed and never resolve a provider.
// with --list-tools must still succeed without constructing a provider.
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -475,7 +475,7 @@ func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
return cwd, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
return execResolvedConfig(), nil
},
})
@@ -505,6 +505,49 @@ func TestRunExecModeToolFilterReflectedInListTools(t *testing.T) {
}
}
func TestResolveExecPermissionModeMember(t *testing.T) {
cases := []struct {
autonomy string
want agent.PermissionMode
}{
{"", agent.PermissionModeAuto},
{"low", agent.PermissionModeAuto},
{"medium", agent.PermissionModeAuto},
{"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell
{"high", agent.PermissionModeUnsafe},
}
for _, c := range cases {
got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy})
if err != nil {
t.Fatalf("resolveExecPermissionMode(%q): %v", c.autonomy, err)
}
if got != c.want {
t.Errorf("resolveExecPermissionMode(%q) = %q, want %q", c.autonomy, got, c.want)
}
}
if _, err := resolveExecPermissionMode(execOptions{autonomy: "bogus"}); err == nil {
t.Fatal("an unknown autonomy level must still be rejected")
}
}
// A member-auto headless tool list must include the in-workspace mutators that
// plain Auto hides, so a swarm member can actually build. Match the tool ENTRY
// line (" write_file [") — a bare substring would false-match tool descriptions.
func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
const writeEntry = "\n write_file ["
member := formatExecToolList(registry, execOptions{}, agent.PermissionModeMemberAuto)
if !strings.Contains(member, writeEntry) {
t.Fatalf("member-auto tool list must include write_file, got %q", member)
}
// Plain Auto must still hide it (unchanged behavior — this is the read-only gate).
auto := formatExecToolList(registry, execOptions{}, agent.PermissionModeAuto)
if strings.Contains(auto, writeEntry) {
t.Fatalf("plain Auto must still hide write_file, got %q", auto)
}
}
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
exitCode, stdout, stderr := runExecWithEcho(t, []string{
"exec",
+5
View File
@@ -81,6 +81,11 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
switch strings.ToLower(strings.TrimSpace(options.autonomy)) {
case "", "low", "medium":
mode = agent.PermissionModeAuto
case "member":
// Internal autonomy for headless swarm/specialist members: Auto plus
// advertised in-workspace mutators (see PermissionModeMemberAuto). The
// swarm launcher sets this; it is not part of the public low|medium|high set.
mode = agent.PermissionModeMemberAuto
case "high":
mode = agent.PermissionModeUnsafe
default:
+111
View File
@@ -0,0 +1,111 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
)
func TestCoreRegistryIncludesDefaultLocalControlTools(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
tool, ok := registry.Get(name)
if !ok {
t.Fatalf("%s not registered", name)
}
if tool.Safety().Permission != tools.PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
desktop, ok := registry.Get("desktop_action")
if !ok {
t.Fatal("desktop_action not registered")
}
if desktop.Safety().Permission != tools.PermissionDeny {
t.Fatalf("desktop_action permission = %s, want deny", desktop.Safety().Permission)
}
}
func TestCoreRegistryHonorsExplicitLocalControlDisable(t *testing.T) {
registry := newCoreRegistry(t.TempDir())
var cfg config.LocalControlConfig
if err := json.Unmarshal([]byte(`{"enabled":false}`), &cfg); err != nil {
t.Fatalf("unmarshal local control config: %v", err)
}
registerLocalControlTools(registry, t.TempDir(), cfg)
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "desktop_action", "terminal_session", "capture_artifact"} {
tool, ok := registry.Get(name)
if !ok {
t.Fatalf("%s not registered", name)
}
if tool.Safety().Permission != tools.PermissionDeny {
t.Fatalf("%s permission = %s, want deny", name, tool.Safety().Permission)
}
}
}
func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cfg := execResolvedConfig()
var localControl config.LocalControlConfig
if err := json.Unmarshal([]byte(`{"enabled":false}`), &localControl); err != nil {
t.Fatalf("unmarshal local control config: %v", err)
}
cfg.LocalControl = localControl
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return t.TempDir(), nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return cfg, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
var payload struct {
Tools []struct {
Name string `json:"name"`
Permission string `json:"permission"`
} `json:"tools"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil {
t.Fatalf("parse tools JSON: %v\n%s", err, stdout.String())
}
for _, tool := range payload.Tools {
if tool.Name == "browser_open" {
t.Fatalf("browser_open should be hidden when local control is disabled, got permission %s", tool.Permission)
}
}
}
func TestRegisterLocalControlToolsAppliesDefaults(t *testing.T) {
workspaceRoot := t.TempDir()
registry := newCoreRegistry(workspaceRoot)
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{Enabled: true})
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
tool, ok := registry.Get(name)
if !ok {
t.Fatalf("%s not registered", name)
}
if tool.Safety().Permission != tools.PermissionPrompt {
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
}
}
desktop, ok := registry.Get("desktop_action")
if !ok {
t.Fatal("desktop_action not registered")
}
if desktop.Safety().Permission != tools.PermissionDeny {
t.Fatalf("desktop_action permission = %s, want deny without nested desktop opt-in", desktop.Safety().Permission)
}
}
+4 -2
View File
@@ -51,7 +51,9 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.UpsertProvider(configPath, profile, options.setActive)
// Persist with the key moved into the encrypted credential store (capture flip);
// the local profile keeps the key for the verification build below.
cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
@@ -486,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
+2 -2
View File
@@ -31,8 +31,8 @@ func TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite(t *testing.T) {
deps, _ := sandboxCheckDeps(t)
// An absolute path outside the workspace, portable across OSes: a Unix literal
// like /etc/passwd is not absolute on Windows (it would be joined into the
// workspace and allowed), so build one under a separate temp dir instead.
outside := filepath.Join(t.TempDir(), "outside.txt")
// workspace and allowed), so build one under a non-temp test dir instead.
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
+112 -9
View File
@@ -510,15 +510,94 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
if err != nil {
t.Fatalf("read golden: %v", err)
}
if !jsonValuesEqual(t, wantBytes, []byte(got)) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), got)
if !jsonValuesEqual(t, wantBytes, gotBytes) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
}
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
t.Helper()
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope(%q): %v", workspace, err)
}
roots := scope.Roots()
if len(roots) <= 1 {
return gotBytes
}
tempRoots := map[string]struct{}{}
for _, root := range roots[1:] {
tempRoots[root] = struct{}{}
}
var value any
if err := json.Unmarshal(gotBytes, &value); err != nil {
t.Fatalf("decode got JSON for normalization: %v\n%s", err, string(gotBytes))
}
plan, _ := value.(map[string]any)["plan"].(map[string]any)
profile, _ := plan["permissionProfile"].(map[string]any)
fileSystem, _ := profile["fileSystem"].(map[string]any)
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
normalized, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("encode normalized policy JSON: %v", err)
}
return append(normalized, '\n')
}
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
return value
}
out := make([]any, 0, len(roots))
for _, root := range roots {
text, ok := root.(string)
if !ok {
out = append(out, root)
continue
}
if _, skip := excluded[text]; !skip {
out = append(out, root)
}
}
return out
}
func filterJSONWriteRoots(value any, excluded map[string]struct{}) any {
roots, ok := value.([]any)
if !ok {
return value
}
out := make([]any, 0, len(roots))
for _, root := range roots {
entry, ok := root.(map[string]any)
if !ok {
out = append(out, root)
continue
}
text, _ := entry["root"].(string)
if _, skip := excluded[text]; !skip {
out = append(out, root)
}
}
return out
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
t.Helper()
var wantValue any
@@ -529,9 +608,30 @@ func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
}
normalizePortableJSONRootString(&wantValue)
normalizePortableJSONRootString(&gotValue)
return reflect.DeepEqual(wantValue, gotValue)
}
func normalizePortableJSONRootString(value *any) {
switch current := (*value).(type) {
case string:
if current == `\` {
*value = "/"
}
case []any:
for index := range current {
normalizePortableJSONRootString(&current[index])
}
case map[string]any:
for key := range current {
entry := current[key]
normalizePortableJSONRootString(&entry)
current[key] = entry
}
}
}
func replacePathToken(value string, path string, token string) string {
replace := func(value string, path string) string {
value = strings.ReplaceAll(value, path, token)
@@ -654,7 +754,7 @@ func TestEffectiveSandboxPolicyShowsWriteRootsError(t *testing.T) {
func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
store := newSandboxTestStore(t)
extra := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
resolvedExtra, err := filepath.EvalSymlinks(extra)
if err != nil {
t.Fatalf("EvalSymlinks(%q) returned error: %v", extra, err)
@@ -693,11 +793,11 @@ func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
}
if len(payload.WriteRoots) != 2 {
t.Fatalf("writeRoots = %#v, want workspace root + extra root", payload.WriteRoots)
if len(payload.WriteRoots) < 2 {
t.Fatalf("writeRoots = %#v, want workspace root, extra root, and default temp roots", payload.WriteRoots)
}
if payload.WriteRoots[1] != resolvedExtra {
t.Fatalf("writeRoots[1] = %q, want %q", payload.WriteRoots[1], resolvedExtra)
if !containsString(payload.WriteRoots, resolvedExtra) {
t.Fatalf("writeRoots = %#v, want configured extra root %q", payload.WriteRoots, resolvedExtra)
}
if strings.Contains(stdout.String(), "writeRootsError") {
t.Fatalf("unexpected writeRootsError key for valid roots:\n%s", stdout.String())
@@ -745,8 +845,11 @@ func TestRunSandboxPolicyEffectiveWriteRootsFailSoft(t *testing.T) {
if !strings.Contains(payload.WriteRootsError, missing) {
t.Fatalf("writeRootsError = %q, want it to name the stale root %q", payload.WriteRootsError, missing)
}
if len(payload.WriteRoots) != 1 {
t.Fatalf("writeRoots = %#v, want workspace-only fallback", payload.WriteRoots)
if len(payload.WriteRoots) == 0 {
t.Fatalf("writeRoots = %#v, want workspace/default-temp fallback", payload.WriteRoots)
}
if containsString(payload.WriteRoots, missing) {
t.Fatalf("writeRoots = %#v, must not include stale root %q", payload.WriteRoots, missing)
}
}
+47 -1
View File
@@ -263,7 +263,9 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup
if err != nil {
return tui.SetupResult{}, err
}
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
// Persist with the key moved into the encrypted credential store (capture flip);
// the returned profile keeps the key for this run's immediate use.
if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil {
return tui.SetupResult{}, err
}
return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil
@@ -381,6 +383,50 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
return config.ProviderProfile{}, false
}
// usableSavedProviders filters configured providers to those the user can actually
// use: an inline/stored/env key, an auth header, a stored OAuth login, or a no-auth
// local provider. This keeps /model from listing providers that are merely present
// in config.json but never authenticated (e.g. a default openai entry with no key).
func usableSavedProviders(providers []config.ProviderProfile) []config.ProviderProfile {
logins := oauthLoggedInProviders()
store, storeErr := config.ProviderKeyStore()
usable := make([]config.ProviderProfile, 0, len(providers))
for _, profile := range providers {
if !config.HasProviderProfile(profile) {
continue
}
// A provider whose only credential is the APIKeyStored marker counts as usable
// only when the key is actually retrievable — the keyring/file entry may have
// been deleted, leaving a stale marker that would otherwise list it in /model.
if profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" {
if storeErr == nil {
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
usable = append(usable, profile)
continue
}
}
// Marker present but key missing/unreadable: fall through and judge the
// profile on its other signals (env var, OAuth, local) with the marker off.
withoutMarker := profile
withoutMarker.APIKeyStored = false
if _, missing := setupMissingCredentialEnv(withoutMarker); !missing {
usable = append(usable, profile)
} else if providerHasOAuthLogin(profile, logins) {
usable = append(usable, profile)
}
continue
}
if _, missing := setupMissingCredentialEnv(profile); !missing {
usable = append(usable, profile)
continue
}
if providerHasOAuthLogin(profile, logins) {
usable = append(usable, profile)
}
}
return usable
}
// providerProfileIsLocal reports whether a provider points at a local endpoint
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
// remote keyed provider that is more likely reachable.
+17 -2
View File
@@ -103,6 +103,9 @@ func TestSetupProviderOptionsUseRuntimeSupportedCatalog(t *testing.T) {
}
func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
// Use the encrypted-file backend in the temp config dir so the test never
// touches the real OS keychain.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
result, err := saveSetupProvider(appDeps{
@@ -132,8 +135,20 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
if len(cfg.Providers) != 1 {
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
}
if cfg.Providers[0].APIKey != "sk-pasted-secret" || cfg.Providers[0].APIKeyEnv != "" {
t.Fatalf("stored provider credentials = APIKey %q APIKeyEnv %q, want pasted key only", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
// The capture flip: config.json must NOT hold the cleartext key — it carries the
// APIKeyStored marker, and the secret lives in the co-located credential store.
if cfg.Providers[0].APIKey != "" || cfg.Providers[0].APIKeyEnv != "" {
t.Fatalf("config must not persist the key: APIKey %q APIKeyEnv %q", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatal("expected APIKeyStored marker in config")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
if key, ok, _ := store.Get("ollama-cloud"); !ok || key != "sk-pasted-secret" {
t.Fatalf("stored key = %q,%v; want sk-pasted-secret in the credential store", key, ok)
}
}
@@ -34,6 +34,7 @@
"fileSystem": {
"kind": "restricted",
"readRoots": [
"/",
"$WORKSPACE"
],
"writeRoots": [
+177
View File
@@ -0,0 +1,177 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/credstore"
)
// ProviderKeyStoreAt opens the encrypted credential store whose file backend lives
// in dir. The backend resolves keyring-first, then encrypted-file, with a plaintext
// opt-out via ZERO_CRED_STORAGE. The dir parameter exists so tests can point the file
// backend at a temp directory; production always uses the user config directory
// (ProviderKeyStore) because provider API keys are user-scoped by design — they are
// only ever captured under the user config, never project config (a cloned repo must
// not carry keys), so runtime lookups deliberately use the user store regardless of
// where a provider profile was resolved from.
func ProviderKeyStoreAt(dir string) (*credstore.Store, error) {
return credstore.New(credstore.Options{Dir: dir})
}
// ProviderKeyStore opens the credential store beside the default user config.
func ProviderKeyStore() (*credstore.Store, error) {
configPath, err := DefaultUserConfigPath()
if err != nil {
return nil, err
}
return ProviderKeyStoreAt(filepath.Dir(configPath))
}
// APIKeyGetter is the read surface of the credential store; *credstore.Store
// satisfies it. Kept here so callers can inject a fake in tests.
type APIKeyGetter interface {
Get(provider string) (string, bool, error)
}
// APIKeySetter is the write surface of the credential store.
type APIKeySetter interface {
Set(provider, key string) error
}
// SecureProviderProfile moves an inline APIKey on the profile into the credential
// store co-located with configPath, returning a profile with APIKeyStored set and
// APIKey cleared so the secret is never written to config.json. On any store error
// (e.g. no keyring) it returns the profile unchanged — the inline key persists and
// the startup migration retries later — so capturing a provider never fails on a
// flaky credential backend.
func SecureProviderProfile(profile ProviderProfile, configPath string) ProviderProfile {
if strings.TrimSpace(profile.APIKey) == "" || strings.TrimSpace(profile.Name) == "" {
return profile
}
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return profile
}
if err := store.Set(profile.Name, profile.APIKey); err != nil {
return profile
}
secured := profile
secured.APIKey = ""
secured.APIKeyStored = true
return secured
}
// ForgetProviderKey removes a provider's stored API key from the credential store,
// reporting whether one existed. Used by the lifecycle "remove key" / auth logout.
func ForgetProviderKey(provider string) (bool, error) {
store, err := ProviderKeyStore()
if err != nil {
return false, err
}
return store.Delete(provider)
}
// ClearProviderKeyStored unsets the APIKeyStored marker for a provider in the
// config at path, so credential checks no longer claim a stored key after one is
// removed. No-op when path/provider is empty, the config is absent, or the marker
// is already unset.
func ClearProviderKeyStored(path, provider string) (bool, error) {
path = strings.TrimSpace(path)
provider = strings.TrimSpace(provider)
if path == "" || provider == "" {
return false, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("read config %s: %w", path, err)
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return false, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
changed := false
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) && cfg.Providers[index].APIKeyStored {
cfg.Providers[index].APIKeyStored = false
changed = true
}
}
if !changed {
return false, nil
}
return true, writeConfigFile(path, cfg)
}
// MigratePlaintextProviderKeys moves any inline plaintext API key in the config at
// path into the credential store, marking the profile APIKeyStored and stripping
// the inline secret — but ONLY after the store write succeeds, so a failed Set
// leaves the plaintext key in place and never strands a credential. Returns how
// many keys were migrated; a no-op (0, nil) when path is empty/absent or nothing
// needs migrating. Safe to run on every startup (idempotent).
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) {
path = strings.TrimSpace(path)
if path == "" || store == nil {
return 0, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("read config %s: %w", path, err)
}
var cfg FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return 0, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
migrated := 0
for index := range cfg.Providers {
profile := &cfg.Providers[index]
key := strings.TrimSpace(profile.APIKey)
if key == "" || strings.TrimSpace(profile.Name) == "" {
continue
}
if err := store.Set(profile.Name, key); err != nil {
// Leave the plaintext key untouched; a failed Set must not strand it.
continue
}
profile.APIKey = ""
profile.APIKeyStored = true
migrated++
}
if migrated == 0 {
return 0, nil
}
if err := writeConfigFile(path, cfg); err != nil {
return migrated, fmt.Errorf("rewrite config after migration %s: %w", path, err)
}
return migrated, nil
}
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
// this only supplies a key for providers whose secret lives in the store. A nil
// store, a miss, or a store error leaves the profile unchanged.
func ApplyStoredAPIKey(profile ProviderProfile, store APIKeyGetter) ProviderProfile {
// Only load for profiles that opted into stored-key auth (APIKeyStored). Without
// this gate a stale keyring/file entry could silently reactivate credentials for a
// profile that no longer uses the store.
if store == nil || !profile.APIKeyStored || strings.TrimSpace(profile.APIKey) != "" {
return profile
}
name := strings.TrimSpace(profile.Name)
if name == "" {
return profile
}
if key, ok, err := store.Get(name); err == nil && ok && strings.TrimSpace(key) != "" {
profile.APIKey = key
}
return profile
}
+174
View File
@@ -0,0 +1,174 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
type fakeKeyGetter struct {
keys map[string]string
err error
}
func (f fakeKeyGetter) Get(provider string) (string, bool, error) {
if f.err != nil {
return "", false, f.err
}
v, ok := f.keys[provider]
return v, ok, nil
}
// fakeKeySetter records Set calls; setErr (when non-nil) makes Set fail.
type fakeKeySetter struct {
keys map[string]string
setErr error
}
func (f *fakeKeySetter) Set(provider, key string) error {
if f.setErr != nil {
return f.setErr
}
f.keys[provider] = key
return nil
}
func TestApplyStoredAPIKey(t *testing.T) {
store := fakeKeyGetter{keys: map[string]string{"openai": "sk-stored"}}
// Stored marker + empty key => filled from the store.
got := ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, store)
if got.APIKey != "sk-stored" {
t.Fatalf("expected stored key to fill empty APIKey, got %q", got.APIKey)
}
// No APIKeyStored marker => store is NOT consulted (don't reactivate a stale key).
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai"}, store)
if got.APIKey != "" {
t.Fatalf("expected no load without APIKeyStored, got %q", got.APIKey)
}
// Inline key present => store is NOT consulted (inline wins).
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true, APIKey: "sk-inline"}, store)
if got.APIKey != "sk-inline" {
t.Fatalf("inline key must win, got %q", got.APIKey)
}
// Marker set but no stored key for this provider => unchanged (empty).
got = ApplyStoredAPIKey(ProviderProfile{Name: "anthropic", APIKeyStored: true}, store)
if got.APIKey != "" {
t.Fatalf("expected no key for unstored provider, got %q", got.APIKey)
}
// Nil store => unchanged.
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, nil)
if got.APIKey != "" {
t.Fatalf("nil store must leave profile unchanged, got %q", got.APIKey)
}
// Empty name => unchanged (don't query the store).
got = ApplyStoredAPIKey(ProviderProfile{APIKeyStored: true}, store)
if got.APIKey != "" {
t.Fatalf("empty name must not be filled, got %q", got.APIKey)
}
}
func TestMigratePlaintextProviderKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
cfgJSON := `{"activeProvider":"openai","providers":[` +
`{"name":"openai","apiKey":"sk-PLAINTEXT","model":"gpt"},` +
`{"name":"local","baseURL":"http://localhost"},` +
`{"name":"acme","apiKeyEnv":"ACME_KEY"}]}`
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
t.Fatal(err)
}
store := &fakeKeySetter{keys: map[string]string{}}
n, err := MigratePlaintextProviderKeys(path, store)
if err != nil || n != 1 {
t.Fatalf("migrate = %d,%v; want 1,nil", n, err)
}
if store.keys["openai"] != "sk-PLAINTEXT" {
t.Fatalf("key not moved to store: %v", store.keys)
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), "sk-PLAINTEXT") {
t.Fatalf("plaintext key still in config.json:\n%s", raw)
}
if !strings.Contains(string(raw), "apiKeyStored") {
t.Fatalf("apiKeyStored marker not written:\n%s", raw)
}
// Idempotent: a second run migrates nothing.
if n2, _ := MigratePlaintextProviderKeys(path, store); n2 != 0 {
t.Fatalf("second migrate = %d, want 0", n2)
}
}
func TestMigrateLeavesKeyWhenStoreSetFails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKey":"sk-KEEP"}]}`), 0o600); err != nil {
t.Fatal(err)
}
store := &fakeKeySetter{keys: map[string]string{}, setErr: errors.New("keychain locked")}
n, err := MigratePlaintextProviderKeys(path, store)
if err != nil || n != 0 {
t.Fatalf("migrate with failing store = %d,%v; want 0,nil", n, err)
}
raw, _ := os.ReadFile(path)
if !strings.Contains(string(raw), "sk-KEEP") {
t.Fatalf("a failed Set must not strand the key; config.json:\n%s", raw)
}
}
func TestClearProviderKeyStored(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKeyStored":true},{"name":"other","apiKeyStored":true}]}`), 0o600); err != nil {
t.Fatal(err)
}
cleared, err := ClearProviderKeyStored(path, "openai")
if err != nil || !cleared {
t.Fatalf("clear = %v,%v; want true,nil", cleared, err)
}
raw, _ := os.ReadFile(path)
// openai's marker is gone; other's remains.
var cfg FileConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
for _, p := range cfg.Providers {
if p.Name == "openai" && p.APIKeyStored {
t.Fatal("openai marker should be cleared")
}
if p.Name == "other" && !p.APIKeyStored {
t.Fatal("other marker should be untouched")
}
}
// Idempotent: clearing again reports no change.
if cleared, _ := ClearProviderKeyStored(path, "openai"); cleared {
t.Fatal("second clear should report no change")
}
// Unknown provider: no change.
if cleared, _ := ClearProviderKeyStored(path, "nope"); cleared {
t.Fatal("unknown provider should report no change")
}
}
func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
// The apiKeyStored marker survives JSON decode (custom UnmarshalJSON).
var p ProviderProfile
if err := p.UnmarshalJSON([]byte(`{"name":"openai","apiKeyStored":true}`)); err != nil {
t.Fatal(err)
}
if !p.APIKeyStored {
t.Fatal("expected apiKeyStored=true to decode")
}
if p.APIKey != "" {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
+130
View File
@@ -0,0 +1,130 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveLocalControlFromUserConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{
"localControl": {
"enabled": true,
"browser": {"driver": "agent-browser"},
"desktop": {"enabled": true},
"terminal": {"enabled": false}
}
}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if !resolved.LocalControl.Enabled {
t.Fatal("localControl.enabled = false, want true")
}
if !resolved.LocalControl.BrowserEnabled() {
t.Fatal("BrowserEnabled = false, want true by default under localControl.enabled")
}
if !resolved.LocalControl.DesktopEnabled() {
t.Fatal("DesktopEnabled = false, want explicit true")
}
if resolved.LocalControl.TerminalEnabled() {
t.Fatal("TerminalEnabled = true, want explicit false")
}
if resolved.LocalControl.Browser.Driver != "agent-browser" {
t.Fatalf("browser driver = %q, want agent-browser", resolved.LocalControl.Browser.Driver)
}
}
func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
projectPath := filepath.Join(t.TempDir(), "project.json")
if err := os.WriteFile(projectPath, []byte(`{"localControl":{"enabled":true,"desktop":{"enabled":true}}}`), 0o600); err != nil {
t.Fatalf("write project config: %v", err)
}
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if resolved.LocalControl.Enabled || resolved.LocalControl.DesktopEnabled() {
t.Fatalf("project config enabled gated local control: %#v", resolved.LocalControl)
}
if !resolved.LocalControl.BrowserEnabled() || !resolved.LocalControl.TerminalEnabled() {
t.Fatalf("browser/terminal should remain available by default: %#v", resolved.LocalControl)
}
}
func TestLocalControlDefaultsBrowserAndTerminalOn(t *testing.T) {
resolved, err := Resolve(ResolveOptions{})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if !resolved.LocalControl.BrowserEnabled() {
t.Fatal("BrowserEnabled = false, want default true")
}
if !resolved.LocalControl.TerminalEnabled() {
t.Fatal("TerminalEnabled = false, want default true")
}
if resolved.LocalControl.DesktopEnabled() {
t.Fatal("DesktopEnabled = true, want explicit opt-in")
}
}
func TestLocalControlExplicitGlobalDisable(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"localControl":{"enabled":false}}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if resolved.LocalControl.BrowserEnabled() || resolved.LocalControl.TerminalEnabled() || resolved.LocalControl.DesktopEnabled() {
t.Fatalf("local control not disabled globally: %#v", resolved.LocalControl)
}
}
func TestLocalControlExplicitFalseSurvivesConfigRewrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{
"activeProvider": "openai",
"providers": [{"name": "openai", "provider_kind": "openai", "model": "gpt-4.1"}],
"localControl": {
"enabled": true,
"browser": {"enabled": false},
"terminal": {"enabled": false}
}
}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := SetFavoriteModels(path, []string{"gpt-4.1"}); err != nil {
t.Fatalf("SetFavoriteModels returned error: %v", err)
}
persisted := readConfigFixture(t, path)
if !persisted.LocalControl.enabledSet || !persisted.LocalControl.Enabled {
t.Fatalf("localControl enabled not preserved: %#v", persisted.LocalControl)
}
if !persisted.LocalControl.Browser.enabledSet || persisted.LocalControl.Browser.Enabled {
t.Fatalf("browser explicit false not preserved: %#v", persisted.LocalControl.Browser)
}
if !persisted.LocalControl.Terminal.enabledSet || persisted.LocalControl.Terminal.Enabled {
t.Fatalf("terminal explicit false not preserved: %#v", persisted.LocalControl.Terminal)
}
}
func TestEmptyLocalControlIsOmittedFromConfigJSON(t *testing.T) {
data, err := json.Marshal(FileConfig{})
if err != nil {
t.Fatalf("marshal config: %v", err)
}
if strings.Contains(string(data), "localControl") {
t.Fatalf("empty localControl should be omitted, got %s", string(data))
}
}
+35
View File
@@ -109,6 +109,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
LocalControl: cfg.LocalControl,
}, nil
}
@@ -182,6 +183,10 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.FavoriteModels != nil {
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
}
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -225,6 +230,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
// appear in the model's tool surface.
return nil
}
@@ -555,6 +563,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
cfg.Tools.deferThresholdSet = true
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -564,6 +573,32 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
mergeMCPConfig(&cfg.MCP, overrides.MCP)
}
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
if src.enabledSet {
dst.Enabled = src.Enabled
dst.enabledSet = true
}
mergeLocalControlDriverConfig(&dst.Browser, src.Browser)
mergeLocalControlDriverConfig(&dst.Desktop, src.Desktop)
mergeLocalControlDriverConfig(&dst.Terminal, src.Terminal)
if artifactsDir := strings.TrimSpace(src.ArtifactsDir); artifactsDir != "" {
dst.ArtifactsDir = artifactsDir
}
}
func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalControlDriverConfig) {
if src.enabledSet {
dst.Enabled = src.Enabled
dst.enabledSet = true
}
if helperPath := strings.TrimSpace(src.HelperPath); helperPath != "" {
dst.HelperPath = helperPath
}
if driver := strings.TrimSpace(src.Driver); driver != "" {
dst.Driver = driver
}
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
+207 -16
View File
@@ -21,13 +21,18 @@ const (
)
type ProviderProfile struct {
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
CatalogID string `json:"catalogID,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
Name string `json:"name"`
Provider string `json:"provider,omitempty"`
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
CatalogID string `json:"catalogID,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
APIKey string `json:"apiKey,omitempty"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
// APIKeyStored marks that this provider's API key lives in the encrypted
// credential store (internal/credstore), not inline in APIKey. The effective
// key is loaded from the store at provider-build time; config.json holds only
// this marker, never the secret.
APIKeyStored bool `json:"apiKeyStored,omitempty"`
APIFormat string `json:"apiFormat,omitempty"`
AuthHeader string `json:"authHeader,omitempty"`
AuthScheme string `json:"authScheme,omitempty"`
@@ -92,6 +97,66 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
}
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
// Helpers are discovered lazily by the tool that needs them; no setup command or
// background probe is required during startup.
type LocalControlConfig struct {
Enabled bool `json:"enabled,omitempty"`
Browser LocalControlDriverConfig `json:"browser,omitempty"`
Desktop LocalControlDriverConfig `json:"desktop,omitempty"`
Terminal LocalControlDriverConfig `json:"terminal,omitempty"`
ArtifactsDir string `json:"artifactsDir,omitempty"`
enabledSet bool
}
type LocalControlDriverConfig struct {
Enabled bool `json:"enabled,omitempty"`
HelperPath string `json:"helperPath,omitempty"`
Driver string `json:"driver,omitempty"`
enabledSet bool
}
func (cfg LocalControlConfig) BrowserEnabled() bool {
return !cfg.Disabled() && (!cfg.Browser.enabledSet || cfg.Browser.Enabled)
}
func (cfg LocalControlConfig) DesktopEnabled() bool {
return !cfg.Disabled() && cfg.Desktop.enabledSet && cfg.Desktop.Enabled
}
func (cfg LocalControlConfig) TerminalEnabled() bool {
return !cfg.Disabled() && (!cfg.Terminal.enabledSet || cfg.Terminal.Enabled)
}
func (cfg LocalControlConfig) Disabled() bool {
return cfg.enabledSet && !cfg.Enabled
}
func (cfg LocalControlConfig) Empty() bool {
return !cfg.Enabled &&
!cfg.enabledSet &&
cfg.ArtifactsDir == "" &&
cfg.Browser.Empty() &&
cfg.Desktop.Empty() &&
cfg.Terminal.Empty()
}
func (cfg LocalControlDriverConfig) Empty() bool {
return !cfg.Enabled &&
!cfg.enabledSet &&
cfg.HelperPath == "" &&
cfg.Driver == ""
}
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
@@ -128,15 +193,46 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
}
type FileConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
}
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
type rawConfig struct {
ActiveProvider string `json:"activeProvider,omitempty"`
Providers []ProviderProfile `json:"providers,omitempty"`
MaxTurns int `json:"maxTurns,omitempty"`
MCP MCPConfig `json:"mcp,omitempty"`
Sandbox SandboxConfig `json:"sandbox,omitempty"`
Notify NotifyConfig `json:"notify,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
}
raw := rawConfig{
ActiveProvider: cfg.ActiveProvider,
Providers: cfg.Providers,
MaxTurns: cfg.MaxTurns,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
}
return json.Marshal(raw)
}
type ResolveOptions struct {
@@ -156,6 +252,7 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
LocalControl LocalControlConfig
}
type ResolvedConfig struct {
@@ -169,6 +266,7 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
LocalControl LocalControlConfig
}
type MCPConfig struct {
@@ -214,6 +312,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
LocalControl LocalControlConfig `json:"localControl"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
}
@@ -239,6 +338,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
cfg.LocalControl = raw.LocalControl
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
}
@@ -254,6 +354,94 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
return nil
}
func (cfg *LocalControlConfig) UnmarshalJSON(data []byte) error {
type rawLocalControl struct {
Enabled *bool `json:"enabled"`
Browser LocalControlDriverConfig `json:"browser"`
Desktop LocalControlDriverConfig `json:"desktop"`
Terminal LocalControlDriverConfig `json:"terminal"`
ArtifactsDir string `json:"artifactsDir"`
}
var raw rawLocalControl
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*cfg = LocalControlConfig{
Browser: raw.Browser,
Desktop: raw.Desktop,
Terminal: raw.Terminal,
ArtifactsDir: raw.ArtifactsDir,
}
if raw.Enabled != nil {
cfg.Enabled = *raw.Enabled
cfg.enabledSet = true
}
return nil
}
func (cfg LocalControlConfig) MarshalJSON() ([]byte, error) {
type rawLocalControl struct {
Enabled *bool `json:"enabled,omitempty"`
Browser *LocalControlDriverConfig `json:"browser,omitempty"`
Desktop *LocalControlDriverConfig `json:"desktop,omitempty"`
Terminal *LocalControlDriverConfig `json:"terminal,omitempty"`
ArtifactsDir string `json:"artifactsDir,omitempty"`
}
raw := rawLocalControl{
ArtifactsDir: cfg.ArtifactsDir,
}
if cfg.enabledSet || cfg.Enabled {
raw.Enabled = &cfg.Enabled
}
if !cfg.Browser.Empty() {
raw.Browser = &cfg.Browser
}
if !cfg.Desktop.Empty() {
raw.Desktop = &cfg.Desktop
}
if !cfg.Terminal.Empty() {
raw.Terminal = &cfg.Terminal
}
return json.Marshal(raw)
}
func (cfg *LocalControlDriverConfig) UnmarshalJSON(data []byte) error {
type rawDriver struct {
Enabled *bool `json:"enabled"`
HelperPath string `json:"helperPath"`
Driver string `json:"driver"`
}
var raw rawDriver
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*cfg = LocalControlDriverConfig{
HelperPath: raw.HelperPath,
Driver: raw.Driver,
}
if raw.Enabled != nil {
cfg.Enabled = *raw.Enabled
cfg.enabledSet = true
}
return nil
}
func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
type rawDriver struct {
Enabled *bool `json:"enabled,omitempty"`
HelperPath string `json:"helperPath,omitempty"`
Driver string `json:"driver,omitempty"`
}
raw := rawDriver{
HelperPath: cfg.HelperPath,
Driver: cfg.Driver,
}
if cfg.enabledSet || cfg.Enabled {
raw.Enabled = &cfg.Enabled
}
return json.Marshal(raw)
}
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
type rawServer struct {
Type string `json:"type"`
@@ -302,6 +490,8 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
APIKeySnake string `json:"api_key"`
APIKeyEnv string `json:"apiKeyEnv"`
APIKeyEnvSnake string `json:"api_key_env"`
APIKeyStored bool `json:"apiKeyStored"`
APIKeyStoredSnake bool `json:"api_key_stored"`
APIFormat string `json:"apiFormat"`
APIFormatSnake string `json:"api_format"`
AuthHeader string `json:"authHeader"`
@@ -331,6 +521,7 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
profile.APIKeyEnv = strings.TrimSpace(firstNonEmpty(raw.APIKeyEnv, raw.APIKeyEnvSnake))
profile.APIKeyStored = raw.APIKeyStored || raw.APIKeyStoredSnake
profile.APIFormat = strings.TrimSpace(firstNonEmpty(raw.APIFormat, raw.APIFormatSnake))
profile.AuthHeader = strings.TrimSpace(firstNonEmpty(raw.AuthHeader, raw.AuthHeaderSnake))
profile.AuthScheme = strings.TrimSpace(firstNonEmpty(raw.AuthScheme, raw.AuthSchemeSnake))
+23
View File
@@ -131,6 +131,29 @@ func SetFavoriteModels(path string, models []string) (FileConfig, error) {
return cfg, nil
}
// SetRecapsEnabled persists the post-turn recap preference, mirroring
// SetFavoriteModels (read-modify-atomic-write).
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
v := enabled
cfg.Preferences.Recaps = &v
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
+38
View File
@@ -272,6 +272,44 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
t.Error("unset recaps should default to ON")
}
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
// Persist OFF, then read it back.
cfg, err := SetRecapsEnabled(path, false)
if err != nil {
t.Fatalf("SetRecapsEnabled(false) error = %v", err)
}
if cfg.Preferences.RecapsEnabled() {
t.Error("after SetRecapsEnabled(false), RecapsEnabled() should be false")
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Recaps == nil || *persisted.Preferences.Recaps {
t.Errorf("persisted recaps should be explicit false, got %v", persisted.Preferences.Recaps)
}
if persisted.ActiveProvider != "openai" {
t.Errorf("unrelated config must be preserved, got %q", persisted.ActiveProvider)
}
// Flip back ON — the write must succeed and persist an explicit true.
cfg, err = SetRecapsEnabled(path, true)
if err != nil {
t.Fatalf("SetRecapsEnabled(true) error = %v", err)
}
if !cfg.Preferences.RecapsEnabled() {
t.Error("after SetRecapsEnabled(true), RecapsEnabled() should be true")
}
if reread := readConfigFixture(t, path); reread.Preferences.Recaps == nil || !*reread.Preferences.Recaps {
t.Errorf("re-enable should persist an explicit true, got %v", reread.Preferences.Recaps)
}
}
func TestSetFavoriteModelsCreatesMissingConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero", "config.json")
+6 -1
View File
@@ -221,7 +221,12 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
return all[left].Name() < all[right].Name()
})
total := 0
count := 0
for _, tool := range all {
if tool.Safety().Permission == tools.PermissionDeny {
continue
}
count++
total += estimateTextTokens(tool.Name())
total += estimateTextTokens(tool.Description())
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
@@ -229,7 +234,7 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
}
total += toolDefinitionOverheadTokens
}
return len(all), total
return count, total
}
func readProjectGuidelines(root string, names []string) (string, string) {
+265
View File
@@ -0,0 +1,265 @@
// Package credstore persists provider API keys outside config.json so secrets are
// not written to disk in cleartext. It resolves one backend at construction: the OS
// keyring on macOS (the `security` keychain), otherwise an AES-256-GCM encrypted file
// (the Linux secret-tool keyring needs a running secret service that is absent on
// headless/CI machines, so it is opt-in via ZERO_CRED_STORAGE=keyring). A plaintext
// file is available only as an explicit ZERO_CRED_STORAGE=file opt-out. OAuth tokens
// stay in internal/oauth; this store is for raw API keys.
package credstore
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/keyring"
"github.com/Gitlawb/zero/internal/securefile"
)
const (
keyringService = "zero"
keyringPrefix = "apikey:" // keyring account = "apikey:<provider>"
encryptedFileName = "credentials.enc"
plaintextFileName = "credentials.json"
)
// KeyringClient is the minimal OS-keyring surface the store needs;
// *keyring.Keyring satisfies it. Injectable for tests.
type KeyringClient interface {
Available() bool
Set(service, account, secret string) error
Get(service, account string) (string, bool, error)
Delete(service, account string) (bool, error)
}
// Options configures the credential store.
type Options struct {
// Dir is the per-user data directory for the file backends.
Dir string
// Storage selects the backend: "" (auto), "keyring", "encrypted-file", or "file"
// (plaintext opt-out). When empty it falls back to ZERO_CRED_STORAGE, then auto:
// keyring on macOS (the `security` keychain is reliable and needs no daemon),
// encrypted-file everywhere else (Linux `secret-tool` needs a running secret
// service that is absent on headless/CI boxes and would block/error). Linux/other
// users can still opt into the keyring with ZERO_CRED_STORAGE=keyring.
Storage string
// Keyring is the keyring client; nil => keyring.New().
Keyring KeyringClient
// Env reads environment variables; nil => os.Getenv.
Env func(string) string
// GOOS overrides the platform for auto backend resolution; "" => runtime.GOOS.
// Exists so tests can exercise the per-OS default deterministically.
GOOS string
}
// Store reads/writes provider API keys via the resolved backend.
type Store struct {
backend string // "keyring", "encrypted-file", or "file"
kr KeyringClient
file string
crypter *securefile.Crypter // non-nil only for "encrypted-file"
}
// New resolves the backend. It never silently downgrades to plaintext: "file" must
// be chosen explicitly; auto mode picks keyring then encrypted-file.
func New(options Options) (*Store, error) {
env := options.Env
if env == nil {
env = os.Getenv
}
kr := options.Keyring
if kr == nil {
kr = keyring.New()
}
goos := strings.TrimSpace(options.GOOS)
if goos == "" {
goos = runtime.GOOS
}
storage := strings.TrimSpace(options.Storage)
if storage == "" {
storage = strings.TrimSpace(env("ZERO_CRED_STORAGE"))
}
if storage == "" {
// Keyring is the default only on macOS, where `security` is reliable and needs
// no running daemon. Elsewhere (Linux secret-tool, headless/CI) default to the
// encrypted file; keyring stays available via an explicit ZERO_CRED_STORAGE.
if goos == "darwin" && kr.Available() {
storage = "keyring"
} else {
storage = "encrypted-file"
}
}
switch storage {
case "keyring":
if !kr.Available() {
return nil, fmt.Errorf("credstore: keyring requested but no OS keyring backend on this platform; use encrypted-file")
}
return &Store{backend: "keyring", kr: kr}, nil
case "encrypted-file":
if strings.TrimSpace(options.Dir) == "" {
return nil, fmt.Errorf("credstore: Dir is required for encrypted-file storage")
}
path := filepath.Join(options.Dir, encryptedFileName)
return &Store{backend: "encrypted-file", file: path, crypter: securefile.NewCrypter(path + ".secret")}, nil
case "file":
if strings.TrimSpace(options.Dir) == "" {
return nil, fmt.Errorf("credstore: Dir is required for file storage")
}
return &Store{backend: "file", file: filepath.Join(options.Dir, plaintextFileName)}, nil
default:
return nil, fmt.Errorf("credstore: unknown storage %q (want \"keyring\", \"encrypted-file\", or \"file\")", storage)
}
}
// Backend reports the resolved backend ("keyring", "encrypted-file", "file"), for
// display ("how is this key stored").
func (s *Store) Backend() string { return s.backend }
// Encrypted reports whether secrets are protected at rest (keyring or AES file).
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
// Set stores (or overwrites) the API key for provider.
func (s *Store) Set(provider, key string) error {
provider = normalizeProvider(provider)
if provider == "" {
return fmt.Errorf("credstore: provider is required")
}
if s.backend == "keyring" {
return s.kr.Set(keyringService, keyringPrefix+provider, key)
}
data, err := s.read()
if err != nil {
return err
}
data[provider] = key
return s.write(data)
}
// Get returns the API key for provider and whether one is stored.
func (s *Store) Get(provider string) (string, bool, error) {
provider = normalizeProvider(provider)
if provider == "" {
return "", false, nil
}
if s.backend == "keyring" {
return s.kr.Get(keyringService, keyringPrefix+provider)
}
data, err := s.read()
if err != nil {
return "", false, err
}
key, ok := data[provider]
return key, ok, nil
}
// Delete removes the API key for provider, reporting whether one existed.
func (s *Store) Delete(provider string) (bool, error) {
provider = normalizeProvider(provider)
if provider == "" {
return false, nil
}
if s.backend == "keyring" {
return s.kr.Delete(keyringService, keyringPrefix+provider)
}
data, err := s.read()
if err != nil {
return false, err
}
if _, ok := data[provider]; !ok {
return false, nil
}
delete(data, provider)
return true, s.write(data)
}
// Providers lists providers with a stored key (sorted), for display/audit.
func (s *Store) Providers() ([]string, error) {
if s.backend == "keyring" {
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
// that need a list pass known provider names to Get. Return empty here.
return nil, nil
}
data, err := s.read()
if err != nil {
return nil, err
}
names := make([]string, 0, len(data))
for name := range data {
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
// --- file backends (encrypted-file / file) ---------------------------------
func (s *Store) read() (map[string]string, error) {
raw, err := os.ReadFile(s.file)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, fmt.Errorf("credstore: read %s: %w", s.file, err)
}
if s.crypter != nil {
raw, err = s.crypter.Open(raw)
if err != nil {
return nil, err
}
}
data := map[string]string{}
if len(raw) == 0 {
return data, nil
}
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("credstore: parse %s: %w", s.file, err)
}
return data, nil
}
func (s *Store) write(data map[string]string) error {
payload, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("credstore: encode: %w", err)
}
if s.crypter != nil {
payload, err = s.crypter.Seal(payload)
if err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Dir(s.file), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(s.file), filepath.Base(s.file)+".*.tmp")
if err != nil {
return fmt.Errorf("credstore: temp file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("credstore: chmod: %w", err)
}
if _, err := tmp.Write(payload); err != nil {
_ = tmp.Close()
return fmt.Errorf("credstore: write: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("credstore: write: %w", err)
}
if err := os.Rename(tmpPath, s.file); err != nil {
return fmt.Errorf("credstore: publish: %w", err)
}
return nil
}
func normalizeProvider(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
+174
View File
@@ -0,0 +1,174 @@
package credstore
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// fakeKeyring is an in-memory KeyringClient so tests never touch the real OS
// keychain.
type fakeKeyring struct {
available bool
data map[string]string
}
func newFakeKeyring(available bool) *fakeKeyring {
return &fakeKeyring{available: available, data: map[string]string{}}
}
func (f *fakeKeyring) Available() bool { return f.available }
func (f *fakeKeyring) Set(service, account, secret string) error {
f.data[service+"/"+account] = secret
return nil
}
func (f *fakeKeyring) Get(service, account string) (string, bool, error) {
v, ok := f.data[service+"/"+account]
return v, ok, nil
}
func (f *fakeKeyring) Delete(service, account string) (bool, error) {
k := service + "/" + account
_, ok := f.data[k]
delete(f.data, k)
return ok, nil
}
func noEnv(string) string { return "" }
func roundTrip(t *testing.T, s *Store) {
t.Helper()
if _, ok, _ := s.Get("openai"); ok {
t.Fatal("expected no key initially")
}
if err := s.Set("OpenAI", "sk-abc-123"); err != nil { // mixed case normalizes
t.Fatalf("set: %v", err)
}
got, ok, err := s.Get("openai")
if err != nil || !ok || got != "sk-abc-123" {
t.Fatalf("get = %q,%v,%v; want sk-abc-123,true,nil", got, ok, err)
}
if err := s.Set("openai", "sk-new-456"); err != nil { // overwrite
t.Fatal(err)
}
if got, _, _ := s.Get("openai"); got != "sk-new-456" {
t.Fatalf("overwrite get = %q, want sk-new-456", got)
}
existed, err := s.Delete("openai")
if err != nil || !existed {
t.Fatalf("delete = %v,%v; want true,nil", existed, err)
}
if _, ok, _ := s.Get("openai"); ok {
t.Fatal("expected key gone after delete")
}
if existed, _ := s.Delete("openai"); existed {
t.Fatal("delete of absent key should report not-existed")
}
}
func TestKeyringBackendRoundTrip(t *testing.T) {
s, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(true), Env: noEnv})
if err != nil {
t.Fatal(err)
}
if s.Backend() != "keyring" || !s.Encrypted() {
t.Fatalf("backend=%q encrypted=%v", s.Backend(), s.Encrypted())
}
roundTrip(t, s)
}
func TestEncryptedFileBackendRoundTripAndAtRest(t *testing.T) {
dir := t.TempDir()
s, err := New(Options{Storage: "encrypted-file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
if err != nil {
t.Fatal(err)
}
if !s.Encrypted() {
t.Fatal("encrypted-file must report Encrypted()")
}
if err := s.Set("anthropic", "sk-ant-SECRET"); err != nil {
t.Fatal(err)
}
// The on-disk file must NOT contain the plaintext key, and must be 0600.
raw, err := os.ReadFile(filepath.Join(dir, encryptedFileName))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "sk-ant-SECRET") {
t.Fatal("encrypted-file leaked the plaintext key on disk")
}
if runtime.GOOS != "windows" { // Windows doesn't honor Unix permission bits
info, _ := os.Stat(filepath.Join(dir, encryptedFileName))
if info.Mode().Perm() != 0o600 {
t.Fatalf("perm = %o, want 600", info.Mode().Perm())
}
}
if got, ok, _ := s.Get("anthropic"); !ok || got != "sk-ant-SECRET" {
t.Fatalf("get = %q,%v", got, ok)
}
roundTrip(t, s)
}
func TestPlaintextFileBackendIsOptOut(t *testing.T) {
dir := t.TempDir()
s, err := New(Options{Storage: "file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
if err != nil {
t.Fatal(err)
}
if s.Encrypted() {
t.Fatal("plaintext file must NOT report Encrypted()")
}
roundTrip(t, s)
}
func TestAutoPrefersKeyringThenEncryptedFile(t *testing.T) {
// macOS + keyring available -> keyring
s, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "darwin", Env: noEnv})
if err != nil || s.Backend() != "keyring" {
t.Fatalf("auto on darwin with keyring = %q,%v; want keyring", s.Backend(), err)
}
// macOS but keyring unavailable -> encrypted-file (never plaintext)
s2, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(false), GOOS: "darwin", Env: noEnv})
if err != nil || s2.Backend() != "encrypted-file" {
t.Fatalf("auto on darwin without keyring = %q,%v; want encrypted-file", s2.Backend(), err)
}
// Non-macOS (e.g. Linux) -> encrypted-file even when a keyring is available, since
// secret-tool needs a running secret service (keyring stays opt-in via env).
s3, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "linux", Env: noEnv})
if err != nil || s3.Backend() != "encrypted-file" {
t.Fatalf("auto on linux = %q,%v; want encrypted-file", s3.Backend(), err)
}
}
func TestKeyringRequestedButUnavailableErrors(t *testing.T) {
if _, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(false), Env: noEnv}); err == nil {
t.Fatal("expected error when keyring requested but unavailable")
}
}
func TestEnvSelectsStorage(t *testing.T) {
dir := t.TempDir()
env := func(k string) string {
if k == "ZERO_CRED_STORAGE" {
return "encrypted-file"
}
return ""
}
s, err := New(Options{Dir: dir, Keyring: newFakeKeyring(true), Env: env}) // keyring available, but env forces encrypted-file
if err != nil || s.Backend() != "encrypted-file" {
t.Fatalf("env override = %q,%v; want encrypted-file", s.Backend(), err)
}
}
func TestProvidersListsFileBackend(t *testing.T) {
s, _ := New(Options{Storage: "encrypted-file", Dir: t.TempDir(), Keyring: newFakeKeyring(false), Env: noEnv})
_ = s.Set("openai", "a")
_ = s.Set("gemini", "b")
names, err := s.Providers()
if err != nil {
t.Fatal(err)
}
if len(names) != 2 || names[0] != "gemini" || names[1] != "openai" {
t.Fatalf("providers = %v, want [gemini openai]", names)
}
}
@@ -31,6 +31,9 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
`tar -xzf "$archive_path" -C "$extract_dir"`,
`find_extracted_binary "$extract_dir"`,
`cp "$binary_path" "$ZERO_INSTALL_DIR/zero"`,
`copy_optional_file "zero-linux-sandbox"`,
`copy_optional_file "zero-seccomp"`,
`copy_optional_dir "helpers"`,
})
}
@@ -48,6 +51,8 @@ func TestPowerShellInstallerScriptMatchesWindowsReleaseContracts(t *testing.T) {
`"zero-windows-command-runner.exe"`,
`"zero-windows-sandbox-setup.exe"`,
`Copy-Item -Path $sourcePath -Destination (Join-Path $InstallDir $fileName) -Force`,
`Find-ZeroOptionalExtractedDirectory -Root $extractDir -DirectoryName "helpers"`,
`Copy-Item -Path $helpersPath -Destination $targetHelpersPath -Recurse -Force`,
})
}
@@ -68,6 +73,12 @@ func TestUnixInstallerInstallsFromPrefixedReleaseArchiveWithoutNetwork(t *testin
if !strings.Contains(string(installed), "mock-zero") {
t.Fatalf("installed binary = %q, want mock-zero script", string(installed))
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
t.Fatalf("installed helper package missing: %v", err)
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "zero-linux-sandbox")); err != nil {
t.Fatalf("installed linux sandbox helper missing: %v", err)
}
}
func TestUnixInstallerRejectsChecksumMismatchWithoutNetwork(t *testing.T) {
@@ -127,7 +138,16 @@ func newUnixInstallFixture(t *testing.T) unixInstallFixture {
mustMkdirAll(t, packageDir)
mustMkdirAll(t, filepath.Dir(fixture.archivePath))
mustMkdirAll(t, fixture.installDir)
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin"))
writeFile(t, filepath.Join(packageDir, "zero"), []byte("#!/usr/bin/env sh\necho mock-zero\n"), 0o755)
writeFile(t, filepath.Join(packageDir, "zero-linux-sandbox"), []byte("#!/usr/bin/env sh\n"), 0o755)
writeFile(t, filepath.Join(packageDir, "zero-seccomp"), []byte("#!/usr/bin/env sh\n"), 0o755)
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin"))
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin", "agent-browser.js"), []byte("#!/usr/bin/env node\n"), 0o755)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(packageDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser helper: %v", err)
}
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin", "tuistory"), []byte("#!/usr/bin/env sh\n"), 0o755)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
+189
View File
@@ -0,0 +1,189 @@
package localcontrol
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
const (
DefaultDevToolsPort = 9222
defaultDevToolsWaitTimeout = 20 * time.Second
)
type BrowserAppLaunchOptions struct {
Runner BrowserAppRunner
WaitTimeout time.Duration
}
type BrowserAppLaunchRequest struct {
App string
DebugPort int
StopExisting bool
Wait bool
}
type BrowserAppLaunchResult struct {
App string
Command string
Args []string
PID int
DebugPort int
DevToolsURL string
}
type BrowserAppRunner interface {
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
StartDetached(ctx context.Context, path string, args []string, env []string) (int, error)
}
type BrowserAppLauncher struct {
runner BrowserAppRunner
waitTimeout time.Duration
}
type browserAppSpec struct {
name string
command string
args []string
env []string
stopPath string
stopArgs []string
}
func NewBrowserAppLauncher(options BrowserAppLaunchOptions) BrowserAppLauncher {
if options.Runner == nil {
options.Runner = ExecBrowserAppRunner{}
}
if options.WaitTimeout <= 0 {
options.WaitTimeout = defaultDevToolsWaitTimeout
}
return BrowserAppLauncher{
runner: options.Runner,
waitTimeout: options.WaitTimeout,
}
}
func (launcher BrowserAppLauncher) LaunchBrowserApp(ctx context.Context, request BrowserAppLaunchRequest) (BrowserAppLaunchResult, error) {
if ctx == nil {
ctx = context.Background()
}
port := request.DebugPort
if port == 0 {
port = DefaultDevToolsPort
}
if port < 1024 || port > 65535 {
return BrowserAppLaunchResult{}, fmt.Errorf("debug_port must be between 1024 and 65535")
}
spec, err := browserAppLaunchSpec(request.App, port)
if err != nil {
return BrowserAppLaunchResult{}, err
}
if request.StopExisting && spec.stopPath != "" {
_, _ = launcher.runner.Run(ctx, spec.stopPath, spec.stopArgs, nil, 5*time.Second)
}
pid, err := launcher.runner.StartDetached(ctx, spec.command, spec.args, spec.env)
if err != nil {
return BrowserAppLaunchResult{}, fmt.Errorf("launch %s: %w", spec.name, err)
}
devToolsURL := "http://127.0.0.1:" + strconv.Itoa(port)
if request.Wait {
if err := waitForDevTools(ctx, devToolsURL, launcher.waitTimeout); err != nil {
return BrowserAppLaunchResult{}, err
}
}
return BrowserAppLaunchResult{
App: spec.name,
Command: spec.command,
Args: append([]string(nil), spec.args...),
PID: pid,
DebugPort: port,
DevToolsURL: devToolsURL,
}, nil
}
func browserAppLaunchSpec(app string, port int) (browserAppSpec, error) {
switch strings.ToLower(strings.TrimSpace(app)) {
case "discord":
if runtime.GOOS != "linux" {
return browserAppSpec{}, fmt.Errorf("discord launch is only supported for Linux flatpak installs")
}
return browserAppSpec{
name: "discord",
command: "flatpak",
args: []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=" + strconv.Itoa(port)},
stopPath: "flatpak",
stopArgs: []string{"kill", "com.discordapp.Discord"},
}, nil
default:
return browserAppSpec{}, fmt.Errorf("app must be one of: discord")
}
}
func waitForDevTools(ctx context.Context, baseURL string, timeout time.Duration) error {
if timeout <= 0 {
timeout = defaultDevToolsWaitTimeout
}
deadline := time.Now().Add(timeout)
client := http.Client{Timeout: 750 * time.Millisecond}
endpoint := strings.TrimRight(baseURL, "/") + "/json/version"
var lastErr error
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
lastErr = fmt.Errorf("DevTools endpoint returned HTTP %d", resp.StatusCode)
} else {
lastErr = err
}
if time.Now().After(deadline) {
return fmt.Errorf("DevTools endpoint %s was not ready after %dms: %w", endpoint, timeout.Milliseconds(), lastErr)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
}
type ExecBrowserAppRunner struct{}
func (ExecBrowserAppRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
return ExecRunner{}.Run(ctx, path, args, env, timeout)
}
func (ExecBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, env []string) (int, error) {
if strings.TrimSpace(path) == "" {
return 0, errors.New("launch command is required")
}
cmd := exec.Command(path, args...)
cmd.Env = mergeEnv(os.Environ(), env)
configureDetachedProcess(cmd)
if err := cmd.Start(); err != nil {
return 0, err
}
pid := cmd.Process.Pid
if err := cmd.Process.Release(); err != nil {
return pid, err
}
return pid, nil
}
+129
View File
@@ -0,0 +1,129 @@
package localcontrol
import (
"context"
"errors"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
type fakeBrowserAppRunner struct {
runPath string
runArgs []string
startPath string
startArgs []string
pid int
err error
}
func (runner *fakeBrowserAppRunner) Run(_ context.Context, path string, args []string, _ []string, _ time.Duration) (CommandResult, error) {
runner.runPath = path
runner.runArgs = append([]string(nil), args...)
return CommandResult{Path: path, Args: append([]string(nil), args...), ExitCode: 0}, nil
}
func (runner *fakeBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, _ []string) (int, error) {
runner.startPath = path
runner.startArgs = append([]string(nil), args...)
if runner.err != nil {
return 0, runner.err
}
if runner.pid == 0 {
runner.pid = 4321
}
return runner.pid, nil
}
func TestBrowserAppLauncherMapsDiscordToFlatpakDevToolsLaunch(t *testing.T) {
requireLinuxFlatpakLaunch(t)
runner := &fakeBrowserAppRunner{}
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
App: "discord",
DebugPort: 9222,
StopExisting: true,
Wait: false,
})
if err != nil {
t.Fatalf("LaunchBrowserApp returned error: %v", err)
}
if runner.runPath != "flatpak" {
t.Fatalf("stop path = %q, want flatpak", runner.runPath)
}
if want := []string{"kill", "com.discordapp.Discord"}; !reflect.DeepEqual(runner.runArgs, want) {
t.Fatalf("stop args = %#v, want %#v", runner.runArgs, want)
}
if runner.startPath != "flatpak" {
t.Fatalf("start path = %q, want flatpak", runner.startPath)
}
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
}
if result.PID != 4321 || result.DevToolsURL != "http://127.0.0.1:9222" {
t.Fatalf("result = %#v, want pid/devtools URL", result)
}
}
func TestBrowserAppLauncherDefaultsPortAndCanSkipStop(t *testing.T) {
requireLinuxFlatpakLaunch(t)
runner := &fakeBrowserAppRunner{}
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
App: "discord",
Wait: false,
})
if err != nil {
t.Fatalf("LaunchBrowserApp returned error: %v", err)
}
if runner.runPath != "" {
t.Fatalf("stop path = %q, want no stop", runner.runPath)
}
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
}
if result.DebugPort != DefaultDevToolsPort {
t.Fatalf("debug port = %d, want %d", result.DebugPort, DefaultDevToolsPort)
}
}
func TestBrowserAppLauncherRejectsUnsupportedApp(t *testing.T) {
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "telegram"})
if err == nil || !strings.Contains(err.Error(), "app must be one of") {
t.Fatalf("error = %v, want unsupported app", err)
}
}
func TestBrowserAppLauncherReportsStartFailure(t *testing.T) {
requireLinuxFlatpakLaunch(t)
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{err: errors.New("boom")}})
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
if err == nil || !strings.Contains(err.Error(), "launch discord") {
t.Fatalf("error = %v, want launch failure", err)
}
}
func TestBrowserAppLauncherRejectsDiscordOnUnsupportedPlatform(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("discord launch is supported on Linux")
}
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
if err == nil || !strings.Contains(err.Error(), "only supported") {
t.Fatalf("error = %v, want unsupported platform", err)
}
}
func requireLinuxFlatpakLaunch(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("discord launch uses Linux flatpak")
}
}
@@ -0,0 +1,15 @@
//go:build !windows
package localcontrol
import (
"os/exec"
"syscall"
)
func configureDetachedProcess(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setsid = true
}
@@ -0,0 +1,17 @@
//go:build windows
package localcontrol
import (
"os/exec"
"syscall"
)
const detachedProcess = 0x00000008
func configureDetachedProcess(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess
}
+451
View File
@@ -0,0 +1,451 @@
package localcontrol
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const (
DefaultBrowserDriver = "agent-browser"
DefaultDesktopDriver = "cua-driver"
DefaultTerminalDriver = "tuistory"
DefaultTimeout = 30 * time.Second
EnvHelperManifest = "ZERO_LOCAL_CONTROL_HELPERS"
)
type HelperOptions struct {
Enabled bool
Driver string
HelperPath string
ConfigPath string
DisabledMessage string
MissingHint string
Timeout time.Duration
Runner CommandRunner
}
type BrowserOptions = HelperOptions
type DesktopOptions = HelperOptions
type TerminalOptions = HelperOptions
type CommandResult struct {
Path string
Args []string
Stdout string
Stderr string
ExitCode int
}
func (result CommandResult) Output() string {
stdout := strings.TrimRight(result.Stdout, "\n")
stderr := strings.TrimRight(result.Stderr, "\n")
switch {
case stdout == "" && stderr == "":
return ""
case stdout == "":
return stderr
case stderr == "":
return stdout
default:
return stdout + "\n" + stderr
}
}
type CommandRunner interface {
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
}
type ResolvedHelper struct {
Command string
PrefixArgs []string
Env []string
}
type Helper struct {
options HelperOptions
}
type Browser struct {
helper Helper
}
type Desktop struct {
helper Helper
}
type Terminal struct {
helper Helper
}
func NewHelper(options HelperOptions) Helper {
if options.Timeout <= 0 {
options.Timeout = DefaultTimeout
}
if options.Runner == nil {
options.Runner = ExecRunner{}
}
return Helper{options: options}
}
func NewBrowser(options BrowserOptions) Browser {
if strings.TrimSpace(options.Driver) == "" {
options.Driver = DefaultBrowserDriver
}
options.ConfigPath = "localControl.browser.helperPath"
options.DisabledMessage = "local browser control is disabled by user config"
options.MissingHint = "install the agent-browser package or set localControl.browser.helperPath"
return Browser{helper: NewHelper(options)}
}
func NewDesktop(options DesktopOptions) Desktop {
if strings.TrimSpace(options.Driver) == "" {
options.Driver = DefaultDesktopDriver
}
options.ConfigPath = "localControl.desktop.helperPath"
options.DisabledMessage = "local desktop control is disabled; enable localControl.desktop.enabled in user config"
options.MissingHint = "install cua-driver or set localControl.desktop.helperPath"
return Desktop{helper: NewHelper(options)}
}
func NewTerminal(options TerminalOptions) Terminal {
if strings.TrimSpace(options.Driver) == "" {
options.Driver = DefaultTerminalDriver
}
options.ConfigPath = "localControl.terminal.helperPath"
options.DisabledMessage = "local terminal control is disabled by user config"
options.MissingHint = "install the tuistory package or set localControl.terminal.helperPath"
return Terminal{helper: NewHelper(options)}
}
func (helper Helper) Enabled() bool {
return helper.options.Enabled
}
func (helper Helper) Run(ctx context.Context, args ...string) (CommandResult, error) {
if !helper.options.Enabled {
message := strings.TrimSpace(helper.options.DisabledMessage)
if message == "" {
message = "local control helper is disabled"
}
return CommandResult{}, errors.New(message)
}
resolved, err := helper.resolvedHelper()
if err != nil {
return CommandResult{}, err
}
runArgs := append([]string{}, resolved.PrefixArgs...)
runArgs = append(runArgs, args...)
return helper.options.Runner.Run(ctx, resolved.Command, runArgs, resolved.Env, helper.options.Timeout)
}
func (helper Helper) resolvedHelper() (ResolvedHelper, error) {
label := helper.helperLabel()
if helperPath := strings.TrimSpace(helper.options.HelperPath); helperPath != "" {
if !filepath.IsAbs(helperPath) {
abs, err := filepath.Abs(helperPath)
if err != nil {
return ResolvedHelper{}, fmt.Errorf("resolve %s helper path: %w", label, err)
}
helperPath = abs
}
if err := validateHelperFile(label, helperPath); err != nil {
return ResolvedHelper{}, err
}
return ResolvedHelper{Command: helperPath}, nil
}
driver := strings.TrimSpace(helper.options.Driver)
if driver == "" {
driver = DefaultBrowserDriver
}
if resolved, ok, err := helperFromManifest(driver); ok || err != nil {
return resolved, err
}
if resolved, ok, err := adjacentHelper(driver); ok || err != nil {
return resolved, err
}
path, err := exec.LookPath(driver)
if err != nil {
hint := strings.TrimSpace(helper.options.MissingHint)
if hint == "" {
hint = "install it or configure the helper path"
}
return ResolvedHelper{}, fmt.Errorf("%s was not found on PATH; %s", driver, hint)
}
return ResolvedHelper{Command: path}, nil
}
func (helper Helper) helperLabel() string {
driver := strings.TrimSpace(helper.options.Driver)
if driver == "" {
return "local control"
}
return driver
}
func (browser Browser) Enabled() bool {
return browser.helper.Enabled()
}
func (browser Browser) Run(ctx context.Context, args ...string) (CommandResult, error) {
return browser.helper.Run(ctx, args...)
}
func (desktop Desktop) Run(ctx context.Context, args ...string) (CommandResult, error) {
return desktop.helper.Run(ctx, args...)
}
func (desktop Desktop) Enabled() bool {
return desktop.helper.Enabled()
}
func (terminal Terminal) Run(ctx context.Context, args ...string) (CommandResult, error) {
return terminal.helper.Run(ctx, args...)
}
func (terminal Terminal) Enabled() bool {
return terminal.helper.Enabled()
}
type ExecRunner struct{}
var executablePath = os.Executable
func (ExecRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
if ctx == nil {
ctx = context.Background()
}
if timeout <= 0 {
timeout = DefaultTimeout
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, path, args...)
cmd.Env = mergeEnv(os.Environ(), env)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
result := CommandResult{
Path: path,
Args: append([]string(nil), args...),
Stdout: stdout.String(),
Stderr: stderr.String(),
}
if cmd.ProcessState != nil {
result.ExitCode = cmd.ProcessState.ExitCode()
} else if err != nil {
result.ExitCode = -1
}
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return result, fmt.Errorf("local control helper timed out after %dms", timeout.Milliseconds())
}
if err != nil {
return result, err
}
return result, nil
}
type helperManifest struct {
Version int `json:"version"`
Helpers map[string]manifestEntry `json:"helpers"`
}
type manifestEntry struct {
Command string `json:"command"`
PrefixArgs []string `json:"prefixArgs"`
PathPrepend []string `json:"pathPrepend"`
Env map[string]string `json:"env"`
}
func helperFromManifest(driver string) (ResolvedHelper, bool, error) {
raw := strings.TrimSpace(os.Getenv(EnvHelperManifest))
if raw == "" {
return ResolvedHelper{}, false, nil
}
manifest, err := parseHelperManifest(raw)
if err != nil {
return ResolvedHelper{}, true, err
}
entry, ok := manifest.Helpers[driver]
if !ok {
return ResolvedHelper{}, false, nil
}
command := strings.TrimSpace(entry.Command)
if command == "" {
return ResolvedHelper{}, true, fmt.Errorf("%s entry in %s is missing command", driver, EnvHelperManifest)
}
if filepath.IsAbs(command) {
if err := validateHelperFile(driver, command); err != nil {
return ResolvedHelper{}, true, err
}
}
return ResolvedHelper{
Command: command,
PrefixArgs: cleanStringSlice(entry.PrefixArgs),
Env: manifestEnv(entry),
}, true, nil
}
func parseHelperManifest(raw string) (helperManifest, error) {
var manifest helperManifest
if err := json.Unmarshal([]byte(raw), &manifest); err != nil {
return helperManifest{}, fmt.Errorf("invalid %s JSON: %w", EnvHelperManifest, err)
}
if manifest.Helpers == nil {
manifest.Helpers = map[string]manifestEntry{}
}
return manifest, nil
}
func validateHelperFile(label string, path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s helper not found at %s: %w", label, path, err)
}
if info.IsDir() {
return fmt.Errorf("%s helper path is a directory: %s", label, path)
}
return nil
}
func adjacentHelper(driver string) (ResolvedHelper, bool, error) {
executable, err := executablePath()
if err != nil {
return ResolvedHelper{}, false, nil
}
executableDir := filepath.Dir(executable)
for _, dir := range []string{
executableDir,
filepath.Join(executableDir, "helpers"),
filepath.Join(executableDir, "helpers", "node_modules", ".bin"),
filepath.Join(executableDir, "node_modules", ".bin"),
} {
for _, name := range helperExecutableNames(driver) {
path := filepath.Join(dir, name)
if err := validateHelperFile(driver, path); err == nil {
return helperCommandForPath(path), true, nil
}
}
}
return ResolvedHelper{}, false, nil
}
func helperExecutableNames(driver string) []string {
if runtime.GOOS == "windows" {
return []string{driver + ".cmd", driver + ".exe", driver}
}
return []string{driver}
}
func helperCommandForPath(path string) ResolvedHelper {
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".cmd") {
command := os.Getenv("ComSpec")
if strings.TrimSpace(command) == "" {
command = "cmd.exe"
}
return ResolvedHelper{
Command: command,
PrefixArgs: []string{"/d", "/s", "/c", `"` + strings.ReplaceAll(path, `"`, `""`) + `"`},
}
}
return ResolvedHelper{Command: path}
}
func manifestEnv(entry manifestEntry) []string {
env := map[string]string{}
for key, value := range entry.Env {
key = strings.TrimSpace(key)
if key != "" {
env[key] = value
}
}
pathPrepend := cleanStringSlice(entry.PathPrepend)
if len(pathPrepend) > 0 {
basePath := env["PATH"]
if basePath == "" {
basePath = os.Getenv("PATH")
}
prefix := strings.Join(pathPrepend, string(os.PathListSeparator))
if basePath != "" {
env["PATH"] = prefix + string(os.PathListSeparator) + basePath
} else {
env["PATH"] = prefix
}
}
return envMapToList(env)
}
func normalizeEnvKey(key string) string {
if runtime.GOOS == "windows" {
return strings.ToUpper(key)
}
return key
}
func mergeEnv(base []string, overlay []string) []string {
if len(overlay) == 0 {
return base
}
merged := append([]string{}, base...)
index := map[string]int{}
for i, item := range merged {
if key, _, ok := strings.Cut(item, "="); ok {
index[normalizeEnvKey(key)] = i
}
}
for _, item := range overlay {
key, _, ok := strings.Cut(item, "=")
if !ok || key == "" {
continue
}
normalized := normalizeEnvKey(key)
if existing, ok := index[normalized]; ok {
merged[existing] = item
} else {
index[normalized] = len(merged)
merged = append(merged, item)
}
}
return merged
}
func cleanStringSlice(values []string) []string {
cleaned := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
cleaned = append(cleaned, value)
}
}
return cleaned
}
func envMapToList(env map[string]string) []string {
if len(env) == 0 {
return nil
}
keys := make([]string, 0, len(env))
for key := range env {
keys = append(keys, key)
}
sort.Strings(keys)
values := make([]string, 0, len(keys))
for _, key := range keys {
values = append(values, key+"="+env[key])
}
return values
}
+237
View File
@@ -0,0 +1,237 @@
package localcontrol
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
type fakeCommandRunner struct {
path string
args []string
env []string
}
func (runner *fakeCommandRunner) Run(_ context.Context, path string, args []string, env []string, _ time.Duration) (CommandResult, error) {
runner.path = path
runner.args = append([]string(nil), args...)
runner.env = append([]string(nil), env...)
return CommandResult{
Path: path,
Args: append([]string(nil), args...),
Stdout: "ok\n",
ExitCode: 0,
}, nil
}
func TestBrowserRunUsesConfiguredHelperPath(t *testing.T) {
dir := t.TempDir()
helper := filepath.Join(dir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
HelperPath: helper,
Runner: runner,
})
result, err := browser.Run(context.Background(), "open", "https://example.com")
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if result.Output() != "ok" {
t.Fatalf("Output = %q, want ok", result.Output())
}
if runner.path != helper {
t.Fatalf("runner path = %q, want %q", runner.path, helper)
}
if want := []string{"open", "https://example.com"}; !reflect.DeepEqual(runner.args, want) {
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
}
}
func TestBrowserRunDisabledFailsBeforeDiscovery(t *testing.T) {
browser := NewBrowser(BrowserOptions{Enabled: false, HelperPath: "/does/not/exist"})
_, err := browser.Run(context.Background(), "snapshot")
if err == nil || !strings.Contains(err.Error(), "disabled") {
t.Fatalf("Run error = %v, want disabled", err)
}
}
func TestBrowserRunMissingHelperPathIsActionable(t *testing.T) {
browser := NewBrowser(BrowserOptions{Enabled: true, HelperPath: filepath.Join(t.TempDir(), "missing")})
_, err := browser.Run(context.Background(), "snapshot")
if err == nil || !strings.Contains(err.Error(), "helper not found") {
t.Fatalf("Run error = %v, want missing helper", err)
}
}
func TestBrowserRunUsesHelperManifest(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
helper := filepath.Join(binDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":`+quoteJSON(helper)+`,"pathPrepend":[`+quoteJSON(binDir)+`],"env":{"ZERO_HELPER_TEST":"1"}}}}`)
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want manifest helper %q", runner.path, helper)
}
if !envContains(runner.env, "ZERO_HELPER_TEST=1") {
t.Fatalf("env = %#v, want ZERO_HELPER_TEST", runner.env)
}
pathValue := envValue(runner.env, "PATH")
if !strings.HasPrefix(pathValue, binDir+string(os.PathListSeparator)) && pathValue != binDir {
t.Fatalf("PATH overlay = %q, want prefix %q", pathValue, binDir)
}
}
func TestBrowserRunUsesManifestPrefixArgs(t *testing.T) {
runner := &fakeCommandRunner{}
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":"cmd.exe","prefixArgs":["/d","/s","/c","C:\\zero\\agent-browser.cmd"]}}}`)
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "open", "https://example.com"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != "cmd.exe" {
t.Fatalf("runner path = %q, want cmd.exe", runner.path)
}
want := []string{"/d", "/s", "/c", `C:\zero\agent-browser.cmd`, "open", "https://example.com"}
if !reflect.DeepEqual(runner.args, want) {
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
}
}
func TestMergeEnvReplacesPathCaseInsensitivelyOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows env keys are case-insensitive")
}
env := mergeEnv([]string{`Path=C:\Windows`, "ZERO=1"}, []string{`PATH=C:\Zero`})
pathCount := 0
for _, item := range env {
key, value, ok := strings.Cut(item, "=")
if !ok || !strings.EqualFold(key, "PATH") {
continue
}
pathCount++
if value != `C:\Zero` {
t.Fatalf("PATH value = %q, want C:\\Zero", value)
}
}
if pathCount != 1 {
t.Fatalf("PATH entries = %d in %#v, want 1", pathCount, env)
}
}
func TestBrowserRunUsesAdjacentPackagedHelper(t *testing.T) {
root := t.TempDir()
native := filepath.Join(root, "zero")
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write native: %v", err)
}
helpersDir := filepath.Join(root, "helpers")
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
t.Fatalf("mkdir helpers: %v", err)
}
helper := filepath.Join(helpersDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
oldExecutablePath := executablePath
executablePath = func() (string, error) { return native, nil }
t.Cleanup(func() { executablePath = oldExecutablePath })
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want adjacent helper %q", runner.path, helper)
}
}
func TestBrowserRunUsesAdjacentPackagedNodeBinHelper(t *testing.T) {
root := t.TempDir()
native := filepath.Join(root, "zero")
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write native: %v", err)
}
binDir := filepath.Join(root, "helpers", "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir helper bin: %v", err)
}
helper := filepath.Join(binDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
oldExecutablePath := executablePath
executablePath = func() (string, error) { return native, nil }
t.Cleanup(func() { executablePath = oldExecutablePath })
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want packaged node bin helper %q", runner.path, helper)
}
}
func quoteJSON(value string) string {
data, err := json.Marshal(value)
if err != nil {
panic(err)
}
return string(data)
}
func envContains(env []string, want string) bool {
for _, item := range env {
if item == want {
return true
}
}
return false
}
func envValue(env []string, key string) string {
for _, item := range env {
if got, value, ok := strings.Cut(item, "="); ok && got == key {
return value
}
}
return ""
}
+41 -4
View File
@@ -1,6 +1,9 @@
package modelregistry
import "fmt"
import (
"fmt"
"strings"
)
const DefaultModelID = "gpt-4.1"
@@ -169,11 +172,45 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
}
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
model, ok := registry.Get(pattern)
if !ok {
if model, ok := registry.Get(pattern); ok && len(model.ReasoningEfforts) > 0 {
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
}
// Fallback for reasoning-model families that aren't enumerated in the curated
// catalog — e.g. the GPT-5 / Codex / o-series variants served via the ChatGPT
// proxy or custom OpenAI-compatible endpoints. Without this, /effort shows no
// controls for those models even though they support reasoning_effort.
return reasoningEffortsForModelName(pattern)
}
// reasoningEffortsForModelName infers reasoning-effort controls from a model name
// for known reasoning families not present in the catalog. It returns nil for
// non-reasoning models (e.g. GPT-4.1 / GPT-4o), so /effort stays empty there.
func reasoningEffortsForModelName(name string) []ReasoningEffort {
n := strings.ToLower(strings.TrimSpace(name))
if i := strings.IndexByte(n, ':'); i >= 0 { // drop a "provider:" qualifier
n = n[i+1:]
}
switch {
case strings.HasPrefix(n, "gpt-5") || strings.HasPrefix(n, "gpt5"):
// GPT-5 / Codex (gpt-5.x) add a "minimal" tier below low.
return []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
case strings.Contains(n, "codex") || isOSeriesModelName(n):
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
default:
return nil
}
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
}
// isOSeriesModelName reports whether name is an OpenAI o-series reasoning model
// (o1/o3/o4, optionally with a -mini/-pro suffix), without matching unrelated
// names that merely start with the same letters.
func isOSeriesModelName(n string) bool {
for _, prefix := range []string{"o1", "o3", "o4"} {
if n == prefix || strings.HasPrefix(n, prefix+"-") || strings.HasPrefix(n, prefix+"mini") {
return true
}
}
return false
}
func (registry Registry) RequireProvider(pattern string, provider ProviderKind) (ModelEntry, error) {
+1 -2
View File
@@ -92,11 +92,10 @@ func CalculateCost(model ModelEntry, usage zeroruntime.Usage) (CostBreakdown, er
if uncachedInputTokens < 0 {
uncachedInputTokens = 0
}
billableOutputTokens := outputTokens + reasoningTokens
inputCost := costForTokens(uncachedInputTokens, inputRate)
cachedInputCost := costForTokens(cachedInputTokens, cachedRate)
cacheWriteCost := costForTokens(cacheWriteTokens, cacheWriteRate)
outputCost := costForTokens(billableOutputTokens, outputRate)
outputCost := costForTokens(outputTokens, outputRate)
breakdown := CostBreakdown{
ModelID: model.ID,
+27
View File
@@ -94,6 +94,33 @@ func TestRegistryCostIgnoresCachedInputWithoutCachePricing(t *testing.T) {
assertClose(t, cost.OutputCost, 0.03)
}
func TestRegistryCostTreatsReasoningAsOutputBreakdown(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry returned error: %v", err)
}
withReasoning, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
InputTokens: 1_000,
OutputTokens: 1_000,
ReasoningTokens: 400,
})
if err != nil {
t.Fatalf("EstimateCost(withReasoning) returned error: %v", err)
}
plain, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
InputTokens: 1_000,
OutputTokens: 1_000,
})
if err != nil {
t.Fatalf("EstimateCost(plain) returned error: %v", err)
}
if withReasoning.OutputCost != plain.OutputCost || withReasoning.TotalCost != plain.TotalCost {
t.Fatalf("reasoning should be a breakdown of output cost, with=%#v plain=%#v", withReasoning, plain)
}
}
func TestRegistryCostSelectsTierForLongContextPricing(t *testing.T) {
registry, err := DefaultRegistry()
if err != nil {
@@ -0,0 +1,34 @@
package modelregistry
import "testing"
func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
reg, err := DefaultRegistry()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
want []ReasoningEffort
}{
{"gpt-5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-5.5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-5.4-mini", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-5.3-codex-spark", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
{"gpt-4o-mini", nil},
{"ollama/llama3.1", nil},
}
for _, c := range cases {
got := reg.ReasoningEfforts(c.name)
if len(got) != len(c.want) {
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
}
+5 -1
View File
@@ -25,6 +25,10 @@ const (
ReasoningEffortMedium ReasoningEffort = "medium"
ReasoningEffortHigh ReasoningEffort = "high"
ReasoningEffortXHigh ReasoningEffort = "xhigh"
// ReasoningEffortMax is the top tier some providers expose above xhigh (e.g.
// newer Anthropic models). Defined so a capability catalog that lists "max"
// has a constant to map to; no curated model lists it yet.
ReasoningEffortMax ReasoningEffort = "max"
)
type ModelCapability string
@@ -406,7 +410,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh:
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
return true
default:
return false
+117
View File
@@ -25,6 +25,7 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
Scripts map[string]string `json:"scripts"`
License string `json:"license"`
Files []string `json:"files"`
Deps map[string]string `json:"dependencies"`
Repository json.RawMessage `json:"repository"`
Engines map[string]string `json:"engines"`
}
@@ -60,6 +61,11 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if pkg.Engines["node"] == "" {
t.Fatalf("package.json engines.node is empty; the wrapper and installer require a modern Node")
}
for _, name := range []string{"agent-browser", "tuistory"} {
if pkg.Deps[name] == "" {
t.Fatalf("package.json dependencies is missing %q", name)
}
}
wantFiles := map[string]bool{"bin/zero.js": false, "scripts/postinstall.mjs": false}
for _, f := range pkg.Files {
if _, ok := wantFiles[f]; ok {
@@ -218,6 +224,7 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out reporting missing native binary: %v; output: %s", ctx.Err(), output)
@@ -261,6 +268,116 @@ func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
}
}
func TestNodeWrapperPassesLocalControlHelperManifest(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
nativePath := filepath.Join(root, "zero")
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nprintf '%s\\n' \"$ZERO_LOCAL_CONTROL_HELPERS\"\n"), 0o755); err != nil {
t.Fatalf("WriteFile native fixture: %v", err)
}
binDir := filepath.Join(root, "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll node_modules/.bin: %v", err)
}
for _, name := range []string{"agent-browser", "tuistory"} {
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/usr/bin/env sh\n"), 0o755); err != nil {
t.Fatalf("WriteFile helper %s: %v", name, err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
var manifest struct {
Version int `json:"version"`
Helpers map[string]struct {
Command string `json:"command"`
PrefixArgs []string `json:"prefixArgs"`
PathPrepend []string `json:"pathPrepend"`
} `json:"helpers"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &manifest); err != nil {
t.Fatalf("manifest JSON = %q: %v", output, err)
}
if manifest.Version != 1 {
t.Fatalf("manifest version = %d, want 1", manifest.Version)
}
for _, name := range []string{"agent-browser", "tuistory"} {
helper, ok := manifest.Helpers[name]
if !ok {
t.Fatalf("manifest missing helper %q: %#v", name, manifest.Helpers)
}
wantCommand := canonicalTestPath(t, filepath.Join(binDir, name))
if helper.Command != wantCommand {
t.Fatalf("%s command = %q, want %q", name, helper.Command, wantCommand)
}
wantBinDir := canonicalTestPath(t, binDir)
if len(helper.PathPrepend) != 1 || helper.PathPrepend[0] != wantBinDir {
t.Fatalf("%s pathPrepend = %#v, want [%q]", name, helper.PathPrepend, wantBinDir)
}
}
}
func TestNodeWrapperClearsInheritedLocalControlHelperManifestWhenNoHelpers(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
node := requireNode(t)
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
nativePath := filepath.Join(root, "zero")
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nif [ -n \"${ZERO_LOCAL_CONTROL_HELPERS+x}\" ]; then printf 'set\\n'; else printf 'unset\\n'; fi\n"), 0o755); err != nil {
t.Fatalf("WriteFile native fixture: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), `ZERO_LOCAL_CONTROL_HELPERS={"version":1,"helpers":{"agent-browser":{"command":"stale"}}}`)
output, err := command.CombinedOutput()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
}
if err != nil {
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
}
if got := strings.TrimSpace(string(output)); got != "unset" {
t.Fatalf("ZERO_LOCAL_CONTROL_HELPERS state = %q, want unset", got)
}
}
func canonicalTestPath(t *testing.T, path string) string {
t.Helper()
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatalf("EvalSymlinks %s: %v", path, err)
}
return realPath
}
func withoutEnvKey(env []string, key string) []string {
prefix := key + "="
out := make([]string, 0, len(env))
for _, item := range env {
if strings.HasPrefix(item, prefix) {
continue
}
out = append(out, item)
}
return out
}
func copyWrapperFixture(t *testing.T) string {
t.Helper()
root := repoRoot(t)
+1 -1
View File
@@ -104,7 +104,7 @@ var descriptors = []Descriptor{
// way in; RequiresAuth is forced true so catalog consumers know the provider
// needs an interactive login before a request will succeed.
func() Descriptor {
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5", nil)
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
d.RequiresAuth = true
return oauthProvider(d, false, false)
}(),
+6
View File
@@ -56,6 +56,12 @@ var curatedModels = map[string][]Model{
{ID: "minimax/minimax-m2.1", Description: "agentic coding model"},
{ID: "deepseek/deepseek-chat", Description: "coding model"},
},
"chatgpt": {
{ID: "gpt-5.5", Description: "recommended Codex model"},
{ID: "gpt-5.4", Description: "strong Codex model"},
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
},
"deepseek": {
{ID: "deepseek-chat", Description: "catalog default"},
{ID: "deepseek-reasoner", Description: "reasoning model"},
@@ -27,6 +27,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"llama-3.3-70b-versatile", "openai/gpt-oss-120b"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "chatgpt",
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
+1 -1
View File
@@ -212,8 +212,8 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *s
}
if payload.UsageMetadata != nil {
state.inputTokens = payload.UsageMetadata.PromptTokenCount
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount
state.reasoningTokens = payload.UsageMetadata.ThoughtsTokenCount
state.outputTokens = payload.UsageMetadata.CandidatesTokenCount + state.reasoningTokens
state.cachedTokens = payload.UsageMetadata.CachedContentTokenCount
state.hasUsage = true
}
+1 -1
View File
@@ -288,7 +288,7 @@ func TestStreamCompletionEmitsTextUsageAndReasoningTokens(t *testing.T) {
want := []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "Hello"},
{Type: zeroruntime.StreamEventText, Content: " Zero"},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 15, PromptTokens: 25, CompletionTokens: 15, ReasoningTokens: 3, CachedInputTokens: 7}},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 25, OutputTokens: 18, PromptTokens: 25, CompletionTokens: 18, ReasoningTokens: 3, CachedInputTokens: 7}},
{Type: zeroruntime.StreamEventDone},
}
if !reflect.DeepEqual(events, want) {
+7 -9
View File
@@ -72,8 +72,10 @@ const (
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
type responsesRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
}
@@ -203,18 +205,13 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
Stream: true,
MaxOutputTokens: p.inner.maxTokens,
}
instructions := []string{}
for _, msg := range request.Messages {
switch msg.Role {
case zeroruntime.MessageRoleSystem:
// System → first-class user message in Responses API. Keeping it as
// an input message (rather than a separate `instructions` field)
// matches the chat-completions semantics the runtime was already
// producing — every system turn reaches the model in order.
req.Input = append(req.Input, inputItem{
Type: "message",
Role: "system",
Content: []contentItem{{Type: "input_text", Text: msg.Content}},
})
if content := strings.TrimSpace(msg.Content); content != "" {
instructions = append(instructions, content)
}
case zeroruntime.MessageRoleUser:
req.Input = append(req.Input, p.userInputItem(msg))
case zeroruntime.MessageRoleAssistant:
@@ -236,6 +233,7 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
return nil, fmt.Errorf("codex provider: unsupported message role %q", msg.Role)
}
}
req.Instructions = strings.Join(instructions, "\n\n")
for _, tool := range request.Tools {
req.Tools = append(req.Tools, responsesTool{
Type: "function",
+15 -12
View File
@@ -385,9 +385,10 @@ func TestCodexProviderOmitsAccountIDWhenResolverSaysNo(t *testing.T) {
func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
// The Codex provider speaks the Responses API, not the chat-completions
// API. The request body must carry `input` items (not `messages`), a
// `stream: true` flag, and `tools` (when the caller supplies any) — a
// chat-completions body would be rejected by the live Codex backend.
// API. The request body must carry top-level `instructions`, `input` items
// (not `messages`), a `stream: true` flag, and `tools` (when the caller
// supplies any) — a chat-completions body would be rejected by the live
// Codex backend.
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
@@ -423,23 +424,25 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
if rec.body["stream"] != true {
t.Fatalf("body.stream = %#v, want true", rec.body["stream"])
}
if store, ok := rec.body["store"].(bool); !ok || store {
t.Fatalf("body.store = %#v, want explicit false", rec.body["store"])
}
if _, ok := rec.body["messages"]; ok {
t.Fatalf("body must not carry chat-completions `messages` (got %#v); the Codex backend serves the Responses API", rec.body["messages"])
}
if rec.body["instructions"] != "sys" {
t.Fatalf("body.instructions = %#v, want system prompt", rec.body["instructions"])
}
input, ok := rec.body["input"].([]any)
if !ok {
t.Fatalf("body.input = %#v, want []any with two message items", rec.body["input"])
t.Fatalf("body.input = %#v, want []any with user message item", rec.body["input"])
}
if len(input) != 2 {
t.Fatalf("body.input has %d items, want 2 (system + user)", len(input))
if len(input) != 1 {
t.Fatalf("body.input has %d items, want 1 (user)", len(input))
}
system, _ := input[0].(map[string]any)
if system["type"] != "message" || system["role"] != "system" {
t.Fatalf("body.input[0] = %#v, want type=message role=system", system)
}
user, _ := input[1].(map[string]any)
user, _ := input[0].(map[string]any)
if user["type"] != "message" || user["role"] != "user" {
t.Fatalf("body.input[1] = %#v, want type=message role=user", user)
t.Fatalf("body.input[0] = %#v, want type=message role=user", user)
}
tools, ok := rec.body["tools"].([]any)
if !ok || len(tools) != 1 {
+1
View File
@@ -310,6 +310,7 @@ func (provider *Provider) emitChunk(
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
ReasoningTokens: chunk.Usage.CompletionTokensDetails.ReasoningTokens,
},
})
}
+8 -3
View File
@@ -88,15 +88,20 @@ type streamFunctionDelta struct {
}
type usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails promptTokenDetails `json:"prompt_tokens_details"`
CompletionTokensDetails completionTokenDetails `json:"completion_tokens_details"`
}
type promptTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
}
type completionTokenDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
}
type apiError struct {
Message string `json:"message"`
Type string `json:"type"`
+147
View File
@@ -0,0 +1,147 @@
// Package reasoning models how each provider's models expose reasoning-effort
// control. Providers disagree on the concept — OpenAI uses a discrete effort
// enum, Anthropic and Gemini 2.5 use a thinking-token budget, some models only
// toggle thinking on/off — so a single flat effort string cannot describe them.
//
// Capability is the typed, per-model description of that control, sourced from a
// community capability catalog (models.dev). It is the data the rest of Zero
// consults to decide which reasoning tiers a model actually supports, replacing
// model-name guessing. This package depends only on the standard library, so the
// model registry and provider adapters can import it without a cycle.
//
// Source of truth: this catalog is intended to become the authoritative reasoning
// -capability source. When it is wired into the live path, modelregistry's
// name-pattern inference (reasoningEffortsForModelName) is demoted to a fallback
// for models the catalog does not cover; the catalog wins where it has an entry.
// This commit is additive groundwork — nothing consumes it yet — so there is no
// behavioral divergence until that wiring lands.
package reasoning
import "strings"
// ControlKind enumerates how a model exposes reasoning control. The values
// mirror the models.dev reasoning_options[].type field.
type ControlKind string
const (
// ControlEffort is a discrete effort enum (OpenAI reasoning_effort, Gemini 3
// thinkingLevel, newer Claude output_config.effort).
ControlEffort ControlKind = "effort"
// ControlBudget is a thinking-token budget (Gemini 2.5 thinkingBudget, legacy
// Claude thinking.budget_tokens).
ControlBudget ControlKind = "budget_tokens"
// ControlToggle is an on/off thinking switch with no levels.
ControlToggle ControlKind = "toggle"
)
// Control is one reasoning control a model accepts. For an effort control,
// Values lists the accepted tiers ordered weakest to strongest. For a budget
// control, Min/Max bound the thinking-token budget; each is nil when the
// provider does not bound that side, kept distinct from an explicit 0 (Gemini
// uses min: 0 to mean "thinking can be disabled", which a plain int would lose).
type Control struct {
Kind ControlKind `json:"type"`
Values []string `json:"values,omitempty"`
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
}
// clone returns a deep copy of the control so the embedded catalog cannot be
// mutated through a returned Control's slice or budget pointers.
func (ctrl Control) clone() Control {
out := Control{Kind: ctrl.Kind}
if ctrl.Values != nil {
out.Values = append([]string(nil), ctrl.Values...)
}
if ctrl.Min != nil {
min := *ctrl.Min
out.Min = &min
}
if ctrl.Max != nil {
max := *ctrl.Max
out.Max = &max
}
return out
}
// Capability is the reasoning capability of a single model: whether it reasons
// at all, and through which controls. A model may carry more than one control
// (e.g. a newer Claude model exposes both an effort enum and a token budget); a
// reasoning model with no controls reasons but exposes no knob (always-on).
type Capability struct {
Reasoning bool `json:"reasoning"`
Controls []Control `json:"reasoning_options,omitempty"`
}
// clone returns a deep copy of the capability, cloning each control, so a caller
// cannot mutate the shared catalog through the returned value's slices.
func (c Capability) clone() Capability {
out := Capability{Reasoning: c.Reasoning}
if c.Controls != nil {
out.Controls = make([]Control, len(c.Controls))
for i, ctrl := range c.Controls {
out.Controls[i] = ctrl.clone()
}
}
return out
}
// Supported reports whether the model performs any reasoning.
func (c Capability) Supported() bool { return c.Reasoning }
// EffortControl returns the model's effort control and whether it has one. The
// returned Control is a deep copy, so a caller cannot mutate the shared catalog
// through its Values slice.
func (c Capability) EffortControl() (Control, bool) {
for _, ctrl := range c.Controls {
if ctrl.Kind == ControlEffort {
return ctrl.clone(), true
}
}
return Control{}, false
}
// EffortValues returns the ordered effort tiers the model accepts, or nil when
// it has no effort control (a budget- or toggle-only model, or a non-reasoning
// model).
func (c Capability) EffortValues() []string {
if ctrl, ok := c.EffortControl(); ok {
return ctrl.Values
}
return nil
}
// SupportsEffort reports whether tier is one of the model's accepted effort
// values (case-insensitive).
func (c Capability) SupportsEffort(tier string) bool {
tier = strings.ToLower(strings.TrimSpace(tier))
for _, v := range c.EffortValues() {
if strings.ToLower(v) == tier {
return true
}
}
return false
}
// BudgetControl returns the model's token-budget control and whether it has one.
// The returned Control is a deep copy: its Min/Max are independent pointers, so a
// caller cannot mutate the shared catalog through them. Min/Max are nil when that
// bound is unspecified (a real 0, e.g. Gemini's min: 0, is a non-nil pointer to 0).
func (c Capability) BudgetControl() (Control, bool) {
for _, ctrl := range c.Controls {
if ctrl.Kind == ControlBudget {
return ctrl.clone(), true
}
}
return Control{}, false
}
// HasControl reports whether the model exposes a control of the given kind.
func (c Capability) HasControl(kind ControlKind) bool {
for _, ctrl := range c.Controls {
if ctrl.Kind == kind {
return true
}
}
return false
}
+99
View File
@@ -0,0 +1,99 @@
package reasoning
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
)
// modelsdev_snapshot.json is a trimmed snapshot of https://models.dev/api.json:
// for a set of first-party providers, each model's api id mapped to its
// reasoning capability (the `reasoning` flag and `reasoning_options`). Routers
// (OpenRouter/Azure/etc.) are intentionally excluded — they carry generic or
// stale option lists, so a lookup resolves to the first-party provider instead.
//
// Provenance is recorded in the snapshot's `_source`/`_fetched` header fields.
// To regenerate: fetch api.json, then for each provider in
// {anthropic, openai, google, xai, groq, deepseek} keep `{reasoning,
// reasoning_options}` per api id, and bump `_fetched`. Runtime auto-refresh is a
// separate change.
//
//go:embed modelsdev_snapshot.json
var snapshotBytes []byte
// snapshotDoc is the on-disk shape of the embedded snapshot.
type snapshotDoc struct {
Providers map[string]map[string]Capability `json:"providers"`
}
// Catalog is a reasoning-capability lookup keyed by provider slug and api model
// id. It is read-only after construction.
type Catalog struct {
byProvider map[string]map[string]Capability
}
// ParseCatalog builds a Catalog from a models.dev-style snapshot document.
func ParseCatalog(data []byte) (Catalog, error) {
var doc snapshotDoc
if err := json.Unmarshal(data, &doc); err != nil {
return Catalog{}, fmt.Errorf("reasoning: parse catalog: %w", err)
}
if len(doc.Providers) == 0 {
return Catalog{}, fmt.Errorf("reasoning: catalog has no providers")
}
return Catalog{byProvider: doc.Providers}, nil
}
// embedded is the catalog parsed from the bundled snapshot. A malformed embed is
// a build/release error, so it panics at init rather than failing silently.
var embedded = mustParseEmbedded()
func mustParseEmbedded() Catalog {
c, err := ParseCatalog(snapshotBytes)
if err != nil {
panic("reasoning: invalid embedded snapshot: " + err.Error())
}
return c
}
// Embedded returns the catalog parsed from the bundled models.dev snapshot.
func Embedded() Catalog { return embedded }
// Lookup returns the reasoning capability for a model identified by its Zero
// provider kind and the API model id — the provider's wire name (e.g.
// "claude-opus-4-1-20250805"), NOT the friendly registry id ("claude-opus-4.1").
// The match is exact: the api model id is only trimmed, not case-folded, and no
// router prefixes ("openai/…") or suffixes (":cloud") are stripped. ok is false
// when the provider or model is not in the snapshot, in which case the caller
// falls back to its next capability source.
func (c Catalog) Lookup(provider, apiModel string) (Capability, bool) {
apiModel = strings.TrimSpace(apiModel)
if apiModel == "" {
return Capability{}, false
}
for _, slug := range providerSlugs(provider) {
models, ok := c.byProvider[slug]
if !ok {
continue
}
if entry, ok := models[apiModel]; ok {
// Deep copy so callers cannot mutate the shared catalog through the
// returned Controls / Values slices or budget pointers.
return entry.clone(), true
}
}
return Capability{}, false
}
// providerSlugs maps a Zero provider kind to the models.dev provider slug to
// look up. Gemini and Google both resolve to "google" (AI Studio), the
// first-party source for Gemini models.
func providerSlugs(provider string) []string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "gemini":
return []string{"google"}
default:
return []string{strings.ToLower(strings.TrimSpace(provider))}
}
}
+249
View File
@@ -0,0 +1,249 @@
package reasoning
import "testing"
func TestEmbeddedCatalogLoads(t *testing.T) {
c := Embedded()
if len(c.byProvider) == 0 {
t.Fatal("embedded catalog is empty")
}
if _, ok := c.Lookup("openai", "gpt-5"); !ok {
t.Fatal("embedded catalog missing a well-known model (gpt-5)")
}
}
func TestParseCatalogRejectsEmpty(t *testing.T) {
if _, err := ParseCatalog([]byte(`{}`)); err == nil {
t.Fatal("expected an error for a catalog with no providers")
}
if _, err := ParseCatalog([]byte(`not json`)); err == nil {
t.Fatal("expected an error for malformed json")
}
}
func TestGroundTruthOpenAI(t *testing.T) {
c := Embedded()
cases := []struct {
api string
reason bool
kind ControlKind
values []string
}{
{"gpt-5", true, ControlEffort, []string{"minimal", "low", "medium", "high"}},
{"gpt-5-codex", true, ControlEffort, []string{"low", "medium", "high"}},
{"gpt-5-pro", true, ControlEffort, []string{"high"}},
{"gpt-5.1", true, ControlEffort, []string{"none", "low", "medium", "high"}},
{"gpt-5.1-codex-max", true, ControlEffort, []string{"low", "medium", "high", "xhigh"}},
{"o3", true, ControlEffort, []string{"low", "medium", "high"}},
}
for _, tc := range cases {
entry, ok := c.Lookup("openai", tc.api)
if !ok {
t.Errorf("%s: not found", tc.api)
continue
}
if entry.Supported() != tc.reason {
t.Errorf("%s: Supported=%v want %v", tc.api, entry.Supported(), tc.reason)
}
if !equalStrings(entry.EffortValues(), tc.values) {
t.Errorf("%s: efforts=%v want %v", tc.api, entry.EffortValues(), tc.values)
}
}
// Non-reasoning models report reasoning:false and expose no effort.
for _, api := range []string{"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4-turbo"} {
entry, ok := c.Lookup("openai", api)
if !ok {
t.Errorf("%s: not found", api)
continue
}
if entry.Supported() {
t.Errorf("%s: Supported=true, want false (non-reasoning)", api)
}
if entry.EffortValues() != nil {
t.Errorf("%s: efforts=%v, want nil", api, entry.EffortValues())
}
}
// Version splits: minimal vs none are not interchangeable.
gpt5, _ := c.Lookup("openai", "gpt-5")
if !gpt5.SupportsEffort("minimal") || gpt5.SupportsEffort("none") {
t.Error("gpt-5 should support minimal, not none")
}
gpt51, _ := c.Lookup("openai", "gpt-5.1")
if gpt51.SupportsEffort("minimal") || !gpt51.SupportsEffort("none") {
t.Error("gpt-5.1 should support none, not minimal")
}
}
func TestGroundTruthAnthropic(t *testing.T) {
c := Embedded()
// Legacy / budget-only models: a thinking-token budget, no effort enum. The
// budget carries an explicit min (1024) and no max — Max stays nil.
for _, api := range []string{
"claude-opus-4-1-20250805", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001",
} {
entry, ok := c.Lookup("anthropic", api)
if !ok {
t.Errorf("%s: not found", api)
continue
}
ctrl, ok := entry.BudgetControl()
if !ok {
t.Errorf("%s: expected a budget control", api)
continue
}
if ctrl.Min == nil || *ctrl.Min != 1024 {
t.Errorf("%s: budget Min=%v, want non-nil 1024", api, ctrl.Min)
}
if ctrl.Max != nil {
t.Errorf("%s: budget Max=%d, want nil (unbounded)", api, *ctrl.Max)
}
if _, ok := entry.EffortControl(); ok {
t.Errorf("%s: did not expect an effort control", api)
}
}
// Newer models: native effort enum with xhigh and the budget control removed.
opus48, ok := c.Lookup("anthropic", "claude-opus-4-8")
if !ok {
t.Fatal("claude-opus-4-8 not found")
}
if !equalStrings(opus48.EffortValues(), []string{"low", "medium", "high", "xhigh", "max"}) {
t.Errorf("opus-4-8 efforts=%v", opus48.EffortValues())
}
if _, ok := opus48.BudgetControl(); ok {
t.Error("opus-4-8 should not carry a budget control (removed)")
}
}
func TestGroundTruthGemini(t *testing.T) {
c := Embedded()
pro, ok := c.Lookup("gemini", "gemini-2.5-pro") // gemini kind -> google slug
if !ok {
t.Fatal("gemini-2.5-pro not found via the gemini provider kind")
}
ctrl, ok := pro.BudgetControl()
if !ok || ctrl.Min == nil || *ctrl.Min != 128 || ctrl.Max == nil || *ctrl.Max != 32768 {
t.Errorf("gemini-2.5-pro budget=%+v ok=%v, want min=128 max=32768", ctrl, ok)
}
if _, ok := pro.EffortControl(); ok {
t.Error("gemini-2.5-pro should be budget-only, no effort control")
}
// gemini-2.5-flash can disable thinking: a toggle plus a budget whose explicit
// min: 0 must survive (a plain int + omitempty would drop it).
flash, _ := c.Lookup("google", "gemini-2.5-flash")
if !flash.HasControl(ControlToggle) {
t.Error("gemini-2.5-flash should expose a toggle (thinking can be disabled)")
}
if fctrl, ok := flash.BudgetControl(); !ok || fctrl.Min == nil || *fctrl.Min != 0 {
t.Errorf("gemini-2.5-flash budget Min=%v, want a non-nil pointer to 0", fctrl.Min)
}
// Gemini 3.x switched to an effort enum.
if g3, ok := c.Lookup("google", "gemini-3-pro-preview"); ok {
if _, ok := g3.EffortControl(); !ok {
t.Error("gemini-3-pro-preview should expose an effort control")
}
}
}
// TestCoversZeroShippedReasoningModels pins that every reasoning model Zero
// currently ships resolves in the catalog (by its api model id), so the
// models.dev fallback actually covers Zero's catalog rather than just well-known
// ids. The api ids mirror internal/modelregistry's curated entries.
func TestCoversZeroShippedReasoningModels(t *testing.T) {
c := Embedded()
shipped := []struct {
provider, api string
wantKind ControlKind
}{
{"anthropic", "claude-opus-4-1-20250805", ControlBudget},
{"anthropic", "claude-sonnet-4-5-20250929", ControlBudget},
{"anthropic", "claude-haiku-4-5-20251001", ControlBudget},
{"google", "gemini-2.5-pro", ControlBudget},
{"google", "gemini-2.5-flash", ControlToggle},
{"google", "gemini-2.5-flash-lite", ControlToggle},
}
for _, m := range shipped {
entry, ok := c.Lookup(m.provider, m.api)
if !ok {
t.Errorf("%s/%s: not covered by the snapshot", m.provider, m.api)
continue
}
if !entry.Supported() {
t.Errorf("%s/%s: Supported=false, want a reasoning model", m.provider, m.api)
}
if !entry.HasControl(m.wantKind) {
t.Errorf("%s/%s: missing expected control %q (controls=%+v)", m.provider, m.api, m.wantKind, entry.Controls)
}
}
}
// TestLookupReturnsDeepCopy pins that a caller cannot corrupt the shared
// embedded catalog by mutating a returned Capability's slices.
func TestLookupReturnsDeepCopy(t *testing.T) {
c := Embedded()
first, ok := c.Lookup("openai", "gpt-5")
if !ok || len(first.Controls) == 0 || len(first.Controls[0].Values) == 0 {
t.Fatal("setup: gpt-5 should have an effort control with values")
}
first.Controls[0].Values[0] = "MUTATED"
first.Controls[0].Kind = "MUTATED"
second, _ := c.Lookup("openai", "gpt-5")
if second.Controls[0].Values[0] == "MUTATED" || second.Controls[0].Kind == "MUTATED" {
t.Error("Lookup leaked a shared reference; a caller's mutation reached the catalog")
}
}
// TestAccessorsReturnDeepCopies pins that EffortControl/BudgetControl hand back
// independent copies, so a caller holding a Capability that shares state with the
// catalog cannot corrupt it by mutating an accessor result's slice or pointers.
func TestAccessorsReturnDeepCopies(t *testing.T) {
min := 100
c := Capability{Reasoning: true, Controls: []Control{
{Kind: ControlEffort, Values: []string{"low", "high"}},
{Kind: ControlBudget, Min: &min},
}}
eff, _ := c.EffortControl()
eff.Values[0] = "MUTATED"
if c.Controls[0].Values[0] != "low" {
t.Error("EffortControl leaked the Values slice")
}
bud, _ := c.BudgetControl()
*bud.Min = 999
if *c.Controls[1].Min != 100 {
t.Error("BudgetControl leaked the Min pointer")
}
}
func TestLookupMissFallsThrough(t *testing.T) {
c := Embedded()
if _, ok := c.Lookup("openai", "totally-made-up-model"); ok {
t.Error("unknown model should miss")
}
if _, ok := c.Lookup("no-such-provider", "gpt-5"); ok {
t.Error("unknown provider should miss")
}
if _, ok := c.Lookup("openai", " "); ok {
t.Error("blank api model should miss")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
File diff suppressed because it is too large. Load diff
+111 -2
View File
@@ -243,6 +243,9 @@ func Package(ctx context.Context, options PackageOptions) (PackageResult, error)
if err := copyPackageFiles(rootDir, stagingDir, artifactPath, stagedBinaryPath, goos, version, helperArtifacts); err != nil {
return PackageResult{}, err
}
if err := stageLocalControlHelpers(ctx, rootDir, filepath.Join(stagingDir, "helpers")); err != nil {
return PackageResult{}, err
}
if err := createArchive(stagingDir, archivePath, goos); err != nil {
return PackageResult{}, err
}
@@ -707,6 +710,105 @@ func copyPackageFiles(rootDir string, stagingDir string, artifactPath string, st
return nil
}
var localControlHelperPackages = []string{"agent-browser", "tuistory"}
func stageLocalControlHelpers(ctx context.Context, rootDir string, helpersDir string) error {
dependencies, err := localControlHelperDependencies(rootDir)
if err != nil {
return err
}
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
return fmt.Errorf("create local-control helper staging dir: %w", err)
}
manifest := map[string]any{
"private": true,
"type": "module",
"dependencies": dependencies,
}
bytes, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(helpersDir, "package.json"), append(bytes, '\n'), 0o644); err != nil {
return fmt.Errorf("write local-control helper package.json: %w", err)
}
lockfilePath := filepath.Join(rootDir, "package-lock.json")
if _, err := os.Stat(lockfilePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("package-lock.json is required to package local-control helpers")
}
return fmt.Errorf("stat package-lock.json: %w", err)
}
if err := copyFile(lockfilePath, filepath.Join(helpersDir, "package-lock.json"), 0o644); err != nil {
return fmt.Errorf("copy local-control helper package-lock.json: %w", err)
}
npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm is required to package local-control helpers: %w", err)
}
command := exec.CommandContext(ctx, npmPath, "ci", "--omit=dev", "--no-audit", "--no-fund", "--loglevel=error")
command.Dir = helpersDir
command.Env = append(os.Environ(), "npm_config_update_notifier=false")
output, err := command.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return fmt.Errorf("install local-control helper packages: %s", message)
}
if err := verifyStagedLocalControlHelpers(helpersDir, runtime.GOOS); err != nil {
return err
}
return nil
}
func localControlHelperDependencies(rootDir string) (map[string]string, error) {
bytes, err := os.ReadFile(filepath.Join(rootDir, "package.json"))
if err != nil {
return nil, err
}
var payload struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(bytes, &payload); err != nil {
return nil, fmt.Errorf("parse package.json: %w", err)
}
dependencies := map[string]string{}
for _, name := range localControlHelperPackages {
version := strings.TrimSpace(payload.Dependencies[name])
if version == "" {
return nil, fmt.Errorf("package.json dependency %q is required for packaged local-control helpers", name)
}
dependencies[name] = version
}
return dependencies, nil
}
func verifyStagedLocalControlHelpers(helpersDir string, goos string) error {
for _, name := range localControlHelperPackages {
found := false
for _, shimName := range localControlHelperShimNames(name, goos) {
if info, err := os.Stat(filepath.Join(helpersDir, "node_modules", ".bin", shimName)); err == nil && !info.IsDir() {
found = true
break
}
}
if !found {
return fmt.Errorf("local-control helper package did not create a %s executable shim", name)
}
}
return nil
}
func localControlHelperShimNames(name string, goos string) []string {
if goos == "windows" {
return []string{name + ".cmd", name + ".exe", name}
}
return []string{name}
}
func copyFile(source string, destination string, mode fs.FileMode) error {
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return err
@@ -763,7 +865,14 @@ func createTarGzArchive(stagingDir string, archivePath string) error {
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
linkTarget := ""
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(path)
if err != nil {
return err
}
}
header, err := tar.FileInfoHeader(info, linkTarget)
if err != nil {
return err
}
@@ -771,7 +880,7 @@ func createTarGzArchive(stagingDir string, archivePath string) error {
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() {
if entry.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil
}
entryFile, err := os.Open(path)
+166 -3
View File
@@ -357,6 +357,32 @@ func TestCreateArchivesWithRootPackageFiles(t *testing.T) {
})
}
func TestCreateTarArchivePreservesSymlinkTargets(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix symlink archive behavior")
}
stagingDir := packageStagingFixture(t, "zero")
linkPath := filepath.Join(stagingDir, "helpers", "node_modules", ".bin", "agent-browser")
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
linkTarget := "../agent-browser/bin/agent-browser.js"
if err := os.Symlink(linkTarget, linkPath); err != nil {
t.Fatalf("Symlink: %v", err)
}
archivePath := filepath.Join(t.TempDir(), "zero-v0.1.0-linux-x64.tar.gz")
if err := createArchive(stagingDir, archivePath, "linux"); err != nil {
t.Fatalf("createArchive returned error: %v", err)
}
header := tarArchiveHeaders(t, archivePath)["helpers/node_modules/.bin/agent-browser"]
if header == nil {
t.Fatal("archive missing symlink header")
}
if header.Typeflag != tar.TypeSymlink || header.Linkname != linkTarget {
t.Fatalf("symlink header type/link = %v/%q, want %v/%q", header.Typeflag, header.Linkname, tar.TypeSymlink, linkTarget)
}
}
func TestCopyPackageFilesStagesLinuxSandboxHelper(t *testing.T) {
root := t.TempDir()
staging := t.TempDir()
@@ -417,6 +443,95 @@ func TestCopyPackageFilesStagesWindowsSandboxHelpers(t *testing.T) {
}
}
func TestStageLocalControlHelpersUsesPackageDependencies(t *testing.T) {
root := t.TempDir()
helpers := filepath.Join(t.TempDir(), "helpers")
mustWriteFile(t, filepath.Join(root, "package.json"), `{
"version": "0.1.0",
"dependencies": {
"agent-browser": "0.30.1",
"tuistory": "0.10.0"
}
}`)
mustWriteFile(t, filepath.Join(root, "package-lock.json"), `{
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"agent-browser": "0.30.1",
"tuistory": "0.10.0"
}
},
"node_modules/agent-browser": {
"version": "0.30.1"
},
"node_modules/tuistory": {
"version": "0.10.0"
}
}
}`)
fakeBin := t.TempDir()
writeFakeNPM(t, fakeBin)
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := stageLocalControlHelpers(context.Background(), root, helpers); err != nil {
t.Fatalf("stageLocalControlHelpers: %v", err)
}
manifestBytes, err := os.ReadFile(filepath.Join(helpers, "package.json"))
if err != nil {
t.Fatalf("ReadFile helper package.json: %v", err)
}
manifest := string(manifestBytes)
for _, want := range []string{`"agent-browser": "0.30.1"`, `"tuistory": "0.10.0"`} {
if !strings.Contains(manifest, want) {
t.Fatalf("helper package.json missing %s:\n%s", want, manifest)
}
}
if _, err := os.Stat(filepath.Join(helpers, "package-lock.json")); err != nil {
t.Fatalf("helper package-lock.json was not copied: %v", err)
}
for _, name := range localControlHelperPackages {
found := false
for _, shimName := range localControlHelperShimNames(name, runtime.GOOS) {
if _, err := os.Stat(filepath.Join(helpers, "node_modules", ".bin", shimName)); err == nil {
found = true
break
}
}
if !found {
t.Fatalf("missing shim for %s", name)
}
}
}
func TestStageLocalControlHelpersRequiresPackageLock(t *testing.T) {
root := t.TempDir()
helpers := filepath.Join(t.TempDir(), "helpers")
mustWriteFile(t, filepath.Join(root, "package.json"), `{
"version": "0.1.0",
"dependencies": {
"agent-browser": "0.30.1",
"tuistory": "0.10.0"
}
}`)
err := stageLocalControlHelpers(context.Background(), root, helpers)
if err == nil || !strings.Contains(err.Error(), "package-lock.json is required") {
t.Fatalf("stageLocalControlHelpers error = %v, want package-lock requirement", err)
}
}
func TestLocalControlHelperDependenciesRequiresConfiguredPackages(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "package.json"), `{"version":"0.1.0","dependencies":{"agent-browser":"0.30.1"}}`)
_, err := localControlHelperDependencies(root)
if err == nil || !strings.Contains(err.Error(), `dependency "tuistory"`) {
t.Fatalf("localControlHelperDependencies error = %v, want missing tuistory", err)
}
}
func packageStagingFixture(t *testing.T, binaryName string) string {
t.Helper()
dir := t.TempDir()
@@ -438,6 +553,43 @@ func packageStagingFixture(t *testing.T, binaryName string) string {
return dir
}
func writeFakeNPM(t *testing.T, dir string) {
t.Helper()
if runtime.GOOS == "windows" {
path := filepath.Join(dir, "npm.cmd")
content := `@echo off
if not "%1"=="ci" (
echo expected npm ci 1>&2
exit /b 2
)
mkdir node_modules 2>NUL
mkdir node_modules\.bin 2>NUL
echo @echo off> node_modules\.bin\agent-browser.cmd
echo @echo off> node_modules\.bin\tuistory.cmd
exit /b 0
`
mustWriteFile(t, path, content)
return
}
path := filepath.Join(dir, "npm")
content := `#!/usr/bin/env sh
set -eu
if [ "${1:-}" != "ci" ]; then
echo "expected npm ci" >&2
exit 2
fi
mkdir -p node_modules/.bin
for name in agent-browser tuistory; do
printf '#!/usr/bin/env sh\n' > "node_modules/.bin/$name"
chmod 755 "node_modules/.bin/$name"
done
`
mustWriteFile(t, path, content)
if err := os.Chmod(path, 0o755); err != nil {
t.Fatalf("Chmod fake npm: %v", err)
}
}
func mustWriteFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@@ -449,6 +601,16 @@ func mustWriteFile(t *testing.T, path string, content string) {
}
func tarArchiveNames(t *testing.T, archivePath string) map[string]bool {
t.Helper()
headers := tarArchiveHeaders(t, archivePath)
names := map[string]bool{}
for name := range headers {
names[name] = true
}
return names
}
func tarArchiveHeaders(t *testing.T, archivePath string) map[string]*tar.Header {
t.Helper()
file, err := os.Open(archivePath)
if err != nil {
@@ -465,7 +627,7 @@ func tarArchiveNames(t *testing.T, archivePath string) map[string]bool {
_ = gzipReader.Close()
}()
reader := tar.NewReader(gzipReader)
names := map[string]bool{}
headers := map[string]*tar.Header{}
for {
header, err := reader.Next()
if err != nil {
@@ -474,9 +636,10 @@ func tarArchiveNames(t *testing.T, archivePath string) map[string]bool {
}
t.Fatalf("tar Next: %v", err)
}
names[header.Name] = true
copied := *header
headers[header.Name] = &copied
}
return names
return headers
}
func zipArchiveNames(t *testing.T, archivePath string) map[string]bool {
+65
View File
@@ -0,0 +1,65 @@
package reltime
import "fmt"
// RelTime renders a duration in seconds as a human-readable relative-time
// string. This is the ORIGINAL implementation, kept verbatim so
// characterization tests can be pinned against it before refactoring.
func RelTime(d int) string {
if d < 0 {
d = -d
if d < 60 {
if d == 1 {
return "1 second ago"
}
return fmt.Sprintf("%d seconds ago", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
}
x := d / 86400
if x == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", x)
} else if d == 0 {
return "just now"
} else {
if d < 60 {
if d == 1 {
return "in 1 second"
}
return fmt.Sprintf("in %d seconds", d)
}
if d < 3600 {
m := d / 60
if m == 1 {
return "in 1 minute"
}
return fmt.Sprintf("in %d minutes", m)
}
if d < 86400 {
h := d / 3600
if h == 1 {
return "in 1 hour"
}
return fmt.Sprintf("in %d hours", h)
}
x := d / 86400
if x == 1 {
return "in 1 day"
}
return fmt.Sprintf("in %d days", x)
}
}
+61 -12
View File
@@ -34,6 +34,15 @@ var networkPrograms = map[string]bool{
"ftp": true,
}
var localServerPrograms = map[string]bool{
"http-server": true,
"serve": true,
"vite": true,
"next": true,
"nuxt": true,
"astro": true,
}
// AnalyzeCommand parses script and reports interactive/destructive/network usage
// from the shell AST. A script that cannot be parsed yields TooComplex (with no
// other flags set) so the caller can decide how to treat an unanalyzable command.
@@ -105,25 +114,37 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
return true
}
words := literalWordTexts(args)
if localServerPrograms[prog] {
return true
}
switch prog {
case "python", "python2", "python3", "py":
return pythonModuleUsesNetwork(words)
case "npm":
return firstSubcommand(words, nil) == "install" ||
firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "publish" ||
firstSubcommand(words, nil) == "login"
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"exec": "exec",
"x": "exec",
})
case "pnpm":
return firstSubcommand(words, nil) == "install" ||
firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "publish"
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"exec": "exec",
"dlx": "exec",
})
case "yarn":
return firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "publish"
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"exec": "exec",
"dlx": "exec",
})
case "bun":
return firstSubcommand(words, nil) == "add" ||
firstSubcommand(words, nil) == "install" ||
firstSubcommand(words, nil) == "publish"
return packageManagerUsesNetwork(words, map[string]string{
"run": "run",
"x": "exec",
})
case "npx":
return npxUsesNetwork(words)
case "pip", "pip2", "pip3":
return firstSubcommand(words, nil) == "install"
case "go":
@@ -137,6 +158,34 @@ func commandUsesNetwork(prog string, args []*syntax.Word) bool {
}
}
func packageManagerUsesNetwork(words []string, aliases map[string]string) bool {
first := firstSubcommand(words, aliases)
switch first {
case "install", "add", "publish", "login":
return true
case "start", "serve", "dev", "preview":
return true
case "run":
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]
}
}
return false
}
func npxUsesNetwork(_ []string) bool {
return true
}
func literalWordTexts(args []*syntax.Word) []string {
words := make([]string, 0, len(args))
for _, arg := range args {
+7
View File
@@ -49,10 +49,17 @@ 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 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},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true},
{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: "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},
{name: "process listing is not special-cased", script: "ps aux", network: false},
{name: "unparseable", script: `'unterminated quote`, tooComplex: true},
}
+13 -6
View File
@@ -103,12 +103,16 @@ func TestCommandPlanCarriesSandboxMetadata(t *testing.T) {
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendUnavailable, Platform: "linux", Fallback: true, Message: "native sandbox unavailable"},
})
if _, err := unavailable.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root}); !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildCommandPlan unavailable error = %v, want native sandbox unavailable", err)
degraded, err := unavailable.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan unavailable auto plan: %v", err)
}
if degraded.Wrapped || degraded.EnforcementLevel != EnforcementDegraded || degraded.DowngradeReason != "native sandbox unavailable" {
t.Fatalf("unavailable command metadata = %#v, want degraded direct plan", degraded)
}
}
func TestUnavailableFailClosedForTargetPlatforms(t *testing.T) {
func TestUnavailableBackendsDegradeForTargetPlatforms(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
tests := []struct {
@@ -136,9 +140,12 @@ func TestUnavailableFailClosedForTargetPlatforms(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: test.backend})
_, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
if !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildCommandPlan error = %v, want native sandbox unavailable", err)
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan degraded auto plan: %v", err)
}
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || !plan.RequiresPlatformSandbox {
t.Fatalf("BuildCommandPlan = %#v, want degraded direct plan requiring platform sandbox metadata", plan)
}
})
}
+28
View File
@@ -40,6 +40,34 @@ func TestSandboxedBashAutoAllowedWhenSandboxActive(t *testing.T) {
}
}
func TestSandboxedBashRequireEscalatedStillPromptsWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
request := bashRequest()
request.Args["sandbox_permissions"] = "require_escalated"
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionPrompt || decision.Reason != ReasonEscalatedSandboxRequired {
t.Fatalf("decision = %#v, want require_escalated prompt", decision)
}
}
func TestSandboxedBashProcessListingAutoAllowedWhenSandboxActive(t *testing.T) {
engine := sandboxedShellEngine(t, nativeWrappingBackend)
request := bashRequest()
request.Args["command"] = "ps aux"
decision := engine.Evaluate(context.Background(), request)
if decision.Action != ActionAllow {
t.Fatalf("decision = %#v, want allow (sandbox active)", decision)
}
if len(decision.Risk.Categories) != 1 || !HasRiskCategory(decision.Risk, "shell") {
t.Fatalf("risk = %#v, want only shell category", decision.Risk)
}
if !decision.AutoAllowed {
t.Fatalf("sandboxed shell command should be auto-allowed: %#v", decision)
}
}
func TestSandboxedBashStillPromptsWithoutSandbox(t *testing.T) {
engine := sandboxedShellEngine(t, Backend{Name: BackendUnavailable})
decision := engine.Evaluate(context.Background(), bashRequest())
+37 -2
View File
@@ -46,7 +46,7 @@ func NewEngine(options EngineOptions) *Engine {
}
}
if scope == nil && workspaceRoot != "" {
scope = &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
scope = newScopeBestEffort(workspaceRoot)
}
return &Engine{
workspaceRoot: workspaceRoot,
@@ -170,6 +170,19 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
return NormalizeNetworkMode(policy.Network)
}
// UnsandboxedExecutionAllowed reports whether an escalated shell attempt may
// bypass the native sandbox without dropping active denied-read restrictions.
func (engine *Engine) UnsandboxedExecutionAllowed() bool {
if engine == nil {
return true
}
policy := engine.effectivePolicy(engine.policy)
if policy.Mode == ModeDisabled {
return true
}
return len(normalizeProfilePaths(policy.DenyRead)) == 0
}
// toolNetworkExempt reports whether a request is exempt from the engine-level
// network deny because it is a first-party, in-process network tool. Such tools
// do not use sandboxed shell egress; they keep their own SSRF/host safeguards. A
@@ -190,7 +203,15 @@ func (engine *Engine) scopeFor(requestRoot string) *Scope {
if engine.scope != nil && requestRoot == engine.workspaceRoot {
return engine.scope
}
return &Scope{workspaceRoot: requestRoot}
return newScopeBestEffort(requestRoot)
}
func newScopeBestEffort(workspaceRoot string) *Scope {
scope, err := NewScope(workspaceRoot, nil)
if err == nil {
return scope
}
return &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
}
// shellSandboxActive reports whether a native wrapping sandbox would actually
@@ -340,6 +361,16 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
return deny(request, risk, BlockDestructiveCommand, "", "destructive shell command requires approval", false)
}
}
if request.SideEffect == SideEffectShell && requestRequiresEscalatedSandbox(request) {
if !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonEscalatedSandboxRequired}
}
// Unsafe mode may auto-allow ordinary shell prompts, but it must not
// silently convert a sandboxed shell run into an unsandboxed one.
if !request.PermissionGranted {
return deny(request, risk, BlockDeniedPermission, "", ReasonEscalatedSandboxRequired, false)
}
}
if persistentAllow != nil {
return Decision{
Action: ActionAllow,
@@ -380,6 +411,10 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
return Decision{Action: ActionPrompt, Risk: risk, Reason: permissionReason(request)}
}
func requestRequiresEscalatedSandbox(request Request) bool {
return strings.TrimSpace(firstArgString(request.Args, "sandbox_permissions")) == "require_escalated"
}
func (engine *Engine) Grant(input GrantInput) (Grant, error) {
if engine == nil || engine.store == nil {
return Grant{}, errors.New("sandbox grant store is not configured")
+57 -12
View File
@@ -70,6 +70,23 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy})
if engine.UnsandboxedExecutionAllowed() {
t.Fatal("denied-read policy must not allow unsandboxed escalation")
}
policy.DenyRead = nil
engine = NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy})
if !engine.UnsandboxedExecutionAllowed() {
t.Fatal("policy without denied reads should allow unsandboxed escalation")
}
}
func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
root := t.TempDir()
store, err := NewGrantStore(StoreOptions{
@@ -222,7 +239,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
Args: map[string]any{"path": outsideDefaultTempPath(root, "escape.txt")},
})
if outside.Action != ActionPrompt || outside.Block == nil || outside.Block.Code != BlockOutsideWorkspace || !outside.Block.Recoverable {
t.Fatalf("outside workspace mutation decision = %#v, want recoverable prompt", outside)
@@ -308,7 +325,7 @@ func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
func TestEnginePersistentGrantAllowsPromptableOutsideWorkspacePath(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
outside := outsideDefaultTempPath(root, "escape.txt")
store, err := NewGrantStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
Now: fixedSandboxTime("2026-06-05T14:00:00Z"),
@@ -345,7 +362,7 @@ func TestEnginePersistentGrantAllowsPromptableOutsideWorkspacePath(t *testing.T)
func TestEnginePersistentGrantDoesNotBypassSymlinkTraversal(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outside := outsideDefaultTempPath(root, "symlink-target")
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
@@ -384,7 +401,7 @@ func TestEnginePersistentGrantDoesNotBypassSymlinkTraversal(t *testing.T) {
func TestEngineDeniesAutoAllowedSymlinkEscape(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outside := outsideDefaultTempPath(root, "symlink-target")
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
@@ -443,7 +460,7 @@ func TestEngineGrantScopesWebFetchToHost(t *testing.T) {
func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
outside := outsideDefaultTempPath(root, "escape.txt")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
@@ -467,7 +484,7 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "escape.txt")
outside := outsideDefaultTempPath(root, "escape.txt")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
// A denied request reports its block without running anything.
@@ -502,7 +519,7 @@ func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
func TestEngineDeniesWorkspaceSymlinkTraversal(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outside := outsideDefaultTempPath(root, "symlink-target")
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
@@ -625,7 +642,7 @@ func TestEngineReportsContextCancellation(t *testing.T) {
func TestEvaluateOverrideRootDoesNotInheritEngineScope(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
@@ -685,7 +702,7 @@ func promptWorkspaceWritePolicy() Policy {
func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
@@ -713,7 +730,7 @@ func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionAllow,
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
Args: map[string]any{"path": outsideDefaultTempPath(workspace, "escape.txt")},
})
if outside.Action != ActionPrompt || outside.Block == nil || !outside.Block.Recoverable {
t.Fatalf("outside write Action=%q, want recoverable prompt with block", outside.Action)
@@ -730,7 +747,7 @@ func TestEvaluateAllowsWritesInsideExtraScopeRoot(t *testing.T) {
// skip, turning the engine into an escape hatch.
func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
extra := tempDirOutsideDefaultTemp(t)
scope, err := NewScope(workspace, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
@@ -747,7 +764,7 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionAllow,
Args: map[string]any{"path": filepath.Join(t.TempDir(), "escape.txt")},
Args: map[string]any{"path": outsideDefaultTempPath(workspace, "escape.txt")},
})
if outside.Action != ActionPrompt || outside.Block == nil {
t.Fatalf("scope-only engine, out-of-scope write Action=%q, want prompt with block", outside.Action)
@@ -764,3 +781,31 @@ func TestNewEngineDerivesWorkspaceRootFromScope(t *testing.T) {
t.Fatalf("scope-only engine, in-workspace write Action=%q (%s), want allow", inside.Action, inside.Reason)
}
}
func TestEvaluateAllowsWritesInsideDefaultTempRoot(t *testing.T) {
workspace := t.TempDir()
tempRoot := t.TempDir()
t.Setenv("TMPDIR", tempRoot)
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
engine := NewEngine(EngineOptions{
WorkspaceRoot: workspace,
Policy: DefaultPolicy(),
Scope: scope,
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "write_file",
SideEffect: SideEffectWrite,
Permission: PermissionAllow,
Args: map[string]any{"path": filepath.Join(tempRoot, "zero-toolcheck", "go.mod")},
})
if decision.Action != ActionAllow {
t.Fatalf("temp-root write Action=%q (%s), want allow", decision.Action, decision.Reason)
}
if HasRiskCategory(decision.Risk, "out_of_workspace") {
t.Fatalf("temp-root write risk=%v, must not be out_of_workspace", decision.Risk)
}
}
+69
View File
@@ -41,6 +41,11 @@ var scopeArgKeys = []struct {
// no scoped argument is present, the value is not a string, or the value points
// at the workspace root (".") -- in those cases the grant is plainly tool-wide.
func DeriveScope(toolName string, args map[string]any) (string, ScopeKind) {
if toolName == "browser_open" {
if host, ok := deriveBrowserHostScope(args["url"]); ok {
return host, ScopeHost
}
}
if toolName == "web_fetch" {
if host, ok := deriveHostScope(args["url"]); ok {
return host, ScopeHost
@@ -78,6 +83,70 @@ func deriveHostScope(value any) (string, bool) {
return host, host != ""
}
func deriveBrowserHostScope(value any) (string, bool) {
raw, ok := value.(string)
if !ok {
return "", false
}
trimmed := strings.TrimSpace(raw)
if trimmed != "" && !strings.Contains(trimmed, "://") {
if !browserURLLooksLikeBareHost(trimmed) {
return "", false
}
trimmed = "https://" + trimmed
}
return deriveHostScope(trimmed)
}
func browserURLLooksLikeBareHost(raw string) bool {
if raw == "" || strings.HasPrefix(raw, "//") {
return false
}
authority := raw
if cut := strings.IndexAny(authority, "/?#"); cut >= 0 {
authority = authority[:cut]
}
if authority == "" || strings.Contains(authority, "@") {
return false
}
host := authority
if strings.HasPrefix(authority, "[") {
end := strings.Index(authority, "]")
if end <= 1 {
return false
}
host = authority[1:end]
if rest := authority[end+1:]; rest != "" {
if !strings.HasPrefix(rest, ":") || !isDecimalPort(rest[1:]) {
return false
}
}
} else if strings.Contains(authority, ":") {
before, after, _ := strings.Cut(authority, ":")
if before == "" || !isDecimalPort(after) {
return false
}
host = before
}
host = strings.TrimSpace(host)
if host == "" {
return false
}
return host == "localhost" || strings.Contains(host, ".") || net.ParseIP(host) != nil
}
func isDecimalPort(raw string) bool {
if raw == "" {
return false
}
for _, r := range raw {
if r < '0' || r > '9' {
return false
}
}
return true
}
// resolveScopeAbs converts a raw scope to an absolute, cleaned path. A relative
// scope is anchored to workspaceRoot; when workspaceRoot is empty it falls back
// to filepath.Abs (process working directory). An empty scope resolves to "".
+5
View File
@@ -140,6 +140,11 @@ func TestDeriveScope(t *testing.T) {
{name: "whitespace path is tool-wide", tool: "write_file", args: map[string]any{"path": " "}, wantRaw: "", wantKind: ScopeToolWide},
{name: "web fetch url host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
{name: "web fetch invalid url is tool-wide", tool: "web_fetch", args: map[string]any{"url": "://bad"}, wantRaw: "", wantKind: ScopeToolWide},
{name: "browser open url host", tool: "browser_open", args: map[string]any{"url": "https://Example.COM/path?q=1"}, wantRaw: "example.com", wantKind: ScopeHost},
{name: "browser open bare host", tool: "browser_open", args: map[string]any{"url": "Example.COM/path"}, wantRaw: "example.com", wantKind: ScopeHost},
{name: "browser open loopback bare host with port", tool: "browser_open", args: map[string]any{"url": "localhost:8080/path"}, wantRaw: "localhost", wantKind: ScopeHost},
{name: "browser open about blank is tool-wide", tool: "browser_open", args: map[string]any{"url": "about:blank"}, wantRaw: "", wantKind: ScopeToolWide},
{name: "browser open javascript url is tool-wide", tool: "browser_open", args: map[string]any{"url": "javascript:alert(1)"}, wantRaw: "", wantKind: ScopeToolWide},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+3 -2
View File
@@ -96,8 +96,9 @@ func TestSeatbeltProfileAllowsUserToolAncestorMetadata(t *testing.T) {
}
}
// On macOS the scrubbed sandbox environment must still expose the Homebrew bin
// dir so `python3`/`node` resolve to the user's install, not a system stub.
// On macOS the sandbox environment preserves the caller env while still exposing
// the Homebrew bin dir so `python3`/`node` resolve to the user's install, not a
// system stub.
func TestSandboxEnvironmentExposesHomebrewOnMac(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("PATH augmentation is macOS-only")
+79 -26
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
@@ -158,15 +159,17 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
args := []string{
"--new-session",
"--die-with-parent",
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
}
args = append(args, linuxBwrapFilesystemArgs(config.PermissionProfile)...)
if pathExists(helperPath) {
args = append(args, "--ro-bind", helperPath, helperPath)
}
args = append(args,
"--unshare-user",
"--unshare-pid",
)
// Keep IPC and UTS shared for compatibility with the host CLI environment;
// network isolation is still applied below when the policy denies egress.
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
args = append(args, "--unshare-net")
}
@@ -174,8 +177,7 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
args = append(args, "--proc", "/proc")
}
args = append(args, "--chdir", commandCWD)
args = append(args, "--clearenv")
for _, env := range linuxHelperSandboxEnvironment(config.PermissionProfile, commandCWD) {
for _, env := range linuxHelperSandboxEnvironmentOverrides(config.PermissionProfile) {
key, value, ok := strings.Cut(env, "=")
if ok {
args = append(args, "--setenv", key, value)
@@ -189,10 +191,10 @@ func BuildLinuxSandboxBwrapArgs(options LinuxSandboxBwrapOptions) ([]string, err
func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
fs := profile.FileSystem
if fs.Kind == FileSystemUnrestricted {
args := []string{"--ro-bind", "/", "/", "--dev", "/dev"}
if fs.AllowTemp {
args = append(args, "--tmpfs", "/tmp")
}
// Disabled filesystem policy means no write jail: expose the host root
// read-write, including the host /dev tree, rather than synthesizing a
// restricted bubblewrap filesystem.
args := []string{"--bind", "/", "/"}
for _, root := range fs.WriteRoots {
if pathExists(root.Root) {
args = append(args, "--bind", root.Root, root.Root)
@@ -201,21 +203,26 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return args
}
args := []string{"--tmpfs", "/", "--dev", "/dev"}
if fs.IncludePlatformRoots {
for _, root := range linuxPlatformReadRoots() {
args = append(args, "--ro-bind", root, root)
args := []string{}
if linuxProfileHasFullReadRoot(fs) {
args = append(args, "--ro-bind", "/", "/", "--dev", "/dev")
} else {
args = append(args, "--tmpfs", "/", "--dev", "/dev")
if fs.IncludePlatformRoots {
for _, root := range linuxPlatformReadRoots() {
args = append(args, "--ro-bind", root, root)
}
}
}
for _, root := range fs.ReadRoots {
if pathExists(root) {
args = append(args, "--ro-bind", root, root)
for _, root := range fs.ReadRoots {
if pathExists(root) {
args = append(args, "--ro-bind", root, root)
}
}
}
if fs.AllowTemp {
args = append(args, "--tmpfs", "/tmp")
fs.WriteRoots = linuxWriteRootsWithTemp(fs)
}
for _, root := range fs.WriteRoots {
for _, root := range linuxSortedWriteRoots(fs.WriteRoots) {
if !pathExists(root.Root) {
continue
}
@@ -236,6 +243,52 @@ func linuxBwrapFilesystemArgs(profile PermissionProfile) []string {
return args
}
func linuxWriteRootsWithTemp(fs FileSystemPolicy) []WritableRoot {
roots := append([]WritableRoot{}, fs.WriteRoots...)
for _, tempRoot := range defaultTempWriteRoots() {
found := false
for _, root := range roots {
if filepath.Clean(root.Root) == filepath.Clean(tempRoot) {
found = true
break
}
}
if !found {
roots = append(roots, WritableRoot{Root: tempRoot})
}
}
return roots
}
func linuxSortedWriteRoots(roots []WritableRoot) []WritableRoot {
sorted := append([]WritableRoot{}, roots...)
sort.SliceStable(sorted, func(i, j int) bool {
return pathDepth(sorted[i].Root) < pathDepth(sorted[j].Root)
})
return sorted
}
func pathDepth(path string) int {
cleaned := filepath.Clean(path)
if cleaned == "" || filepath.Dir(cleaned) == cleaned {
return 0
}
trimmed := strings.Trim(cleaned, string(filepath.Separator))
if trimmed == "" {
return 0
}
return strings.Count(trimmed, string(filepath.Separator)) + 1
}
func linuxProfileHasFullReadRoot(fs FileSystemPolicy) bool {
for _, root := range fs.ReadRoots {
if filepath.Clean(root) == string(filepath.Separator) {
return true
}
}
return false
}
func linuxPlatformReadRoots() []string {
candidates := []string{"/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/nix/store", "/run/current-system/sw"}
roots := make([]string, 0, len(candidates))
@@ -273,16 +326,16 @@ func shouldUnshareLinuxNetwork(policy NetworkPolicy) bool {
return NormalizeNetworkMode(policy.Mode) == NetworkDeny
}
func linuxHelperSandboxEnvironment(profile PermissionProfile, home string) []string {
env := []string{
"HOME=" + home,
"PATH=" + firstEnv("PATH", defaultPath()),
"TERM=" + firstEnv("TERM", "dumb"),
func linuxHelperSandboxEnvironment(profile PermissionProfile, base []string) []string {
return upsertEnvList(base, linuxHelperSandboxEnvironmentOverrides(profile)...)
}
func linuxHelperSandboxEnvironmentOverrides(profile PermissionProfile) []string {
return []string{
EnvSandboxBackend + "=" + string(linuxSandboxBackendEnv),
"ZERO_SANDBOX_NETWORK=" + string(profile.Network.Mode),
EnvSandboxed + "=1",
}
return env
}
func pathExists(path string) bool {
+13 -2
View File
@@ -10,6 +10,11 @@ import (
"syscall"
)
var (
applyUnixSocketBlockFilter = ApplyUnixSocketBlock
applyLinuxNetworkDenyFilter = ApplyLinuxNetworkDeny
)
func RunLinuxSandboxHelper(args []string, stderr io.Writer) int {
config, err := ParseLinuxSandboxHelperArgs(args)
if err != nil {
@@ -64,8 +69,14 @@ func runLinuxSandboxInnerStage(config LinuxSandboxHelperConfig, stderr io.Writer
fmt.Fprintln(stderr, LinuxSandboxHelperName+": inner seccomp stage is incompatible with Landlock mode")
return 2
}
if shouldUnshareLinuxNetwork(config.PermissionProfile.Network) {
if err := applyLinuxNetworkDenyFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": apply network deny: "+err.Error())
return 125
}
}
if config.BlockUnixSockets {
if err := ApplyUnixSocketBlock(); err != nil {
if err := applyUnixSocketBlockFilter(); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": warning: "+err.Error()+"; running without the Unix-socket filter")
}
}
@@ -91,7 +102,7 @@ func runLinuxSandboxLandlockStage(config LinuxSandboxHelperConfig, stderr io.Wri
fmt.Fprintln(stderr, LinuxSandboxHelperName+": "+err.Error())
return 127
}
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, config.CommandCWD)); err != nil {
if err := syscall.Exec(binary, config.Command, linuxHelperSandboxEnvironment(config.PermissionProfile, os.Environ())); err != nil {
fmt.Fprintln(stderr, LinuxSandboxHelperName+": exec command: "+err.Error())
return 126
}
@@ -0,0 +1,67 @@
//go:build linux
package sandbox
import (
"bytes"
"errors"
"testing"
)
func TestLinuxSandboxInnerStageAppliesNetworkDenySeccomp(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
originalUnixBlock := applyUnixSocketBlockFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
applyUnixSocketBlockFilter = originalUnixBlock
})
networkDenyCalls := 0
unixBlockCalls := 0
applyLinuxNetworkDenyFilter = func() error {
networkDenyCalls++
return nil
}
applyUnixSocketBlockFilter = func() error {
unixBlockCalls++
return nil
}
var stderr bytes.Buffer
code := runLinuxSandboxInnerStage(LinuxSandboxHelperConfig{
PermissionProfile: PermissionProfile{Network: NetworkPolicy{Mode: NetworkDeny}},
BlockUnixSockets: true,
Command: []string{"definitely-not-a-real-zero-test-command"},
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 after filters; stderr=%s", code, stderr.String())
}
if networkDenyCalls != 1 {
t.Fatalf("network deny filter calls = %d, want 1", networkDenyCalls)
}
if unixBlockCalls != 1 {
t.Fatalf("unix socket filter calls = %d, want 1", unixBlockCalls)
}
}
func TestLinuxSandboxInnerStageSkipsNetworkDenySeccompWhenAllowed(t *testing.T) {
originalNetworkDeny := applyLinuxNetworkDenyFilter
t.Cleanup(func() {
applyLinuxNetworkDenyFilter = originalNetworkDeny
})
applyLinuxNetworkDenyFilter = func() error {
return errors.New("network deny filter should not run")
}
var stderr bytes.Buffer
code := runLinuxSandboxInnerStage(LinuxSandboxHelperConfig{
PermissionProfile: PermissionProfile{Network: NetworkPolicy{Mode: NetworkAllow}},
Command: []string{"definitely-not-a-real-zero-test-command"},
}, &stderr)
if code != 127 {
t.Fatalf("exit code = %d, want lookup failure 127 without network filter; stderr=%s", code, stderr.String())
}
}
+147
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
@@ -116,6 +117,7 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
{"--unshare-user"},
{"--unshare-pid"},
{"--unshare-net"},
{"--ro-bind", "/", "/"},
{"--chdir", "/workspace"},
{"--setenv", EnvSandboxBackend, string(BackendLinuxBwrap)},
{"--ro-bind", helperPath, helperPath},
@@ -126,6 +128,20 @@ func TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage(t *testing.T) {
} {
assertArgsContainSequence(t, bwrapArgs, want...)
}
if argsContainSequence(bwrapArgs, "--tmpfs", "/") {
t.Fatalf("default workspace-write profile must not start from an empty root: %#v", bwrapArgs)
}
if argsContainSequence(bwrapArgs, "--tmpfs", "/tmp") {
t.Fatalf("default workspace-write profile must not replace host /tmp: %#v", bwrapArgs)
}
if stringSliceContains(bwrapArgs, "--clearenv") {
t.Fatalf("Linux bwrap args must preserve caller environment like upstream: %#v", bwrapArgs)
}
for _, unwanted := range []string{"--unshare-ipc", "--unshare-uts"} {
if stringSliceContains(bwrapArgs, unwanted) {
t.Fatalf("Linux bwrap args should match upstream namespace set; found %s in %#v", unwanted, bwrapArgs)
}
}
}
func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) {
@@ -161,6 +177,114 @@ func TestBuildLinuxSandboxBwrapArgsKeepsHostNetworkWhenAllowed(t *testing.T) {
assertArgsContainSequence(t, bwrapArgs, "--setenv", "ZERO_SANDBOX_NETWORK", string(NetworkAllow))
}
func TestLinuxBwrapRootReadUsesReadOnlyHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: "/workspace"}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkAllow},
}
args := linuxBwrapFilesystemArgs(profile)
assertArgsContainSequence(t, args, "--ro-bind", "/", "/")
if argsContainSequence(args, "--tmpfs", "/") {
t.Fatalf("root-read profile must not start from an empty root: %#v", args)
}
}
func TestLinuxBwrapTempUsesHostWriteRoots(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Linux bwrap temp root assertions use Unix paths")
}
tmpdir := t.TempDir()
t.Setenv("TMPDIR", tmpdir)
workspace := filepath.Join(tmpdir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("MkdirAll workspace: %v", err)
}
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{string(filepath.Separator)},
WriteRoots: []WritableRoot{{Root: workspace, ProtectedMetadataNames: []string{".git"}}},
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkAllow},
}
args := linuxBwrapFilesystemArgs(profile)
if argsContainSequence(args, "--tmpfs", "/tmp") {
t.Fatalf("workspace-write temp access must bind host /tmp, not create private tmpfs: %#v", args)
}
for _, tempRoot := range defaultTempWriteRoots() {
if pathExists(tempRoot) {
assertArgsContainSequence(t, args, "--bind", tempRoot, tempRoot)
}
}
assertArgsContainSequence(t, args, "--bind", workspace, workspace)
if runtime.GOOS == "linux" {
tmpdirBind := argsSequenceIndex(args, "--bind", tmpdir, tmpdir)
workspaceBind := argsSequenceIndex(args, "--bind", workspace, workspace)
if tmpdirBind < 0 || workspaceBind < 0 || tmpdirBind > workspaceBind {
t.Fatalf("broader temp root must be bound before nested workspace root; args=%#v", args)
}
}
}
func TestLinuxBwrapUnrestrictedFilesystemUsesWritableHostRoot(t *testing.T) {
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemUnrestricted,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
args := linuxBwrapFilesystemArgs(profile)
assertArgsContainSequence(t, args, "--bind", "/", "/")
if argsContainSequence(args, "--ro-bind", "/", "/") {
t.Fatalf("unrestricted filesystem profile must not make host root read-only: %#v", args)
}
if argsContainSequence(args, "--tmpfs", "/tmp") {
t.Fatalf("unrestricted filesystem profile must not replace host /tmp: %#v", args)
}
if argsContainSequence(args, "--dev", "/dev") {
t.Fatalf("unrestricted filesystem profile must not replace host /dev: %#v", args)
}
}
func TestLinuxHelperSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
env := linuxHelperSandboxEnvironment(
PermissionProfile{Network: NetworkPolicy{Mode: NetworkDeny}},
[]string{
"PATH=/custom/bin",
"HOME=/home/user",
EnvSandboxed + "=0",
EnvSandboxBackend + "=other",
},
)
for _, want := range []string{
"PATH=/custom/bin",
"HOME=/home/user",
EnvSandboxed + "=1",
EnvSandboxBackend + "=" + string(BackendLinuxBwrap),
"ZERO_SANDBOX_NETWORK=deny",
} {
if !stringSliceContains(env, want) {
t.Fatalf("linux helper env = %#v, missing %q", env, want)
}
}
if stringSliceContains(env, EnvSandboxed+"=0") || stringSliceContains(env, EnvSandboxBackend+"=other") {
t.Fatalf("linux helper env did not replace stale sandbox markers: %#v", env)
}
}
func indexString(values []string, want string) int {
for index, value := range values {
if value == want {
@@ -169,3 +293,26 @@ func indexString(values []string, want string) int {
}
return -1
}
func argsContainSequence(args []string, sequence ...string) bool {
return argsSequenceIndex(args, sequence...) >= 0
}
func argsSequenceIndex(args []string, sequence ...string) int {
if len(sequence) == 0 {
return 0
}
for index := 0; index <= len(args)-len(sequence); index++ {
matched := true
for offset, want := range sequence {
if args[index+offset] != want {
matched = false
break
}
}
if matched {
return index
}
}
return -1
}
+20 -4
View File
@@ -76,11 +76,17 @@ type SandboxExecutionRequest struct {
}
func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
backend := options.Backend
if backend.Name != "" && backend.Platform == "" {
backend.Platform = platformForBackendName(backend.Name)
}
goos := options.GOOS
if goos == "" {
goos = backend.Platform
}
if goos == "" {
goos = runtime.GOOS
}
backend := options.Backend
if backend.Name == "" {
backend = selectPlatformBackend(goos, options.LookupExecutable)
}
@@ -91,6 +97,19 @@ func NewSandboxManager(options SandboxManagerOptions) SandboxManager {
return SandboxManager{goos: goos, backend: backend}
}
func platformForBackendName(name BackendName) string {
switch name {
case BackendMacOSSeatbelt:
return "darwin"
case BackendLinuxBwrap, BackendLinuxLandlock, BackendWSL:
return "linux"
case BackendWindowsRestrictedToken, BackendWindowsElevated:
return "windows"
default:
return ""
}
}
func (manager SandboxManager) Backend() Backend {
return manager.backend
}
@@ -155,9 +174,6 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
if request.ValidateExecution && requiresPlatformSandbox && backend.SupportLevel() != BackendSupportNative {
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
}
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
// applies WFP network filters + workspace ACLs and writes a marker). Without
// it the command runner hard-fails — so on the default preference, DEGRADE to
+78 -11
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
@@ -28,11 +29,17 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
if profile.FileSystem.Kind != FileSystemRestricted {
t.Fatalf("filesystem kind = %q, want restricted", profile.FileSystem.Kind)
}
if len(profile.FileSystem.WriteRoots) != 2 {
t.Fatalf("write roots = %#v, want workspace + extra", profile.FileSystem.WriteRoots)
roots := scope.Roots()
if len(profile.FileSystem.WriteRoots) != len(roots) {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
}
if profile.FileSystem.WriteRoots[0].Root != scope.Roots()[0] || profile.FileSystem.WriteRoots[1].Root != scope.Roots()[1] {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, scope.Roots())
for i, root := range roots {
if profile.FileSystem.WriteRoots[i].Root != root {
t.Fatalf("write roots = %#v, want scope roots %#v", profile.FileSystem.WriteRoots, roots)
}
}
if !stringSliceContains(profile.FileSystem.ReadRoots, profileRootPath()) {
t.Fatalf("read roots = %#v, want full read root %q", profile.FileSystem.ReadRoots, profileRootPath())
}
if !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".git") || !stringSliceContains(profile.FileSystem.WriteRoots[0].ProtectedMetadataNames, ".zero") {
t.Fatalf("protected metadata names = %#v, want workspace metadata protected", profile.FileSystem.WriteRoots[0].ProtectedMetadataNames)
@@ -48,9 +55,43 @@ func TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile(t *testing.T) {
}
}
func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T) {
tmpdir := t.TempDir()
if runtime.GOOS == "windows" {
t.Setenv("TEMP", tmpdir)
t.Setenv("TMP", tmpdir)
} else {
t.Setenv("TMPDIR", tmpdir)
}
workspace := t.TempDir()
profile := PermissionProfileFromPolicy(workspace, DefaultPolicy(), nil)
if !writeRootsContain(profile.FileSystem.WriteRoots, workspace) {
t.Fatalf("write roots = %#v, want workspace %q", profile.FileSystem.WriteRoots, workspace)
}
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
}
func writeRootsContain(roots []WritableRoot, want string) bool {
want = normalizeProfilePath(want)
for _, root := range roots {
if normalizeProfilePath(root.Root) == want {
return true
}
}
return false
}
func TestUnknownNetworkModeFailsClosed(t *testing.T) {
if got := NormalizeNetworkMode(NetworkMode("scoped")); got != NetworkDeny {
t.Fatalf("NormalizeNetworkMode(scoped) = %q, want %q", got, NetworkDeny)
for _, mode := range []NetworkMode{"scoped", "proxy"} {
if got := NormalizeNetworkMode(mode); got != NetworkDeny {
t.Fatalf("NormalizeNetworkMode(%s) = %q, want %q", mode, got, NetworkDeny)
}
}
profile := PermissionProfileFromPolicy(t.TempDir(), Policy{
Mode: ModeEnforce,
@@ -175,11 +216,11 @@ func TestSandboxManagerBuildsCommandPlanThroughWindowsRunner(t *testing.T) {
}
}
func TestSandboxManagerRejectsUnavailableCommandPlan(t *testing.T) {
func TestSandboxManagerDegradesUnavailableCommandPlan(t *testing.T) {
policy := DefaultPolicy()
backend := Backend{Name: BackendUnavailable, Platform: "windows", Fallback: true, Message: "native sandbox unavailable"}
manager := NewSandboxManager(SandboxManagerOptions{GOOS: "windows", Backend: backend})
_, err := manager.BuildCommandPlan(SandboxManagerRequest{
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: `C:\workspace`,
Command: CommandSpec{Name: "cmd.exe", Args: []string{"/c", "dir"}, Dir: `C:\workspace`},
Policy: policy,
@@ -187,8 +228,11 @@ func TestSandboxManagerRejectsUnavailableCommandPlan(t *testing.T) {
Preference: SandboxPreferenceAuto,
ValidateExecution: true,
})
if !errors.Is(err, errNativeSandboxUnavailable) {
t.Fatalf("BuildCommandPlan error = %v, want native sandbox unavailable", err)
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped || plan.EnforcementLevel != EnforcementDegraded || plan.DowngradeReason != "native sandbox unavailable" {
t.Fatalf("plan = %#v, want degraded direct plan", plan)
}
}
@@ -236,6 +280,29 @@ func TestSandboxManagerSelectsPlatformBackend(t *testing.T) {
}
}
func TestSandboxManagerInfersPlatformFromExplicitBackend(t *testing.T) {
tests := []struct {
name string
backend BackendName
wantGOOS string
}{
{name: "linux helper", backend: BackendLinuxBwrap, wantGOOS: "linux"},
{name: "macos seatbelt", backend: BackendMacOSSeatbelt, wantGOOS: "darwin"},
{name: "windows runner", backend: BackendWindowsRestrictedToken, wantGOOS: "windows"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manager := NewSandboxManager(SandboxManagerOptions{
Backend: Backend{Name: test.backend, Available: true, Executable: "sandbox-helper"},
})
if manager.goos != test.wantGOOS || manager.backend.Platform != test.wantGOOS {
t.Fatalf("manager = %#v, want platform/goos %q", manager, test.wantGOOS)
}
})
}
}
func TestSelectBackendDelegatesToSandboxManagerSelection(t *testing.T) {
backend := SelectBackend(BackendOptions{
GOOS: "linux",
@@ -277,7 +344,7 @@ func TestSandboxManagerFailsClosedWhenNativeRequiredAndUnavailable(t *testing.T)
Command: CommandSpec{Name: "cmd.exe", Dir: "/workspace"},
Policy: policy,
Profile: profile,
Preference: SandboxPreferenceAuto,
Preference: SandboxPreferenceRequire,
ValidateExecution: true,
})
if !errors.Is(err, errNativeSandboxUnavailable) {
+1 -1
View File
@@ -30,7 +30,7 @@ func NormalizePermission(value Permission) Permission {
func NormalizeSideEffect(value SideEffect) SideEffect {
normalized := SideEffect(strings.ToLower(strings.TrimSpace(string(value))))
switch normalized {
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectLocalControl, SideEffectLocalBrowser, SideEffectLocalDesktop, SideEffectLocalTerminal, SideEffectOutOfWorkspace, SideEffectNone:
return normalized
default:
return SideEffectOutOfWorkspace
+4 -4
View File
@@ -84,10 +84,10 @@ func TestWritePrecedenceMatrix(t *testing.T) {
ws := resolvedTempDir(t)
mkdir(t, filepath.Join(ws, "src"))
wsSecret := mkdir(t, filepath.Join(ws, "secret"))
ext := resolvedTempDir(t)
ext := tempDirOutsideDefaultTemp(t)
mkdir(t, filepath.Join(ext, "build"))
extProtected := mkdir(t, filepath.Join(ext, "build", "protected"))
outside := resolvedTempDir(t) // never allowed
outside := outsideDefaultTempPath(ws, "outside")
scope, err := NewScope(ws, nil)
if err != nil {
@@ -348,7 +348,7 @@ func TestEvaluateAppliesReadDeny(t *testing.T) {
// but permitted once the path is on AllowWrite.
func TestEvaluateAppliesWriteAllow(t *testing.T) {
ws := resolvedTempDir(t)
ext := resolvedTempDir(t)
ext := tempDirOutsideDefaultTemp(t)
build := mkdir(t, filepath.Join(ext, "build"))
base := Policy{Mode: ModeEnforce, EnforceWorkspace: true}
@@ -381,7 +381,7 @@ func TestEvaluateAppliesWriteAllow(t *testing.T) {
// backend write binds (so a sandboxed shell can write there).
func TestWriteRootsReflectAllowWrite(t *testing.T) {
ws := resolvedTempDir(t)
ext := resolvedTempDir(t)
ext := tempDirOutsideDefaultTemp(t)
build := mkdir(t, filepath.Join(ext, "build"))
engine := NewEngine(EngineOptions{
+33 -4
View File
@@ -93,14 +93,21 @@ func permissionProfileRoots(workspaceRoot string, scope *Scope) []string {
if scope != nil {
return scope.Roots()
}
var roots []string
if root := normalizeProfilePath(workspaceRoot); root != "" {
return []string{root}
roots = append(roots, root)
}
return nil
roots = append(roots, defaultTempWriteRoots()...)
return dedupeStrings(roots)
}
func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scope, writeRoots []string) []string {
readRoots := append([]string{}, writeRoots...)
// Workspace-write follows the upstream sandbox model: full disk is readable,
// while writes are narrowed to workspace/extra roots below. This is a
// deliberate read-all/write-jail posture; callers that must hide secrets use
// DenyRead to carve them out.
readRoots := []string{profileRootPath()}
readRoots = append(readRoots, writeRoots...)
if scope != nil {
readRoots = dedupeStrings(append(readRoots, scope.ReadRoots()...))
} else if root := normalizeProfilePath(workspaceRoot); root != "" {
@@ -109,7 +116,29 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
if extra := normalizeProfileDirs(policy.AllowRead); len(extra) > 0 {
readRoots = dedupeStrings(append(readRoots, extra...))
}
return readRoots
return dedupeStrings(readRoots)
}
// userGitConfigReadPaths returns the user's global git config FILES so a
// sandboxed git can read identity and config (user.name/email, aliases) instead
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
// files only — not the ~/.config/git directory, which can hold an XDG credential
// store — so credentials and the rest of HOME stay unreadable. Granted at the
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
func userGitConfigReadPaths() []string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
return nil
}
return []string{
filepath.Join(home, ".gitconfig"),
filepath.Join(home, ".config", "git", "config"),
}
}
func profileRootPath() string {
return filepath.Clean(string(filepath.Separator))
}
func normalizeProfileDirs(entries []string) []string {
+46 -1
View File
@@ -1,6 +1,9 @@
package sandbox
import "testing"
import (
"strings"
"testing"
)
func TestIsAlreadySandboxed(t *testing.T) {
// A lone ZERO_SANDBOXED=1 (no corroborating backend marker) must NOT count as
@@ -47,6 +50,48 @@ func TestSandboxEnvironmentMarksSandboxed(t *testing.T) {
}
}
func TestSandboxEnvironmentPreservesCallerEnv(t *testing.T) {
env := sandboxEnvironmentForCommand(
[]string{
"PATH=/custom/bin",
"TERM=xterm-256color",
"HOME=/home/user",
EnvSandboxed + "=0",
EnvSandboxBackend + "=other",
},
DefaultPolicy(),
BackendMacOSSeatbelt,
)
for _, want := range []string{
"TERM=xterm-256color",
"HOME=/home/user",
EnvSandboxed + "=1",
EnvSandboxBackend + "=" + string(BackendMacOSSeatbelt),
"ZERO_SANDBOX_NETWORK=deny",
} {
if !stringSliceContains(env, want) {
t.Fatalf("sandbox env = %#v, missing %q", env, want)
}
}
path := envListValue(env, "PATH", "")
if !pathListContains(path, "/custom/bin") {
t.Fatalf("sandbox PATH = %q, want it to preserve caller path segment /custom/bin", path)
}
if stringSliceContains(env, EnvSandboxed+"=0") || stringSliceContains(env, EnvSandboxBackend+"=other") {
t.Fatalf("sandbox env did not replace stale sandbox markers: %#v", env)
}
}
func pathListContains(path string, want string) bool {
for _, entry := range strings.Split(path, ":") {
if entry == want {
return true
}
}
return false
}
func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) {
// Ensure neither re-entrancy marker is set so we are NOT seen as sandboxed.
t.Setenv(EnvSandboxed, "")
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more