Compare commits

...
Author SHA1 Message Date
Gnanam e6d43c62d9 sandbox: wire scoped network egress through the config surface
The engine and the Linux/Windows backends already enforce NetworkScoped (an
egress allowlist), but no static config could reach it: resolver.go rejected
"scoped" ("expected allow or deny"), the config-to-policy translations silently
ignored it, and there was no config field for the allowed-domains list — so
scoped was reachable only via the runtime permission flow.

- Add SandboxConfig.NetworkAllowedDomains / NetworkDeniedDomains; document
  network: "scoped".
- resolver.go: accept "scoped"; reject it with an empty allowlist (fail loud
  rather than let the engine silently collapse it to deny); union the domain
  lists from GLOBAL user config only, never project config (mirrors
  AdditionalWriteRoots) so a cloned repo cannot widen its own egress.
- applyConfiguredSandboxPolicy (the central exec/TUI/sandbox-CLI path) and
  doctorSandboxPolicy: thread scoped mode + the domain lists into the Policy.

Tests: resolver accepts scoped+domains, rejects empty-allowlist scoped, rejects
unknown modes, and a project config cannot widen the allowlist; both policy
translations carry the lists through. The engine still fails closed (empty
allowlist or an unenforceable backend collapses to deny).
2026-06-19 12:37:09 +05:30
Anandan 673fce11bd Persist command prefix approvals (#252)
Add file-backed command prefix grants to the sandbox store and load them through the engine so approved prefixes skip future prompts while still running through sandbox policy checks.

Tighten reusable prefix validation for runner-style launchers and script paths, and classify find -delete as destructive.

Surface persistent prefix choices in the TUI and include approved prefixes in the run prompt context and sandbox grants list.

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

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

Tested: git diff --check
2026-06-18 21:56:24 +05:30
Anandan f00e83a5ed Add session command prefix approvals (#250)
Bash permission prompts can now expose a validated session-scoped prefix approval. Prefixes are parsed from simple shell commands or an explicit prefix_rule argument, with redirects, control operators, assignments, expansions, globs, and broad launcher prefixes rejected before the option is shown.

Approved prefixes are stored in the session sandbox engine and only skip future matching bash prompts; commands still execute through the existing sandbox backend.

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

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

Tested: GOCACHE=/tmp/zero-go-cache go test ./...
2026-06-18 21:00:13 +05:30
KRATOS 1f92b25e8c sessions: make the events log authoritative for sequencing + fsync checkpoints/rewind (#251)
Two data-integrity gaps in the session store:

1. Sequence numbers were derived from metadata.EventCount, which is persisted
   AFTER the event line (a separate file). A crash between the two writes left
   EventCount behind the log, so the next append reused a sequence number -- a
   duplicate id that mis-targets /rewind (findRewindTarget returns the first
   match). Now the next sequence is derived from the events log itself
   (lastEventSequence, an O(1) tail read that ignores a torn trailing partial),
   so a stale/desynced EventCount can never cause a reused number; it falls back
   to the metadata count only if the log is unreadable.

2. Checkpoint blobs and rewind restores used plain WriteFile+Rename with no
   fsync, while session metadata fsyncs the temp file AND the parent dir -- so
   the /rewind safety net and restored workspace files were not crash-durable.
   They now go through the same writeFileSync + syncDir barrier
   (writeFileAtomicSync), preserving the exact-perm chmod on restore.

Tests: stale-low metadata -> no duplicate (the reported repro); stale-high ->
benign gap; lastEventSequence over empty / multi / torn-tail / large-event logs.
2026-06-18 20:58:26 +05:30
KRATOS a4279662b9 redaction: catch compound secret keys + non-bearer auth schemes; stop scanner over-redaction (#249)
* redaction: catch compound secret keys + non-bearer auth schemes; stop scanner over-redaction

Hardens the secret-redaction last line of defense after an audit of
internal/redaction + internal/secrets.

1. Compound secret keys are now redacted. IsSensitiveKey was exact-match only, so
   db_password, session_secret, stripe_secret_key, auth_token, csrf_token,
   ssh_private_key, backup_api_key, etc. passed through in cleartext. A new
   keyLooksSensitive applies conservative structural heuristics (secret /
   password / passphrase / credential / apikey segments; a singular trailing
   "_token"; api_key / private_key pairs). Critically, the agent's token-COUNT
   fields (max_tokens, prompt_tokens, token_count) and ordinary *_key fields
   (primary_key, public_key, cache_key) are deliberately NOT redacted.

2. Authorization headers with non-bearer/basic schemes were leaking. The header
   pattern now also covers token, apikey, digest, negotiate, oauth, and
   aws4-hmac-sha256.

3. The secrets scanner over-redacted ordinary text: its prefixed patterns (sk-,
   gh*, AIza, AKIA, xox, github_pat, jwt) lacked the word boundaries that
   internal/redaction already uses, so kebab-case words like
   "task-management-and-coordination" matched as a fake key. Added \b anchors.

Design note: a free-text "key: value" colon pattern was prototyped but dropped --
it mangled "file.go:line: message" output (a filename's "secret" segment looked
like a sensitive key), the same over-redaction class fix #3 addresses. The
structured paths (RedactValue for maps/structs, plus assign/JSON/query forms in
RedactString) are covered; free-text colon-form remains out of scope.

Tests: compound-key + token-count-safety matrix, auth-scheme redaction, the
file:line guard, and scanner boundary positives/negatives.

* redaction: redact the full auth-header value for multi-part schemes

The broadened header pattern captured only the first token after the scheme, so
parameterized schemes (Digest "…, response=…", AWS4-HMAC-SHA256 "…, Signature=…")
left the actual secret param visible. Redact the entire value to end of line; the
scheme name is kept. Adds tests with realistic Digest + AWS SigV4 headers.

Addresses review feedback.
2026-06-18 20:01:25 +05:30
Anandan 2cf6fd2334 Add request permissions flow (#248)
* Add request permissions flow

Adds a runtime-handled request_permissions tool that can ask for turn or session filesystem/network grants and return the granted profile as structured JSON.

Temporary turn grants update the shared sandbox scope and effective policy, then clean up at run end; session grants persist in memory for the session.

Read grants now stay read-only through tool path resolution, policy evaluation, and native sandbox profile construction.

The TUI renders request_permissions with grant-specific choices, including strict review, and Esc continues without granting permissions.

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

* Align request permission grants with prompts

Display and return the normalized grant roots that the sandbox actually applies, so file-like permission requests no longer show a narrower path than the directory root being granted.

Also makes the network prompt explicit about all outbound access and relabels the strict option as a model review request instead of implying enforced command review.

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

* Retry transient OAuth secret read locks

Windows can briefly deny reads of the freshly published token secret while concurrent OAuth secret creation converges. Treat Windows sharing and lock violations as transient and retry those reads before returning an error.

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

Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c ./internal/oauth -o /tmp/zero-oauth-windows.test.exe

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

Tested: GOCACHE=/tmp/zero-go-cache go test ./...
2026-06-18 20:00:35 +05:30
KRATOS 995b9086c7 feat(subagent): proactive specialist delegation + swarm execution fixes (#243)
* feat(subagent): proactive specialist delegation + swarm execution fixes

Fixes:
- Task tool dropped the parent's permission mode, so every delegated
  sub-agent ran at --auto high regardless of the parent. Forward
  PermissionMode so a child never exceeds its parent's authority.
- Swarm members never executed: swarm_spawn accepts agent types
  teammate/subagent, but the launcher looked those up as specialist names
  (only worker/explorer/code-review exist), so every member failed with
  "specialist not found". Run each member from its swarm definition via an
  inline manifest on the executor.

Enhancements:
- Auto-delegation: the orchestrator system prompt now lists the available
  specialists and when to use each, nudging it to offload read-heavy work to
  a sub-agent so large tool output stays out of the main context.
- @-mention: a leading "@<specialist> <task>" in the TUI composer delegates
  to that specialist (transcript keeps the verbatim mention); the @ palette
  suggests specialists on the first token, file references mid-message.
- swarm.maxTeamSize config caps concurrent members per team (0 => default 8;
  negative rejected), honored in user and project config.
- Proactive spawning: read-only specialists (explorer/code-review) auto-
  approve via a new args-aware tool permission (tools.ArgsPermissioner), so
  the agent delegates exploration off a normal prompt without an approval
  prompt; write-capable specialists, resume, and unknown names still prompt.
  The exec gate registers specialist tools at medium/high autonomy so a
  non-trivial headless run can auto-delegate read-only too.

* fix(specialist): fail-safe child autonomy when PermissionMode is unset

specialistAutonomy mapped an empty PermissionMode to "--auto high" (unsafe),
silently re-introducing the privilege escalation the rest of this PR fixes: an
orchestrator that forgets to wire PermissionMode would get an unsafe child. The
Task tool and swarm both now propagate a resolved mode, so "" only occurs for a
caller that omitted it -- default that case to "low".

Only an explicit unsafe parent now yields --auto high; everything else (incl.
unset/unknown) is fail-safe low. Updates the stale "back-compat" comments and the
tests that documented the old behavior.

Addresses the non-blocking follow-up from PR review.
2026-06-18 20:00:12 +05:30
KRATOS 4582727f95 tui: image-input UX — drag-drop attach, inline numbered chips, vision fallback, cursor + copy fixes (#247)
* tui: image-input UX — drag-drop attach, inline numbered chips, vision fallback, cursor + copy fixes

Improves the image/PDF attachment flow and two long-standing composer papercuts.

- Drag-and-drop: dropping an image/PDF onto the composer attaches it instead of
  erroring as an unknown "/…" command. The dropped path is detected on paste and
  on submit (absolute/escaped/quoted paths to an existing image or PDF).
- Inline numbered chips: staged attachments render as compact [Image #1] /
  [Doc #1] chips INSIDE the input box; the verbose "Attached … (image/png)"
  system line is dropped. Backspace on an empty composer removes the last chip
  (/image clear still drops all).
- Vision fallback: a real multimodal model absent from the curated catalog
  (custom / openai-compatible / local ids) now accepts images via a conservative
  name match; text-only ids stay refused, and the catalog stays authoritative.
- Blinking composer cursor, including when the box is empty (previously no caret
  was drawn for a focused, empty box).
- Ctrl+E releases/recaptures the mouse so native drag-select + copy works (mouse
  capture otherwise intercepts terminal text selection).

Tests: dropped-path detection, vision-by-name + unknown-model fallback,
mouse-release capture toggle, attachment removal, and updated chip-render tests.

* fix(vision): a "vision-less" model name must not match the bare-vision fallback

visionCapableByName treated any id containing "vision" as multimodal, so a model
named "...vision-less..." (i.e. WITHOUT vision) was wrongly classified vision-
capable — which silently skipped the non-vision image gate and broke
TestRunExecDropsImagesOnNonVisionModelWithWarning in internal/cli.

Add mentionsVision, which still matches the "vision" word but excludes the
negated forms (vision-less / visionless / no-vision / non-vision). Used for both
the grok-vision and the general open-multimodal fallback. Added negation cases to
the name table.

* fix(tui): drag-drop path detection corrupted Windows paths (backslash unescape)

unescapeDroppedPath stripped backslashes to undo Unix terminal drag-drop escaping
("\ " -> " "), but on Windows the backslash is the path separator, so a plain
dropped path like C:\Users\x\file.png became C:Usersxfile.png and never resolved
-- TestDroppedAttachmentPath failed on windows-latest.

No-op the unescape on Windows (dropped paths there arrive quoted or plain), and
gate the Unix-only backslash-escaped positive case behind runtime.GOOS.
2026-06-18 19:42:29 +05:30
KRATOS b903825139 perf(tools): lower default deferThreshold to stop eager MCP-schema token waste (#241)
* perf(tools): lower default deferThreshold 10 -> 3 to stop eager MCP-schema waste

MCP tool schemas are only collapsed into compact tool_search reminder lines once
the deferred-eligible tool count reaches tools.deferThreshold. The default of 10
meant a typical single MCP server (Exa-style, a handful of tools) stayed BELOW it,
so every full MCP schema — 300-600 tokens each — was re-sent on every turn, even
for a one-word message. A small MCP set could add several thousand input tokens
per message.

Lower the default to 3 so a normal MCP server's toolset defers and stays cheap.
Capability is unchanged: every tool is still reachable; the model just loads a
deferred tool's schema via tool_search before its first use, after which it is
loaded for the rest of the run. Fully overridable via tools.deferThreshold
(0 keeps the old always-eager behavior for models without tool_search).

* test(agent): measure the deferral token saving (66% smaller tool payload)

* fix(tokens): count non-whitespace bytes so estimates match the real tokenizer

The token estimate was naive len/4, which overcounted by ~15-20%: `zero context`
reported ~6k for a prompt the provider's tokenizer actually counted as 5,203.
BPE tokenizers (the ones zero targets) fold a run's leading space into the
following token rather than emitting whitespace separately, so counting
NON-whitespace bytes / 4 tracks the real count closely (now ~5.2k, ~1% off the
measured 5,203) while staying dependency-free.

Add agent.ApproxTextTokens and route the context-budget preview
(contextreport), the compaction threshold (agent), and the TUI transcript
estimate through it so every surface uses one accurate scale. The provider's
exact usage is still authoritative on each request; this only sharpens the
pre-request preview and compaction timing.
2026-06-18 19:41:36 +05:30
Anandan eb608741b0 Align permission prompt rendering (#246)
* Align permission prompt rendering

Remove normal permission prompt risk labels from focused cards, transcript rows, and permission detail text so risk metadata is not presented as the permission UI contract.

Rename the recoverable deny option from a redirect-style prompt to a continue-without-running label that matches its current behavior.

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

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

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

Tested: git diff --check

* Tighten permission prompt fallbacks

Use conservative allow/deny choices when a permission request lacks backend-supplied decisions, avoiding impossible session or persistent grant options.

Document that a failed session-grant recording attempt must not deny the current user-approved tool call; it only means a later matching call prompts again.

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

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

Tested: git diff --check

* Split permission deny and cancel decisions

Add an explicit cancel permission decision that aborts the active run while keeping deny as a recoverable tool refusal.

Expose cancel choices for command and apply-patch approval prompts, keep apply-patch off the recoverable deny path, and stop offering persistent always approvals for command/apply-patch prompts until scoped prefix approval exists.

Classify cancel decisions as permission-decision events for TUI session logs and exec JSON output.

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

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

Tested: git diff --check

* Stabilize OAuth secret creation

Publish encrypted OAuth token secrets atomically under a short-lived creation lock so concurrent first-run callers never observe a half-written secret file.

This fixes the Zero Review failure in TestLoadOrCreateSecretConcurrentConverges.

Tested: go test ./internal/oauth -run TestLoadOrCreateSecretConcurrentConverges -count=100

Tested: go test ./internal/oauth

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

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

Tested: git diff --check
2026-06-18 16:53:20 +05:30
Anandan 2d62387e08 Align permission session approvals (#244)
* Align permission session approvals

Add in-memory session grants, workspace-write auto-allow for built-in file mutation tools, and dynamic permission choices for once/session/future approvals.

Persist future approvals only when a grant store is configured, while keeping session approvals scoped to the active engine.

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

* Guard workspace write auto-allow

Exclude protected workspace metadata from built-in file-tool auto-allow so writes under .git, .zero, and .agents re-prompt instead of silently running.

Derive apply_patch targets from patch headers inside the sandbox engine so patch-body escapes and symlink traversal are denied before workspace auto-allow.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDoesNotAutoAllowProtectedMetadataWrites|TestEngineDeniesApplyPatchEscapesFromPatchBody|TestEngineSessionGrantDoesNotMatchSiblingFile|TestEngineDeniesAutoAllowedSymlinkEscape|TestEngineDeniesWorkspaceSymlinkTraversal|TestDeriveScope'

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

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

Tested: git diff --check
2026-06-18 14:54:00 +05:30
Anandan a1b9bbb07c Align permission prompts for web tools (#242)
Treat hosted web_search as an allowed read-only network discovery tool so normal searches do not stop on a permission prompt, while retaining sandbox network enforcement when tool-network policy is enabled.

Add host-scoped persistent grants for web_fetch URLs so an always-allow decision is bounded to the requested host instead of becoming a blanket tool-wide fetch grant.

Update permission prompt and transcript rendering to show network targets, scoped always labels, and preserved scope metadata across live rows, render caching, and session hydration.

Tested: GOCACHE=/tmp/zero-go-cache go test ./...
2026-06-18 13:45:49 +05:30
Anandan 4cb1f10952 Add sandbox architecture baseline (#240)
* Add sandbox architecture baseline

Record target backend names, enforcement metadata, legacy sandbox entrypoints, and policy JSON baseline coverage for the sandbox rewrite.

Add a focused sandbox smoke script with cross-platform compile checks and an opt-in real backend smoke path.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestTargetBackendForPlatformBaseline|TestLegacySandboxEntrypointsAreExplicitAndExist|TestBackendPlanCarriesPhase0ManagerFields|TestCommandPlanCarriesSandboxMetadata|TestPolicyOnlyDisabledFailClosedForTargetPlatforms|TestSelectBackendChoosesPlatformAdapterWithFallback|TestBackendBuildPlanDocumentsBestEffortIsolation|TestBackendCapabilitiesReflectDisabledPolicy'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyEffectiveTextAndJSON'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Introduce sandbox manager and permission profile

Add a normalized permission profile and sandbox manager execution request that owns target backend selection, enforcement metadata, and fail-closed validation.

Route command planning and sandbox policy output through the manager boundary while keeping legacy adapters explicit as temporary integration points.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestPermissionProfileFromPolicyBuildsWorkspaceWriteProfile|TestPermissionProfileFromDisabledPolicyDoesNotRequirePlatformSandbox|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled|TestTargetBackendForPlatformBaseline|TestBackendPlanCarriesPhase0ManagerFields|TestCommandPlanCarriesSandboxMetadata|TestPolicyOnlyDisabledFailClosedForTargetPlatforms'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyEffectiveTextAndJSON'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsBashThroughSandboxEngine|TestBashToolReportsDisabledPolicyOnlyRunner|TestBashToolBuildsWrappedSandboxExecCommand'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Move sandbox backend selection into manager

Make SandboxManager own platform backend selection and keep SelectBackend as a compatibility shim for existing CLI and TUI wiring.

Add tests proving manager-owned selection and the compatibility wrapper agree.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxManagerSelectsPlatformBackend|TestSelectBackendDelegatesToSandboxManagerSelection|TestSelectBackendChoosesPlatformAdapterWithFallback|TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Route command planning through sandbox manager

Make SandboxManager build command plans from sandbox execution requests, with backend-specific wrapping isolated behind a temporary adapter.

Move Engine.BuildCommandPlan to a single manager boundary and mark buildLegacyCommandPlan as the explicit delete target for later backend work.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxManagerBuildsCommandPlanThroughTemporaryAdapter|TestSandboxManagerBuildsDegradedPolicyOnlyCommandPlan|TestBuildCommandPlanWrapsBubblewrap|TestBuildCommandPlanWrapsSandboxExec|TestBuildCommandPlanUsesPolicyOnlyFallback|TestBuildCommandPlanCanRejectPolicyOnlyFallback|TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsExecutionRequestFromProfile|TestSandboxManagerFailsClosedWhenNativeRequiredAndPolicyOnlyDisabled'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Add Linux sandbox helper command boundary

Introduce the zero-linux-sandbox command surface and typed permission-profile argv builder used by the upcoming Linux helper rewrite.

Keep the helper fail-closed until enforcement is wired, and add parser tests plus smoke compilation for the new helper package.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile|TestParseLinuxSandboxHelperArgs|TestRunLinuxSandboxHelperFailsClosedUntilEnforcementIsWired'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Fix sandbox policy golden path aliases

Normalize both original and symlink-resolved temp paths before comparing sandbox policy JSON goldens, so macOS /private/var aliases do not fail the smoke job.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Fix Windows sandbox smoke test assumptions

Normalize JSON-escaped Windows paths in the sandbox policy golden test and mark the fake bubblewrap backend as Linux in the command metadata test.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestCommandPlanCarriesSandboxMetadata|TestBackendPlanCarriesPhase0ManagerFields|TestPolicyOnlyDisabledFailClosedForTargetPlatforms'

Tested: scripts/sandbox-smoke.sh

Tested: git diff --cached --check

* Stabilize sandbox policy golden test

Compare the Windows sandbox policy golden as decoded JSON after path tokenization so platform-specific byte formatting does not fail smoke tests while preserving the same field-level regression coverage.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields|TestRunSandboxPolicyInspectTextAndJSON|TestRunSandboxPolicyEffectiveTextAndJSON'

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Route Linux sandboxing through helper

Move Linux command planning to invoke zero-linux-sandbox with the permission profile, command cwd, and command argv instead of exposing bubblewrap construction from the runner boundary.

The helper now builds the bubblewrap argv, binds its own executable for the inner stage, applies the seccomp/no-new-privs step before exec, and preserves the original command cwd path.

Package Linux releases with zero-linux-sandbox next to zero so release builds use the helper directly, while local development can fall back to go run for the helper.

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

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

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Normalize Linux helper cwd test paths

Compare native paths in the helper cwd regression test so Windows smoke does not fail on path separator formatting while preserving the same helper behavior assertion.

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Add Linux Landlock helper enforcement

Implement the zero-linux-sandbox Landlock helper path with fail-closed profile validation, Landlock filesystem rules, and seccomp network-deny enforcement for the fallback backend.

Broaden the Linux real smoke coverage to cover helper write allow/deny, deny-read, temp writes, metadata protection, network deny, and direct Landlock fallback behavior.

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

Tested: scripts/sandbox-smoke.sh

Tested: GOCACHE=/tmp/zero-go-cache go test ./cmd/zero-linux-sandbox ./internal/cli ./internal/tools ./internal/release ./cmd/zero-release

Tested: git diff --check

* Route Seatbelt profiles through permission profiles

Build macOS sandbox-exec profiles from the shared PermissionProfile so read roots, write roots, temp access, deny-read, deny-write, and protected metadata are expressed by the platform profile instead of legacy write-root-only inputs.

Keep the existing sandbox-exec adapter path as a compatibility wrapper while adding target macos-seatbelt capability handling.

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

Tested: scripts/sandbox-smoke.sh

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

Tested: git diff --check

* Stabilize Seatbelt write carveouts

Emit protected metadata and read-only write-root carveouts as explicit Seatbelt deny rules after the broad write allow. This preserves deny precedence while avoiding fragile allow-rule predicates in real sandbox-exec runs.

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Expand macOS Seatbelt runtime allowances

Restore the platform startup allowances needed by sandbox-exec-wrapped commands under restricted filesystem profiles: executable mapping, system metadata reads, pty/device access, preferences, IPC, and standard library/runtime roots.

This keeps project writes constrained to the PermissionProfile roots while preventing macOS shells from aborting before user commands run.

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Normalize Seatbelt deny expectations in tests

Build Seatbelt deny-rule expectations from normalized profile paths so the metadata ordering test matches production behavior on Windows and Unix hosts.

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

Tested: scripts/sandbox-smoke.sh

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: git diff --check

* Add Windows sandbox runner command planning

Select the windows-restricted-token backend only when the Windows sandbox command runner is discoverable, and keep the missing-runner path as an explicit policy-only downgrade.

Add the Windows runner argument contract for command cwd, workspace roots, permission profile JSON, child environment JSON, restricted-token level, and payload command parsing.

Update sandbox policy output and tests to describe the missing Windows runner instead of an unimplemented adapter.

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

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

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: scripts/sandbox-smoke.sh

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

Tested: git diff --check

* Add Windows sandbox command runner

Add the zero-windows-command-runner executable and wire windows-restricted-token plans to pass sandbox home, command cwd, workspace roots, permission profile JSON, child env JSON, sandbox level, and command argv.

Persist Windows capability SIDs under the resolved sandbox home, select read-only or writable-root capability SIDs from the permission profile, and create a restricted token before launching the payload with CreateProcessAsUserW.

Package the Windows runner next to zero.exe and extend sandbox smoke coverage to cross-build it.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/release ./cmd/zero-windows-command-runner ./cmd/zero-release

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

Tested: scripts/sandbox-smoke.sh

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

Tested: git diff --check

* Plan Windows sandbox ACL operations

Derive a deterministic Windows ACL operation list from the restricted permission profile, including writable root allows, protected metadata deny-write entries, explicit deny-write paths, and deny-read materialization targets.

The plan maps workspace and extra writable roots onto the persisted capability SIDs so the elevated setup helper can apply the same contract without reinterpreting the profile.

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

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Add Windows sandbox setup boundary

Add the zero-windows-sandbox-setup command, setup argument parsing, deterministic ACL plan hashing, and a setup marker contract that can detect profile changes requiring refresh.

Make Windows native backend discovery require both the command runner and setup helper, package the setup helper with Windows releases, and include it in sandbox smoke builds.

The setup command remains fail-closed until the Windows ACL applier is wired, so it cannot create a setup-complete marker without applying enforcement.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/release ./cmd/zero-windows-sandbox-setup

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Apply Windows sandbox ACL setup

Implement the Windows ACL setup path with grouped native DACL updates, deny-read materialization, existing DACL snapshots, and rollback for partial application failures.

Write the setup marker only after ACL setup succeeds, roll back applied ACLs if marker persistence fails, and make the Windows command runner validate the setup marker before creating the restricted token.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./cmd/zero-windows-sandbox-setup ./cmd/zero-windows-command-runner

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Fail closed for Windows network policies

Add Windows network policy validation that allows only explicit network-allow until native Windows network enforcement is implemented.

Make the Windows command runner reject deny, scoped, and unset network modes before launching a restricted-token process so default network-deny cannot silently run open.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./cmd/zero-windows-command-runner

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Track Windows network setup state

Add a canonical Windows network-policy hash and persist it in the Windows sandbox setup marker so setup refresh can detect network mode, domain, and proxy-requirement changes.

Bump the setup marker schema and add tests for domain-order normalization plus network-change marker invalidation.

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

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Add sandbox setup CLI command

Add zero sandbox setup to resolve the active sandbox policy, derive the Windows setup helper next to the command runner, and invoke it with the shared Windows setup argument contract.

Return text or JSON setup results, no-op cleanly for non-Windows native backends, and add tests using an injected helper runner.

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

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/cli -o /tmp/zero-cli.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go test -c ./internal/sandbox -o /tmp/zero-sandbox.test.exe

Tested: GOOS=windows GOARCH=amd64 GOCACHE=/tmp/zero-go-cache go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup

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

Tested: scripts/sandbox-smoke.sh

Tested: git diff --check

* Stabilize Windows ACL canonical path test

* Add TUI sandbox setup command

* Gate TUI sandbox setup command

* Clarify sandbox doctor setup status

* Add Windows WFP network setup

Adds a Windows network enforcement plan with stable WFP provider, sublayer, and filter specs for deny-mode sandbox identities. The setup helper now installs or removes those filters transactionally before writing the setup marker, and the marker tracks the network enforcement plan so stale filters require refresh.

Scoped Windows network mode remains fail-closed until the proxy exception path is implemented, so it cannot silently become open network.

Diagnostics now report native Windows network enforcement when the restricted-token backend is active.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestValidateWindowsNetworkPolicyAllowsNativeModes|TestValidateWindowsNetworkPolicyFailsClosedForScoped|TestBuildWindowsNetworkPlanForDenyUsesCapabilityIdentity|TestBuildWindowsNetworkPlanFailsClosedForScoped|TestWindowsNetworkPlanHashIsStableAcrossEntryOrder|TestWindowsSandboxSetupMarkerRefreshesWhenNetworkChanges|TestSelectBackendChoosesPlatformAdapterWithFallback'

Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup

Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

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

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

Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh

Tested: git diff --check

* Add Windows sandbox network smoke

Adds a gated Windows real sandbox smoke test that runs the compiled setup helper and command runner, verifies sandboxed writes still work, and proves network=deny blocks a loopback TCP connection from a restricted-token command.

The sandbox smoke script now runs this test on Windows using the helper binaries it builds in its temporary directory.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestValidateWindowsNetworkPolicyAllowsNativeModes|TestBuildWindowsNetworkPlanForDenyUsesCapabilityIdentity'

Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c -o /tmp/zero-windows-sandbox.test ./internal/sandbox

Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup

Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go build -o /tmp/zero-windows-command-runner.exe ./cmd/zero-windows-command-runner

Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh

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

Tested: git diff --check

* Remove legacy sandbox runner paths

Drop the old direct bubblewrap adapter, the standalone zero-seccomp wrapper, legacy adapter metadata, and the Phase 0 legacy inventory now that command planning goes through the sandbox manager backends.

Switch platform selection and tests to target backend names: linux-bwrap, macos-seatbelt, windows-restricted-token, and explicit policy-only degradation.

Keep policy as the shared permission/profile and diagnostics surface, but stop exposing legacy adapter fields in CLI policy output and tool metadata.

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

Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh

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

Tested: git diff --check

* Address sandbox baseline review blockers

Restore sandbox.blockUnixSockets as a resolved config and policy field, route it into the Linux helper as --block-unix-sockets, and keep zero-seccomp as a Linux compatibility wrapper packaged with release artifacts.

Add the missing legacy-entrypoint test referenced by the smoke script, extend helper/release/config tests, and make CLI startup tests hermetic by stubbing MCP registration where they assert quiet output.

Document the compatibility path, release helper scope, and the already-resolved #237 prompt-file overlap in the PR body.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/cli ./internal/config ./internal/release ./cmd/zero-seccomp -run 'TestLegacySandboxEntrypointsAreExplicitAndExist|TestSandboxManagerBuildsCommandPlanThroughLinuxHelper|TestBuildLinuxSandboxCommandArgsSerializesPermissionProfile|TestParseLinuxSandboxHelperArgs|TestBuildLinuxSandboxBwrapArgsWrapsInnerSeccompStage|TestApplyConfiguredSandboxPolicyDiagnosticsFlags|TestResolveSandboxBlockUnixSocketsFromFile|TestCopyPackageFilesStagesLinuxSandboxHelper'

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

Tested: GOCACHE=/tmp/zero-go-cache scripts/sandbox-smoke.sh

Tested: git diff --cached --check

Tested: local leak scan found no matches
2026-06-18 12:03:10 +05:30
KRATOS c0fedade8d feat: free out-of-the-box web (keyless Firecrawl default) + resilient MCP startup (#239)
* fix(mcp): skip an unreachable MCP server at startup instead of aborting

RegisterTools connected to every server eagerly and failed the whole batch if any
server could not be reached or list its tools — and the caller aborts startup with
exit 1 on that error. So a single unreachable MCP server prevented zero from
launching at all and disabled every other (working) server.

Make registration best-effort across servers but still atomic within a server:
each server's tools are validated as a unit before anything commits, and a server
that cannot be connected, returns a nameless tool, or conflicts is SKIPPED —
recorded on Runtime.Skipped(), not fatal. The caller surfaces each skip as a
warning and keeps launching. The "no dead tools" and no-partial-tool-set
guarantees are preserved.

* feat(mcp): ship keyless Firecrawl as a default web search/scrape server

Seed an enabled-by-default Firecrawl MCP server (keyless: 1,000 free credits/month,
no API key, no setup) into ResolveMCP before user/project config is merged, so web
search and scraping work out of the box for open-source users. A user can override
any field (self-hosted URL, bring-your-own-key header) or disable it entirely;
`zero mcp disable firecrawl` writes the override even though the default lives in
code rather than the config file.

The built-in web_fetch tool stays the always-local floor. Firecrawl is AGPL-3.0,
but zero only calls it over the network, so the license never reaches zero's own
code. README documents disable / self-host / bring-your-own-key / the privacy note
that keyless routes through firecrawl.dev.

Depends on the resilient-startup change: on an offline machine the default is
skipped with a warning, not fatal.

* perf(mcp): connect MCP servers concurrently with a per-server startup timeout

RegisterTools connected servers serially with a 30s handshake timeout, so startup
waited for the SUM of all servers and one slow/unreachable server (e.g. a hosted
endpoint blocked by the local network) blocked every other server AND the first
model response — often 10-30s of dead time per launch.

Connect all servers CONCURRENTLY, each bounded by a per-server ConnectTimeout
(default 8s). A server that doesn't connect+list in time is abandoned (its context
cancelled to tear down the half-open connection/subprocess) and skipped, so total
startup cost is now the slowest REACHABLE server, not the sum. The concurrent
phase does only connect+list (no shared state); validation, conflict detection,
and registration stay in a deterministic serial phase, so the outcome is identical
regardless of completion order. A kept server's context is released at Close — a
stdio subprocess is tied to its context, so it must outlive the connect.

Pairs with the resilient skip-on-failure behavior already in this branch.
2026-06-18 10:50:37 +05:30
Vasanth T 7d66942564 docs: rewrite AGENTS.md as user-facing 'Extending Zero' guide (#237)
* docs: rewrite AGENTS.md as a user-facing 'Extending Zero' guide

The previous AGENTS.md (a 19-line Go-specific instruction set) was replaced
with a user-facing extension guide for Zero as an open-source CLI agent
product. Covers:

- Project-level AGENTS.md (where to drop one, format, tips)
- Custom specialists (droids): scopes, manifest format, CLI
- Skills: discovery, SKILL.md format, ZERO_SKILLS_DIR override
- Hooks: lifecycle events, JSON config locations, exit code semantics
- MCP: as client (config.json mcp.servers) and as server (zero serve --mcp)
- Plugins: plugin.json manifest, install/enable/disable/remove
- Configuration locations: built-in < user < project < CLI < env
- End-to-end example: what a team commits vs what a contributor adds
- Reference links to README and docs/SPECIALISTS.md, docs/PRD.md, etc.

This positions AGENTS.md as the entry point for end users who want to
customize Zero, mirroring the role that similar files play in Claude Code
and Codex.

* docs(AGENTS): align Extending Zero with actual loader behavior + D-style features

Fix six claims that were wrong about Zero itself, document the new
upward path-walking the loader now does, and surface three roadmap
gaps in the relevant sections.

What changed in the doc:
- Section 1 (Drop a project AGENTS.md): rewrote the file table to
  match the real priority order [AGENTS.md, ZERO.md, .zero/AGENTS.md]
  and added the case-insensitive basename callout (git tracks
  AGENTS.MD uppercase on case-sensitive filesystems).
- Section 1: documented the per-file (8 KiB) and total (32 KiB) caps
  and the eager-load + restart-required caveat.
- Section 1: added the monorepo tip and the path-walking explanation
  ("walks from cwd up to git root, general-to-specific order").
- Section 2 (Custom specialists): added a roadmap note about the
  planned /droids in-UI manager.
- Section 3 (Skills): corrected the discovery-root chain
  (ZERO_SKILLS_DIR -> XDG_DATA_HOME/zero/skills -> ~/.local/share/zero/skills),
  noted that project-level skills aren't supported yet, and called
  out the plugin-declared-skill gap.
- Section 4 (Hooks): roadmap note about the planned /hooks in-UI
  manager.
- Section 6 (Plugins): roadmap note about the planned /plugins in-UI
  manager; cross-link to the skills gap.
- Frontmatter globs broadened to include *.json / *.toml / *.yaml /
  *.yml, matching the actual file types Zero reads.

* feat(agent): make AGENTS.md loader case-insensitive + add path-walking

Two changes to the project guideline loader that bring Zero closer
in line with D's DynamicContextDiscovery and fix a real bug.

1. Case-insensitive basename matching. The git-tracked filename is
   AGENTS.MD (uppercase MD), but the loader was looking for AGENTS.md
   (lowercase md). On a case-sensitive filesystem (Linux, the WSL
   filesystem, or a CI runner) this means the file was invisible to
   the loader. The new findProjectContextFile uses ReadDir + ToLower
   to resolve the on-disk filename and matches AGENTS.MD, Agents.md,
   agents.md, etc. to the same entry. On Windows/macOS the visible
   filename in the prompt label is now the actual on-disk name
   (AGENTS.MD), not the lookup name.

2. Upward path-walking. The loader previously did a single
   first-match-wins lookup at cwd. The new projectGuidelines walks
   from cwd up to the nearest git root, finds the first matching
   project context file at each level, and injects them in
   general-to-specific order (git root first, cwd last). A monorepo
   with a root AGENTS.md and per-service AGENTS.md files now sees
   both, with the most specific rules last. This mirrors what D
   does per-Read on the dynamic path, just at session start.

Cap behavior:
- Per-file cap is unchanged: 8 KiB.
- New total cap of 32 KiB across all matched files, enforced via a
  rolling counter; when the budget is exhausted, further files are
  dropped (and the per-file limit is the lesser of the per-file cap
  and what's left of the total).
- Truncation marker is unchanged: \n… (truncated).

New helpers and tests:
- projectGuidelineDirs / projectGuidelineLabel / findProjectContextFile
  / resolveDirCaseInsensitive / findProjectGitRoot.
- 8 new tests covering case-insensitive matching, monorepo
  path-walking, ZERO.md fallback, .zero/AGENTS.md fallback, total
  cap truncation, and the helper functions.

Existing TestBuildSystemPromptInjectsWorkspaceContext still passes;
the label for a single-file repo is still AGENTS.md / AGENTS.MD as
appropriate to the on-disk name.

* docs(AGENTS): drop syntax that doesn't exist in the current code

Per the review on #237, the previous "Extending Zero" guide documented
several commands, flags, and conventions that the runtime does not
implement. Concretely:

- `zero plugins install` / `enable` / `disable` are not subcommands.
  `runPlugins` (internal/cli/extensions.go) only dispatches
  `list` / `add` / `remove`(`rm`). Replace the example with `add` and
  note that enable/disable are not CLI verbs today (the plugin is
  enabled by being present and disabled by being removed, or by
  setting `"enabled": false` in its manifest).

- `zero specialist create ... --prompt-file api-reviewer.md` is wrong:
  there is no `--prompt-file` flag. `runSpecialistCreate` takes the
  prompt inline via `--prompt`. Show a `$(cat ...)` shell substitution
  as the practical way to load a file, and add `zero specialist path`
  to the example.

- `"Authorization": "Bearer ${env:GITHUB_TOKEN}"` implies env-var
  expansion that does not exist: MCP header values are sent verbatim
  (internal/mcp/permissions.go, client.go mergeProcessEnv). Use a
  literal placeholder and document the three real ways to keep the
  token out of the file (wrapper script, command substitution, server
  reading its own env).

- Hook `args: ["${tool}", "${input}"]` is the same mistake plus a
  shape mismatch: the actual hook payload (event, matcher, tool call
  id, tool input/output, status) is delivered as JSON on stdin
  (internal/hooks/dispatch.go `execCommandRunner`), not via `${...}`
  substitution. Drop the placeholders, add a short bash handler that
  reads stdin, and show the `args` field as verbatim CLI args.

- "use `zero doctor` to surface them" — the doctor backend
  (internal/doctor/doctor.go) has no hook check; its checks are
  runtime / config / provider / connectivity / sandbox / lsp.
  Reword to point at the audit log instead.

Secondary: drop the `globs:` and `alwaysApply:` example and the
"Use globs: to scope which files the rules apply to" tip — the loader
injects the whole file (frontmatter included) as prompt text and does
not parse those keys. Add a one-liner noting the frontmatter is
preserved verbatim. Also drop the parenthetical "(droids)" on
§2 to match the "specialists" terminology the rest of the codebase
(and the CLI) actually use, and reword the §2/§4/§6 roadmaps that
promised in-UI managers via non-existent `/droids`, `/hooks`,
`/plugins` slash commands.
2026-06-18 10:41:36 +05:30
KRATOS 29fc29a41d feat: add providers detect and sandbox check (wire audit-found unwired features) + coverage (#238)
* feat(providers): add `zero providers detect` for local-runtime onboarding

Wire the previously-unwired provideronboarding detect/advice surface into the
CLI. `zero providers detect` probes for running local OpenAI-compatible runtimes
(Ollama, LM Studio) on their default ports and prints a no-key adopt command for
each, followed by the next-step actions (use / check / set key) for every
configured provider. Supports --json.

This connects DetectLocalRuntimes, LocalRuntimeCandidates,
DetectedLocalRuntime.SetupAction, ProviderActions, MissingCredentialAction,
SetupCommand, and ProviderState.Actions — a fully built, tested feature that had
no caller. Adds an injectable detectLocalRuntimes appDep for hermetic tests.

* feat(sandbox): add `zero sandbox check` to evaluate a decision as JSON

Wire the previously-unwired zerocommands sandbox-snapshot contract into the CLI.
`zero sandbox check <tool>` evaluates what the sandbox engine would decide for a
hypothetical tool action against the resolved policy, and emits the active plan
(policy + backend + restrictions), the decision (allow/prompt/deny with risk and
any violation), and any persistent grant matching the tool. Supports --json;
grant reasons are redacted.

This connects SandboxPlanSnapshotFromPlan, SandboxPolicySnapshotFromPolicy,
SandboxBackendSnapshotFromBackend, SandboxDecisionSnapshotFromDecision,
SandboxRiskSnapshotFromRisk, SandboxViolationSnapshotFromViolation, and
SandboxGrantMatchSnapshotFromLookup — a fully built, tested JSON contract that
had no production consumer. The existing `sandbox policy` output is unchanged.

* test(providermodelcatalog): cover model predicates and remote fetch layer

Add targeted tests for the audit-identified coverage gaps: the pure logic
(LooksLikeCodingModelID, modelMatchesProvider, defaultedOpenGatewayURL,
modelSortLabel/sortModels) and the HTTP fetch layer (fetchJSON, FetchModelsDev,
FetchOpenGateway, FetchRemote routing) via httptest. Package coverage 48.9% ->
79.5%. cmd/zero (main shim) and browser.OpenURL (exec wrapper) are intentionally
left to CI smoke — their only logic seams (openCommand) are already covered.

* fix(sandbox): address CodeRabbit review on `sandbox check`

- Pass the configured write-root scope (NewScope with AdditionalWriteRoots) into
  the check engine, so its decision matches real enforcement and a stale
  extra-root config surfaces here too instead of a workspace-only fallback.
- Validate --side-effect and --autonomy against their advertised value sets and
  return a usage error on a typo, rather than letting the engine normalize it.
- Reword the unmatched-grant line to "no grant matched this action" — Lookup only
  checks this tool/scope/autonomy, not whether any grant exists for the tool.
- Add tests: invalid-flag rejection and a matched-grant reason-redaction
  regression.

* test(sandbox): make the out-of-workspace check test path OS-portable

Smoke (windows-latest) failed: "/etc/passwd" is not absolute on Windows, so the
check joined it into the workspace and returned "prompt" instead of "deny".
Build the out-of-workspace path under a separate temp dir so it is absolute and
outside the workspace on every OS.
2026-06-18 10:20:24 +05:30
KRATOS 7c36805505 fix(providers): clear error when a model host is unreachable (#233)
* fix(providers): surface a clear error when a model host is unreachable

A local Ollama daemon serving a "-cloud" model answers on localhost but
returns HTTP 502 carrying an opaque proxied transport error (e.g.
`Post "https://ollama.com:443/...": net/http: TLS handshake timeout`) when it
cannot reach its own cloud backend. A direct hosted endpoint blocked by the
local network fails the same way at the transport layer. Both previously
surfaced as raw strings ("provider error:" / "provider stream error:") that
read like a zero bug and gave the user nothing to act on.

Add providerio.UpstreamUnreachable: it detects a transport-level connectivity
failure (TLS handshake timeout, context deadline exceeded, connection refused,
no such host, network unreachable, i/o timeout) bound to a concrete host and
rewrites it into an actionable message naming the host and explaining the
request never reached the model. It requires both a marker and a host, so the
agent's own request-deadline cancellations are left untouched.

Wire it into the OpenAI-compatible adapter's gateway-error and transport-error
paths, covering both the local daemon proxy and direct hosted providers;
secrets are still redacted. Covered by unit tests over the real error shapes
and an end-to-end 502 adapter test.

* fix(providers): don't humanize a caller-context timeout as an upstream outage

Address CodeRabbit review: in the OpenAI-compatible adapter's transport-error
path, a caller-driven cancel/deadline surfaces as an error string containing
"context deadline exceeded" plus the request host, which UpstreamUnreachable
would mislabel as "upstream unreachable" — the host is reachable; the caller's
clock ran out. Check the parent ctx.Err() first and surface it verbatim, so only
genuine connect failures (ctx still live) are humanized.

Adds a deadline regression test; the existing cancel test used Cancel, whose
"context canceled" is not a marker, so it never covered this path.
2026-06-18 10:19:58 +05:30
922a5bab5f feat(tui): Ctrl+T cycle + divider display for reasoning effort (#229)
* feat(tui): Ctrl+T cycle + divider display for reasoning effort

Reasoning effort was already fully wired (per-model supported lists,
DefaultReasoningEffort, the EffectiveReasoningEffort clamp, /effort and
/mode), but it was invisible in the UI and reachable only by typing a
command. Add the opencode-style one-key fast path:

- Ctrl+T cycles the active model's effort ring (auto -> low -> medium ->
  high -> auto), gated by the same modal condition shift+tab uses and a
  silent no-op on models with no effort controls.
- The composer divider shows the active effort in the brand lime when
  set; the segment is omitted on "auto", so its presence/absence is
  itself the auto-state feedback.

Zero per-frame registry lookups: the cycle runs only on the rare
keypress and the divider branches purely on m.reasoningEffort != ""
(DefaultRegistry rebuilds the whole catalog on every call, so it must
never be touched from the render path).

Tests cover every cycle branch (auto->first, mid-ring advance,
last->auto wrap, unknown->auto, no-op on unsupported model) and the
divider show/omit paths.

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

* fix(tui): address CodeRabbit review on Ctrl+T effort cycle

- Block Ctrl+T effort cycling inside the detailed transcript (matches the
  shift+tab gate) so state changes don't happen while the user is reading
  a frozen view.
- Use a guaranteed-unplaceable sentinel in the unknown-effort reset test
  instead of ReasoningEffortMinimal, so the test stays correct if Minimal
  becomes a supported ring level later.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix(tui): extract shared noBlockingModal() for shortcut gates

The 7-term modal gate was duplicated verbatim between the shift+tab and
Ctrl+T cases. Fold it into a single noBlockingModal() helper so the two
shortcuts can't drift the day a new modal is added to one and not the
other. Pure refactor, no behavior change.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix(tui): hide divider effort segment on models without effort controls

The divider showed whatever m.reasoningEffort was set to, even when the
active model had no effort controls (e.g. gpt-4.1). Switching from
claude-sonnet-4.5 (low/medium/high) to gpt-4.1 would leave a stale
"high" floating in the divider even though it has no effect.

Make the segment selective: only render it when the active model exposes
availableReasoningEfforts, matching the same appearing/disappearing
state feedback the divider already uses for the auto case. The lookup
hits a hard-coded static catalog, not a per-frame rebuild, so it's
safe on the render path.

handleModelCommand already resets an unsupported effort preference on
model switch, so no changes are needed there.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix(tui): drop model-aware effort gate in divider; /effort picker already selective

The previous commit added `&& len(m.availableReasoningEfforts()) > 0` to
the divider's effort-segment gate so a stale value couldn't be shown
after switching to a model without effort controls. Reverting that
change: the underlying state mutation is already gated elsewhere
(handleModelCommand clears an unsupported preference on switch, and the
/effort picker only opens for models that actually expose effort
controls), so the divider doesn't need its own check.

The /effort picker (newEffortPicker, pickerEffort kind) is already
fully wired and tested — /effort with no args on a supported model
opens the picker; on a model with no effort controls, the text path
returns "Active model does not expose reasoning effort controls."

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix(tui): open effort picker even when active model has no effort controls

Previously newEffortPicker returned nil for any model not in the hard-coded
catalog (e.g. ollama-cloud providers serving glm-5.1 or similar). The
case commandEffort dispatch fell through to handleEffortCommand, which
rendered a grey "Effort / status: warning / available: none for active
model" status card -- the very static panel users were reporting.

Now newEffortPicker always returns a picker: a single "auto" option when
the active model exposes no effort controls, or "auto" plus the model's
known efforts otherwise. handleEffortCommand already returns the "Active
model does not expose reasoning effort controls" message if any
non-auto value is later set, so no other gate is needed.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat(tui): render description hint below composer for single slash match

Claude-code-style: when the user types a slash command that resolves to
exactly one command in the autocomplete palette, surface that command's
description on a muted line just below the composer box. Multiple
matches keep the existing dropdown as the right affordance; once the
user starts typing arguments the hint clears; the @file palette keeps
its own rows and the hint stays scoped to slash commands.

The inline argument hint ([low|medium|high|auto]) still renders inside
the composer box after the slash, so both UIs coexist.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat(tui): render /effort output as a polished command card

The /effort command used to push its status text through renderCommandOutput,
which renders as a thin grey panel (faint text on panel background, neutral
border). Replace that path with renderCommandCardTranscript so the result
renders the same way as /tools, /mcp, /permissions, etc.: a lime-bordered
titled card with a key/value "State" section and a footer of next-step actions.

Three call sites updated:
- handleEffortCommand("auto") -> sets effort to auto, shows confirmation card
- handleEffortCommand(<value>) -> sets effort, shows confirmation card
- handleEffortCommand(<bad>) -> unknown / unsupported / not exposed -> shows
  the same card with the relevant detail line

effortText() (the no-arg / list / picker-dismiss path) follows the same
pattern. The "status: warning" line and grey panel are gone; the warning
case instead keeps the lime accent and surfaces "no reasoning controls on
this model" in the summary, matching the rest of the TUI's visual language.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-06-18 09:57:04 +05:30
KRATOS 6df5c2b4cb Improve /resume: interactive session picker + model-generated titles (#224)
* tui: interactive /resume session picker + resume fixes

- /resume (no arg) now opens an interactive picker like /model and /provider:
  one row per resumable session showing the title and session id (+ relative
  age); arrow to highlight, type to filter, enter to load. /resume <id> and
  /resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
  raw log, so a resumed session honors a prior /compact — matching the CLI's
  `zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
  rehydrate error.
- The resume summary reports the model/provider the continuation will actually
  use (the active ones), noting the session's recorded model/provider when it
  differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
  and its hint was circular); resumeText is now the no-session/error fallback.

Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).

* tui: lead /resume picker rows with a precise timestamp

Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.

* tui: hide empty/no-output sessions from the /resume picker

Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.

* feat(tui): model-generated session titles (auto + /retitle backfill)

Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.

- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
  per-session lock, re-reading the latest metadata first; leaves
  UpdatedAt untouched (a retitle is not activity and must not reorder
  the resumable list), rejects a blank title, and no-ops an unchanged
  one.
- Title generator: a bounded digest of the conversation (user/assistant
  text + tool names, per-message and total caps, skipping the no-output
  guardrail stop) is sent as a one-shot completion; the response is
  cleaned (first content line, quotes/markup/"Title:" label stripped,
  word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
  carrying its default first-message title gets a title in the
  background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
  first-message title, one at a time, skipping empty/failed runs and
  already-named sessions, with kickoff and completion status lines.

Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.

* address review: tighten no-progress detection, fix fallback errors + nil guard

Three review findings on the /resume work:

- IsNoProgressStop matched any content CONTAINING the no-output marker
  substring (strings.Contains), so a legitimate assistant/tool message that
  quoted the marker could be misread as a failed empty run — wrongly hiding a
  real session from the /resume picker and skipping its title generation. Now
  it requires the full answer structure (prefix + marker + suffix); a quote no
  longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
  fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
  test paths) would panic. Now renders a safe "store unavailable" message.
  (newSessionPicker already guarded nil.)

Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.

* address review (Vasanth): structural no-progress check, retryable auto-title, nil-store guard, deep resume assertion

- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
  emits — prefix + "<int> turns " + marker + " " + suffix — instead of
  prefix && contains(marker) && suffix. A genuine message that merely quotes the
  marker amid other prose is no longer misclassified as a failed empty run (which
  would wrongly hide a real session from /resume and skip its title generation).
  Adds the hostile-input regression cases (quoted marker, text between
  marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
  FAILS (provider error, empty title, store write error) so a later turn or
  /retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
  (rather than mark-on-success) to preserve the no-double-fire guarantee while a
  generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
  (the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
  rehydrated context, not just slice length, so a reordered/substituted-but-
  same-length regression is caught.
2026-06-17 12:41:20 +05:30
KRATOS b7f90a787f tui: right-click pastes the clipboard directly (no menu) (#231)
* fix(tui): right-click pastes the clipboard directly (no menu)

A right-click now pastes the OS clipboard straight into the focused input —
composer, provider/MCP wizard, or setup — with no context menu, mirroring the
bracketed-paste behavior. Keyboard and selection copy/paste are unchanged.

- clipboard.go: pasteFromClipboardCmd reads the clipboard off the Update
  goroutine; routePaste is the shared router for both bracketed paste (PasteMsg)
  and right-click paste (clipboardReadMsg), so the two behave identically.
- mouse.go: a right-click returns pasteFromClipboardCmd; input_compat.go adds the
  mouseRightPress helper.
- model.go: PasteMsg now routes through routePaste; clipboardReadMsg inserts on
  success or shows a brief "Paste failed" status when the clipboard can't be read
  (e.g. no clipboard utility on a remote session).

Tests: right-click returns the read command (chat + wizard), clipboard content
routes to the focused field, read errors surface a status, empty is a no-op.

* fix(tui): right-click paste in setup mode + scope the paste test

Setup mode routes mouse events to handleSetupMouse, which only handled
left-click and wheel — right-clicks were swallowed, so paste never fired
during onboarding. Add the same mouseRightPress -> pasteFromClipboardCmd
branch handleMouse uses; routePaste already targets the setup field.

Test: assert the right-click contract at handleMouse/handleSetupMouse
scope instead of via Update (which batches unrelated commands and masks a
regression), and cover the new setup-mode path.

Addresses review on #231: mouse.go (setup paste, critical) and
clipboard_test.go (weak Update-level assertion, minor).
2026-06-17 11:53:39 +05:30
KRATOS b1cf24831d fix(tui): selectable + clickable permission popup (#232)
The permission prompt now has a moving cursor and clickable options, on top of
the existing a/y/d hotkeys (which still resolve directly):
- ↑/↓ and Tab/Shift+Tab move the highlight across allow once / always / deny
  (resting default: allow once); Enter confirms the highlighted option.
- each option is rendered on its own row, and a left-click on a row resolves
  that choice directly.

- permission_prompt.go: permissionOptions + cursor move/clamp/confirm helpers.
- model.go: pendingPermissionPrompt gains a cursor; Enter confirms it, ↑/↓/Tab
  move it; a/y/d are unchanged.
- rendering.go: renderFocusedPermissionPrompt renders per-option rows with the
  cursor highlighted (▸ + reverse label) and returns each option's card-relative
  Y offset so the caller can register clickable rows.
- transcript_selection.go: transcriptSelectableLine gains permOption/permChoice;
  the pending-permission item registers each option row as clickable, and a
  left-click on one resolves the permission.

Tests: cursor default/move/wrap, Enter confirms the highlight, hotkeys still
resolve directly, render emits highlighted clickable offsets. Existing card/
submit-block tests updated for the new per-option layout + Enter-confirms.
2026-06-17 11:16:23 +05:30
Anandan 0b496ce87d Render transcript viewport from measured items (#227)
Add a bounded per-model transcript body height cache keyed by the existing row render fingerprint, then use measured spans to render only the visible alt-screen item window instead of building the full body string every frame.

Mouse hit testing now uses the same visible item layout, while inline rendering and selection copy keep the full layout path for behavior parity.

Tests cover height cache reuse, visible-only item rendering, absolute selectable body coordinates, and output parity with the previous full-layout renderer.

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

Tested: git diff --check

Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
2026-06-17 09:01:50 +05:30
Anandan 724167e90e Harden TUI layout and mouse routing (#226)
* Centralize TUI frame layout

Add a reusable transcript frame snapshot with terminal regions for header, body, footer, composer, and status lines. Mouse composer hit testing, overlay positioning, and transcript viewport mapping now consume the same frame geometry instead of recalculating pinned-header and clipped-footer offsets separately.

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

* Route TUI mouse hits through local regions

Return overlay hit coordinates relative to the overlay block and derive transcript hit rows from the shared body rect. This keeps the current scroll behavior while making mouse handling consume component-local geometry from the frame layout.

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

* Cover TUI frame geometry

Add focused tests for the shared frame snapshot: pinned header/body/footer regions, tiny-terminal footer clipping, composer mouse hit regions, overlay centering, and transcript mouse row mapping through the body region.

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

* Extract transcript viewport line window

Introduce a line-based transcript viewport adapter that preserves Zero's existing bottom-anchored scroll offset. Render slicing, chat scroll metrics, scrollChat, and transcript selection viewport mapping now share the same clamping and visible-window calculation.

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

* Build transcript body from layout items

Introduce a compatibility transcript body item layer for title, empty state, separators, transcript rows, pending prompts, streaming interim content, and spec review prompts. The body still renders to the same line/selectable output, but layout now records item spans and local selectable offsets for the next visible-range step.

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

Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."

* Address TUI layout review findings

Clamp overlay mouse hit regions to the visible transcript body and block transcript selection while the MCP add wizard is open. Strengthen layout and viewport edge-case tests, including full composer containment and degenerate dimensions.

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

Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."

* Make MCP wizard overlay test hit transcript text

CodeRabbit caught that the MCP add wizard blocking test clicked at x=0, which could miss selectable transcript text and pass without proving the overlay guard. This updates the test helper to return both textStart and y, then clicks that known selectable point before asserting the wizard blocks transcript selection behind the modal.

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

Tested: git diff --check

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

* Route transcript windows through body layout

Thread the structured transcriptBodyLayout through alt-screen rendering, scroll metrics, and transcript mouse lookup instead of repeatedly joining and splitting the rendered body string. This preserves the current line-based viewport behavior while making the body layout the source of truth for visible windows and selectable line lookup.

This is an incremental step toward visible-item rendering and item-level caching; it does not change the transcript visual output or input flow.

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

Tested: git diff --check

Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."

* Reuse row cache for selectable transcript rows

Selectable user and assistant rows need selectable metadata for mouse interactions, but when no transcript selection is active their rendered strings are identical to the normal row render path. Route those rendered strings through renderRow so stable rows reuse the existing row cache while selection-active frames still render highlight-aware output.

This keeps transcript behavior unchanged and adds a regression test that verifies selectable assistant rows produce stable metadata while hitting the render cache on the second render.

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

Tested: git diff --check

Tested: /bin/bash -lc "GOCACHE=/tmp/zero-go-cache go test ./..."
2026-06-16 23:40:32 +05:30
Vasanth TandClaude 6092303e73 feat(tui): rotating "working word" for the liveness spinner (#221)
* feat(tui): rotating "working word" for the liveness spinner

The assistant interim block used to show a static "working…" placeholder
while the model was generating. That felt dead on long runs. Replace it
with a rotating gerund tuned for the Gitlawb / Zero brand: project-name
verbs (gitlawbmaxxing, zeroling, …) weighted 1.5x so the brand word
anchors the rotation, plus feature-as-verbs, gen-Z crowd-pleasers, and
a few Claude-Code originals for variety.

The rotation is a tiny ring buffer (working_words.go) advanced on each
spinner.Tick so the verb and the glyph stay in lockstep. Brand words
are duplicated in the ring, not randomized, so the cadence is
deterministic and the first frame is always "gitlawbmaxxing" — easy
to test, easy to reason about, and the first thing a long-running user
sees is the project name. Reset() rewinds the ring when a new run
starts (model.go: submit, spec_mode.go: spec approval → impl).

Lowercase throughout to brand-differentiate from Claude Code's Title
Case defaults. No config wiring (hardcoded list); can be a follow-up
if users ask for customisation.

Tests: 7 new tests in working_words_test.go (first frame, tick,
wrap, reset, brand weighting, base-list integrity, nil safety) plus
the existing interim-block test updated to assert "gitlawbmaxxing"
as the new default. Full internal/tui/ suite green in 2.7s.

* Address review feedback on working-words PR

Three review threads to close out:

**Cadence (~12 words/sec → ~1/sec)**
@anandh8x and @gnanam1990 both flagged the on-every-tick advance as
flicker. The spinner glyph should still spin fast (it conveys liveness),
but the word needs to be readable. Solution: the model owns a
`workingVerbTicks` counter and gates the advance at
`WorkingWordsStepEvery = 12` spinner ticks (~960ms at 80ms cadence).
The `workingWords` ring stays a dumb "advance one slot" type so unit
tests can tick it rapidly without waiting for a real spinner. New test
`TestWorkingVerbAdvancesOnStepEverySpinnerTicks` locks the cadence
behaviour in.

**Brand-name "inconsistency" (intentional)**
@gnanam1990 noted the code says "Gitlawb / OpenFable" while the repo,
binary, and PR description say "Zero". Author's call: the working-words
PR ships the rebrand-ahead-of-the-rebrand verbs on purpose. Kept the
existing strings (`gitlawbmaxxing`, `openfablemaxxing`, …) and tidied
the comments so the trade-off is explicit ("a planned rename; the
spinner brand is intentionally ahead of the rest of the project").
Also fixed a stale `model.go` comment that still said "zeroling"
(superseded by `openfablemaxxing` in the previous force-push).

**Taste call (pilled / aura-farming / maxxing)**
Both reviewers flagged these as user-facing with no config escape.
Author's call: ship as-is, with a comment on `vibeVerbs` calling out
the trade-off explicitly so the next reader can see it was a
deliberate decision rather than an oversight.

**weightedRing comment vs implementation**
CodeRabbit and @gnanam1990 both caught that the comment claimed brand
verbs are "interleaved" but the implementation appends them as a
sequential block at the end of the ring. Fixed the comment to match
the implementation and added a note on how to implement true
interleaving if it's ever wanted.

**CodeRabbit nits**
- `Reset()` now also checks `len(w.weighted) == 0` for consistency
  with `Tick()` and `Current()`.
- `weightedRing` body simplified to `append(ring, brandVerbs...)`
  instead of the conditional loop (same result, clearer intent).

**Competitor naming in comments**
@gnanam1990 asked to trim the few "Claude Code" mentions in package
comments. Replaced the explicit "Claude Code" references with neutral
phrasing ("upstream Claude-Code-compatible spinners", "a few classic
gerunds") that keeps the design rationale without naming the
competitor. The user-facing verb list is untouched.

Rebased onto current main (1e25fde) and resolved the spec_mode.go
merge conflict by keeping both the fade-seed and the working-verb
reset on the spec-impl path.

Build + vet + gofmt clean. Full internal/tui/ test suite green except
for the pre-existing env-fragile `TestModelCommandPersistsSelectedModelToUserConfig`
from PR #220 (fails here only because of local env, not this PR).

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

* Fix cadence test post-advance window (CodeRabbit)

The final block of `TestWorkingVerbAdvancesOnStepEverySpinnerTicks`
had two issues caught by CodeRabbit:

1. A `_ = m` no-op statement that asserted nothing.
2. A tautological check — it compared the post-advance verb against
   the *original* `before` value, which is always going to differ
   after the previous block already advanced.

The real intent was to verify that the cadence counter *resets* after
each advance (rather than carrying over), so a follow-up advance
lands one full `WorkingWordsStepEvery` window later — not one window
early due to a missed reset.

Rewrote the trailing block to:
- Capture the post-advance value explicitly (`afterAdvance`).
- Drive another (WorkingWordsStepEvery - 1) ticks after the advance.
- Assert the verb stayed at `afterAdvance` — catching the "counter
  doesn't reset" regression the original test was meant to guard
  against.

This now meaningfully tests three regressions:
- Gate removed (verb advances every tick) — caught by the first block.
- Gate set to 1 (same effect) — caught by the first block.
- Counter doesn't reset after advance (verb advances too eagerly) —
  caught by the new third block.

Updated the test docstring to call out all three failure modes.

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

---------

Co-authored-by: Claude <[email protected]>
2026-06-16 22:07:40 +05:30
Vasanth TandClaude 1e25fde3b1 feat(tui): age-based fade for streaming assistant text (#223)
* feat(tui): age-based fade for streaming assistant text

While the assistant reply streams in, the latest line appears in the
brand lime (\#caff3f) and gradually settles to the standard off-white
ink over ~1.2 seconds, line by line. The effect mirrors the lime
spinner glyph in the liveness block: a quiet visual signal that the
model is still emitting, not a stylized neon glow.

Design choices (per line, not per rune):
  * 12-step palette interpolated in linear sRGB from colorAccent to
    colorInk, pre-computed at init() so per-frame lookups are a
    struct-field access, not a hex parse.
  * 1.2s fade duration; 150ms tick (independent of the 80ms spinner).
  * Hard stop at stream end: the agentResponseMsg handler flips
    fadeActive = false and the next streamingFadeTickMsg short-circuits.
  * Reset on terminal resize (visual line count changes) and on cancel.
  * The last visual line uses lastStreamActivity (always freshest) so
    the user can see exactly where the model is currently typing.

Test fixtures that pre-populate m.streamingText without going through
the agentTextMsg branch keep rendering identically to before — the
fade code is defensive when lineAges is nil.

The spec-impl run (which calls runAgent directly, bypassing the
normal launchPrompt path) seeds the fade state explicitly so its
streaming text fades the same way regular runs do.

* fix(tui): address CodeRabbit review on streaming-fade PR

Four findings from CodeRabbit's review of #223, applied:

1. model.go agentTextMsg: schedule streamingFadeTick only on the
   inactive->active transition instead of on every delta. The tick
   is self-perpetuating (the streamingFadeTickMsg case schedules the
   next one), so re-scheduling on every incoming delta is redundant
   and would create multiple concurrent tick chains during long
   streams.

2. model.go agentResponseMsg: call m.resetStreamingFade() instead of
   setting only fadeActive = false. Leaving lineAges and
   lastStreamActivity populated allowed stale age data to carry over
   to the next turn and made lineAges grow indefinitely across many
   runs.

3. streaming_fade.go streamingLineBornAt: when the visual index is
   beyond the lineAges slice (a wrapped middle line), clamp to the
   last known logical age instead of returning time.Time{}. Wrapped
   continuation lines now keep fading in step with their siblings
   instead of snapping to base ink mid-paragraph.

4. streaming_fade_test.go TestStreamingFadePaletteHandlesBadHex:
   assert that every bucket's foreground is the unparseable base
   string instead of asserting on len(palette), which is a constant
   for a fixed-size array (the original assertion passed regardless
   of behavior and triggered SA4006). Also split the
   OutOfRangeReturnsZero test into ClampsToLast (for the new clamp
   behavior) and EmptyLineAgesReturnsZero (for the truly-empty case
   that should still return zero).

---------

Co-authored-by: Claude <[email protected]>
2026-06-16 21:17:08 +05:30
Anandan 7fae5c530f Fix provider setup paste handling (#225) 2026-06-16 21:16:39 +05:30
Anandan 7af2a1b3b6 TUI polish and fixes (#220)
* Persist TUI model switches

* Pin TUI title bar in chat view
2026-06-16 20:14:17 +05:30
Anandan 9c1cc69b59 Migrate TUI to Bubble Tea v2 (#222)
* Migrate TUI to Bubble Tea v2

Move the TUI stack to charm.land Bubble Tea, Bubbles, and Lip Gloss v2, including key, paste, mouse, and view handling changes for the v2 APIs.

Add compatibility helpers for the new input event shapes and update TUI tests accordingly.

Refresh related Go dependencies and make the PDF truncation fixture deterministic across poppler and pure-Go extraction.

Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui

Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/imageinput -count=1 -v

Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./...

* Address provider wizard OAuth review feedback

Remove the unused mouseButton compatibility helper flagged by lint.

Correlate provider wizard OAuth and device-code async results with the active provider and attempt id so stale results from abandoned attempts are ignored.

Add regression coverage for stale browser OAuth and stale device-code results.

Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui -run 'ProviderWizard.*OAuth|ProviderWizard.*Device|ApplyProviderWizardOAuth|RenderCredentialStepShowsOAuth' -count=1 -v

Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./internal/tui

Tested: GOCACHE=/tmp/zero-go-cache GOMODCACHE=/tmp/zero-go-mod-cache go test ./...

Tested: git diff --check

* Smooth TUI mouse scrolling
2026-06-16 19:04:38 +05:30
KRATOS a0b9876b69 OAuth / account login for providers (OpenRouter, xAI) + subscription-proxy support (#217)
* providers: authenticate model calls with an OAuth login when present

Foundation for account/subscription login: when a user is logged in via
`zero auth login <name>`, the OpenAI and Anthropic providers now authenticate
model requests with that OAuth bearer token, falling back to the API key when
there is no login. Auto-select, per the agreed design — no config needed.

  - providerio.SendWithAuthRetry: issues a request with an OAuth bearer (from a
    TokenResolver) or the API key, and retries ONCE with a force-refreshed token
    after a 401. A 401 means the server rejected the request without processing a
    completion, so replay-after-refresh is safe — the same no-duplicate-work basis
    SendWithRetry uses for 429/503. Never sends API key and bearer together.
  - openai + anthropic providers take an optional OAuthResolver and use
    SendWithAuthRetry. When OAuth is active the credential goes on Authorization
    (so Anthropic's x-api-key is correctly omitted). Gemini is unchanged.
  - providers.New threads Options.OAuthResolver to those two providers.
  - cli.oauthResolverForProfile builds the resolver from the unified oauth store:
    it attaches a resolver ONLY when a login already exists for the provider
    name, so API-key users incur no per-request store lookups. The resolver uses
    Manager.GetFresh (refresh near expiry) and Manager.Handle401 (force refresh on
    the 401 retry).
  - oauth: added ErrNoToken sentinel so "not logged in" is distinguishable from a
    real refresh failure (login removed mid-session → clean API-key fallback).

Tests (httptest): API-key fallback (nil resolver / ok=false), bearer wins +
never-both, 401→force-refresh→retry, stops after one retry, resolver error
surfaced. Local run check (real binary + mock OAuth + mock OpenAI-compatible LLM
requiring the bearer): with a login, `zero exec` sends Bearer <oauth-token> and
gets a completion; without a login it falls back to the (wrong) API key and the
mock returns 401 — proving the OAuth path.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* providers: add ChatGPT-subscription-via-proxy preset + OAuth/subscriptions docs

Follows verified research (cross-checked across ~30 sources) into using ChatGPT
and Claude subscriptions: a subscription OAuth token does NOT work as a standard
bearer on the public APIs. OpenAI's token only works against the Cloudflare-gated
ChatGPT backend (headless clients get cf-mitigated 403); Anthropic rejects
third-party subscription OAuth, requires Claude-Code identity spoofing, 400s on
tool calls (Max overage lane), and is contractually banned with active
enforcement. So zero does NOT call those backends or spoof those clients.

Instead, the supported pattern is a local proxy that holds the subscription
session and exposes a clean OpenAI/Anthropic-compatible endpoint on 127.0.0.1;
zero points at it (it already supports custom base URLs, and C1 supplies OAuth
bearer for gateways that issue standard tokens).

  - providercatalog: add `chatgpt-proxy` ("ChatGPT (local OAuth proxy)") — an
    OpenAI-compatible, local, no-API-key preset defaulting to the established
    proxy port (http://localhost:10531/v1), base URL overridable. Claude uses the
    existing `custom-anthropic-compatible` entry (no canonical Claude-proxy port
    to hardcode — not invented).
  - docs/oauth-subscriptions.md: full recipe — built-in OAuth login (C1), the
    verified reason subscriptions need a proxy, copy-paste config for ChatGPT and
    Claude via a local proxy, and the sanctioned alternatives (API key; spawning
    the real `claude` CLI for Anthropic subscription automation).

Local run check: a config using `catalogID: chatgpt-proxy` (base URL pointed at a
mock OpenAI-compatible proxy) routes `zero exec` through the proxy with no auth
header and returns a completion.

Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.

* provideroauth: OpenRouter browser PKCE login (mints an API key) + `zero auth openrouter`

Adds the cleanest real OAuth login surfaced by the provider survey: OpenRouter's
loopback PKCE flow. It needs no client_id (public), uses S256, and the exchange
mints a normal OpenRouter API key — so no token-spoofing or vendor-backend gating
is involved (unlike ChatGPT/Claude subscriptions).

  - internal/provideroauth.OpenRouterLogin: binds a 127.0.0.1 loopback, opens/prints
    https://openrouter.ai/auth?callback_url=…&code_challenge=…&code_challenge_method=S256,
    captures the code, and POSTs it + the PKCE verifier to
    /api/v1/auth/keys → {key}. PKCE binds the code, so no separate CSRF state is
    needed. Injectable base URL / HTTP client / browser-opener for tests.
  - cli: `zero auth openrouter` runs the flow and prints the minted key with usage
    guidance. ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted/tests).

Tests: full flow over httptest (mint, missing-code, non-2xx exchange, empty key,
browser-error). Local run check via the real binary against a mock: the command
emits the correct PKCE authorize URL, the loopback callback is captured, and the
key is minted end-to-end.

First of the in-wizard OAuth providers; the wizard "Log in with OAuth" option
(next commit) calls this same flow and saves the key to the profile.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass. No new module deps.

* oauth: built-in provider presets + xAI (Grok) preset

Adds an overridable preset layer to the env-driven OAuth registry so well-known
providers work without per-field env configuration. ResolveConfig now seeds each
field from a baked-in preset and lets the matching ZERO_OAUTH_<NAME>_* env var
override it (env wins); flow falls back to the preset then loopback.

First preset: xAI (Grok) — standard OIDC, PUBLIC client (no secret), loopback +
device. The access token is accepted directly as a bearer on api.x.ai/v1 (an
OpenAI-compatible endpoint), so the existing C1 wiring uses it for an xai provider
profile — no header/identity spoofing. `zero auth login xai` now works out of the
box. CAVEATS (documented in code + help): the client_id is a public Grok-CLI
client not formally documented by xAI (may change; override via env), and use
requires a SuperGrok / X Premium+ subscription.

Tests: preset resolution, env-overrides-preset (per field + flow), and that a
provider with neither preset nor env still errors. Local run check: `zero auth
login xai --device` (endpoints pointed at a mock, client_id NOT overridden) logs
in and the mock confirms the preset client_id b1a00492-… was sent.

Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.

* tui: in-wizard "Log in with OAuth" option (OpenRouter) + browser opener

Adds the credential-step OAuth login the setup wizard was missing. For a provider
that supports a usable browser OAuth flow (currently OpenRouter), the credential
screen now offers "ctrl+o to log in with OAuth in the browser (no key needed)"
alongside pasting a key.

Flow: ctrl+o → the wizard marks itself oauthPending and dispatches an async
tea.Cmd that runs provideroauth.OpenRouterLogin (opens the browser via the new
internal/browser opener, captures the loopback callback, mints a key). While it
runs the step shows "Opening your browser… (or run: zero auth openrouter)";
Esc cancels. On success the minted key fills the credential and the wizard
advances to model selection; on failure the redacted error is shown and the user
can retry or paste a key.

  - internal/browser: cross-platform OpenURL (open / xdg-open / rundll32) with a
    pure, unit-tested per-OS command builder.
  - provider_wizard: oauthPending/oauthErr state, providerWizardSupportsOAuth
    (OpenRouter only — ChatGPT/Claude use the proxy preset, others paste a key),
    the async cmd + result msg, and credential-step render/keys.
  - model.Update routes the OAuth result message.

Tests: openCommand per-OS; supportsOAuth (openrouter yes / openai no); ctrl+o
starts OAuth for OpenRouter and is a no-op otherwise; apply-success advances +
applies the key; apply-error stays + surfaces the message; render shows the hint
and the pending state. The interactive browser flow itself is the same one C3
verified end-to-end against a mock.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new — the
SA1019 mouse nits are pre-existing on origin/main)/govulncheck(0)/deadcode(no new).
No new module deps.

* docs: cover the built-in OpenRouter + xAI OAuth logins

Document the two turnkey OAuth providers added in this branch (OpenRouter browser
key-mint via `zero auth openrouter` / wizard ctrl+o; xAI via `zero auth login
xai`), alongside the existing subscription-proxy and custom-provider guidance.

* tui: method-first provider setup — a prominent "Sign in with OAuth" path

Reworks `/provider` setup to lead with a connect-method chooser, modeled on Codex
/ Claude Code / gemini-cli (verified via a UX survey), so OAuth is a first-class,
separate option instead of a key buried at the credential step.

New first step "How do you want to connect?":
  ❯ Sign in with OAuth                    (default when any OAuth provider exists)
      One-click browser login, no API key to copy (OpenRouter, xAI).
    Paste an API key / browse providers
      Any of 20+ providers, a local model, or a subscription via proxy.

  - OAuth path → a dedicated list of ONLY OAuth-capable providers
    (providercatalog.OAuthProviders()), each with a mode badge; selecting one
    runs the browser login (OpenRouter mints a key; xAI/preset providers store a
    refreshable token via the engine) and jumps straight to model selection,
    skipping the key/endpoint steps. A waiting screen shows progress + a CLI
    fallback; Esc cancels; errors are redacted and retryable.
  - Browse path → the existing full catalog → key/endpoint/model, unchanged.
  - ChatGPT/Claude are NOT offered as in-app OAuth (they can't work) — they remain
    reachable as the proxy/custom providers under "browse".

  - providercatalog: Descriptor gains OAuth/OAuthMintsKey/OAuthDeviceFlow; openrouter
    + xai are flagged; OAuthProviders() is the single source of truth.
  - wizard: new method step + OAuth provider list + per-provider OAuth dispatch
    (reuses provideroauth.OpenRouterLogin and the oauth engine login), waiting/
    render/keys/step-line/retreat all updated; mouse + keys ignored while a login
    is in flight.

Tests: catalog OAuth flags + OAuthProviders(); method chooser OAuth vs browse
paths; OAuth dispatch from the list; retreat→method; render shows the chooser +
provider list; existing wizard tests updated for the new first step. All tui tests
pass under -race.

Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.

* docs: document the /provider "Sign in with OAuth" method chooser

* tui: mirror the "Sign in with OAuth" method chooser in first-run onboarding

First-run setup (onboarding.go) was a separate flow that still showed its bare
provider list. Add the same connect-method chooser so new users get the prominent
OAuth path too, keeping /provider and first-run as one mental model.

  - New setupStageMethod (after Welcome): "How do you want to connect?" with
    "Sign in with OAuth" (default) and "Paste an API key / browse providers".
  - OAuth path filters the provider list to OAuth-capable providers, runs the same
    browser login (OpenRouter mints a key → captured as the setup credential; xAI
    stores a refreshable token and the profile is named after the provider so the
    runtime resolver attaches the bearer), shows a waiting screen, then jumps to
    model selection (skipping endpoint/name/credentials). Esc cancels; errors are
    redacted.
  - Browse path is the existing flow, unchanged. setupStages()/navigation/back/
    progress/footer/mouse all updated; OAuth result routed via model.Update.

Tests: method chooser OAuth path (OAuth-only list + retreat clears it) and
apply-OAuth-success advances to model with the minted key; existing onboarding
tests updated for the new step (pressSetupContinue transparently skips it). All
tui tests pass under -race.

Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/govulncheck(0)/
deadcode(no new) all pass. No new module deps.

* catalog+tui: show ChatGPT/Claude in the OAuth chooser as local-proxy entries

The "Sign in with OAuth" chooser listed only the real-OAuth providers (OpenRouter,
xAI), so ChatGPT and Claude looked missing. They can't do real in-app OAuth — a
subscription token is Cloudflare-gated (ChatGPT) / ToS-restricted for third-party
use (Claude) — so instead of hiding them or faking a login, list them honestly as
"subscription · needs a local proxy" entries that route into proxy setup.

  - catalog: new Descriptor.OAuthProxy flag + OAuthProxyProviders(); mark
    chatgpt-proxy and a new claude-proxy (local Anthropic-compatible, keyless)
    entry. localAnthropic() helper. Proxy entries are not OAuth (no fake login).
  - wizard + onboarding: the OAuth list now appends the proxy entries after the
    real OAuth providers (catalog order keeps real ones first). Selecting a proxy
    entry leaves OAuth mode and drops into the normal browse flow with the proxy
    provider selected and its default URL/model pre-filled, so the user just
    points Zero at the local proxy they run. No browser login is launched.
  - providerWizardNeedsEndpoint now true for proxy entries (the local port
    varies); faint badge "subscription · needs a local proxy".
  - docs/oauth-subscriptions.md: chooser now shows the four entries and explains
    the proxy routing.

Tests: catalog classification (OAuthProviders vs OAuthProxyProviders, flags);
wizard + onboarding proxy-entry routing (leaves OAuth mode, starts no login,
lands on endpoint with the URL pre-filled). Full suite green under -race;
staticcheck/govulncheck/deadcode clean. No new deps.

* oauth+tui: add xAI device-code (RFC 8628) login to the OAuth chooser

The OAuth chooser only did browser/loopback login, which can't work on a headless
or SSH box (no browser to open). Add device-code as a first-class option for
providers that support it (xAI).

  - oauth.Manager: PrepareDeviceLogin (request code) + CompleteDeviceLogin (poll +
    store), splitting the monolithic device flow so the UI can display the
    user_code + verification URI while it waits. CLI Login(Device:true) unchanged.
  - tui (shared oauth_device.go): oauthPreferDeviceFlow() picks device by default
    on SSH / headless Linux (no DISPLAY) or when ZERO_OAUTH_DEVICE is set;
    oauthDevicePrepare/Complete drive the two phases off the UI goroutine.
  - /provider wizard + first-run onboarding: device-capable providers show
    "Enter sign in · d device code" — Enter uses the browser (or device if
    headless), "d" forces device. The waiting screen shows the code + URL to
    enter elsewhere, then polls; success advances to model selection. Esc cancels;
    errors are redacted; device state is cleared on success/cancel/back.
  - docs: document the device-code path and ZERO_OAUTH_DEVICE.

Tests: manager Prepare/Complete two-phase; wizard + onboarding 'd' shortcut,
device-code display + poll dispatch, error surfacing, and success clearing device
state. Full suite green under -race; staticcheck/govulncheck/deadcode clean. No
new deps.

* tui: authenticate model discovery with the OAuth token so the live list shows

After an xAI sign-in the bearer lives in the token store, not as a pasted key, so
the post-login model step ran /models with no credential → it always fell back to
the curated list (with a discovery error). Now the user picks from the real live
model list after OAuth, like other CLIs.

  - oauthStoredToken() resolves a fresh stored token for a token-login provider.
  - wizard + onboarding discovery resolve and inject that token into the discovery
    profile inside the async goroutine (token refresh is network I/O); OpenRouter
    still uses its minted key, API-key users are unchanged. Discovery timeout
    bumped 8s→12s to cover an on-demand refresh.
  - The resolved token is added to the redaction secret set so it can never leak
    into a surfaced discovery error. If discovery still fails (offline), the
    curated per-provider fallback list is shown as before — the user always gets a
    pickable list.

Tests: oauthStoredToken reads a seeded login / returns empty when absent;
wizard discovery injects the stored token into the /models profile. Hermetic via
ZERO_OAUTH_TOKENS_PATH. Full suite green under -race; staticcheck/govulncheck/
deadcode clean. No new deps.

* catalog+tui: drop ChatGPT/Claude from the OAuth chooser — OpenRouter + xAI only

Reverts the proxy-entry listing: the "Sign in with OAuth" chooser now shows only
the providers that do real OAuth (OpenRouter, xAI). ChatGPT/Claude looked
confusing there since they can't actually sign in — they remain reachable via
"Paste an API key / browse providers" + the local-proxy recipe in
docs/oauth-subscriptions.md.

  - catalog: remove the Descriptor.OAuthProxy flag, OAuthProxyProviders(), the
    oauthProxyProvider/localAnthropic helpers, and the claude-proxy entry.
    chatgpt-proxy stays as a plain browse/config entry (its pre-existing role).
  - wizard + onboarding: OAuth list = OAuthProviders() again; removed the
    proxy→browse routing, the OAuthProxy badge, and the OAuthProxy endpoint rule.
  - device-code + OAuth-token model discovery (the later features) are untouched.
  - docs + tests updated to the two-entry chooser.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck/deadcode all
clean. No new deps.

* cli: recognize a stored OAuth login as a provider credential

A provider authenticated via OAuth (e.g. `zero auth login xai`) has no inline
key or env var, so setupRequired treated it as unconfigured and forced onboarding
on every launch — and the active provider could be passed over for a different
one. setupRequired now also counts a stored OAuth login (read from the token
store) as a credential, so an OAuth-logged-in provider stays usable without
onboarding.

Tests: providerHasOAuthLogin (by name/catalog id); setupRequired returns false
for an OAuth-logged-in provider, true for a keyless one with no login.

* Address review feedback on OAuth provider login

Resolve CodeRabbit review comments on this PR:

- PrepareDeviceLogin: self-bound the network prepare (endpoint discovery +
  device-code request) with opts.Timeout (default 5m) like Login, so a caller
  passing an unbounded context still gets the timeout guarantee.
- CompleteDeviceLogin: bound the poll by the device code's own ExpiresAt so an
  unbounded caller context cannot leave an in-flight request hanging past the
  code's lifetime (PollDeviceToken already re-checks between polls).
- OAuthProviders(): return cloned descriptors, matching All()/the other
  accessors, so callers can't mutate the shared catalog backing slices.
- Onboarding applySetupOAuth / applySetupOAuthDeviceCode: ignore a stale result
  for a provider the user has since switched away from, so a late login can't
  capture a credential against the wrong provider.
- Onboarding: surface a failed OAuth login on the provider list the user lands
  on (the waiting screen that owned the error is gone once oauthPending clears),
  so the failure isn't silent and the user can retry.
- Onboarding: hide the "Sign in with OAuth" method when this setup's own
  provider list has no OAuth-capable entry, so it can't be selected into an
  empty provider list (setupMethodOptions).
- docs/oauth-subscriptions.md: label the bare example fences (text) and update
  the Anthropic policy section for the April 4 2026 third-party cutoff.

Adds tests for the descriptor-clone isolation and the method-option filtering.

* oauth: gate built-in presets behind ZERO_OAUTH_ALLOW_PRESETS

The xAI preset baked a third-party OAuth client_id into the default
credential path, which breaks the engine's documented invariant that no
third-party OAuth client identity is used unless the operator configures
it. Rather than drop the convenience entirely, make presets opt-in:

- ResolveConfig only consults builtinOAuthPresets when ZERO_OAUTH_ALLOW_PRESETS
  is truthy (presetsAllowed); by default every field must come from
  ZERO_OAUTH_<NAME>_* env, so the binary's preset client_id is never used
  without an explicit opt-in.
- The "not configured" error now points the user at ZERO_OAUTH_ALLOW_PRESETS
  when a preset exists, so the wizard/CLI failure is self-documenting.
- Refresh the package, Registry, and preset docs to describe the opt-in;
  env still overrides any preset field.
- docs/oauth-subscriptions.md: document the flag for one-click xAI sign-in.

Keeps both: invariant-clean by default, one-click xAI for opted-in users.
Tests: preset resolution now requires the opt-in; added a gate-off test
asserting xAI stays inert (and the error names the flag) without it.

* oauth: read a stored token without resolving provider config

GetFresh resolved the provider OAuth config for every read via loadForKey,
even when the stored token was still valid and no refresh would happen.
With presets gated behind ZERO_OAUTH_ALLOW_PRESETS, that made a stored xAI
login unreadable (and stalled the proactive refresh scheduler) unless the
opt-in flag was set at read time — config that is only needed to refresh.

Split loadForKey into loadToken (token only) and resolveConfigForKey
(refresh-only). GetFresh now returns a still-valid token without touching
the config; only an actual refresh (or Handle401) resolves endpoints +
client. The scheduler's load uses loadToken too, so proactive refresh
survives a gated preset and only the refresh attempt needs the config.

Fixes the Smoke failures TestOAuthStoredTokenReadsStoredLogin and
TestProviderWizardDiscoveryUsesOAuthToken (a fresh stored token must be
readable without the preset opt-in).

* Address CodeRabbit re-review round 2 on OAuth provider login

- providerio SendWithAuthRetry: resolve the auth headers BEFORE dispatching, so
  a resolver error returns immediately instead of letting SendWithRetry send an
  unauthenticated request (leaking the path/body).
- tui handleMouse: ignore all mouse input (clicks + wheel) while a provider-wizard
  OAuth login is pending, so a stray scroll can't change the selected provider
  mid-login (mirrors the keyboard pending guard; the setup mouse handler already
  did this).
- cli runAuthOpenRouter: reject unexpected args/flags (e.g. --json) instead of
  silently running the login, matching the other auth subcommands.
- cli auth help: align the preset wording with the opt-in gate — openrouter works
  out of the box; xai needs ZERO_OAUTH_ALLOW_PRESETS=1 or ZERO_OAUTH_XAI_*.
- onboarding_test: compute selectedMethod from m.setupMethodOptions() (the
  setup-filtered list) instead of providerWizardMethodOptions(), which could index
  past the end when OAuth is hidden.
- docs/oauth-subscriptions.md: sharpen the Anthropic timeline — Feb 19 2026 docs
  update, then the April 4 2026 enforcement that stopped subscription OAuth tokens
  working in third-party harnesses; current path is an API key or pay-as-you-go.

Adds tests for the no-dispatch-on-resolver-error path and openrouter arg rejection.

* Address CodeRabbit re-review round 3 on OAuth provider login

Two findings missed in the previous round:

- oauth manager resolveConfigForKey: resolve issuer metadata before refreshing.
  It returned ResolveConfig's result without calling resolveEndpoints, so a
  provider configured with only ZERO_OAUTH_<NAME>_ISSUER_URL (no TOKEN_URL) had
  an empty token endpoint and could not refresh on GetFresh/Handle401. Thread the
  context through and run discovery; adds a test that an issuer-only provider
  refreshes via the discovered token endpoint.
- provider_wizard_oauth_test: assert openrouter is actually present in the OAuth
  list before selecting it, so the test can't silently pass against a different
  provider if openrouter is renamed/removed.

(The re-flagged PrepareDeviceLogin timeout is already handled — it self-bounds
with opts.Timeout, same as Login.)

* Address CodeRabbit re-review round 4 (nitpicks) on OAuth provider login

- presets scopesOrPreset: return a copy of the preset scopes so a caller
  appending to cfg.Scopes can't mutate the shared global preset slice.
- provideroauth/openrouter: use bytes.NewReader(payload) instead of
  strings.NewReader(string(payload)) (avoid a needless []byte->string copy).
- onboarding setupCredentialSummary: show "OAuth token" on the Ready screen for
  an OAuth token-login provider (e.g. xAI) instead of "env var XAI_API_KEY" — it
  is authenticated by a stored token, not a key. Key-minting OAuth (OpenRouter)
  still resolves to a saved key.

(The flagged "Registry doc says no built-ins" was already corrected in the
preset opt-in commit — the doc now describes the ZERO_OAUTH_ALLOW_PRESETS gate.)

Adds tests for the scope-slice copy and the OAuth-token credential summary.
2026-06-16 15:33:36 +05:30
KRATOS c38444157c Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune (#216)
* tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019)

The mouse classification helpers checked both the current Button/Action pair and
the deprecated msg.Type / tea.Mouse* constants. Bubble Tea's parser always
populates Button+Action and only DERIVES Type from them, so the Type fallbacks
were pure redundancy (a left-button drag is Action==Motion, already covered by
mouseMotion). Drop them — clears all 11 SA1019 findings in internal/tui with no
behavior change. The left-drag test now asserts the Action==Motion path directly.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(SA1019 0)/
deadcode(no new) all pass.

* notify: wire the webhook sink behind an opt-in env var

The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.

Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
  - reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
  - no-op when the URL is unset/blank, so it is safe to call unconditionally
  - sourcing the URL from the environment keeps the secret token out of any
    on-disk config file

Attached at both notifier construction sites:
  - headless exec: failed deliveries log to stderr (never stdout); the sink
    redacts before logging
  - TUI: logf=nil so a delivery failure can't corrupt the alt-screen

The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.

deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.

* swarm: add opt-in recurring-spawn scheduler (wakeup + daily)

Adds a dependency-free Scheduler to internal/swarm so a member can be spawned on
a recurring schedule, plus a swarm_schedule tool to drive it.

  - Schedule is interval-based ("wakeup", every >= 1s) with an optional first
    delay and a MaxRuns cap; a daily "HH:MM" ("cron") time is expressed as a 24h
    interval whose first delay lands on the next occurrence.
  - Each fire spawns a fresh member through the existing Swarm.Spawn path, so
    members inherit the recorded policy and are bounded by the team slot cap +
    queue. Non-overlapping by default: a fire is skipped while the job's previous
    spawn is still running, preventing queue pile-up.
  - Scheduler is lazily created (Swarm.Scheduler()), opt-in (nothing runs until a
    job is added), and tied to the Swarm's base context — Swarm.Close() stops the
    scheduler first so no spawn fires during shutdown.
  - swarm_schedule tool: action add|list|cancel; prompt-gated like swarm_spawn;
    add validates schedule + agent type up front (fail fast).

The timing loop takes an injectable ticker, so the tests drive fires
deterministically with no real time (fire/skip/cap/cancel/close, plus pure
nextDailyDelay/parseClock/swarmInt). All swarm tests pass 3x under -race.

Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass.

* daemon/remote: SessionLink + git-bundle upload over the bridge

Adds an opt-in way to ship a local repo's git history to a remote daemon and
link a remote working tree to it — without a shared filesystem or a network git
remote. The daemon core (protocol/server) is untouched; all changes live in the
remote package plus a CLI hook.

Wire:
  - The auth handshake gains an optional Mode field ("" / "session" => a daemon
    session as before; "bundle" => a one-shot git-bundle upload). Unknown modes,
    or bundle mode when uploads are disabled, are denied (fail closed).
  - Bundle transfer reuses the daemon frame codec: a header frame (link id +
    exact size), then the bundle bytes as KindData frames, then a result frame.

Server (Bridge):
  - BridgeOptions.BundleDir enables uploads (empty => refused). A received bundle
    is size-capped (default 256 MiB), staged to a temp file, `git bundle verify`-d,
    then `git clone`-d into a staging dir and atomically renamed over
    <BundleDir>/<link-id>. Link ids are sanitized to a single path component and
    containment is re-checked, so an upload can never escape the bundle dir.

Client:
  - UploadRepoBundle creates `git bundle create --all` of a work tree, hashes it
    (sha256), streams it over an authenticated bundle-mode TLS connection, and
    returns a SessionLink {address, link id, remote path, bundle sha}. SessionLink
    persists as a 0600 atomic JSON file (token never stored) with Save/Load/Validate.

CLI:
  - `daemon serve-remote --bundle-dir <d>` enables uploads.
  - `daemon link --remote <addr> --repo <dir> --id <name> [--out <file>]` uploads
    and prints the remote path (+ a ready run command); `daemon link --show <file>`
    prints a saved link.

Tests: git bundle create/verify/clone roundtrip; link-id sanitization + path
containment; SessionLink save/load/validate (0600); and a full bridge upload E2E
over real TLS (extract + content check + re-upload-replaces + disabled + bad
token). All remote tests pass 2x under -race; CLI smoke verified via the binary.

Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* mcp: unify OAuth token storage onto the shared oauth store

MCP server tokens lived in their own mcp-oauth-tokens.json with a bespoke
codec/lock, separate from the provider-login store (internal/oauth). The oauth
store already reserved the "mcp:" key namespace for exactly this. Unify them.

  - mcp.TokenStore now delegates to internal/oauth.Store, keying each server's
    token as "mcp:<server>". Its public API (StoredToken/TokenStatus/Save/Load/
    Delete/Status/FilePath) is unchanged, so callers (network client, CLI) are
    untouched. The bespoke read/write/lock code is removed in favor of the shared,
    cross-process-locked, atomic store.
  - Transparent, non-destructive migration on first construction: a legacy
    mcp-oauth-tokens.json is imported into the unified store (only entries not
    already present — a newer unified token is never overwritten), then renamed to
    a ".migrated" backup so it is imported at most once and stays recoverable.
    A missing / unreadable / unknown-schema legacy file is left untouched and
    never blocks startup. An explicit FilePath suppresses the default-path
    migration so tests/overrides can't touch the real user config.
  - Server names that can't form a valid namespaced key (oauth.ValidateKey) are
    rejected on save and skipped during migration. Added oauth.Store.FilePath().

The token format was already identical (access/refresh/type/scopes/expires), so
this is a key rename, not a format change. Existing TokenStore tests pass
unchanged (they exercise the API); added migration + namespacing + newer-wins
tests. Local run check: `zero mcp oauth status` migrates a seeded legacy file
into oauth-tokens.json under mcp:demo, renames the legacy to .migrated, and leaks
no token material.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(clean)/
govulncheck(0)/deadcode(no new) all pass.

* oauth: add an opt-in OS keyring storage backend

Introduces a pluggable storage backend for the unified oauth token store and a
dependency-free OS keyring implementation, selected with
ZERO_OAUTH_STORAGE=keyring (default stays the 0600 file).

  - internal/keyring: a small, third-party-dep-free secret store backed by the OS
    tooling — `security` on macOS, `secret-tool` (libsecret) on Linux; other
    platforms report unsupported. On Linux the secret is passed via stdin (never
    the argv); on macOS `security` takes it as an argument (briefly visible in the
    process list) — documented, and a reason to prefer the file backend there.
    A runner seam makes the per-OS command logic fully testable without a real
    keychain.
  - internal/oauth: Store now persists its JSON blob through a blobStore backend.
    fileBlob preserves the exact prior behavior (0600, atomic write, cross-process
    lock file). keyringBlob stores the blob as one base64 entry (keeps multi-line
    JSON a single control-char-free value) via a KeyringClient. NewStore resolves
    the backend from StoreOptions.Storage or ZERO_OAUTH_STORAGE; unknown storage
    and an unavailable keyring fail closed with a clear error.

Because both `zero auth` and the (now unified) MCP token store construct their
store through oauth.NewStore, the keyring backend is honored by both with no
call-site changes. Added oauth.Store.FilePath() returns a "keyring:" identifier
for that backend.

No new module dependency (go.mod unchanged). Tests: keyring round-trip/not-found/
unsupported/missing-binary/stdin-not-argv via an injected runner; store keyring
round-trip + base64-at-rest + storage selection; all existing file-backend tests
pass unchanged. Local run check (macOS): validated the exact `security`
add/find/delete shapes and the not-found exit code (44) against the real binary,
and `mcp oauth status` with ZERO_OAUTH_STORAGE=keyring reads the real keychain
cleanly.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* all: prune unused unexported functions (dead-code sweep)

Removes 15 unexported functions/types/consts that are unreachable even from
tests (every one flagged by staticcheck U1000), clearing the U1000 noise the v16
audit surfaced. Conservative by design: only unexported, test-unused symbols are
removed — exported internal API (which may be an intentional surface or used only
by tests) is deliberately left in place.

Removed:
  - agenteval: validateExpectedChangedFiles
  - providers/gemini: const providerName
  - tui: stripMarkdownInline, removeTrailingAtToken,
    noopModelSwitchCompactionPolicy (+ BeforeModelSwitch), deleteComposerLineBefore,
    deleteComposerLineAfter, deleteCompletedFileMentionBefore, mcpViewStatus,
    mcpServerLines, fitCommandOutput, (*commandPicker).clearQuery,
    tuiTheme.onPanel2, indentText

No behavior change (the symbols had no callers). staticcheck U1000 is now empty;
deadcode drops accordingly with no genuinely-new unreachable functions (verified
by a name-based diff vs origin/main). gofmt/vet/build(host+linux+windows)/full
test all pass.

* Address review feedback on the audit backlog

Resolve CodeRabbit review comments on this PR:

- keyring isNotFound: match the specific not-found exit codes (macOS `security`
  44 = errSecItemNotFound; `secret-tool` 1) instead of treating any non-zero
  exit as "no entry", so a real failure (locked keychain, permissions) surfaces
  instead of being masked as absent.
- swarm scheduler daily_at: recompute each fire as the next local HH:MM rather
  than adding a fixed 24h, so a wall-clock daily schedule does not drift across
  DST. Adds Daily/Hour/Minute to Schedule and a clock to the Scheduler.
- swarm scheduler: reject Add when the scheduler context is already canceled
  (not just when closed); re-check cancellation after a tick so a tick+cancel
  race can't spawn one extra member after Cancel/Close.
- swarm schedule_tool swarmInt: reject non-integer / non-finite JSON numbers
  (max_runs=1.9 errors instead of silently truncating to 1).
- mcp migrateLegacy: resolve a relative LegacyPath to absolute before the
  same-file guard so it can't be bypassed by a relative spelling.
- oauth keyring store: serialize the keyring read-modify-write with a
  cross-process lock file (best-effort, beside the config dir) so concurrent
  processes don't clobber each other's tokens; previously withLock was a no-op.

Adds tests for the non-not-found keyring error, the daily delay recompute, the
Add-after-cancel guard, and non-integer swarmInt.

* swarm: make nextDailyDelay roll the day, not add 24h (DST)

CodeRabbit follow-up: the run loop already recomputes a daily job's delay each
cycle, but nextDailyDelay itself rolled "passed today" to the next occurrence
with Add(24*time.Hour). On a DST changeover day (23h/25h) that fires an hour
early/late. Use time.Date(..., day+1, hour, minute, ...) so Go normalizes the
date and applies the correct local offset, holding the wall-clock HH:MM across
DST. Adds an America/New_York spring-forward test (skips if tzdata is absent).
2026-06-16 14:48:12 +05:30
KRATOS 75ecf8e385 oauth: opt-in AES-256-GCM encrypted-at-rest token storage (#213)
* oauth: opt-in AES-256-GCM encrypted-at-rest token storage

OAuth Phase 2: add an encrypted token-store backend. The default backend still
writes the 0600 plaintext JSON unchanged; setting ZERO_OAUTH_STORAGE=encrypted-file
encrypts the token file with AES-256-GCM under a per-user random 32-byte secret
persisted 0600 beside the token file (<tokens>.secret).

- internal/oauth/encrypt.go: aesGCMCrypter (nonce||ciphertext, GCM tamper
  detection) + load-or-create secret (atomic 0600 write). Pure stdlib, no dep.
- Store gains an optional crypter: readState decrypts before unmarshal, writeState
  encrypts after marshal. A missing secret, a short blob, or a failed auth tag all
  fail closed (no silent empty/plaintext fallback).
- CLI: newAuthManager selects the backend from ZERO_OAUTH_STORAGE; `zero auth`
  help documents it.

Tests: crypter round-trip + tamper, secret create/persist/0600/wrong-size,
store-through-encrypted (ciphertext on disk, round-trip, Status/Delete, tamper +
missing-secret fail-closed). Gates: gofmt/vet/build(host+linux+windows)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass. Local run
check: device login under ZERO_OAUTH_STORAGE=encrypted-file writes a ciphertext
file (no plaintext token), .secret is 0600, and `zero auth status` reads it back.

* Address review feedback on encrypted token storage

Resolve CodeRabbit review comments on this PR:

- newAuthManager: reject an unsupported ZERO_OAUTH_STORAGE value instead of
  silently downgrading to plaintext. A mistyped non-empty value now fails fast
  ("invalid ZERO_OAUTH_STORAGE ... (supported: encrypted-file)") so a user who
  believes encryption is on is never left writing plaintext.
- loadOrCreateSecret: fix the first-run TOCTOU race. os.Rename publishes by
  clobbering on POSIX, so two concurrent processes could each generate a secret
  and the loser would silently orphan every token it then encrypts. Create the
  secret exclusively (O_CREATE|O_EXCL); the loser adopts the winner's on-disk
  secret. Factored the read+size-validate into readSecretFile and dropped the
  now-unused clock plumbing from the crypter.
- encrypt_test: handle os.Stat / os.ReadFile errors before dereferencing the
  result (mode/index) so a real I/O failure surfaces instead of a panic.

Adds a test asserting an invalid ZERO_OAUTH_STORAGE fails fast.

* oauth: retry the secret read when losing the first-run create race

CodeRabbit follow-up: the O_EXCL create path read the on-disk secret
immediately after EEXIST, but the winner may not have finished writing it
yet, so a loser could observe a short file and fail transiently. Retry the
read briefly (bounded) so concurrent first-run invocations converge on the
winner's secret. Adds a -race concurrency test asserting convergence.
2026-06-16 14:00:26 +05:30
KRATOS efb6b79164 web_search robustness + chat UX: collapse, clipboard, scroll-pin, proactive search (#218)
* web_search: fix #209 follow-ups (tolerant score, render gate, redaction, FQDN)

Verified-and-fixed defects from a review of #209:
  - Tolerant score decode (lenientScore): a stringified/odd/null "score" no longer
    makes json.Unmarshal discard the entire response; it degrades to absent.
  - Score render gate rounds first (>= 0.01) so tiny positives and negatives no
    longer print a noisy "score 0.00".
  - Domain-filter error now goes through redactWebSearchText, matching the other
    error paths.
  - Trailing-dot FQDN symmetry: "react.dev." and "react.dev" normalize the same
    on both the allowlist and result sides.

Left as-is (intended, tested): scheme-less host:port allowlist entries. Includes
mixed-type domains-array tests.

* web_search: only register the built-in tool when a backend is configured

When no search backend is set (ZERO_WEBSEARCH_BASE_URL / ZERO_WEBSEARCH_BACKEND),
CoreNetworkTools no longer offers the built-in web_search. Previously it was always
registered, so a model with an MCP search tool (e.g. Exa) would burn several calls
+ high-risk permission prompts on the built-in tool — which can only return
"no backend configured" — before falling back to the working MCP tool.

Now: no backend -> only web_fetch (+ any MCP search tool) is offered, so the model
uses the MCP search directly. A configured backend behaves exactly as before.

Tests: registry/web_search/specialist tests that asserted web_search is always in
CoreTools now configure a backend; added a test that it is omitted when none is set.

(Pre-existing, unrelated: internal/cli doctor/exec tests fail in the sandbox on
origin/main too — network/provider-dependent, not touched here.)

* tui: copy selected transcript text to the native OS clipboard

Selecting transcript text auto-copies on mouse release, but the copy went only
through OSC52 (ansi.SetSystemClipboard), which macOS Terminal.app doesn't support
at all and iTerm2 / some Windows Terminal builds have off by default — so the
selection silently never reached the clipboard.

Copy via the native clipboard (pbcopy / clip.exe / xclip) first, which works on
local terminals regardless of OSC52, and fall back to OSC52 for remote/SSH
sessions where no local clipboard utility is reachable. atotto/clipboard was
already in the module graph (indirect); promoted to a direct import — no new
dependency.

Gates: gofmt/vet/build(host+linux+windows)/tui test -race/staticcheck all pass.

* tui: collapse long tool output by default with click-to-expand

Long, noisy tool output (web-search / MCP / read dumps) flooded the transcript.
Now a tool result whose output exceeds the live body cap renders collapsed by
default — head + a "▸ N lines — click to expand" footer — and you click the card
(while it is live) to expand (▾) / collapse. Mirrors the existing reasoning-row
collapse, so collapsed rows also flush to scrollback clean.

Scoped to avoid hiding what matters:
  - diff tools (edit_file / apply_patch / write_file) never collapse — their body
    must stay reviewable (matches the deeper flush cap intent);
  - the uncapped detailed transcript view (Ctrl+O, bodyCap==0) never collapses —
    it still shows full output;
  - short output (<= cardBodyMaxLines) stays inline as before.

Reuses transcriptRow.expanded + the row toggle (toggleReasoningRow generalized to
toggleTranscriptRow); the card head is a clickable toggle line. Running tool
cards (spinner) are unchanged.

Tests: collapse/expand, short-output inline, diff tools never collapse, toggle,
and the head exposes a click toggle; existing diff + detailed-view tests updated.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck all clean.

* cli: fall back to a usable saved provider instead of re-running onboarding

If the active provider lacked a credential (e.g. it relies on an env var that
isn't set, or an OAuth login that isn't stored), the TUI forced first-run
onboarding on every launch — even when other saved providers were fully
configured. Each onboarding then appended yet another provider.

Now, when the active provider isn't usable, fall back to the first usable saved
provider (prefer a remote keyed one over a local endpoint) for the session, and
skip onboarding. Onboarding only runs when no configured provider is usable — a
genuinely fresh setup. Saved logins persist; switch the active provider with
`zero provider use <name>`.

Tests: firstUsableProvider prefers remote keyed, falls back to local, accepts a
keyless local proxy, and reports none-usable. Gates: gofmt/vet/build
(host+linux+windows)/staticcheck clean; the only failing cli tests
(doctor/exec, network-dependent) are pre-existing on origin/main.

* tui: hold the scroll position while output streams in

The chat viewport scroll offset is measured from the bottom, so when the
transcript grew (a streaming turn) the window followed the new bottom and yanked
the user off whatever they had scrolled up to read.

syncChatScroll now pins the viewport: while the user is scrolled up, it bumps the
offset by however many lines the body grew, so the absolute view holds where they
are reading; at the bottom (offset 0) it follows the tail as before. Hooked into
the single Update funnel; only the scrolled-up path renders the body, so the
common (at-bottom) case stays cheap.

Tests: pins by the growth delta while scrolled up; resets/follows at the bottom.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck clean.

* agent: search the web proactively instead of answering "I don't know"

When a web search/fetch tool is available and the user asks about something
external the model is unsure of or that may be current (a product, library,
company, price, version, recent event), the system prompt now tells it to search
and read results before answering, rather than replying that it doesn't know
without checking. Addresses the model saying "I don't know about that" until the
user explicitly asked it to search.

Tests: prompt includes the new guidance ("search the web and read"). Existing
prompt assertions use Contains, so the addition is non-breaking.

* tui: show clean labels for MCP tool cards (mcp_exa_web_search_exa → web search)

* agent: sharpen web-search guidance (disambiguate, refine, scale)

Strengthen the search rule so the model searches before answering about an
external entity/product/library/model/version/release it doesn't recognize,
doesn't assume the most common meaning of an ambiguous name (disambiguates by the
conversation's domain), and refines + re-searches when results look off-topic —
rather than confidently answering the wrong interpretation (e.g. reading "Fable 5"
as a video game instead of an AI model). Also: don't web-search timeless facts or
workspace-answerable questions (use the file tools), keep queries short, and scale
the number of searches to the question.

Tests: prompt includes the new guidance ("search the web before answering",
"do not recognize"). Existing assertions use Contains, so non-breaking.

* test: gofmt setup_fallback_test.go (comment alignment)

* Address review feedback on web_search + chat UX

Resolve CodeRabbit review comments on this PR:

- firstUsableProvider: skip a profile whose CatalogID no longer resolves
  *and* that has no explicit BaseURL (no endpoint → unusable). A stale
  CatalogID with a BaseURL still works as a custom endpoint and is kept.
- providerProfileIsLocal: classify by parsed URL hostname (exact
  localhost / 127.0.0.1 / ::1) instead of substring matching, so hosts
  like "notlocalhost.com" are no longer misread as local.
- lenientScore: reject non-finite scores (ParseFloat accepts "NaN"/"Inf")
  so the documented score filter actually holds and the renderer never
  emits a non-finite value.
- syncChatScroll: shift the pinned from-bottom offset by the signed line
  delta so the view also holds when the body shrinks (card collapse,
  transcript clear), clamped at zero; previously only growth was handled.
- copyTranscriptSelectionCmd: report "Copy failed" when both the native
  clipboard and the OSC52 fallback fail, instead of claiming success;
  keep the selection so the user can retry.
- Drop vestigial ZERO_WEBSEARCH_BACKEND test env: the native backend was
  removed (MCP path), so the var is read by no product code and the
  references misrepresented it as a config knob.

Adds tests for the local-host parsing, the catalog/BaseURL skip, and the
non-finite score rejection.
2026-06-16 13:47:08 +05:30
Anandan 2bcb85bfb3 Polish TUI title bar (#219)
* Polish TUI title bar

* Show PR diff stats in TUI title bar

Add a small PR status service that detects the active GitHub PR or GitLab merge request via gh/glab, computes diff stats against the PR base with git diff --shortstat, and publishes updates to the TUI.

Render the resulting +N -N #PR segment in the title bar next to the branch, linked to the PR URL, while keeping the composer footer unchanged.

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

Tested: git diff --check

* Make submitted prompt bubble terminal-safe

Replace half-block top/bottom rows in submitted user prompts with background-filled padding rows. macOS Terminal renders the half-block glyphs as visible horizontal artifacts, while filled-space padding keeps the prompt bubble height without relying on ambiguous block glyph behavior.

Update selectable transcript rendering to use the same padding helper and remove the now-unused half-block theme style.

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

Tested: git diff --check

* Address PR status review feedback
2026-06-16 13:28:28 +05:30
Anandan e1253c826c Polish assistant response transcript (#204)
* Polish assistant response transcript

* Keep copy feedback above composer
2026-06-15 19:28:02 +05:30
313 changed files with 30351 additions and 2773 deletions

No files matched your search

+315 -11
View File
@@ -1,16 +1,320 @@
---
description: Use Go-native project commands; Node is only for the npm wrapper.
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
alwaysApply: false
---
Default to Go commands in this repository.
# Extending Zero
- Use `go run ./cmd/zero` to launch the local CLI.
- Use `go test ./...` for the repository test suite.
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
- Do not add Bun or TypeScript build/test dependencies for repository validation.
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
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.
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
```markdown
# Project conventions for <your project>
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description: Reviews API changes for breaking-change risk and missing tests.
tools: read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes" \
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
zero specialist delete api-reviewer --project
zero specialist path # prints the resolved specialists directory
```
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
## 3. Skills
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description: Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1. `make bench` — captures the wall-clock and RSS before and after.
2. `benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
- User: `~/.config/zero/hooks.json`
- Project: `./.zero/hooks.json`
```json
{
"enabled": true,
"hooks": [
{
"id": "block-rm-rf",
"event": "beforeTool",
"matcher": "bash",
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
"enabled": true
},
{
"id": "log-session",
"event": "sessionStart",
"command": "/usr/local/bin/zero-hook-log.sh",
"enabled": true
}
]
}
```
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
```bash
#!/usr/bin/env bash
# /usr/local/bin/zero-hook-block-rmrf.sh
set -euo pipefail
payload="$(cat)"
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
echo "refusing rm -rf" >&2
exit 1
fi
```
Events the agent emits (in dispatch order):
| Event | Fires when | Matcher allowed? |
| --- | --- | --- |
| `beforeTool` | A tool is about to run | yes (tool name) |
| `afterTool` | A tool just returned | yes (tool name) |
| `sessionStart` | A session begins | no |
| `sessionEnd` | A session ends | no |
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
```json
{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"]
},
"github": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
}
}
}
}
```
Manage via CLI:
```bash
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id": "github-pr-review",
"name": "GitHub PR Review",
"description": "Adds review tools for GitHub PRs.",
"version": "1.0.0",
"tools": [
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
],
"hooks": [
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
],
"skills": [
{ "path": "./skills/review-checklist/SKILL.md" }
]
}
```
Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
| Layer | Path | Notes |
| --- | --- | --- |
| Built-in defaults | compiled in | Lowest priority. |
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
A team that wants every contributor's Zero to behave the same way commits:
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
- `.zero/config.json` — provider catalog, default model, allowed tools.
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
## 9. Reference
- [README](README.md) — install, quickstart, command reference.
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface.
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
+41 -7
View File
@@ -179,6 +179,40 @@ The model registry tracks each model's capabilities, context window, and cost
Every mutating tool routes through the permission policy **before** any side effect.
### Web search & scraping (free, no API key)
`web_fetch` is built in and always available: it pulls a single URL into clean
markdown locally, with no third party. For **search**, JS-rendered scraping,
whole-site crawls, PDF→markdown, and structured extraction, Zero ships the
[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so
web search and scraping work out of the box with **no setup and no API key** (1,000
free credits/month). It is a normal MCP server, so you stay in control:
```bash
zero mcp tools list # see the firecrawl_* tools it exposes
zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor
```
- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you
prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on
`web_fetch`, or self-host below.
- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your
own instance and override the default URL — Zero only *calls* it over the network,
so the AGPL never reaches into Zero's own code:
```jsonc
// config.json
{ "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } }
```
- **Bring your own key (higher limits):** add an auth header over the default:
```bash
zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \
--header "Authorization: Bearer fc-your-key"
```
An MCP server that can't be reached at startup (e.g. Firecrawl on an offline
machine) is **skipped with a warning, not fatal** — Zero still launches and the
rest of its tools, including `web_fetch`, keep working.
### Extra write directories (`--add-dir`)
Zero confines writes to the workspace by default. To let the agent write somewhere
@@ -204,14 +238,14 @@ still resolve against the workspace only, and network and destructive-shell poli
are unchanged. A write denied outside all roots returns an error that suggests
`/add-dir`.
Two extra hardening flags are available in the `sandbox` config block, both **off
by default** and safe to leave unset:
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
both **off by default** and safe to leave unset:
- `sandbox.blockUnixSockets` (Linux) — on the bubblewrap backend, installs a
seccomp filter that denies `AF_UNIX` socket creation in the sandboxed command,
closing the Unix-socket channel that filesystem/network isolation leaves open.
Degrades gracefully (runs without the filter) when the `zero-seccomp` helper is
not installed alongside the binary.
- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a
best-effort seccomp filter that denies `AF_UNIX` socket creation in the
sandboxed command. Linux packages also continue to ship `zero-seccomp` as a
compatibility wrapper for existing direct uses; new sandbox plans do not depend
on that external wrapper.
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
denials and appends them to a command's stderr as a `<sandbox_violations>` block
so blocked operations are visible. A no-op on OS versions that do not deliver
+13
View File
@@ -0,0 +1,13 @@
//go:build linux
package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
}
+4 -9
View File
@@ -1,14 +1,9 @@
//go:build linux
// Command zero-seccomp is a tiny exec wrapper for the Linux sandbox: it installs
// the Unix-socket-blocking seccomp filter on itself and then execs the real
// command, so a bubblewrap-sandboxed process cannot create AF_UNIX sockets (a gap
// bubblewrap's filesystem/network isolation leaves open). Wire it by prefixing the
// sandboxed command with this binary, e.g. `bwrap ... -- zero-seccomp <command>`.
//
// It degrades gracefully: if the filter cannot be installed (e.g. the kernel lacks
// seccomp), it warns and runs the command anyway, since bubblewrap still provides
// the primary isolation.
// Command zero-seccomp is retained as a compatibility wrapper for existing Linux
// installs and scripts. The main sandbox path now applies the same optional
// Unix-socket filter inside zero-linux-sandbox when sandbox.blockUnixSockets is
// enabled.
package main
import (
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "zero-seccomp is only supported on Linux")
os.Exit(2)
}
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
os.Exit(sandbox.RunWindowsSandboxCommandRunner(os.Args[1:], os.Stderr))
}
+11
View File
@@ -0,0 +1,11 @@
package main
import (
"os"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
os.Exit(sandbox.RunWindowsSandboxSetup(os.Args[1:], os.Stderr))
}
+184
View File
@@ -0,0 +1,184 @@
# OAuth logins & using ChatGPT / Claude subscriptions
Zero supports two distinct things people mean by "log in with OAuth":
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
fully built in (`zero auth login …`). Zero attaches the token to model calls
automatically.
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
pay-per-token API key — only possible through a **local proxy**, for the
reasons documented below. Zero ships a convenience preset and this recipe.
---
## 1. OAuth login for a provider or gateway (built in)
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
env vars and log in:
```sh
export ZERO_OAUTH_ACME_CLIENT_ID=…
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
export ZERO_OAUTH_ACME_SCOPES="openid profile"
zero auth login acme # browser (loopback); --device for headless
zero auth status
```
When a login exists for a provider, the **OpenAI and Anthropic** providers send
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
`401`) instead of the API key. With no login they use the API key exactly as
before. Tokens are stored 0600 (or the OS keyring with
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
### In the setup wizard (`/provider`)
Running `/provider` opens a **"How do you want to connect?"** chooser:
```text
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
```
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
```text
❯ OpenRouter browser sign-in · creates a key
xAI (Grok) browser or device code
```
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
**d** on the list to get a code to enter on another device instead of opening a
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
equivalent is `zero auth login xai --device`.
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
### Built-in OAuth providers
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
the provider profile and used normally.
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
ships a built-in preset for the public Grok-CLI client, but to keep third-party
client identities out of the default credential path it is **off by default**.
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
(browser, or `--device` for headless) works one-click; the token is used directly
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
`zero auth login <name>`.
---
## 2. ChatGPT / Claude subscriptions — why a proxy is required
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
**not** work as a drop-in bearer against the standard APIs:
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
requires mimicking the official Codex client (originator + account-id header).
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
for third-party use unless the request spoofs the Claude Code identity
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
then on **April 4 2026** enforcement landed and subscription OAuth tokens
**stopped working in third-party harnesses** (starting with OpenClaw, then the
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
both per-token, not the flat subscription. The request to allow subscription use
(claude-code #37205) was closed *"not planned."*
So Zero does **not** call those backends directly or spoof those clients — that
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
The robust, supported pattern is a **local proxy** that holds your subscription
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
just points at it.
### ChatGPT via a local proxy
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
preset (no API key — the proxy authenticates):
```jsonc
// ~/.config/zero/config.json (or ./.zero/config.json)
{
"activeProvider": "chatgpt",
"providers": [
{
"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
}
]
}
```
```sh
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
```
### Claude via a local proxy
There is no single canonical Claude OAuth-proxy port, so use the generic
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
endpoint:
```jsonc
{
"activeProvider": "claude",
"providers": [
{
"name": "claude",
"catalogID": "custom-anthropic-compatible",
"baseURL": "http://localhost:<port>", // your Claude proxy
"apiKey": "unused-by-proxy",
"model": "claude-sonnet-4.5"
}
]
}
```
---
## 3. Supported alternatives (no proxy)
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
Bills as API usage.
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
first-class subscription session.
---
## Notes
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
setup; they are not an endorsement of any specific proxy implementation.
- Subscription-via-proxy depends on third-party tools and undocumented vendor
backends; it can break without notice. The API-key path is the stable one.
+21 -22
View File
@@ -1,37 +1,36 @@
module github.com/Gitlawb/zero
go 1.24.2
go 1.25.0
toolchain go1.26.4
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.6
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.4
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.38.0
mvdan.cc/sh/v3 v3.11.0
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/go-quicktest/qt v1.102.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sync v0.21.0 // indirect
)
+40 -48
View File
@@ -1,31 +1,33 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -34,33 +36,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+158
View File
@@ -0,0 +1,158 @@
package agent
import (
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
"mvdan.cc/sh/v3/syntax"
)
func proposedCommandPrefix(toolName string, args map[string]any) []string {
if toolName != "bash" {
return nil
}
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
if !ok {
return nil
}
tokens, ok := safeShellCommandTokens(command)
if !ok {
return nil
}
if requested, ok := requestedPrefixRule(args); ok {
if safeRequestedPrefix(requested, tokens) {
return requested
}
return nil
}
if !sandbox.ValidCommandPrefix(tokens) {
return nil
}
return append([]string(nil), tokens...)
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if toolName != "bash" || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
}
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
if !ok {
return sandbox.CommandPrefixGrant{}, false, false
}
tokens, ok := safeShellCommandTokens(command)
if !ok {
return sandbox.CommandPrefixGrant{}, false, false
}
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
return grant, true, false
}
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
return grant, true, true
}
return sandbox.CommandPrefixGrant{}, false, false
}
func firstStringArg(args map[string]any, names ...string) (string, bool) {
for _, name := range names {
if raw, ok := args[name].(string); ok {
value := strings.TrimSpace(raw)
if value != "" {
return value, true
}
}
}
return "", false
}
func requestedPrefixRule(args map[string]any) ([]string, bool) {
raw, ok := args["prefix_rule"]
if !ok {
return nil, false
}
switch value := raw.(type) {
case []string:
return cleanPrefixRule(value), true
case []any:
out := make([]string, 0, len(value))
for _, item := range value {
part, ok := item.(string)
if !ok {
return nil, true
}
out = append(out, part)
}
return cleanPrefixRule(out), true
default:
return nil, true
}
}
func cleanPrefixRule(prefix []string) []string {
cleaned := make([]string, 0, len(prefix))
for _, part := range prefix {
part = strings.TrimSpace(part)
if part == "" {
return nil
}
cleaned = append(cleaned, part)
}
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
return false
}
for index := range prefix {
if prefix[index] != command[index] {
return false
}
}
return true
}
func safeShellCommandTokens(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 {
return nil, false
}
stmt := file.Stmts[0]
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
return nil, false
}
call, ok := stmt.Cmd.(*syntax.CallExpr)
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
return nil, false
}
tokens := make([]string, 0, len(call.Args))
for _, word := range call.Args {
if len(word.Parts) != 1 {
return nil, false
}
lit, ok := word.Parts[0].(*syntax.Lit)
if !ok {
return nil, false
}
tokens = append(tokens, lit.Value)
}
if !sandbox.ValidCommandPrefix(tokens) {
return nil, false
}
return tokens, true
}
func equalStringSlices(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
+88
View File
@@ -0,0 +1,88 @@
package agent
import "testing"
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
want := []string{"go", "test", "./..."}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short",
"prefix_rule": []any{"git", "status"},
})
want := []string{"git", "status"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short",
"prefix_rule": []any{"git"},
})
if got != nil {
t.Fatalf("broad requested prefix should be rejected, got %#v", got)
}
}
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
cases := []string{
"echo hi && echo bye",
"cat < in > out",
"FOO=bar go test",
"echo $(whoami)",
"cat *.go",
"bash -lc go test",
}
for _, command := range cases {
t.Run(command, func(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
t.Fatalf("unsafe command got prefix %#v", got)
}
})
}
}
func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
cases := []string{
"find . -type f",
"xargs rm -rf /tmp/x",
"timeout 5 go test ./...",
"nice go test ./...",
"nohup go test ./...",
"watch ls",
"ssh host ls",
"make test",
"npm run dev",
"command git status",
"eval echo hi",
"exec echo hi",
"python script.py",
"node script.js",
"./script.sh --flag",
"/tmp/script.sh --flag",
}
for _, command := range cases {
t.Run(command, func(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
t.Fatalf("unsafe launcher got prefix %#v", got)
}
})
}
}
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "find . -type f",
"prefix_rule": []any{"find", "."},
})
if got != nil {
t.Fatalf("unsafe requested launcher prefix should be rejected, got %#v", got)
}
}
+28 -7
View File
@@ -77,17 +77,38 @@ type CompactionResult struct {
// ~1k tokens is a representative mid-range cost across providers.
const imageTokenEstimate = 1000
// estimateTokens is a cheap, dependency-free token estimate (~4 chars/token)
// ApproxTextTokens estimates the token count of text WITHOUT a real tokenizer.
// The BPE tokenizers zero targets fold a run's leading space into the following
// token rather than emitting whitespace as its own token, so naive len/4
// overcounts real text by ~15-20% (measured: a 24.8k-char prompt counted 5.2k
// real tokens where len/4 said 6.2k). Counting NON-whitespace bytes / 4 tracks
// the provider's actual count closely (validated against live usage) while
// staying allocation- and dependency-free. zero still receives the exact count
// back as usage on every request; this estimate is only for the pre-request
// context budget preview and the compaction threshold.
func ApproxTextTokens(value string) int {
nonSpace := 0
for i := 0; i < len(value); i++ {
switch value[i] {
case ' ', '\t', '\n', '\r', '\f', '\v':
default:
nonSpace++
}
}
return nonSpace / 4
}
// estimateTokens is a cheap, dependency-free token estimate (see ApproxTextTokens)
// across message content plus tool call names/arguments and a flat per-image
// cost. It deliberately uses no real tokenizer; it only needs to be monotonic
// and roughly proportional so the loop can decide when to compact.
func estimateTokens(messages []zeroruntime.Message) int {
total := 0
for _, message := range messages {
total += len(message.Content) / 4
total += ApproxTextTokens(message.Content)
for _, call := range message.ToolCalls {
total += len(call.Name) / 4
total += len(call.Arguments) / 4
total += ApproxTextTokens(call.Name)
total += ApproxTextTokens(call.Arguments)
total += 4 // small per-call overhead
}
total += len(message.Images) * imageTokenEstimate
@@ -104,11 +125,11 @@ func estimateTokens(messages []zeroruntime.Message) int {
func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
total := 0
for _, tool := range tools {
total += len(tool.Name) / 4
total += len(tool.Description) / 4
total += ApproxTextTokens(tool.Name)
total += ApproxTextTokens(tool.Description)
if len(tool.Parameters) > 0 {
if encoded, err := json.Marshal(tool.Parameters); err == nil {
total += len(encoded) / 4
total += ApproxTextTokens(string(encoded))
}
}
total += 4 // per-tool overhead
+5 -5
View File
@@ -48,16 +48,16 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
}
// estimateToolTokens approximates the token footprint of advertised tool
// definitions (name + description + JSON schema), matching estimateTokens'
// ~4-chars/token heuristic so all categories use the same scale.
// definitions (name + description + JSON schema), using the same ApproxTextTokens
// heuristic as estimateTokens so all categories share one scale.
func estimateToolTokens(tools []zeroruntime.ToolDefinition) int {
total := 0
for _, tool := range tools {
total += len(tool.Name) / 4
total += len(tool.Description) / 4
total += ApproxTextTokens(tool.Name)
total += ApproxTextTokens(tool.Description)
if len(tool.Parameters) > 0 {
if encoded, err := json.Marshal(tool.Parameters); err == nil {
total += len(encoded) / 4
total += ApproxTextTokens(string(encoded))
}
}
total += 4 // per-tool overhead
+83
View File
@@ -0,0 +1,83 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
// object with several typed, described properties. Real MCP tools are this size
// or larger, which is why shipping their full schemas every turn is expensive.
type richMCPTool struct{ name string }
func (t richMCPTool) Name() string { return t.name }
func (t richMCPTool) Description() string {
return "Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
}
func (t richMCPTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
AdditionalProperties: false,
Required: []string{"query"},
Properties: map[string]tools.PropertySchema{
"query": {Type: "string", Description: "The search query to run against the web index. Supports natural-language and keyword queries."},
"num_results": {Type: "integer", Description: "Maximum number of results to return (1-100). Defaults to 10."},
"include_domains": {Type: "array", Description: "Only return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
"exclude_domains": {Type: "array", Description: "Never return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
"start_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or after this date are returned."},
"end_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or before this date are returned."},
"category": {Type: "string", Description: "Restrict results to a content category.", Enum: []string{"news", "research paper", "company", "tweet", "pdf"}},
"include_text": {Type: "boolean", Description: "Include cleaned full-page text for each result (slower, larger payload)."},
},
}
}
func (t richMCPTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
}
func (t richMCPTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
func (t richMCPTool) Deferred() bool { return true }
// TestDeferralTokenSavingsMeasurement quantifies the win from the lowered
// deferThreshold default: with a typical small MCP server (6 tools), threshold 10
// (the OLD default) kept deferral OFF so all six full schemas shipped every turn,
// while threshold 3 (the NEW default) collapses them to compact reminder lines.
func TestDeferralTokenSavingsMeasurement(t *testing.T) {
const mcpToolCount = 6
registry := tools.NewRegistry()
for i := 0; i < mcpToolCount; i++ {
registry.Register(richMCPTool{name: fmt.Sprintf("mcp__websearch__search_v%d", i)})
}
registry.Register(fakeToolSearchTool{})
measure := func(threshold int) (int, int) {
defs, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: threshold}, map[string]bool{})
encoded, err := json.Marshal(defs)
if err != nil {
t.Fatalf("marshal tool defs: %v", err)
}
bytes := len(encoded) + len(reminder)
return bytes, bytes / 4 // ~tokens (approx 4 chars/token)
}
eagerBytes, eagerTok := measure(10) // 6 < 10 -> inactive -> all schemas eager
deferBytes, deferTok := measure(3) // 6 >= 3 -> active -> compact reminder
savedPct := 100 * float64(eagerBytes-deferBytes) / float64(eagerBytes)
t.Logf("MCP tools in set: %d", mcpToolCount)
t.Logf("threshold 10 (OLD, eager schemas): %d bytes (~%d tokens) per turn", eagerBytes, eagerTok)
t.Logf("threshold 3 (NEW, deferred lines): %d bytes (~%d tokens) per turn", deferBytes, deferTok)
t.Logf("SAVED: ~%d tokens per turn (%.0f%% smaller tool payload)", eagerTok-deferTok, savedPct)
if deferBytes >= eagerBytes {
t.Fatalf("deferral should shrink the tool payload: eager=%d defer=%d", eagerBytes, deferBytes)
}
if savedPct < 50 {
t.Fatalf("expected a large reduction, got only %.0f%%", savedPct)
}
}
@@ -0,0 +1,44 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
type argsPermTool struct{ perm tools.Permission }
func (argsPermTool) Name() string { return "args" }
func (argsPermTool) Description() string { return "" }
func (argsPermTool) Parameters() tools.Schema { return tools.Schema{} }
func (argsPermTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
func (argsPermTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
func (t argsPermTool) PermissionForArgs(map[string]any) tools.Permission { return t.perm }
type plainTool struct{ perm tools.Permission }
func (plainTool) Name() string { return "plain" }
func (plainTool) Description() string { return "" }
func (plainTool) Parameters() tools.Schema { return tools.Schema{} }
func (t plainTool) Safety() tools.Safety { return tools.Safety{Permission: t.perm} }
func (plainTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
func TestEffectivePermission(t *testing.T) {
// A tool implementing ArgsPermissioner refines its permission per call.
if got := effectivePermission(argsPermTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
t.Fatalf("args-aware tool: got %q, want allow", got)
}
if got := effectivePermission(argsPermTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
t.Fatalf("args-aware tool: got %q, want prompt", got)
}
// A tool WITHOUT the interface falls back to its static Safety().Permission —
// the args-aware path must never change a normal tool's behavior.
if got := effectivePermission(plainTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
t.Fatalf("plain tool: got %q, want its static allow", got)
}
if got := effectivePermission(plainTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
t.Fatalf("plain tool: got %q, want its static prompt", got)
}
}
+39 -2
View File
@@ -90,11 +90,48 @@ func toolFailureStopAnswer(toolName string, count int) string {
"Please check the request or adjust the tool arguments."
}
// The no-output stop answer is assembled from these fixed parts (only the turn
// count varies). IsNoProgressStop matches all three so a legitimate message that
// merely quotes the marker substring is not misclassified as a failed empty run.
const (
noOutputStopPrefix = "Agent stopped after "
noOutputStopMarker = "with no output (no visible text and no tool calls)"
noOutputStopSuffix = "to avoid consuming tokens without making progress."
)
// noOutputStopAnswer is the final answer returned when the no-output guard
// stops the run. The turn count is interpolated at the call site.
func noOutputStopAnswer(turns int) string {
return "Agent stopped after " + strconv.Itoa(turns) +
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
}
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
// (a run that produced no visible text and no tool calls). It matches the EXACT
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
// suffix, where only the integer turn count varies — rather than just looking for
// the three parts in order. A loose check (prefix && contains-marker && suffix)
// would misclassify a genuine assistant/tool message that merely quotes the
// marker amid other prose, which would wrongly hide a real session from /resume
// and skip its title generation.
func IsNoProgressStop(content string) bool {
trimmed := strings.TrimSpace(content)
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
return false
}
rest := trimmed[len(noOutputStopPrefix):]
const turnsSep = " turns "
sep := strings.Index(rest, turnsSep)
if sep < 0 {
return false
}
// The text between the prefix and " turns " must be exactly the bare integer
// count; anything else means this isn't the guard's own answer.
if _, err := strconv.Atoi(rest[:sep]); err != nil {
return false
}
// The marker must be immediately followed (one space) by the suffix and then
// end — no arbitrary text wedged in between.
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
}
// Reminder markers are stable substrings used both to build the reminder text
+510 -25
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"sort"
"strings"
"sync"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/redaction"
@@ -23,6 +24,35 @@ const (
toolResultControlSpecReview = "spec_review_required"
)
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
type permissionRunState struct {
mu sync.Mutex
cleanups []func()
}
func (state *permissionRunState) add(cleanup func()) {
if state == nil || cleanup == nil {
return
}
state.mu.Lock()
state.cleanups = append(state.cleanups, cleanup)
state.mu.Unlock()
}
func (state *permissionRunState) cleanup() {
if state == nil {
return
}
state.mu.Lock()
cleanups := state.cleanups
state.cleanups = nil
state.mu.Unlock()
for i := len(cleanups) - 1; i >= 0; i-- {
cleanups[i]()
}
}
// abortedToolResultNotice is the placeholder tool result recorded for a tool
// call that was advertised by the assistant turn but never executed because the
// repeated-failure guard halted the run first. It keeps every tool_use paired
@@ -64,6 +94,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if permissionMode == "" {
permissionMode = PermissionModeAuto
}
runPermissions := &permissionRunState{}
options.runPermissions = runPermissions
defer runPermissions.cleanup()
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
@@ -545,36 +578,93 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
if call.Name == "ask_user" {
return executeAskUser(ctx, registry, call, args, permissionMode, options)
}
if call.Name == tools.RequestPermissionsToolName {
return executeRequestPermissions(ctx, call, args, permissionMode, options)
}
permissionGranted := permissionMode == PermissionModeUnsafe
if toolFound && tool.Safety().Permission == tools.PermissionAllow {
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
permissionGranted = true
}
decisionReason := ""
var decisionAction PermissionDecisionAction
var decisionCommandPrefix []string
if toolFound && !permissionGranted {
if grant, ok, session := matchCommandPrefix(call.Name, args, options); ok {
permissionGranted = true
if session {
decisionAction = PermissionDecisionAllowPrefix
decisionReason = "session command prefix approval matched"
} else {
decisionAction = PermissionDecisionAlwaysAllowPrefix
decisionReason = "persistent command prefix approval matched"
}
decisionCommandPrefix = grant.Prefix
}
}
var preflightDecision *sandbox.Decision
if toolFound && options.Sandbox != nil {
decision := options.Sandbox.Evaluate(ctx, sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
preflightDecision = &decision
}
decisionReason := ""
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, permissionGranted, preflightDecision) {
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
if !ok {
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
}
request := permissionRequestFromEvent(requestEvent, args)
request := permissionRequestFromEvent(requestEvent, args, options)
decision, err := requestPermission(ctx, request, options)
if err != nil {
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
}
decision.Action = normalizePermissionDecisionAction(decision.Action)
decisionReason = strings.TrimSpace(decision.Reason)
decisionAction = decision.Action
switch decision.Action {
case PermissionDecisionAllow:
permissionGranted = true
requestEvent.DecisionAction = decision.Action
case PermissionDecisionAllowForSession:
permissionGranted = true
requestEvent.DecisionAction = decision.Action
if options.Sandbox != nil {
// The current call stays allowed if recording the session grant
// fails; the user is simply prompted again for a later match.
if grant, err := persistSessionPermissionGrant(call.Name, args, decisionReason, options); err == nil {
requestEvent.GrantMatched = true
requestEvent.Grant = &grant
}
}
case PermissionDecisionAllowPrefix:
if len(request.CommandPrefix) == 0 {
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
}
permissionGranted = true
requestEvent.DecisionAction = decision.Action
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
options.Sandbox.GrantCommandPrefixForSession(call.Name, decisionCommandPrefix)
}
case PermissionDecisionAlwaysAllowPrefix:
if len(request.CommandPrefix) == 0 {
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
}
permissionGranted = true
requestEvent.DecisionAction = decision.Action
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
if grant, err := persistCommandPrefixGrant(call.Name, decisionCommandPrefix, decisionReason, options); err == nil {
decisionCommandPrefix = append([]string(nil), grant.Prefix...)
}
}
case PermissionDecisionAlwaysAllow:
permissionGranted = true
requestEvent.DecisionAction = decision.Action
// "Always allow" also persists a grant so FUTURE calls skip the prompt.
// With no sandbox engine there is nowhere to persist it, and persistence
// can also fail — neither must deny what the user explicitly allowed, so
@@ -584,8 +674,14 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// (buildPermissionEvent reads decision.GrantMatched/Grant), so the
// persisted grant does not need to be recorded on requestEvent here.
if options.Sandbox != nil {
_, _ = persistPermissionGrant(call.Name, args, decisionReason, options)
if grant, err := persistPermissionGrant(call.Name, args, decisionReason, options); err == nil {
requestEvent.GrantMatched = true
requestEvent.Grant = &grant
}
}
case PermissionDecisionCancel:
emitCanceledPermission(options, call, requestEvent, decisionReason)
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
default:
emitDeniedPermission(options, call, requestEvent, decisionReason)
return deniedPermissionResult(call, decisionReason, requestEvent), nil
@@ -624,6 +720,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
if toolFound && options.OnPermission != nil {
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
event.DecisionReason = decisionReason
if decisionAction != "" {
event.DecisionAction = decisionAction
}
if len(decisionCommandPrefix) > 0 {
event.CommandPrefix = append([]string(nil), decisionCommandPrefix...)
}
options.OnPermission(event)
}
}
@@ -912,6 +1014,17 @@ func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permi
}
}
// effectivePermission returns the permission for THIS specific call. A tool that
// implements tools.ArgsPermissioner may relax its static permission for arguments
// it can prove are safe (the Task tool auto-allows a read-only sub-agent); every
// other tool falls back to its static Safety().Permission unchanged.
func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission {
if p, ok := tool.(tools.ArgsPermissioner); ok {
return p.PermissionForArgs(args)
}
return tool.Safety().Permission
}
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
if permissionGranted || tool.Safety().Permission != tools.PermissionPrompt {
return false
@@ -931,13 +1044,251 @@ func requestPermission(ctx context.Context, request PermissionRequest, options O
func normalizePermissionDecisionAction(action PermissionDecisionAction) PermissionDecisionAction {
switch action {
case PermissionDecisionAllow, PermissionDecisionAlwaysAllow:
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix, PermissionDecisionAlwaysAllow, PermissionDecisionCancel:
return action
default:
return PermissionDecisionDeny
}
}
type requestPermissionsArgs struct {
EnvironmentID string `json:"environment_id"`
Reason string `json:"reason"`
Permissions sandbox.RequestPermissionProfile `json:"permissions"`
}
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
parsed, err := parseRequestPermissionsArgs(args)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid arguments for request_permissions: " + err.Error(),
}, nil
}
normalized, err := sandbox.NormalizeRequestPermissionProfile(parsed.Permissions, options.Cwd)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
}, nil
}
if normalized.Empty() {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: request_permissions requires at least one permission.",
}, nil
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
}, nil
}
request := requestPermissionsPrompt(call, parsed, grantProfile, permissionMode, options)
if options.OnPermissionRequest == nil {
response := sandbox.RequestPermissionsResponse{
Permissions: sandbox.RequestPermissionProfile{},
Scope: sandbox.PermissionGrantScopeTurn,
}
return requestPermissionsResult(call, response, false), nil
}
decision, err := requestPermission(ctx, request, options)
if err != nil {
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
}
decision.Action = normalizePermissionDecisionAction(decision.Action)
decisionReason := strings.TrimSpace(decision.Reason)
if decisionReason == "" {
decisionReason = request.Reason
}
response := sandbox.RequestPermissionsResponse{
Permissions: sandbox.RequestPermissionProfile{},
Scope: sandbox.PermissionGrantScopeTurn,
}
permissionGranted := false
switch decision.Action {
case PermissionDecisionAllow:
response.Permissions = grantProfile
response.Scope = sandbox.PermissionGrantScopeTurn
permissionGranted = true
case PermissionDecisionAllowStrict:
response.Permissions = grantProfile
response.Scope = sandbox.PermissionGrantScopeTurn
response.StrictAutoReview = true
permissionGranted = true
case PermissionDecisionAllowForSession:
response.Permissions = grantProfile
response.Scope = sandbox.PermissionGrantScopeSession
permissionGranted = true
case PermissionDecisionCancel:
decision.Action = PermissionDecisionDeny
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
default:
decision.Action = PermissionDecisionDeny
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
}
if permissionGranted {
if options.Sandbox == nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: request_permissions cannot grant permissions because the sandbox engine is not configured.",
}, nil
}
cleanup, err := options.Sandbox.GrantRequestPermissions(response.Permissions, response.Scope)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Failed to grant requested permissions: " + err.Error(),
}, nil
}
if response.Scope == sandbox.PermissionGrantScopeTurn && options.runPermissions != nil {
options.runPermissions.add(cleanup)
}
}
emitRequestPermissionsDecision(options, call, request, decision.Action, decisionReason, permissionGranted)
return requestPermissionsResult(call, response, false), nil
}
func parseRequestPermissionsArgs(args map[string]any) (requestPermissionsArgs, error) {
data, err := json.Marshal(args)
if err != nil {
return requestPermissionsArgs{}, err
}
var parsed requestPermissionsArgs
if err := json.Unmarshal(data, &parsed); err != nil {
return requestPermissionsArgs{}, err
}
if parsed.EnvironmentID == "" {
if raw, ok := args["environmentId"].(string); ok {
parsed.EnvironmentID = raw
}
}
return parsed, nil
}
func requestPermissionsPrompt(call ToolCall, parsed requestPermissionsArgs, profile sandbox.RequestPermissionProfile, permissionMode PermissionMode, options Options) PermissionRequest {
autonomy := options.Autonomy
if normalized, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(autonomy)); err == nil {
autonomy = string(normalized)
}
reason := strings.TrimSpace(parsed.Reason)
if reason == "" {
reason = "The agent is requesting additional permissions."
}
return PermissionRequest{
ToolCallID: call.ID,
ToolName: tools.RequestPermissionsToolName,
Action: PermissionActionPrompt,
Permission: string(tools.PermissionPrompt),
PermissionMode: permissionMode,
Autonomy: autonomy,
SideEffect: "permissions",
Reason: reason,
Scope: requestPermissionsScope(profile),
Args: map[string]any{
"environment_id": parsed.EnvironmentID,
"reason": parsed.Reason,
"permissions": profile,
},
AvailableDecisions: []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowStrict,
PermissionDecisionAllowForSession,
PermissionDecisionDeny,
},
}
}
func requestPermissionsScope(profile sandbox.RequestPermissionProfile) string {
parts := []string{}
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
parts = append(parts, "network all outbound access")
}
if profile.FileSystem != nil {
for _, path := range profile.FileSystem.Read {
parts = append(parts, "read "+path)
}
for _, path := range profile.FileSystem.Write {
parts = append(parts, "write "+path)
}
for _, path := range profile.FileSystem.DenyRead {
parts = append(parts, "deny read "+path)
}
}
return strings.Join(parts, ", ")
}
func emitRequestPermissionsDecision(options Options, call ToolCall, request PermissionRequest, action PermissionDecisionAction, reason string, granted bool) {
if options.OnPermission == nil {
return
}
eventAction := PermissionActionDeny
if granted {
eventAction = PermissionActionAllow
}
options.OnPermission(PermissionEvent{
ToolCallID: call.ID,
ToolName: call.Name,
Action: eventAction,
DecisionAction: action,
Permission: request.Permission,
PermissionGranted: granted,
PermissionMode: request.PermissionMode,
Autonomy: request.Autonomy,
SideEffect: request.SideEffect,
Reason: reason,
Scope: request.Scope,
DecisionReason: reason,
})
}
func requestPermissionsResult(call ToolCall, response sandbox.RequestPermissionsResponse, redacted bool) ToolResult {
data, err := json.Marshal(response)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Failed to serialize request_permissions response: " + err.Error(),
}
}
output, didRedact := scrubInterceptedOutput(string(data))
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusOK,
Output: output,
Redacted: redacted || didRedact,
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func persistPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
if options.Sandbox == nil {
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
@@ -963,6 +1314,39 @@ func persistPermissionGrant(toolName string, args map[string]any, reason string,
})
}
func persistSessionPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
if options.Sandbox == nil {
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
}
maxAutonomy := sandbox.Autonomy(options.Autonomy)
if maxAutonomy == "" {
maxAutonomy = sandbox.AutonomyMedium
}
if normalized, err := sandbox.NormalizeAutonomy(maxAutonomy); err == nil {
maxAutonomy = normalized
}
scope, kind := sandbox.DeriveScope(toolName, args)
return options.Sandbox.GrantForSession(sandbox.GrantInput{
ToolName: toolName,
Decision: sandbox.GrantAllow,
MaxAutonomy: maxAutonomy,
Reason: reason,
Scope: scope,
ScopeKind: kind,
})
}
func persistCommandPrefixGrant(toolName string, prefix []string, reason string, options Options) (sandbox.CommandPrefixGrant, error) {
if options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, errors.New("sandbox engine is not configured")
}
return options.Sandbox.GrantCommandPrefix(sandbox.CommandPrefixInput{
ToolName: toolName,
Prefix: prefix,
Reason: reason,
})
}
func emitDeniedPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
if options.OnPermission == nil {
return
@@ -975,11 +1359,31 @@ func emitDeniedPermission(options Options, call ToolCall, requestEvent Permissio
event.ToolCallID = call.ID
event.ToolName = call.Name
event.Action = PermissionActionDeny
event.DecisionAction = PermissionDecisionDeny
event.PermissionGranted = false
event.DecisionReason = reason
options.OnPermission(event)
}
func emitCanceledPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
if options.OnPermission == nil {
return
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "cancelled in TUI"
}
event := requestEvent
event.ToolCallID = call.ID
event.ToolName = call.Name
event.Action = PermissionActionCancel
event.DecisionAction = PermissionDecisionCancel
event.PermissionGranted = false
event.DecisionReason = reason
event.Reason = reason
options.OnPermission(event)
}
func deniedPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
reason = strings.TrimSpace(reason)
if reason == "" {
@@ -990,6 +1394,7 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
}
event := requestEvent
event.Action = PermissionActionDeny
event.DecisionAction = PermissionDecisionDeny
event.PermissionGranted = false
event.DecisionReason = reason
if event.Risk.Level == "" {
@@ -1016,12 +1421,38 @@ func deniedPermissionResult(call ToolCall, reason string, requestEvent Permissio
}
}
func canceledPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "cancelled in TUI"
}
event := requestEvent
event.Action = PermissionActionCancel
event.DecisionAction = PermissionDecisionCancel
event.PermissionGranted = false
event.DecisionReason = reason
event.Reason = reason
if requestEvent.ToolName == "" {
event.ToolName = call.Name
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Permission approval cancelled for " + call.Name + ": " + reason,
DenialReason: DenialApprovalCanceled,
Meta: map[string]string{
"permission_action": string(event.Action),
},
}
}
// permissionScope returns a concise, human-readable description of what a tool
// call will actually touch — the file path, directory, or working dir lifted
// from its arguments — so the permission card and the persisted decision can
// show the user exactly what "allow" covers. It is empty when the tool exposes
// no path-like argument (the grant is then plainly tool-wide). The first
// matching key wins, ordered most-specific (a concrete file) first.
// call will actually touch -- a file path, directory, working dir, or network
// host lifted from its arguments -- so the permission card and persisted
// decision can show the user exactly what "allow" covers. It is empty when the
// tool exposes no scoped argument (the grant is then plainly tool-wide). The
// first matching key wins, ordered most-specific (a concrete file) first.
func permissionScope(toolName string, args map[string]any) string {
// sandbox.DeriveScope is the single source of truth for which arguments carry
// a scope, shared with grant persistence and matching so the card display can
@@ -1098,6 +1529,7 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
ToolCallID: call.ID,
ToolName: call.Name,
Action: action,
DecisionAction: grantDecisionAction(grant),
Permission: string(safety.Permission),
PermissionGranted: permissionGranted,
PermissionMode: permissionMode,
@@ -1109,30 +1541,83 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
Violation: violation,
GrantMatched: grantMatched,
Grant: grant,
CommandPrefix: proposedCommandPrefix(call.Name, args),
}, true
}
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
if grant == nil {
return ""
}
if grant.Session {
return PermissionDecisionAllowForSession
}
if grant.Decision == sandbox.GrantAllow {
return PermissionDecisionAlwaysAllow
}
return ""
}
func fallbackPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options) PermissionEvent {
event, _ := buildPermissionEvent(call, tool, args, false, permissionMode, options, nil)
return event
}
func permissionRequestFromEvent(event PermissionEvent, args map[string]any) PermissionRequest {
func permissionRequestFromEvent(event PermissionEvent, args map[string]any, options Options) PermissionRequest {
return PermissionRequest{
ToolCallID: event.ToolCallID,
ToolName: event.ToolName,
Action: event.Action,
Permission: event.Permission,
PermissionMode: event.PermissionMode,
Autonomy: event.Autonomy,
SideEffect: event.SideEffect,
Reason: event.Reason,
Scope: event.Scope,
Risk: event.Risk,
Args: cloneArgs(args),
Violation: event.Violation,
GrantMatched: event.GrantMatched,
Grant: event.Grant,
ToolCallID: event.ToolCallID,
ToolName: event.ToolName,
Action: event.Action,
Permission: event.Permission,
PermissionMode: event.PermissionMode,
Autonomy: event.Autonomy,
SideEffect: event.SideEffect,
Reason: event.Reason,
Scope: event.Scope,
Risk: event.Risk,
Args: cloneArgs(args),
Violation: event.Violation,
GrantMatched: event.GrantMatched,
Grant: event.Grant,
CommandPrefix: append([]string(nil), event.CommandPrefix...),
AvailableDecisions: availablePermissionDecisions(event, options),
}
}
func availablePermissionDecisions(event PermissionEvent, options Options) []PermissionDecisionAction {
if event.Action != PermissionActionPrompt {
return nil
}
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
if options.Sandbox != nil {
decisions = append(decisions, PermissionDecisionAllowForSession)
if event.ToolName == "bash" && len(event.CommandPrefix) > 0 {
decisions = append(decisions, PermissionDecisionAllowPrefix)
if options.Sandbox.CanPersistGrants() {
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
}
}
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) {
decisions = append(decisions, PermissionDecisionAlwaysAllow)
}
}
switch event.ToolName {
case "bash":
decisions = append(decisions, PermissionDecisionDeny, PermissionDecisionCancel)
case "apply_patch":
decisions = append(decisions, PermissionDecisionCancel)
default:
decisions = append(decisions, PermissionDecisionDeny)
}
return decisions
}
func permissionSupportsPersistentDecision(toolName string) bool {
switch toolName {
case "bash", "apply_patch":
return false
default:
return true
}
}
+550 -1
View File
@@ -54,6 +54,7 @@ func TestIsRetriableToolError(t *testing.T) {
// Structured denial categories are authoritative regardless of message text.
{"denial: filtered", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialFiltered}, false},
{"denial: permission", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialPermissionDenied}, false},
{"denial: approval canceled", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialApprovalCanceled}, false},
{"denial: sandbox", ToolResult{Status: tools.StatusError, Output: "anything", DenialReason: DenialSandboxViolation}, false},
}
for _, c := range cases {
@@ -315,6 +316,37 @@ func TestRunAdvertisesWebFetchInAutoMode(t *testing.T) {
}
}
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
registry := tools.NewRegistry()
for _, tool := range tools.CoreNetworkTools() {
if tool.Name() == "web_search" {
registry.Register(tool)
}
}
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}},
}
_, err := Run(context.Background(), "what tools exist?", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != 1 {
t.Fatalf("expected one request, got %d", len(provider.requests))
}
if len(provider.requests[0].Tools) != 1 || provider.requests[0].Tools[0].Name != "web_search" {
t.Fatalf("expected web_search to be advertised in auto mode, got %#v", provider.requests[0].Tools)
}
}
func TestRunFiltersAdvertisedTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -761,6 +793,55 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
}
}
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write done")
var permissionEvents []PermissionEvent
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
t.Fatal("workspace write should not request permission")
return PermissionDecision{}, nil
},
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "write done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "hello" {
t.Fatalf("expected workspace write content, got %q", content)
}
if len(permissionEvents) != 1 {
t.Fatalf("expected one auto permission event, got %#v", permissionEvents)
}
event := permissionEvents[0]
if event.Action != PermissionActionAllow || event.PermissionGranted || event.GrantMatched {
t.Fatalf("expected sandbox policy allow without user grant, got %#v", event)
}
if event.Reason != "workspace write permitted by sandbox policy" {
t.Fatalf("expected workspace-write reason, got %#v", event)
}
}
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -809,6 +890,98 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
}
}
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write should not continue")
var permissionEvents []PermissionEvent
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "redirect needed"}, nil
},
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
})
if !errors.Is(err, errPermissionApprovalCanceled) {
t.Fatalf("expected permission approval cancel error, got %v", err)
}
if result.FinalAnswer != "" {
t.Fatalf("canceled run must not continue to final answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 1 {
t.Fatalf("canceled permission prompt should stop after first turn, got %d provider requests", len(provider.requests))
}
if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) {
t.Fatalf("expected canceled write to leave file missing, stat error: %v", err)
}
if len(permissionEvents) != 1 {
t.Fatalf("expected one cancel permission event, got %#v", permissionEvents)
}
event := permissionEvents[0]
if event.Action != PermissionActionCancel || event.DecisionAction != PermissionDecisionCancel || event.PermissionGranted {
t.Fatalf("expected final cancel event, got %#v", event)
}
if event.DecisionReason != "redirect needed" {
t.Fatalf("expected cancel reason in final event, got %#v", event)
}
}
func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
options := Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})}
cases := []struct {
name string
tool string
want []PermissionDecisionAction
}{
{
name: "bash keeps recoverable deny and explicit cancel",
tool: "bash",
want: []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowForSession,
PermissionDecisionDeny,
PermissionDecisionCancel,
},
},
{
name: "apply patch uses cancel without recoverable deny",
tool: "apply_patch",
want: []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowForSession,
PermissionDecisionCancel,
},
},
{
name: "file write keeps recoverable deny",
tool: "write_file",
want: []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowForSession,
PermissionDecisionDeny,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := availablePermissionDecisions(PermissionEvent{
ToolName: tc.tool,
Action: PermissionActionPrompt,
}, options)
if !equalPermissionDecisions(got, tc.want) {
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
}
})
}
}
func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
root := t.TempDir()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
@@ -819,6 +992,8 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
registry.Register(tools.NewWriteFileTool(root))
provider := providerCallingWriteFileThenAnswer("write approved")
var permissionEvents []PermissionEvent
policy := sandbox.DefaultPolicy()
policy.EnforceWorkspace = false
result, err := Run(context.Background(), "write notes", provider, Options{
Registry: registry,
@@ -826,7 +1001,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Policy: policy,
Store: store,
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
@@ -879,6 +1054,359 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
}
}
func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T) {
root := t.TempDir()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewWriteFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "write_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt","content":"first","overwrite":true}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "write_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt","content":"second","overwrite":true}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var permissionEvents []PermissionEvent
policy := sandbox.DefaultPolicy()
policy.EnforceWorkspace = false
result, err := Run(context.Background(), "write notes twice", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: policy,
Store: store,
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllowForSession, Reason: "trust this file for the session"}, nil
},
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
content, err := os.ReadFile(filepath.Join(root, "notes.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "second" {
t.Fatalf("expected second write content, got %q", content)
}
if len(requests) != 1 {
t.Fatalf("expected one permission request, got %#v", requests)
}
if len(permissionEvents) != 2 {
t.Fatalf("expected two permission events, got %#v", permissionEvents)
}
if permissionEvents[0].DecisionAction != PermissionDecisionAllowForSession || permissionEvents[0].Grant == nil || !permissionEvents[0].Grant.Session {
t.Fatalf("expected first event to carry session grant, got %#v", permissionEvents[0])
}
if !permissionEvents[1].GrantMatched || permissionEvents[1].Grant == nil || !permissionEvents[1].Grant.Session {
t.Fatalf("expected second event to be session-grant matched, got %#v", permissionEvents[1])
}
lookup, err := store.Lookup("write_file", filepath.Join(root, "notes.txt"), sandbox.AutonomyMedium)
if err != nil {
t.Fatal(err)
}
if lookup.Matched {
t.Fatalf("session approval must not persist a grant, got %#v", lookup)
}
}
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
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
var permissionEvents []PermissionEvent
result, err := Run(context.Background(), "run twice", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
if !containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAllowPrefix) {
t.Fatalf("bash prompt missing prefix approval decision: %#v", request.AvailableDecisions)
}
if !equalStringSlices(request.CommandPrefix, []string{"echo", "prefix-ok"}) {
t.Fatalf("request command prefix = %#v", request.CommandPrefix)
}
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
},
OnPermission: func(event PermissionEvent) {
permissionEvents = append(permissionEvents, event)
},
})
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 the first bash call to prompt, got %#v", requests)
}
if len(permissionEvents) != 2 {
t.Fatalf("expected two bash permission events, got %#v", permissionEvents)
}
for index, event := range permissionEvents {
if event.DecisionAction != PermissionDecisionAllowPrefix || !event.PermissionGranted {
t.Fatalf("event %d = %#v, want prefix allow", index, event)
}
}
}
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
root := t.TempDir()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
policy := sandbox.DefaultPolicy()
policy.Network = sandbox.NetworkAllow
firstProvider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo persist-ok"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "first done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var firstRequests []PermissionRequest
var firstEvents []PermissionEvent
if _, err := Run(context.Background(), "run once", firstProvider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: policy,
Store: store,
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
firstRequests = append(firstRequests, request)
if !containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAlwaysAllowPrefix) {
t.Fatalf("bash prompt missing persistent prefix approval decision: %#v", request.AvailableDecisions)
}
return PermissionDecision{Action: PermissionDecisionAlwaysAllowPrefix, Reason: "trust this command prefix"}, nil
},
OnPermission: func(event PermissionEvent) {
firstEvents = append(firstEvents, event)
},
}); err != nil {
t.Fatal(err)
}
if len(firstRequests) != 1 {
t.Fatalf("expected first run to prompt once, got %#v", firstRequests)
}
if len(firstEvents) != 1 || firstEvents[0].DecisionAction != PermissionDecisionAlwaysAllowPrefix {
t.Fatalf("expected persistent prefix event, got %#v", firstEvents)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil {
t.Fatal(err)
}
if len(prefixes) != 1 || !equalStringSlices(prefixes[0].Prefix, []string{"echo", "persist-ok"}) {
t.Fatalf("persisted prefixes = %#v, want echo persist-ok", prefixes)
}
secondProvider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo persist-ok again"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "second done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var secondEvents []PermissionEvent
if _, err := Run(context.Background(), "run again", secondProvider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: policy,
Store: store,
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
t.Fatalf("persistent command prefix should skip prompt, got %#v", request)
return PermissionDecision{}, nil
},
OnPermission: func(event PermissionEvent) {
secondEvents = append(secondEvents, event)
},
}); err != nil {
t.Fatal(err)
}
if len(secondEvents) != 1 || secondEvents[0].DecisionAction != PermissionDecisionAlwaysAllowPrefix || !secondEvents[0].PermissionGranted {
t.Fatalf("expected persistent prefix match event, got %#v", secondEvents)
}
}
func TestRunPersistentCommandPrefixDoesNotBypassNetworkDeny(t *testing.T) {
root := t.TempDir()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
if err != nil {
t.Fatal(err)
}
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"curl"}}); err != nil {
t.Fatalf("seed command prefix: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"curl https://example.com"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var events []PermissionEvent
if _, err := Run(context.Background(), "curl", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Store: store,
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
t.Fatalf("matched persistent command prefix should not prompt before sandbox denial, got %#v", request)
return PermissionDecision{}, nil
},
OnPermission: func(event PermissionEvent) {
events = append(events, event)
},
}); err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Action != PermissionActionDeny || events[0].Violation == nil || events[0].Violation.Code != sandbox.ViolationNetwork {
t.Fatalf("expected network sandbox denial despite prefix match, got %#v", events)
}
}
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
provider := &mockProvider{
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.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
_, err := Run(context.Background(), "run unsafe", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
Autonomy: string(sandbox.AutonomyMedium),
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: root,
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendPolicyOnly, Message: "policy-only fallback"},
}),
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
if len(request.CommandPrefix) != 0 {
t.Fatalf("unsafe command should not have a command prefix: %#v", request.CommandPrefix)
}
if containsPermissionDecision(request.AvailableDecisions, PermissionDecisionAllowPrefix) {
t.Fatalf("unsafe command should not offer prefix approval: %#v", request.AvailableDecisions)
}
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "deny unsafe"}, nil
},
})
if err != nil {
t.Fatal(err)
}
}
func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
// Choosing "always allow" with NO sandbox engine configured must still allow
// THIS call (there is just nowhere to persist a grant for future calls). The
@@ -917,6 +1445,15 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
}
}
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
for _, decision := range decisions {
if decision == want {
return true
}
}
return false
}
// cancelMidStreamProvider cancels the run while the provider stream is open and
// never sends a terminal event, so CollectStream returns via ctx.Done().
type cancelMidStreamProvider struct{ cancel context.CancelFunc }
@@ -1188,6 +1725,18 @@ func quoteJSONString(value string) string {
return string(encoded)
}
func equalPermissionDecisions(a, b []PermissionDecisionAction) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestRunAppendsConfirmationPolicyToSystemPrompt(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{{
+36
View File
@@ -0,0 +1,36 @@
package agent
import "testing"
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
// The actual guardrail answer (any turn count) is recognized.
for _, turns := range []int{1, 3, 12} {
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
}
}
// A legitimate message that merely QUOTES the marker must NOT be classified as
// a no-progress stop — that would wrongly hide a real session from /resume.
rejected := []string{
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
"Agent stopped after 3 turns.", // prefix only
"to avoid consuming tokens without making progress.", // suffix only
"Here is your e-commerce site.", // unrelated
"",
// Hostile input: prefix + marker + suffix ALL present, but the marker is
// quoted mid-sentence rather than being the guard's own structured answer.
// A loose prefix/contains/suffix check misclassifies this.
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
// Arbitrary text wedged between the marker and the suffix.
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
// Non-integer "turn count" between the prefix and the marker.
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
}
for _, content := range rejected {
if IsNoProgressStop(content) {
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
}
}
}
+33
View File
@@ -1,8 +1,11 @@
package agent
import (
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestPermissionScope(t *testing.T) {
@@ -21,6 +24,7 @@ func TestPermissionScope(t *testing.T) {
{name: "non-string path ignored", tool: "write_file", args: map[string]any{"path": 42}, want: ""},
{name: "path wins over cwd", tool: "x", args: map[string]any{"cwd": "a", "path": "b"}, want: "b"},
{name: "whitespace path is no scope", tool: "write_file", args: map[string]any{"path": " "}, want: ""},
{name: "web fetch host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/a"}, want: "example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -41,3 +45,32 @@ func TestPermissionScopeTruncatesLongPaths(t *testing.T) {
t.Fatalf("truncated scope should end with an ellipsis: %q", got)
}
}
func TestPersistPermissionGrantScopesWebFetchToHost(t *testing.T) {
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
if err != nil {
t.Fatal(err)
}
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: sandbox.DefaultPolicy(),
Store: store,
})
grant, err := persistPermissionGrant("web_fetch", map[string]any{"url": "https://Example.COM:443/docs"}, "trust this host", Options{
Sandbox: engine,
Autonomy: string(sandbox.AutonomyMedium),
})
if err != nil {
t.Fatal(err)
}
if grant.ScopeKind != sandbox.ScopeHost || grant.Scope != "example.com" {
t.Fatalf("grant = %#v, want host-scoped example.com", grant)
}
if lookup, err := store.Lookup("web_fetch", "example.com", sandbox.AutonomyMedium); err != nil || !lookup.Matched {
t.Fatalf("expected exact host lookup to match: lookup=%#v err=%v", lookup, err)
}
if lookup, err := store.Lookup("web_fetch", "api.example.com", sandbox.AutonomyMedium); err != nil || lookup.Matched {
t.Fatalf("expected subdomain lookup to re-prompt: lookup=%#v err=%v", lookup, err)
}
}
+211
View File
@@ -0,0 +1,211 @@
package agent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
workspace := t.TempDir()
outside := t.TempDir()
target := filepath.Join(outside, "vegetables.txt")
scope, err := sandbox.NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
engine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspace,
Policy: sandbox.DefaultPolicy(),
Scope: scope,
})
registry := tools.NewRegistry()
registry.Register(tools.NewRequestPermissionsTool())
registry.Register(tools.NewScopedWriteFileTool(workspace, scope))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"Need to write outside the workspace.","permissions":{"file_system":{"write":[` + quoteJSONString(target) + `]}}}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "write-1", ToolName: "write_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "write-1", ArgumentsFragment: `{"path":` + quoteJSONString(target) + `,"content":"carrot\n","overwrite":true}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "write-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
var results []ToolResult
normalizedProfile, err := sandbox.NormalizeRequestPermissionProfile(sandbox.RequestPermissionProfile{
FileSystem: &sandbox.FileSystemPermissions{Write: []string{target}},
}, workspace)
if err != nil {
t.Fatal(err)
}
expectedGrantProfile, err := sandbox.RequestPermissionGrantProfile(normalizedProfile)
if err != nil {
t.Fatal(err)
}
expectedWriteRoot := expectedGrantProfile.FileSystem.Write[0]
result, err := Run(context.Background(), "write the file", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
Autonomy: string(sandbox.AutonomyMedium),
Cwd: workspace,
Sandbox: engine,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
},
OnToolResult: func(result ToolResult) {
results = append(results, result)
},
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
if len(requests) != 1 || requests[0].ToolName != tools.RequestPermissionsToolName {
t.Fatalf("permission requests = %#v, want one request_permissions prompt", requests)
}
if !strings.Contains(requests[0].Scope, "write "+expectedWriteRoot) || strings.Contains(requests[0].Scope, target) {
t.Fatalf("permission request scope = %q, want widened directory root %q and not file %q", requests[0].Scope, expectedWriteRoot, target)
}
if len(results) != 2 || results[0].Status != tools.StatusOK || results[1].Status != tools.StatusOK {
t.Fatalf("tool results = %#v, want request_permissions and write_file ok", results)
}
var grantResponse sandbox.RequestPermissionsResponse
if err := json.Unmarshal([]byte(results[0].Output), &grantResponse); err != nil {
t.Fatalf("unmarshal grant response %q: %v", results[0].Output, err)
}
if grantResponse.Permissions.FileSystem == nil ||
len(grantResponse.Permissions.FileSystem.Write) != 1 ||
grantResponse.Permissions.FileSystem.Write[0] != expectedWriteRoot {
t.Fatalf("grant response permissions = %#v, want write root %q", grantResponse.Permissions, expectedWriteRoot)
}
content, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(content) != "carrot\n" {
t.Fatalf("target content = %q", content)
}
decision := engine.Evaluate(context.Background(), sandbox.Request{
WorkspaceRoot: workspace,
ToolName: "write_file",
SideEffect: sandbox.SideEffectWrite,
Permission: sandbox.PermissionPrompt,
PermissionMode: sandbox.PermissionModeAuto,
Autonomy: sandbox.AutonomyMedium,
Args: map[string]any{"path": target},
})
if decision.Action != sandbox.ActionDeny {
t.Fatalf("turn grant should be cleaned up after Run, got decision %#v", decision)
}
}
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
workspace := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewRequestPermissionsTool())
provider := requestPermissionsOnlyProvider(`{"reason":"Need network.","permissions":{"network":{"enabled":true}}}`, "done")
var results []ToolResult
result, err := Run(context.Background(), "request network", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
Cwd: workspace,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "no"}, nil
},
OnToolResult: func(result ToolResult) { results = append(results, result) },
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
if len(results) != 1 || results[0].Status != tools.StatusOK {
t.Fatalf("request_permissions denial should be an ok tool result, got %#v", results)
}
var response sandbox.RequestPermissionsResponse
if err := json.Unmarshal([]byte(results[0].Output), &response); err != nil {
t.Fatalf("unmarshal response %q: %v", results[0].Output, err)
}
if !response.Permissions.Empty() || response.Scope != sandbox.PermissionGrantScopeTurn {
t.Fatalf("deny response = %#v, want empty turn grant", response)
}
}
func TestRequestPermissionsStrictReviewResponse(t *testing.T) {
workspace := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewRequestPermissionsTool())
provider := requestPermissionsOnlyProvider(`{"reason":"Need read access.","permissions":{"file_system":{"read":[`+quoteJSONString(workspace)+`]}}}`, "done")
var output string
_, err := Run(context.Background(), "request read", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAuto,
Cwd: workspace,
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
return PermissionDecision{Action: PermissionDecisionAllowStrict, Reason: "review commands"}, nil
},
OnToolResult: func(result ToolResult) { output = result.Output },
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}
var response sandbox.RequestPermissionsResponse
if err := json.Unmarshal([]byte(output), &response); err != nil {
t.Fatalf("unmarshal response %q: %v", output, err)
}
if !response.StrictAutoReview || response.Scope != sandbox.PermissionGrantScopeTurn {
t.Fatalf("strict response = %#v, want strict turn grant", response)
}
if response.Permissions.FileSystem == nil || len(response.Permissions.FileSystem.Read) != 1 {
t.Fatalf("strict response permissions = %#v, want read permission", response.Permissions)
}
}
func requestPermissionsOnlyProvider(arguments string, finalAnswer string) *mockProvider {
if !strings.HasPrefix(arguments, "{") {
arguments = "{}"
}
return &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: arguments},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: finalAnswer},
{Type: zeroruntime.StreamEventDone},
},
},
}
}
@@ -0,0 +1,43 @@
package agent
import (
"strings"
"testing"
)
func TestSpecialistDelegationContext(t *testing.T) {
if got := specialistDelegationContext(Options{}); got != "" {
t.Fatalf("no specialists should yield an empty section, got %q", got)
}
got := specialistDelegationContext(Options{Specialists: []SpecialistInfo{
{Name: "explorer", WhenToUse: "Read-only codebase exploration."},
{Name: " ", WhenToUse: "nameless, should be skipped"},
{Name: "worker"},
}})
if !strings.Contains(got, "<specialists>") || !strings.Contains(got, "</specialists>") {
t.Fatalf("missing specialists block: %q", got)
}
if !strings.Contains(got, "- explorer: Read-only codebase exploration.") {
t.Fatalf("missing explorer line: %q", got)
}
if !strings.Contains(got, "- worker\n") {
t.Fatalf("worker (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
with := buildSystemPrompt(Options{Specialists: []SpecialistInfo{
{Name: "explorer", WhenToUse: "Explore."},
}})
if !strings.Contains(with, "<specialists>") || !strings.Contains(with, "Task tool") {
t.Fatalf("expected delegation guidance in system prompt: %q", with)
}
// Default (no specialists) must reproduce the prior prompt: no delegation block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<specialists>") {
t.Fatalf("delegation block must not appear without specialists")
}
}
+266 -14
View File
@@ -31,13 +31,20 @@ var confirmationPolicy string
const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
// projectContextFiles are workspace docs injected into the system prompt so the
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md). The
// first one found wins.
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md).
// The first match at each directory level wins; the loader walks the chain
// from the git root down to the cwd and injects the matches in
// general-to-specific order.
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
// maxProjectContextBytes caps how much of a project doc is injected so a large
// guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB
// maxProjectContextBytes caps how much of a single project doc is injected so
// a large guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
// maxProjectContextTotalBytes caps the total bytes across all matched
// project guideline files. With path-walking, multiple files can match (root
// + sub-tree), so a per-file cap alone is not enough.
const maxProjectContextTotalBytes = 32 << 10 // 32 KiB total
// maxRepoMapContextBytes keeps the repository map useful but compact enough to
// remain stable across normal agent turns.
@@ -67,18 +74,76 @@ func buildSystemPrompt(options Options) string {
if session := sessionRuntimeContext(options); session != "" {
sections = append(sections, session)
}
if prefixes := approvedCommandPrefixContext(options); prefixes != "" {
sections = append(sections, prefixes)
}
if seed := workspaceSeedContext(options.Cwd); seed != "" {
sections = append(sections, seed)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
sections = append(sections, policy)
}
return strings.Join(sections, "\n\n")
}
func approvedCommandPrefixContext(options Options) string {
if options.Sandbox == nil {
return ""
}
prefixes := options.Sandbox.ApprovedCommandPrefixes()
if len(prefixes) == 0 {
return ""
}
lines := make([]string, 0, len(prefixes))
for _, grant := range prefixes {
if len(grant.Prefix) == 0 {
continue
}
lines = append(lines, "- "+grant.ToolName+": "+strings.Join(grant.Prefix, " "))
}
if len(lines) == 0 {
return ""
}
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
}
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
// tool outputs out of the main context. It renders only when specialists are
// known (which is only where the Task tool is actually registered), so a run with
// no delegatable specialists produces the previous prompt unchanged.
func specialistDelegationContext(options Options) string {
if len(options.Specialists) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<specialists>\n")
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
for _, info := range options.Specialists {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
if desc := strings.TrimSpace(info.WhenToUse); desc != "" {
b.WriteString("- " + name + ": " + desc + "\n")
} else {
b.WriteString("- " + name + "\n")
}
}
b.WriteString("</specialists>")
return b.String()
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -121,8 +186,36 @@ func workspaceContext(cwd string) string {
}
b.WriteString("</environment>")
for _, name := range projectContextFiles {
data, err := os.ReadFile(filepath.Join(cwd, name))
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
if repoMap := repoMapContext(cwd); repoMap != "" {
b.WriteString("\n\n## Repo map\n\n" + repoMap)
}
return b.String()
}
// projectGuidelines walks the directory chain from the git root to cwd
// (inclusive), finds the first matching project context file at each level
// (case-insensitive basename match), reads it, and returns the joined
// content in general-to-specific order — the most general file (at the git
// root) appears first, the most specific (at cwd) last. Each file is capped
// at maxProjectContextBytes; the total across all files is capped at
// maxProjectContextTotalBytes. Returns "" when no file matches.
func projectGuidelines(cwd, gitRoot string) string {
dirs := projectGuidelineDirs(cwd, gitRoot)
if len(dirs) == 0 {
return ""
}
var b strings.Builder
totalUsed := 0
for _, dir := range dirs {
if totalUsed >= maxProjectContextTotalBytes {
break
}
match := findProjectContextFile(dir)
if match == "" {
continue
}
data, err := os.ReadFile(match)
if err != nil {
continue
}
@@ -130,22 +223,181 @@ func workspaceContext(cwd string) string {
if content == "" {
continue
}
if len(content) > maxProjectContextBytes {
cut := maxProjectContextBytes
// Per-file cap is the lesser of the configured per-file limit and
// whatever remains of the total budget, so a single file can never
// blow past either bound.
limit := maxProjectContextBytes
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
limit = remaining
}
if len(content) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + "\n… (truncated)"
}
b.WriteString("\n\n## Project guidelines (" + name + ")\n\n" + content)
break // first match wins
}
if repoMap := repoMapContext(cwd); repoMap != "" {
b.WriteString("\n\n## Repo map\n\n" + repoMap)
label := projectGuidelineLabel(match, gitRoot)
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
totalUsed += len(content)
}
return b.String()
}
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
// from cwd, the chain collapses to [cwd].
func projectGuidelineDirs(cwd, gitRoot string) []string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return nil
}
gitRoot = strings.TrimSpace(gitRoot)
if gitRoot == "" {
return []string{cwd}
}
rel, err := filepath.Rel(gitRoot, cwd)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return []string{cwd}
}
if rel == "." {
return []string{gitRoot}
}
dirs := []string{gitRoot}
cur := gitRoot
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "" || seg == "." {
continue
}
cur = filepath.Join(cur, seg)
dirs = append(dirs, cur)
}
return dirs
}
// projectGuidelineLabel renders the path of match as a short label relative
// to gitRoot. When gitRoot is empty, the basename is returned.
func projectGuidelineLabel(match, gitRoot string) string {
if gitRoot == "" {
return filepath.Base(match)
}
rel, err := filepath.Rel(gitRoot, match)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return filepath.Base(match)
}
return rel
}
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
baseLower := strings.ToLower(filepath.Base(name))
// Walk to the file's parent through dir with case-insensitive segment
// matching, then find a regular file with the same lowercased
// basename. This works on both case-sensitive and case-insensitive
// filesystems and always returns the on-disk filename.
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
if !ok {
continue
}
entries, err := os.ReadDir(parent)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
return filepath.Join(parent, e.Name())
}
}
}
return ""
}
// resolveDirCaseInsensitive walks from anchor to target, matching each
// segment case-insensitively. Returns the resolved absolute path and true on
// success, or "" and false if any segment cannot be found.
func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
if target == "" || target == "." {
return anchor, true
}
rel, err := filepath.Rel(anchor, target)
if err != nil {
return "", false
}
if rel == "." {
return anchor, true
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", false
}
parts := strings.Split(rel, string(filepath.Separator))
cur := anchor
for _, p := range parts {
if p == "" {
continue
}
entries, err := os.ReadDir(cur)
if err != nil {
return "", false
}
found := false
for _, e := range entries {
if e.IsDir() && strings.EqualFold(e.Name(), p) {
cur = filepath.Join(cur, e.Name())
found = true
break
}
}
if !found {
return "", false
}
}
return cur, true
}
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
// .git entry (file or directory). Returns "" when no git root is found, so
// the caller can fall back to cwd-only lookup.
func findProjectGitRoot(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
cur := cwd
for {
if hasGitMetadata(cur) {
return cur
}
parent := filepath.Dir(cur)
if parent == cur {
return ""
}
cur = parent
}
}
func hasGitMetadata(dir string) bool {
gitPath := filepath.Join(dir, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return false
}
if info.IsDir() {
_, err := os.Stat(filepath.Join(gitPath, "HEAD"))
return err == nil
}
data, err := os.ReadFile(gitPath)
if err != nil {
return false
}
return strings.HasPrefix(strings.TrimSpace(string(data)), "gitdir: ")
}
func workspaceSeedContext(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
+11
View File
@@ -71,6 +71,17 @@ work.
managers). It is not a substitute for the file tools.
- Treat tool output as ground truth. If a command fails, read the error, form a
hypothesis, and address the root cause; do not retry the same call blindly.
- When a web search or fetch tool is available, search the web before answering
about an external entity, product, library, model, company, version, or recent
release you do not recognize. Do not guess, and do not assume the most common
meaning of an ambiguous name — use the conversation's domain (this is an AI /
software tool) to disambiguate. If the first results look off-topic, refine the
query and search again before answering; never reply that you don't know without
searching first.
- Do not web-search timeless facts, fundamentals, or anything answerable from the
workspace — read the code with the file tools instead. Keep queries short (a few
words), start broad then narrow, and scale the number of searches to the
question: one for a single fact, a few for a deeper answer.
## Permission and safety
+196
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
@@ -19,6 +21,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"final response",
} {
if !strings.Contains(prompt, want) {
@@ -69,6 +73,22 @@ func TestBuildSystemPromptOmitsWorkspaceSeedWithoutCwd(t *testing.T) {
}
}
func TestBuildSystemPromptIncludesApprovedCommandPrefixes(t *testing.T) {
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}}); err != nil {
t.Fatalf("GrantCommandPrefix returned error: %v", err)
}
prompt := buildSystemPrompt(Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{Store: store})})
for _, want := range []string{"Approved Command Prefixes", "bash: git status"} {
if !strings.Contains(prompt, want) {
t.Fatalf("system prompt missing %q:\n%s", want, prompt)
}
}
}
func writeSystemPromptTestFile(t *testing.T, root, rel, contents string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(rel))
@@ -93,3 +113,179 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
}
return body
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
if !strings.Contains(prompt, "## Project guidelines (AGENTS.MD)") {
t.Fatalf("expected case-insensitive AGENTS.MD resolution, got:\n%s", prompt)
}
if !strings.Contains(prompt, "make lint") {
t.Fatalf("expected AGENTS.MD content injected, got:\n%s", prompt)
}
}
func TestBuildSystemPromptProjectGuidelinesPathWalkingMonorepo(t *testing.T) {
// Simulate a monorepo: root + sub-tree each have their own AGENTS.md.
// The user launches Zero from the sub-tree, so both files should be
// injected in general-to-specific order (root first, cwd last).
root := t.TempDir()
leaf := filepath.Join(root, "services", "api")
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("Repo-wide: prefer Go."), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte("API: follow REST conventions."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: leaf})
normalized := filepath.ToSlash(prompt)
rootLabel := "Project guidelines (AGENTS.md)"
leafLabel := "Project guidelines (services/api/AGENTS.md)"
rootBlock := systemPromptTestBlock(t, normalized, rootLabel, leafLabel)
leafBlock := systemPromptTestBlock(t, normalized, leafLabel, "## Repo map")
if !strings.Contains(rootBlock, "Repo-wide: prefer Go.") {
t.Fatalf("expected root AGENTS.md in general-to-specific slot, got:\n%s", rootBlock)
}
if !strings.Contains(leafBlock, "API: follow REST conventions.") {
t.Fatalf("expected leaf AGENTS.md in specific slot, got:\n%s", leafBlock)
}
// Root must appear before leaf in the prompt.
rootIdx := strings.Index(normalized, "## "+rootLabel)
leafIdx := strings.Index(normalized, "## "+leafLabel)
if rootIdx < 0 || leafIdx < 0 || rootIdx > leafIdx {
t.Fatalf("expected root (general) before leaf (specific) in prompt, got root=%d leaf=%d", rootIdx, leafIdx)
}
}
func TestBuildSystemPromptProjectGuidelinesZeroFallback(t *testing.T) {
// ZERO.md is the second-priority name at each level; the loader picks it
// when no AGENTS.md is present.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "ZERO.md"), []byte("Brand-specific rule."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
if !strings.Contains(prompt, "## Project guidelines (ZERO.md)") {
t.Fatalf("expected ZERO.md fallback to be injected, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Brand-specific rule.") {
t.Fatalf("expected ZERO.md content, got:\n%s", prompt)
}
}
func TestBuildSystemPromptProjectGuidelinesProjectLocalFallback(t *testing.T) {
cwd := t.TempDir()
dot := filepath.Join(cwd, ".zero")
if err := os.MkdirAll(dot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dot, "AGENTS.md"), []byte("Personal: use dark theme."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
// Without a git root, the label collapses to the basename; the test
// confirms the .zero/AGENTS.md file's content is the one injected, not
// any other file.
if !strings.Contains(prompt, "Personal: use dark theme.") {
t.Fatalf("expected .zero/AGENTS.md content, got:\n%s", prompt)
}
// The project guidelines block must be present (regardless of label).
if !strings.Contains(prompt, "## Project guidelines (") {
t.Fatalf("expected a project guidelines block, got:\n%s", prompt)
}
}
func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
// Root file fits in the per-file cap; leaf file is bigger than what's
// left of the total budget, so it must be truncated. The truncation
// marker must appear in the prompt and the full untruncated payload
// must not.
root := t.TempDir()
leaf := filepath.Join(root, "sub")
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err)
}
rootContent := strings.Repeat("r", maxProjectContextBytes) // exactly per-file cap
leafContent := strings.Repeat("L", maxProjectContextTotalBytes+1) // over the total cap
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte(rootContent), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte(leafContent), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: leaf})
if !strings.Contains(prompt, "truncated") {
t.Fatalf("expected truncation marker on second file, prompt length=%d", len(prompt))
}
// Sanity: the leaf file's full payload must NOT be present untruncated.
if strings.Contains(prompt, strings.Repeat("L", maxProjectContextTotalBytes-1)) {
t.Fatalf("leaf file appears untruncated; total cap is not enforced")
}
}
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
root := filepath.Join("r")
leaf := filepath.Join(root, "a", "b")
got := projectGuidelineDirs(leaf, root)
want := []string{root, filepath.Join(root, "a"), leaf}
if len(got) != len(want) {
t.Fatalf("projectGuidelineDirs = %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("projectGuidelineDirs[%d] = %q, want %q (full %v)", i, got[i], want[i], got)
}
}
}
func TestProjectGuidelineDirsCollapsesToCwdWithoutGitRoot(t *testing.T) {
got := projectGuidelineDirs(filepath.Join("some", "path"), "")
if len(got) != 1 || got[0] != filepath.Join("some", "path") {
t.Fatalf("projectGuidelineDirs = %v, want [some/path]", got)
}
}
func TestFindProjectContextFileCaseInsensitiveBasename(t *testing.T) {
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
got := findProjectContextFile(cwd)
if filepath.Base(got) != "AGENTS.MD" {
t.Fatalf("findProjectContextFile = %q, want basename AGENTS.MD", got)
}
}
func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
leaf := filepath.Join(root, "child")
if err := os.MkdirAll(leaf, 0o755); err != nil {
t.Fatal(err)
}
if got := findProjectGitRoot(leaf); got != "" {
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
}
}
+27
View File
@@ -0,0 +1,27 @@
package agent
import "testing"
func TestApproxTextTokens(t *testing.T) {
if got := ApproxTextTokens(""); got != 0 {
t.Fatalf("empty = %d, want 0", got)
}
// Whitespace is folded out (BPE merges it into the adjacent token):
// "aaaa bbbb" has 8 non-space bytes -> 2 tokens, where naive len/4 says 3.
if got := ApproxTextTokens("aaaa bbbb"); got != 2 {
t.Fatalf("ApproxTextTokens = %d, want 2 (8 non-space / 4)", got)
}
// It must never exceed naive len/4 — it only removes whitespace — and on
// whitespace-bearing text it should be strictly smaller.
cases := []string{"hello world", "func main() {\n\treturn\n}", "a\tb\nc d e f"}
for _, s := range cases {
approx, naive := ApproxTextTokens(s), len(s)/4
if approx > naive {
t.Fatalf("ApproxTextTokens(%q)=%d exceeded len/4=%d", s, approx, naive)
}
}
// Pure non-whitespace matches len/4 exactly (nothing to fold).
if got := ApproxTextTokens("abcdefgh"); got != 2 {
t.Fatalf("ApproxTextTokens(no spaces) = %d, want 2", got)
}
}
+61 -32
View File
@@ -35,12 +35,18 @@ const (
PermissionActionAllow PermissionAction = "allow"
PermissionActionPrompt PermissionAction = "prompt"
PermissionActionDeny PermissionAction = "deny"
PermissionActionCancel PermissionAction = "cancel"
)
const (
PermissionDecisionAllow PermissionDecisionAction = "allow"
PermissionDecisionDeny PermissionDecisionAction = "deny"
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
PermissionDecisionAllow PermissionDecisionAction = "allow"
PermissionDecisionAllowStrict PermissionDecisionAction = "allow_with_strict_auto_review"
PermissionDecisionAllowForSession PermissionDecisionAction = "allow_for_session"
PermissionDecisionAllowPrefix PermissionDecisionAction = "allow_prefix_for_session"
PermissionDecisionAlwaysAllowPrefix PermissionDecisionAction = "always_allow_prefix"
PermissionDecisionDeny PermissionDecisionAction = "deny"
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
PermissionDecisionCancel PermissionDecisionAction = "cancel"
)
type ToolResult struct {
@@ -73,25 +79,28 @@ const (
DenialNone DenialCategory = ""
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
DenialApprovalCanceled DenialCategory = "approval_canceled" // approval canceled and run aborted
DenialSandboxViolation DenialCategory = "sandbox_violation" // blocked by the sandbox
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
)
type PermissionRequest struct {
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
Action PermissionAction `json:"action"`
Permission string `json:"permission"`
PermissionMode PermissionMode `json:"permissionMode"`
Autonomy string `json:"autonomy,omitempty"`
SideEffect string `json:"sideEffect"`
Reason string `json:"reason,omitempty"`
Scope string `json:"scope,omitempty"`
Risk sandbox.Risk `json:"risk"`
Args map[string]any `json:"args,omitempty"`
Violation *sandbox.Violation `json:"violation,omitempty"`
GrantMatched bool `json:"grantMatched,omitempty"`
Grant *sandbox.Grant `json:"grant,omitempty"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
Action PermissionAction `json:"action"`
Permission string `json:"permission"`
PermissionMode PermissionMode `json:"permissionMode"`
Autonomy string `json:"autonomy,omitempty"`
SideEffect string `json:"sideEffect"`
Reason string `json:"reason,omitempty"`
Scope string `json:"scope,omitempty"`
Risk sandbox.Risk `json:"risk"`
Args map[string]any `json:"args,omitempty"`
Violation *sandbox.Violation `json:"violation,omitempty"`
GrantMatched bool `json:"grantMatched,omitempty"`
Grant *sandbox.Grant `json:"grant,omitempty"`
CommandPrefix []string `json:"commandPrefix,omitempty"`
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
}
type PermissionDecision struct {
@@ -100,21 +109,23 @@ type PermissionDecision struct {
}
type PermissionEvent struct {
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
Action PermissionAction `json:"action"`
Permission string `json:"permission"`
PermissionGranted bool `json:"permissionGranted,omitempty"`
PermissionMode PermissionMode `json:"permissionMode"`
Autonomy string `json:"autonomy,omitempty"`
SideEffect string `json:"sideEffect"`
Reason string `json:"reason,omitempty"`
Scope string `json:"scope,omitempty"`
DecisionReason string `json:"decisionReason,omitempty"`
Risk sandbox.Risk `json:"risk"`
Violation *sandbox.Violation `json:"violation,omitempty"`
GrantMatched bool `json:"grantMatched,omitempty"`
Grant *sandbox.Grant `json:"grant,omitempty"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"name"`
Action PermissionAction `json:"action"`
DecisionAction PermissionDecisionAction `json:"decisionAction,omitempty"`
Permission string `json:"permission"`
PermissionGranted bool `json:"permissionGranted,omitempty"`
PermissionMode PermissionMode `json:"permissionMode"`
Autonomy string `json:"autonomy,omitempty"`
SideEffect string `json:"sideEffect"`
Reason string `json:"reason,omitempty"`
Scope string `json:"scope,omitempty"`
DecisionReason string `json:"decisionReason,omitempty"`
Risk sandbox.Risk `json:"risk"`
Violation *sandbox.Violation `json:"violation,omitempty"`
GrantMatched bool `json:"grantMatched,omitempty"`
Grant *sandbox.Grant `json:"grant,omitempty"`
CommandPrefix []string `json:"commandPrefix,omitempty"`
}
// AskUserQuestion is one clarifying question the agent wants answered.
@@ -136,6 +147,15 @@ type AskUserResponse struct {
Answers []string `json:"answers"`
}
// SpecialistInfo is a one-line summary of a delegatable sub-agent (its name and
// when-to-use description) surfaced to the orchestrator's system prompt so it can
// route work to the right specialist. It is plain data so the agent package needs
// no dependency on internal/specialist.
type SpecialistInfo struct {
Name string
WhenToUse string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -144,6 +164,13 @@ type Options struct {
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
// the pre-deferral behavior.
DeferThreshold int
// Specialists lists the sub-agents the orchestrator may delegate to via the
// Task tool; when non-empty the system prompt gains a delegation section that
// names them and nudges the model to offload read-heavy work (search,
// exploration) so verbose tool output stays out of the main context. It is
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -208,6 +235,8 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
runPermissions *permissionRunState
}
type Result struct {
-4
View File
@@ -186,10 +186,6 @@ func validateFileList(taskPath string, field string, files []string, required bo
return problems
}
func validateExpectedChangedFiles(taskPath string, files []string) []string {
return validateFileList(taskPath, "expectedChangedFiles", files, true)
}
func validateStringList(taskPath string, field string, values []string) []string {
problems := []string{}
seen := map[string]int{}
+38
View File
@@ -0,0 +1,38 @@
// Package browser opens a URL in the user's default web browser. It is used by
// interactive flows (e.g. the provider setup wizard's OAuth login) that need to
// hand the user off to a browser for authorization.
package browser
import (
"fmt"
"os/exec"
"runtime"
)
// OpenURL launches url in the default browser. It returns once the launcher
// process has started (it does not wait for the browser to close). A failure to
// start the launcher is returned so callers can fall back to printing the URL.
func OpenURL(url string) error {
name, args := openCommand(runtime.GOOS, url)
cmd := exec.Command(name, args...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("browser: open url: %w", err)
}
// Reap the short-lived launcher (open/xdg-open/rundll32 exit promptly) so it
// does not linger as a zombie; the browser window itself is independent.
go func() { _ = cmd.Wait() }()
return nil
}
// openCommand returns the launcher command + args for a platform. Split out as a
// pure function so the per-OS choice is unit-testable without spawning anything.
func openCommand(goos, url string) (string, []string) {
switch goos {
case "darwin":
return "open", []string{url}
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
default: // linux and other unixes
return "xdg-open", []string{url}
}
}
+27
View File
@@ -0,0 +1,27 @@
package browser
import (
"reflect"
"testing"
)
func TestOpenCommand(t *testing.T) {
cases := []struct {
goos string
name string
args []string
}{
{"darwin", "open", []string{"https://x.test"}},
{"linux", "xdg-open", []string{"https://x.test"}},
{"freebsd", "xdg-open", []string{"https://x.test"}},
{"windows", "rundll32", []string{"url.dll,FileProtocolHandler", "https://x.test"}},
}
for _, tc := range cases {
t.Run(tc.goos, func(t *testing.T) {
name, args := openCommand(tc.goos, "https://x.test")
if name != tc.name || !reflect.DeepEqual(args, tc.args) {
t.Fatalf("openCommand(%s) = %q %v, want %q %v", tc.goos, name, args, tc.name, tc.args)
}
})
}
}
+143 -37
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -18,7 +19,9 @@ import (
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/sessions"
@@ -37,39 +40,42 @@ import (
var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
}
type mcpToolRuntime interface {
Close() error
Skipped() []mcp.SkippedServer
}
type noopMCPRuntime struct{}
@@ -78,6 +84,10 @@ func (noopMCPRuntime) Close() error {
return nil
}
func (noopMCPRuntime) Skipped() []mcp.SkippedServer {
return nil
}
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
// exercise command behavior without terminating the test process.
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
@@ -105,9 +115,13 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return providers.New(profile, providers.Options{UserAgent: userAgent()})
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: oauthResolverForProfile(profile),
})
},
probeProviderHealth: providerhealth.Probe,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -130,6 +144,12 @@ func defaultAppDeps() appDeps {
return sandbox.NewGrantStore(sandbox.StoreOptions{})
},
selectSandboxBackend: sandbox.SelectBackend,
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
cmd := exec.Command(path, args...)
cmd.Stdout = stdout
cmd.Stderr = stderr
return cmd.Run()
},
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
return mcp.RegisterTools(ctx, registry, cfg, options)
},
@@ -334,6 +354,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.probeProviderHealth == nil {
deps.probeProviderHealth = defaults.probeProviderHealth
}
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
if deps.newSessionStore == nil {
deps.newSessionStore = defaults.newSessionStore
}
@@ -364,6 +387,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.selectSandboxBackend == nil {
deps.selectSandboxBackend = defaults.selectSandboxBackend
}
if deps.runSandboxSetupHelper == nil {
deps.runSandboxSetupHelper = defaults.runSandboxSetupHelper
}
if deps.registerMCPTools == nil {
deps.registerMCPTools = defaults.registerMCPTools
}
@@ -429,6 +455,18 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
needsSetup := setupRequired(resolved)
if needsSetup && !forceSetup {
// The active provider lacks a usable credential, but if another saved
// provider already has one, fall back to it instead of forcing onboarding
// again. Saved logins persist across launches; switch the active provider
// any time with `zero provider use <name>`. Onboarding only runs when no
// configured provider is usable (a genuinely fresh setup).
if usable, ok := firstUsableProvider(resolved.Providers); ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
needsSetup = false
}
}
setupVisible := forceSetup || needsSetup
configPath := ""
if setupVisible {
@@ -449,7 +487,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
@@ -480,6 +518,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return writeAppError(stderr, err.Error(), 1)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent.
for _, skipped := range mcpRuntime.Skipped() {
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
// Make local plugins live: register their declared tools into the registry and
// collect their hooks + skill roots for the dispatcher and skill tool below.
// Done after specialist + MCP registration so plugin tools are part of the
@@ -510,11 +554,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
Backend: sandboxBackend,
Scope: scope,
})
lastKnownMCPConfig := mcpConfig
@@ -555,6 +600,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ExitCode: exitCode,
}
},
SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps),
AgentOptions: agent.Options{
MaxTurns: resolved.MaxTurns,
Registry: registry,
@@ -564,6 +610,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
@@ -579,6 +626,21 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
})
}
func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.Context) tui.SandboxSetupCommandResult {
if backend.Platform != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
return nil
}
return func(context.Context) tui.SandboxSetupCommandResult {
var stdout, stderr bytes.Buffer
exitCode := runSandboxSetup(nil, &stdout, &stderr, deps)
return tui.SandboxSetupCommandResult{
Output: strings.TrimSpace(stdout.String()),
Error: strings.TrimSpace(stderr.String()),
ExitCode: exitCode,
}
}
}
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
@@ -604,9 +666,22 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
type agentToolRuntime struct {
specialist *specialist.Runtime
swarm *swarm.Swarm
// specialists summarizes the registered specialists (name + description) for
// the orchestrator's system-prompt delegation section. Populated alongside the
// tools, so it is present exactly when the Task tool is.
specialists []agent.SpecialistInfo
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
// exec path can call it whether or not specialist tools were registered.
func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
if r == nil {
return nil
}
return r.specialists
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
@@ -619,16 +694,41 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*a
// The swarm reuses the same specialist executor to launch each member, so
// every member runs under the orchestrator's sandbox + policy. Mailbox state
// lives under the workspace so its files fall within the sandbox write rules.
// MaxTeamSize (0 => the swarm's default of 8) caps concurrent members per team.
sw, err := swarm.New(swarm.Options{
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
Launcher: swarm.NewSpecialistLauncher(executor),
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
Launcher: swarm.NewSpecialistLauncher(executor),
MaxTeamSize: maxTeamSize,
})
if err != nil {
runtime.Close()
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
}
// specialistSummaries loads the available specialists (built-ins + user/project
// profiles) and returns their name + description for the orchestrator's
// delegation prompt. A load error yields no summaries (the prompt simply omits
// the delegation section) rather than failing the run.
func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
if err != nil {
return nil
}
summaries := make([]agent.SpecialistInfo, 0, len(result.Specialists))
for _, manifest := range result.Specialists {
name := strings.TrimSpace(manifest.Metadata.Name)
if name == "" {
continue
}
summaries = append(summaries, agent.SpecialistInfo{
Name: name,
WhenToUse: strings.TrimSpace(manifest.Metadata.Description),
})
}
return summaries
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
@@ -638,7 +738,13 @@ func shouldRegisterExecSpecialistTools(options execOptions) bool {
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
return false
}
return options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high")
// Register at medium/high (and unsafe). The Task tool's permission gate
// auto-approves only read-only specialist spawns and prompts for write-capable
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
// write spawn still gets denied at the prompt). Default "low" stays clean — no
// specialist tooling or swarm runtime for trivial/CI one-shots.
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
}
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
+9
View File
@@ -112,6 +112,9 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchedOptions = options
return 7
@@ -446,6 +449,9 @@ func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchedOptions = options
return 0
@@ -845,6 +851,9 @@ func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &cliFakeProvider{}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launchedOptions = options
return 0
+63 -4
View File
@@ -5,10 +5,12 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -34,11 +36,44 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return runAuthStatus(args[1:], stdout, stderr, deps)
case "refresh":
return runAuthRefresh(args[1:], stdout, stderr, deps)
case "openrouter":
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
}
}
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
// provider profile, while this command prints it for manual configuration.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
_ = writeAuthHelp(stdout)
return exitSuccess
}
}
// openrouter takes no positional args or flags; reject the unexpected so a
// typo/unsupported flag fails fast instead of silently running the login.
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
}
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
Out: stdout,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
BaseURL: strings.TrimSpace(os.Getenv("ZERO_OPENROUTER_BASE_URL")),
})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
return exitCrash
}
return exitSuccess
}
// authArgs is the parsed form of an auth subcommand's arguments.
type authArgs struct {
positional []string
@@ -124,9 +159,23 @@ func validateAuthFlags(sub string, a authArgs) error {
// newAuthManager builds an oauth.Manager backed by the file store, printing the
// authorization URL / device code to stdout. The store path honors
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it.
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it. Setting
// ZERO_OAUTH_STORAGE=encrypted-file selects the AES-256-GCM encrypted-at-rest
// backend (a per-user secret is created beside the token file).
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
store, err := oauth.NewStore(oauth.StoreOptions{Now: deps.now})
// Validate ZERO_OAUTH_STORAGE up front: a mistyped value must fail fast rather
// than silently change the backend. Empty = default (plaintext 0600 file);
// "encrypted-file" = AES-256-GCM; "keyring" = the OS keyring.
storage := strings.ToLower(strings.TrimSpace(os.Getenv("ZERO_OAUTH_STORAGE")))
switch storage {
case "", "file", "encrypted-file", "keyring":
default:
return nil, fmt.Errorf("invalid ZERO_OAUTH_STORAGE %q (supported: file, encrypted-file, keyring)", storage)
}
store, err := oauth.NewStore(oauth.StoreOptions{
Now: deps.now,
Storage: storage,
})
if err != nil {
return nil, err
}
@@ -323,9 +372,13 @@ Commands:
logout <provider> Delete a provider's stored login
status [provider] Show login presence/expiry (never the token)
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
openrouter Log in to OpenRouter in the browser; mints an API key
A provider is any OAuth 2.0 / OIDC server you configure via env (no providers are
built in). For a provider named <name>, set:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
@@ -333,6 +386,12 @@ built in). For a provider named <name>, set:
ZERO_OAUTH_<NAME>_SCOPES ZERO_OAUTH_<NAME>_FLOW (loopback|device)
Endpoint URLs must be https (loopback exempt).
Storage: tokens are written 0600 under $XDG_CONFIG_HOME/zero (override with
ZERO_OAUTH_TOKENS_PATH). Set ZERO_OAUTH_STORAGE=encrypted-file to encrypt them at
rest with AES-256-GCM (a per-user secret beside the file), or
ZERO_OAUTH_STORAGE=keyring to use the OS keyring (macOS Keychain / Linux
secret-tool). MCP server tokens share the same store.
Flags:
--device Use the device-code flow (headless/SSH; no browser)
--scope Add an OAuth scope (repeatable)
+29
View File
@@ -17,6 +17,20 @@ func withAuthStore(t *testing.T) string {
return path
}
func TestRunAuthRejectsInvalidStorageMode(t *testing.T) {
withAuthStore(t)
// A mistyped value must fail fast, not silently fall back to plaintext while
// the user believes encryption is active.
t.Setenv("ZERO_OAUTH_STORAGE", "encryptd")
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatalf("invalid ZERO_OAUTH_STORAGE should fail, got success; stdout=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "ZERO_OAUTH_STORAGE") {
t.Fatalf("error should name the offending env var, stderr=%q", stderr.String())
}
}
func TestRunAuthStatusEmpty(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
@@ -116,6 +130,21 @@ func TestRunAuthRejectsWrongFlags(t *testing.T) {
}
}
func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
// An unexpected arg/flag must fail fast, not silently run the login.
if code := runWithDeps([]string{"auth", "openrouter", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatalf("openrouter with an unexpected flag should fail; stdout=%q", stdout.String())
}
// --help still works.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "openrouter", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("openrouter --help should succeed, stderr=%q", stderr.String())
}
}
func TestRunAuthHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
+5
View File
@@ -78,6 +78,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
if command == "detect" {
return runProvidersDetect(args, stdout, stderr, deps)
}
if command != "list" && command != "current" && command != "catalog" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
}
@@ -451,8 +454,10 @@ func writeProvidersHelp(w io.Writer) error {
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Flags:
--json Print JSON summary
+90 -5
View File
@@ -38,6 +38,8 @@ func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int
return runDaemonAttach(rest, stdout, stderr)
case "serve-remote":
return runDaemonServeRemote(rest, stdout, stderr)
case "link":
return runDaemonLink(rest, stdout, stderr)
case "-h", "--help", "help":
return writeDaemonUsage(stdout, exitSuccess)
default:
@@ -56,14 +58,20 @@ Commands:
run --session <id> [--cwd <dir>] [--prompt <text>] [exec flags...]
Create/route a session and stream its output.
attach <session> Attach to a running session's stream.
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f>
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f> [--bundle-dir <d>]
Serve an opt-in, TLS-only network bridge to this
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE).
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE). --bundle-dir enables
git-bundle uploads, extracted into per-link work trees.
link --remote <host:port> --repo <dir> --id <name> [--out <file>]
Upload repo's git history to the remote as a bundle and
print the extracted remote path. --out saves a session
link file (0600). Accepts --token/--ca-cert/--server-name.
link --show <file> Print a saved session link.
run and attach accept --remote <host:port> [--token <t>] [--ca-cert <f>]
[--server-name <name>] to drive a remote daemon over the bridge instead of the
local socket.
local socket. Use the link's remote path as --cwd to run against a linked repo.
`)
return code
}
@@ -399,7 +407,7 @@ func daemonDialError(flags remoteDialFlags, err error) string {
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
// start without a cert/key pair and a token from the environment.
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
addr, certFile, keyFile := "", "", ""
addr, certFile, keyFile, bundleDir := "", "", "", ""
minVersion, maxConns := 0, 0
for i := 0; i < len(args); i++ {
a := args[i]
@@ -449,6 +457,14 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
return writeExecUsageError(stderr, "--max-conns requires a value")
}
maxConns = atoiOrZero(v)
case a == "--bundle-dir":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--bundle-dir requires a value")
}
bundleDir = v
case strings.HasPrefix(a, "--bundle-dir="):
bundleDir = strings.TrimPrefix(a, "--bundle-dir=")
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
}
@@ -491,7 +507,7 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
return writeAppError(stderr, err.Error(), exitCrash)
}
bridge, err := remote.NewBridge(remote.BridgeOptions{
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, Log: logf,
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, BundleDir: bundleDir, Log: logf,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -524,6 +540,75 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
}
}
// runDaemonLink uploads a repo's git history to a remote bridge as a bundle
// (link without --show), or prints a saved session link (--show <file>).
func runDaemonLink(args []string, stdout io.Writer, stderr io.Writer) int {
var addr, repo, id, token, caCert, serverName, out, show string
// flags maps each "--name" to the string it sets; both "--name v" and
// "--name=v" forms are accepted.
flags := map[string]*string{
"--remote": &addr, "--repo": &repo, "--id": &id, "--token": &token,
"--ca-cert": &caCert, "--server-name": &serverName, "--out": &out, "--show": &show,
}
for i := 0; i < len(args); i++ {
a := args[i]
if a == "-h" || a == "--help" {
return writeDaemonUsage(stdout, exitSuccess)
}
name, inlineVal, hasInline := a, "", false
if eq := strings.IndexByte(a, '='); eq >= 0 {
name, inlineVal, hasInline = a[:eq], a[eq+1:], true
}
dst, ok := flags[name]
if !ok {
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon link", a))
}
if hasInline {
*dst = inlineVal
continue
}
if i+1 >= len(args) {
return writeExecUsageError(stderr, fmt.Sprintf("%s requires a value", name))
}
i++
*dst = args[i]
}
if strings.TrimSpace(show) != "" {
link, err := remote.LoadSessionLink(show)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "link %s -> %s on %s\n", link.LinkID, link.RemotePath, link.Address)
return exitSuccess
}
if strings.TrimSpace(addr) == "" || strings.TrimSpace(repo) == "" || strings.TrimSpace(id) == "" {
return writeExecUsageError(stderr, "daemon link requires --remote, --repo, and --id (or --show <file>)")
}
if strings.TrimSpace(token) == "" {
token, _ = remote.TokenFromEnv() // best effort; UploadRepoBundle rejects an empty token
}
link, err := remote.UploadRepoBundle(remote.RemoteConfig{
Address: addr,
Token: token,
CACertFile: caCert,
ServerName: serverName,
}, repo, id)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "uploaded %s; remote repo at %s\n", link.LinkID, link.RemotePath)
fmt.Fprintf(stdout, "run it with: zero daemon run --remote %s --cwd %s ...\n", link.Address, link.RemotePath)
if strings.TrimSpace(out) != "" {
if err := link.Save(out); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "saved session link to %s\n", out)
}
return exitSuccess
}
// remoteDialFlags holds the optional flags that redirect run/attach to a remote
// daemon. When Addr is empty the local control socket is used.
type remoteDialFlags struct {
+30 -5
View File
@@ -164,8 +164,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
var specialistRuntime *agentToolRuntime
if shouldRegisterExecSpecialistTools(options) {
// Specialist tools register before the full config resolve below (so
// --list-tools stays offline). swarm.maxTeamSize is not affected by
// overrides, so an empty-overrides resolve yields the same value; a resolve
// error falls back to the swarm's built-in default (0 => 8).
maxTeamSize := 0
if swarmCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil {
maxTeamSize = swarmCfg.Swarm.MaxTeamSize
}
var err error
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error())
}
@@ -367,6 +375,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
FocusMode: notify.FocusAlways,
})
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Headless runs can safely
// log a failed delivery to stderr (never stdout). The sink redacts before
// logging, so a token in the URL or message is masked.
notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) {
fmt.Fprintf(stderr, "[notify] "+format+"\n", args...)
})
if options.useSpec {
return runExecSpecDraft(execSpecDraftRun{
options: options,
@@ -472,6 +486,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
MaxTurns: resolved.MaxTurns,
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -709,12 +724,22 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
if network := strings.TrimSpace(cfg.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
policy.Network = sandbox.NetworkMode(network)
}
}
// Opt-in hardening flags: only ever turn a feature ON from config, never off,
// so a programmatic default can't be silently disabled by an omitted key.
// Scoped egress allow/deny lists from (global) config; the engine consults
// these only under NetworkScoped and falls closed to deny on an empty
// allowlist or a backend that can't enforce scoping.
if len(cfg.NetworkAllowedDomains) > 0 {
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
}
if len(cfg.NetworkDeniedDomains) > 0 {
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
}
// Opt-in hardening/diagnostic flags: only ever turn a feature ON from
// config, never off, so a programmatic default can't be silently disabled by
// an omitted key.
if cfg.BlockUnixSockets {
policy.BlockUnixSockets = true
}
@@ -1066,7 +1091,7 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
if event.Action == agent.PermissionActionPrompt {
return sessions.EventPermissionRequest
}
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
return sessions.EventPermissionDecision
}
return sessions.EventPermission
+17 -7
View File
@@ -357,7 +357,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
toolCallID: "call_write",
toolName: "write_file",
arguments: `{"path":"notes.txt","content":"hello"}`,
answer: "write denied",
answer: "write allowed",
}, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
@@ -374,16 +374,22 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
events := decodeJSONLines(t, stdout.String())
eventTypes := jsonEventTypes(events)
if !slices.Contains(eventTypes, "permission_request") {
t.Fatalf("expected permission_request event in %v; output %q", eventTypes, stdout.String())
if slices.Contains(eventTypes, "permission_request") {
t.Fatalf("workspace write should not request permission in %v; output %q", eventTypes, stdout.String())
}
permissionEvent := findJSONEvent(t, events, "permission_request")
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "prompt" {
permissionEvent := findJSONEvent(t, events, "permission_decision")
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "allow" {
t.Fatalf("unexpected permission event: %#v", permissionEvent)
}
if permissionEvent["permission"] != "prompt" || permissionEvent["permissionMode"] != "auto" || permissionEvent["sideEffect"] != "write" {
t.Fatalf("unexpected permission metadata: %#v", permissionEvent)
}
if permissionEvent["permissionGranted"] == true {
t.Fatalf("workspace policy allow should not be recorded as user-granted permission: %#v", permissionEvent)
}
if permissionEvent["reason"] != "workspace write permitted by sandbox policy" {
t.Fatalf("unexpected workspace permission reason: %#v", permissionEvent)
}
risk, ok := permissionEvent["risk"].(map[string]any)
if !ok || risk["level"] == "" {
t.Fatalf("expected permission risk payload, got %#v", permissionEvent)
@@ -400,12 +406,12 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionRequest)
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionDecision)
var payload map[string]any
if err := json.Unmarshal(permissionRecord.Payload, &payload); err != nil {
t.Fatalf("decode permission payload: %v", err)
}
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "prompt" {
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "allow" {
t.Fatalf("unexpected recorded permission payload: %#v", payload)
}
@@ -715,6 +721,10 @@ func (fn closeFunc) Close() error {
return fn()
}
func (fn closeFunc) Skipped() []mcp.SkippedServer {
return nil
}
type toolCallingExecProvider struct {
toolCallID string
toolName string
+1 -1
View File
@@ -242,7 +242,7 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
if event.Action == agent.PermissionActionPrompt {
return streamjson.EventPermissionRequest
}
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
return streamjson.EventPermissionDecision
}
return streamjson.EventPermission
+12 -3
View File
@@ -697,11 +697,20 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
if err != nil {
return false, false, err
}
if !legacyFound {
switch {
case legacyFound:
raw = legacyRaw
found = true
case config.IsDefaultMCPServer(name):
// A built-in default server isn't written to the file until the user
// overrides it. Treat it as present with an empty base so disabling it
// writes a minimal {"disabled":true} entry that merges over the default —
// letting `zero mcp disable <default>` work even though it lives in code.
raw = nil
found = true
default:
return false, false, nil
}
raw = legacyRaw
found = true
}
var server map[string]json.RawMessage
if len(raw) > 0 && string(raw) != "null" {
+46
View File
@@ -0,0 +1,46 @@
package cli
import (
"bytes"
"encoding/json"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
// default that is not written to the user's config file until overridden.
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
// A config with no firecrawl entry — the default lives in code, not the file.
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
})
if code != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
}
var payload struct {
ServerName string `json:"serverName"`
Disabled bool `json:"disabled"`
Changed bool `json:"changed"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
}
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
}
// End-to-end: resolving that config now turns the default off.
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
}
}
+47
View File
@@ -0,0 +1,47 @@
package cli
import (
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestProviderHasOAuthLogin(t *testing.T) {
xai := config.ProviderProfile{Name: "xai", CatalogID: "xai"}
if providerHasOAuthLogin(xai, nil) {
t.Fatal("no stored login must be false")
}
if !providerHasOAuthLogin(xai, map[string]bool{"xai": true}) {
t.Fatal("a stored login keyed by name must be true")
}
if !providerHasOAuthLogin(config.ProviderProfile{Name: "grok", CatalogID: "xai"}, map[string]bool{"xai": true}) {
t.Fatal("a stored login keyed by catalog id must be true")
}
}
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// xai has no inline key (env-only) but IS logged in via OAuth → no setup.
loggedIn := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}}
if setupRequired(loggedIn) {
t.Fatal("a provider with a stored OAuth login must not require onboarding")
}
// A keyless provider with no OAuth login still requires setup.
noLogin := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"}}
if !setupRequired(noLogin) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
+67
View File
@@ -0,0 +1,67 @@
package cli
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil
}
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
if err != nil {
return nil
}
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
token, rerr = manager.Handle401(ctx, key)
} else {
token, rerr = manager.GetFresh(ctx, key)
}
if errors.Is(rerr, oauth.ErrNoToken) {
// The login was removed since construction → fall back to the API key.
return "", "", false, nil
}
if rerr != nil {
return "", "", false, rerr
}
return "Authorization", "Bearer " + token, true, nil
}
}
+4
View File
@@ -46,8 +46,10 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
var provider config.ProviderProfile
var sandboxConfig config.SandboxConfig
if resolved, resolveErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); resolveErr == nil {
provider = resolved.Provider
sandboxConfig = resolved.Sandbox
}
var health *providerhealth.Result
if options.connectivity && config.HasProviderProfile(provider) {
@@ -67,6 +69,8 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
UserConfig: userConfig,
ProjectConfig: projectConfig,
Provider: provider,
WorkspaceRoot: workspaceRoot,
Sandbox: sandboxConfig,
Connectivity: options.connectivity,
ProviderHealth: health,
})
+178
View File
@@ -0,0 +1,178 @@
package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/provideronboarding"
)
type providerDetectOptions struct {
json bool
}
// providerDetectAction is the JSON/text shape of a single suggested next step.
type providerDetectAction struct {
Label string `json:"label"`
Command string `json:"command"`
Detail string `json:"detail,omitempty"`
}
type providerDetectRuntime struct {
CatalogID string `json:"catalogID"`
Name string `json:"name"`
BaseURL string `json:"baseURL"`
Models []string `json:"models,omitempty"`
Action providerDetectAction `json:"action"`
}
type providerDetectProvider struct {
Name string `json:"name"`
Active bool `json:"active"`
Actions []providerDetectAction `json:"actions,omitempty"`
}
type providerDetectReport struct {
DetectedRuntimes []providerDetectRuntime `json:"detectedRuntimes"`
Providers []providerDetectProvider `json:"providers"`
}
// runProvidersDetect probes the machine for running local, OpenAI-compatible
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
// one it finds, followed by the next-step actions for every already-configured
// provider. It is the onboarding-advice surface — "what can I do right now?" —
// and never errors on a machine with nothing running locally (it just reports an
// empty detected list).
func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderDetectArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
ctx, stop := signalContext()
defer stop()
detected := deps.detectLocalRuntimes(ctx, provideronboarding.LocalDetectOptions{})
report := buildProviderDetectReport(resolved, detected)
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, formatProviderDetectReport(report)); err != nil {
return exitCrash
}
return exitSuccess
}
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
options := providerDetectOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
}
return options, false, nil
}
func buildProviderDetectReport(resolved config.ResolvedConfig, detected []provideronboarding.DetectedLocalRuntime) providerDetectReport {
report := providerDetectReport{
DetectedRuntimes: make([]providerDetectRuntime, 0, len(detected)),
Providers: make([]providerDetectProvider, 0, len(resolved.Providers)),
}
for _, runtime := range detected {
report.DetectedRuntimes = append(report.DetectedRuntimes, providerDetectRuntime{
CatalogID: runtime.CatalogID,
Name: runtime.Name,
BaseURL: runtime.BaseURL,
Models: runtime.Models,
Action: providerDetectActionFrom(runtime.SetupAction()),
})
}
for _, profile := range resolved.Providers {
active := strings.TrimSpace(profile.Name) != "" && profile.Name == resolved.ActiveProvider
state := provideronboarding.ProviderState{Profile: profile, Active: active}
actions := state.Actions()
entry := providerDetectProvider{
Name: profile.Name,
Active: active,
Actions: make([]providerDetectAction, 0, len(actions)),
}
for _, action := range actions {
entry.Actions = append(entry.Actions, providerDetectActionFrom(action))
}
report.Providers = append(report.Providers, entry)
}
return report
}
func providerDetectActionFrom(action provideronboarding.Action) providerDetectAction {
return providerDetectAction{Label: action.Label, Command: action.Command, Detail: action.Detail}
}
func formatProviderDetectReport(report providerDetectReport) string {
lines := []string{"Detected local runtimes:"}
if len(report.DetectedRuntimes) == 0 {
lines = append(lines, " (none running on default ports)")
}
for _, runtime := range report.DetectedRuntimes {
name := strings.TrimSpace(runtime.Name)
if name == "" {
name = runtime.CatalogID
}
lines = append(lines, " "+name+" — "+runtime.BaseURL)
if len(runtime.Models) > 0 {
lines = append(lines, " models: "+strings.Join(runtime.Models, ", "))
}
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
lines = append(lines, " "+runtime.Action.Label+": "+command)
}
}
lines = append(lines, "", "Configured providers:")
if len(report.Providers) == 0 {
lines = append(lines, " (none configured — run `zero setup`)")
}
for _, provider := range report.Providers {
name := strings.TrimSpace(provider.Name)
if name == "" {
name = "(unnamed)"
}
marker := ""
if provider.Active {
marker = " (active)"
}
lines = append(lines, " "+name+marker)
if len(provider.Actions) == 0 {
lines = append(lines, " (ready)")
}
for _, action := range provider.Actions {
line := " " + action.Label
if command := strings.TrimSpace(action.Command); command != "" {
line += ": " + command
}
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/provideronboarding"
)
func TestRunProvidersDetectSurfacesLocalRuntimeAndProviderActions(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
return []provideronboarding.DetectedLocalRuntime{{
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "ollama", Name: "Ollama Local", BaseURL: "http://localhost:11434/v1"},
Reachable: true,
Models: []string{"llama3.1"},
}}
}
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
// Configured-but-inactive provider with no credential: should yield the full
// "Use provider" / "Check provider" / "Set API key" action set.
profile := config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
Model: "gpt-4o",
APIKeyEnv: "OPENAI_API_KEY",
}
return config.ResolvedConfig{ActiveProvider: "other", Providers: []config.ProviderProfile{profile}}, nil
}
exitCode := runWithDeps([]string{"providers", "detect"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
}
out := stdout.String()
for _, want := range []string{
"Detected local runtimes:",
"Ollama Local — http://localhost:11434/v1",
"models: llama3.1",
"zero providers add ollama", // SetupAction's no-key adopt command
"Configured providers:",
"openai",
"Use provider",
"Check provider",
"Set API key",
} {
if !strings.Contains(out, want) {
t.Fatalf("detect output missing %q:\n%s", want, out)
}
}
}
func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
return nil // nothing running locally
}
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
// Active provider with an inline key: only "Check provider" remains.
profile := config.ProviderProfile{
Name: "local",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://api.example.com/v1",
APIKey: "sk-secret",
Model: "custom",
}
return config.ResolvedConfig{ActiveProvider: "local", Provider: profile, Providers: []config.ProviderProfile{profile}}, nil
}
exitCode := runWithDeps([]string{"providers", "detect", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), "sk-secret") {
t.Fatalf("detect JSON leaked the API key: %s", stdout.String())
}
var payload struct {
DetectedRuntimes []struct {
CatalogID string `json:"catalogID"`
} `json:"detectedRuntimes"`
Providers []struct {
Name string `json:"name"`
Active bool `json:"active"`
Actions []struct {
Label string `json:"label"`
} `json:"actions"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("detect JSON did not decode: %v\n%s", err, stdout.String())
}
if len(payload.DetectedRuntimes) != 0 {
t.Fatalf("expected no detected runtimes, got %#v", payload.DetectedRuntimes)
}
if len(payload.Providers) != 1 || payload.Providers[0].Name != "local" || !payload.Providers[0].Active {
t.Fatalf("expected one active provider 'local', got %#v", payload.Providers)
}
if len(payload.Providers[0].Actions) != 1 || payload.Providers[0].Actions[0].Label != "Check provider" {
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
}
}
+137 -3
View File
@@ -31,6 +31,10 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return exitSuccess
case "policy":
return runSandboxPolicy(args[1:], stdout, stderr, deps)
case "setup":
return runSandboxSetup(args[1:], stdout, stderr, deps)
case "check":
return runSandboxCheck(args[1:], stdout, stderr, deps)
case "grants":
return runSandboxGrants(args[1:], stdout, stderr, deps)
default:
@@ -112,6 +116,102 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
return exitSuccess
}
func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeSandboxSetupHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
if backend.Platform != "windows" {
message := "No native sandbox setup is required for " + displayPlatform(backend.Platform)
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
Platform: displayPlatform(backend.Platform),
Backend: backend.Name,
Ran: false,
Message: message,
})
}
if backend.Name != zeroSandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
message := "Windows sandbox setup helper is not available"
if strings.TrimSpace(backend.Message) != "" {
message += ": " + backend.Message
}
return writeAppError(stderr, message, exitProvider)
}
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
profile := zeroSandbox.PermissionProfileFromPolicy(workspaceRoot, policy, scope)
setupPath := zeroSandbox.WindowsSandboxSetupPathForRunner(backend.Executable)
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if err := deps.runSandboxSetupHelper(setupPath, setupArgs, stdout, stderr); err != nil {
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
}
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
Platform: "windows",
Backend: backend.Name,
Helper: setupPath,
Ran: true,
Message: "Windows sandbox setup complete",
})
}
type sandboxSetupResult struct {
Platform string `json:"platform"`
Backend zeroSandbox.BackendName `json:"backend"`
Helper string `json:"helper,omitempty"`
Ran bool `json:"ran"`
Message string `json:"message"`
}
func writeSandboxSetupResult(stdout io.Writer, asJSON bool, result sandboxSetupResult) int {
if asJSON {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, result.Message); err != nil {
return exitCrash
}
if result.Helper != "" {
if _, err := fmt.Fprintln(stdout, "helper: "+result.Helper); err != nil {
return exitCrash
}
}
return exitSuccess
}
func displayPlatform(platform string) string {
if strings.TrimSpace(platform) == "" {
return "this platform"
}
return platform
}
// sandboxGuards is the resolved set of pre-execution safety guards the engine
// applies for the effective policy. There is no layered/merged config today, so
// the "effective" view is the fully-resolved DefaultPolicy plus the platform
@@ -192,13 +292,20 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
"allow_policy_only_runner: "+fmt.Sprintf("%t", policy.AllowPolicyOnlyRunner),
"max_autonomy: "+string(policy.MaxAutonomy),
"backend: "+string(backend.Name),
"target_backend: "+string(plan.TargetBackend),
"support_level: "+string(plan.SupportLevel),
"command_wrapped: "+fmt.Sprintf("%t", plan.CommandWrapped),
"enforcement_level: "+string(plan.EnforcementLevel),
"requires_platform_sandbox: "+fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
"interactive_command_guard: "+enabledLabel(guards.InteractiveCommand),
"destructive_shell_guard: "+enabledLabel(guards.DestructiveShell),
"network_guard: "+enabledLabel(guards.Network),
"workspace_guard: "+enabledLabel(guards.Workspace),
"grants: "+grantsPath,
)
if plan.DowngradeReason != "" {
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
}
if backend.Platform != "" {
lines = append(lines, "backend_platform: "+backend.Platform)
}
@@ -247,15 +354,20 @@ func runSandboxGrants(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
prefixes, err := store.ListCommandPrefixes()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, struct {
Grants []zeroSandbox.Grant `json:"grants"`
}{Grants: grants}); err != nil {
Grants []zeroSandbox.Grant `json:"grants"`
CommandPrefixes []zeroSandbox.CommandPrefixGrant `json:"commandPrefixes,omitempty"`
}{Grants: grants, CommandPrefixes: prefixes}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantList(grants)); err != nil {
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantListWithCommandPrefixes(grants, prefixes)); err != nil {
return exitCrash
}
return exitSuccess
@@ -499,7 +611,14 @@ func formatSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backen
"network: " + string(policy.Network),
"max_autonomy: " + string(policy.MaxAutonomy),
"backend: " + string(backend.Name),
"target_backend: " + string(plan.TargetBackend),
"support_level: " + string(plan.SupportLevel),
"command_wrapped: " + fmt.Sprintf("%t", plan.CommandWrapped),
"enforcement_level: " + string(plan.EnforcementLevel),
"requires_platform_sandbox: " + fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
}
if plan.DowngradeReason != "" {
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
}
if backend.Platform != "" {
lines = append(lines, "backend_platform: "+backend.Platform)
@@ -526,6 +645,8 @@ func writeSandboxHelp(w io.Writer) error {
Commands:
policy Inspect active sandbox policy and platform backend
setup Run native platform sandbox setup
check Evaluate the sandbox decision for a hypothetical tool action
grants Manage persistent sandbox grants
`)
@@ -544,6 +665,19 @@ Flags:
return err
}
func writeSandboxSetupHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero sandbox setup [flags]
Runs native platform sandbox setup when the selected backend requires it.
Flags:
--json Print JSON output
-h, --help Show this help
`)
return err
}
func writeSandboxGrantsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero sandbox grants <command>
+298
View File
@@ -0,0 +1,298 @@
package cli
import (
"fmt"
"io"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/config"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/zerocommands"
)
type sandboxCheckOptions struct {
tool string
sideEffect string
path string
autonomy string
reason string
json bool
}
// sandboxCheckReport is the combined snapshot `zero sandbox check` emits: the
// active plan (policy + backend + restrictions), the decision the engine would
// make for the described tool action, and any persistent grant that matches the
// tool. It is the production consumer of the zerocommands sandbox-snapshot
// contract, giving operators and CI a stable, redacted JSON view of "what would
// the sandbox do for this action?".
type sandboxCheckReport struct {
Tool string `json:"tool"`
Plan zerocommands.SandboxPlanSnapshot `json:"plan"`
Decision zerocommands.SandboxDecisionSnapshot `json:"decision"`
Grant zerocommands.SandboxGrantMatchSnapshot `json:"grant"`
}
func runSandboxCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseSandboxCheckArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeSandboxCheckHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if strings.TrimSpace(options.tool) == "" {
return writeExecUsageError(stderr, "tool name is required: zero sandbox check <tool> [flags]")
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
store, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
plan := backend.BuildPlan(workspaceRoot, policy)
sideEffect, err := parseSandboxCheckSideEffect(options.sideEffect)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
autonomy, err := parseSandboxCheckAutonomy(options.autonomy)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
requestArgs := map[string]any{}
scopeAbs := workspaceRoot
if path := strings.TrimSpace(options.path); path != "" {
requestArgs["path"] = path
if filepath.IsAbs(path) {
scopeAbs = filepath.Clean(path)
} else {
scopeAbs = filepath.Join(workspaceRoot, path)
}
}
// Build the engine with the SAME write scope the real entrypoints use
// (workspace root + configured additional write roots) so the check's
// decision matches actual enforcement and a stale extra-root config surfaces
// here too, instead of silently falling back to a workspace-only scope.
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: policy,
Store: store,
Backend: backend,
Scope: scope,
})
ctx, stop := signalContext()
defer stop()
decision := engine.Evaluate(ctx, zeroSandbox.Request{
WorkspaceRoot: workspaceRoot,
ToolName: options.tool,
SideEffect: sideEffect,
PermissionMode: zeroSandbox.PermissionModeAsk,
Autonomy: autonomy,
Args: requestArgs,
Reason: strings.TrimSpace(options.reason),
})
lookup, err := store.Lookup(options.tool, scopeAbs, autonomy)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
report := sandboxCheckReport{
Tool: strings.TrimSpace(options.tool),
Plan: zerocommands.SandboxPlanSnapshotFromPlan(plan),
Decision: zerocommands.SandboxDecisionSnapshotFromDecision(decision),
Grant: zerocommands.SandboxGrantMatchSnapshotFromLookup(options.tool, lookup),
}
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, formatSandboxCheckReport(report)); err != nil {
return exitCrash
}
return exitSuccess
}
func parseSandboxCheckArgs(args []string) (sandboxCheckOptions, bool, error) {
options := sandboxCheckOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--side-effect" || arg == "--path" || arg == "--autonomy" || arg == "--reason":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
assignSandboxCheckFlag(&options, arg, value)
index = next
case strings.HasPrefix(arg, "--side-effect="):
value, err := requiredInlineFlagValue(arg, "--side-effect")
if err != nil {
return options, false, err
}
options.sideEffect = value
case strings.HasPrefix(arg, "--path="):
value, err := requiredInlineFlagValue(arg, "--path")
if err != nil {
return options, false, err
}
options.path = value
case strings.HasPrefix(arg, "--autonomy="):
value, err := requiredInlineFlagValue(arg, "--autonomy")
if err != nil {
return options, false, err
}
options.autonomy = value
case strings.HasPrefix(arg, "--reason="):
value, err := requiredInlineFlagValue(arg, "--reason")
if err != nil {
return options, false, err
}
options.reason = value
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.tool != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.tool = arg
}
}
return options, false, nil
}
func assignSandboxCheckFlag(options *sandboxCheckOptions, flag string, value string) {
switch flag {
case "--side-effect":
options.sideEffect = value
case "--path":
options.path = value
case "--autonomy":
options.autonomy = value
case "--reason":
options.reason = value
}
}
// parseSandboxCheckSideEffect validates --side-effect against the closed set the
// help advertises (empty defaults to read), so a typo returns a usage error
// instead of being passed to the engine as an unknown side effect.
func parseSandboxCheckSideEffect(value string) (zeroSandbox.SideEffect, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "read":
return zeroSandbox.SideEffectRead, nil
case "write":
return zeroSandbox.SideEffectWrite, nil
case "shell":
return zeroSandbox.SideEffectShell, nil
case "network":
return zeroSandbox.SideEffectNetwork, nil
case "none":
return zeroSandbox.SideEffectNone, nil
default:
return "", execUsageError{fmt.Sprintf("invalid --side-effect %q: expected read, write, shell, network, or none", value)}
}
}
// parseSandboxCheckAutonomy validates --autonomy against the closed set the help
// advertises (empty defaults to high), so a typo returns a usage error instead
// of the engine silently clamping an unknown tier.
func parseSandboxCheckAutonomy(value string) (zeroSandbox.Autonomy, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
return zeroSandbox.AutonomyHigh, nil
case string(zeroSandbox.AutonomyLow):
return zeroSandbox.AutonomyLow, nil
case string(zeroSandbox.AutonomyMedium):
return zeroSandbox.AutonomyMedium, nil
case string(zeroSandbox.AutonomyHigh):
return zeroSandbox.AutonomyHigh, nil
default:
return "", execUsageError{fmt.Sprintf("invalid --autonomy %q: expected low, medium, or high", value)}
}
}
func formatSandboxCheckReport(report sandboxCheckReport) string {
lines := []string{
"Sandbox check: " + report.Tool,
"decision: " + report.Decision.Action,
}
if reason := strings.TrimSpace(report.Decision.Reason); reason != "" {
lines = append(lines, "reason: "+reason)
}
risk := report.Decision.Risk
riskLine := "risk: " + risk.Level
if len(risk.Categories) > 0 {
riskLine += " [" + strings.Join(risk.Categories, ", ") + "]"
}
lines = append(lines, riskLine)
if violation := report.Decision.Violation; violation != nil {
line := "violation: [" + violation.Code + "] " + violation.Reason
if violation.Path != "" {
line += " (path: " + violation.Path + ")"
}
lines = append(lines, line)
}
if report.Grant.Matched && report.Grant.Grant != nil {
lines = append(lines, "grant: matched "+report.Grant.Grant.Decision+" (maxAutonomy "+report.Grant.Grant.MaxAutonomy+")")
} else {
lines = append(lines, "grant: no grant matched this action")
}
lines = append(lines,
"policy: mode="+report.Plan.Policy.EffectiveMode+" network="+report.Plan.Policy.Network+fmt.Sprintf(" enforceWorkspace=%t", report.Plan.Policy.EnforceWorkspace),
"backend: "+report.Plan.Backend.Name+fmt.Sprintf(" (available=%t)", report.Plan.Backend.Available),
)
if len(report.Plan.Restrictions) > 0 {
lines = append(lines, "restrictions:")
for _, restriction := range report.Plan.Restrictions {
lines = append(lines, " - "+restriction)
}
}
return strings.Join(lines, "\n")
}
func writeSandboxCheckHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero sandbox check <tool> [flags]
Evaluates the sandbox decision for a hypothetical tool action against the
resolved policy, and prints the plan, the decision (allow/prompt/deny with risk
and any violation), and any persistent grant that matches the tool. Secrets in
grant reasons are redacted.
Flags:
--side-effect <kind> read | write | shell | network | none (default read)
--path <path> Target path for the action (read/write checks)
--autonomy <level> low | medium | high (default high)
--reason <text> Reason recorded with the request
--json Print the machine-readable snapshot
-h, --help Show this help
`)
return err
}
+187
View File
@@ -0,0 +1,187 @@
package cli
import (
"bytes"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
func sandboxCheckDeps(t *testing.T) (appDeps, string) {
t.Helper()
store := newSandboxTestStore(t)
root := t.TempDir()
return appDeps{
getwd: func() (string, error) { return root, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendPolicyOnly, Platform: "windows", Fallback: true}
},
}, root
}
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")
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
}
var payload struct {
Tool string `json:"tool"`
Plan struct {
Policy struct {
EffectiveMode string `json:"effectiveMode"`
} `json:"policy"`
Backend struct {
Name string `json:"name"`
} `json:"backend"`
} `json:"plan"`
Decision struct {
Action string `json:"action"`
Risk struct {
Level string `json:"level"`
} `json:"risk"`
Violation *struct {
Code string `json:"code"`
} `json:"violation"`
} `json:"decision"`
Grant struct {
ToolName string `json:"toolName"`
Matched bool `json:"matched"`
} `json:"grant"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
}
if payload.Tool != "write_file" {
t.Fatalf("tool = %q", payload.Tool)
}
if payload.Plan.Policy.EffectiveMode != string(sandbox.ModeEnforce) {
t.Fatalf("effectiveMode = %q, want enforce", payload.Plan.Policy.EffectiveMode)
}
if payload.Plan.Backend.Name != string(sandbox.BackendPolicyOnly) {
t.Fatalf("backend = %q", payload.Plan.Backend.Name)
}
if payload.Decision.Action != string(sandbox.ActionDeny) {
t.Fatalf("expected deny for out-of-workspace write, got %q\n%s", payload.Decision.Action, stdout.String())
}
if payload.Decision.Violation == nil {
t.Fatalf("expected a violation for the out-of-workspace write")
}
if payload.Grant.ToolName != "write_file" || payload.Grant.Matched {
t.Fatalf("expected an unmatched grant for write_file, got %#v", payload.Grant)
}
}
func TestRunSandboxCheckTextRendersDecisionAndPlan(t *testing.T) {
deps, _ := sandboxCheckDeps(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--side-effect", "read", "--path", "notes.txt"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
}
out := stdout.String()
for _, want := range []string{
"Sandbox check: read_file",
"decision: ",
"risk: ",
"policy: mode=enforce",
"backend: policy-only",
"grant: no grant matched this action",
} {
if !strings.Contains(out, want) {
t.Fatalf("check text missing %q:\n%s", want, out)
}
}
}
func TestRunSandboxCheckRequiresTool(t *testing.T) {
deps, _ := sandboxCheckDeps(t)
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "check"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatalf("expected a usage error when no tool is given, got success: %s", stdout.String())
}
if !strings.Contains(stderr.String(), "tool name is required") {
t.Fatalf("expected 'tool name is required', got %q", stderr.String())
}
}
func TestRunSandboxCheckRejectsInvalidFlags(t *testing.T) {
deps, _ := sandboxCheckDeps(t)
for _, args := range [][]string{
{"sandbox", "check", "read_file", "--side-effect", "wrtie"},
{"sandbox", "check", "read_file", "--autonomy", "supreme"},
} {
var stdout, stderr bytes.Buffer
if code := runWithDeps(args, &stdout, &stderr, deps); code == exitSuccess {
t.Fatalf("args %v: expected a usage error, got success: %s", args, stdout.String())
}
if !strings.Contains(stderr.String(), "invalid --") {
t.Fatalf("args %v: expected an 'invalid --…' usage error, got %q", args, stderr.String())
}
}
}
func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) {
store := newSandboxTestStore(t)
root := t.TempDir()
deps := appDeps{
getwd: func() (string, error) { return root, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendPolicyOnly}
},
}
// Seed a tool-wide allow grant whose reason embeds a secret; the matched-grant
// snapshot must redact it.
if _, err := store.Grant(sandbox.GrantInput{
ToolName: "read_file",
Decision: sandbox.GrantAllow,
MaxAutonomy: sandbox.AutonomyHigh,
Reason: "approved with sk-test-secret1234567890",
}); err != nil {
t.Fatalf("seed grant: %v", err)
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--autonomy", "high", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), "sk-test-secret1234567890") {
t.Fatalf("grant reason leaked a secret into the snapshot:\n%s", stdout.String())
}
var payload struct {
Grant struct {
Matched bool `json:"matched"`
Grant *struct {
Reason string `json:"reason"`
} `json:"grant"`
} `json:"grant"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
}
if !payload.Grant.Matched || payload.Grant.Grant == nil {
t.Fatalf("expected a matched grant, got %#v", payload.Grant)
}
if !strings.Contains(payload.Grant.Grant.Reason, "REDACTED") {
t.Fatalf("expected the reason to be redacted, got %q", payload.Grant.Grant.Reason)
}
}
+232 -17
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
@@ -103,6 +105,41 @@ func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
}
}
func TestRunSandboxGrantsListIncludesCommandPrefixes(t *testing.T) {
store := newSandboxTestStore(t)
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}, Reason: "status checks"}); err != nil {
t.Fatalf("GrantCommandPrefix: %v", err)
}
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("list json exit = %d, stderr %q", exitCode, stderr.String())
}
var payload struct {
CommandPrefixes []sandbox.CommandPrefixGrant `json:"commandPrefixes"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
}
if len(payload.CommandPrefixes) != 1 || !reflect.DeepEqual(payload.CommandPrefixes[0].Prefix, []string{"git", "status"}) {
t.Fatalf("unexpected commandPrefixes payload: %#v", payload.CommandPrefixes)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"sandbox", "grants", "list"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("list text exit = %d, stderr %q", exitCode, stderr.String())
}
for _, want := range []string{"bash", "`git status`", "command-prefix", "status checks"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("list text = %q, missing %q", stdout.String(), want)
}
}
}
func TestRunSandboxGrantsCreateAndRevokeByPath(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
@@ -218,7 +255,7 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
Name: sandbox.BackendPolicyOnly,
Platform: "windows",
Fallback: true,
Message: "policy-only fallback: Windows native sandbox adapter is not implemented",
Message: "policy-only fallback: Windows sandbox command runner is not available",
}
},
}
@@ -242,10 +279,14 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
Policy sandbox.Policy `json:"policy"`
Backend sandbox.Backend `json:"backend"`
Plan struct {
SupportLevel string `json:"supportLevel"`
Capabilities []sandbox.BackendCapability `json:"capabilities"`
Restrictions []string `json:"restrictions"`
Warnings []string `json:"warnings"`
TargetBackend sandbox.BackendName `json:"targetBackend"`
CommandWrapped bool `json:"commandWrapped"`
EnforcementLevel sandbox.EnforcementLevel `json:"enforcementLevel"`
DowngradeReason string `json:"downgradeReason"`
SupportLevel string `json:"supportLevel"`
Capabilities []sandbox.BackendCapability `json:"capabilities"`
Restrictions []string `json:"restrictions"`
Warnings []string `json:"warnings"`
} `json:"plan"`
Grants string `json:"grantsPath"`
}
@@ -261,13 +302,16 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) {
t.Fatalf("support level = %q, want policy-only", payload.Plan.SupportLevel)
}
if payload.Plan.TargetBackend != sandbox.BackendWindowsRestrictedToken || payload.Plan.CommandWrapped || payload.Plan.EnforcementLevel != sandbox.EnforcementDegraded || payload.Plan.DowngradeReason == "" {
t.Fatalf("unexpected manager baseline fields: %#v", payload.Plan)
}
if sandboxPolicyCapabilityStatus(payload.Plan.Capabilities, "native_process_isolation") != sandbox.CapabilityUnavailable {
t.Fatalf("expected native isolation unavailable, got %#v", payload.Plan.Capabilities)
}
if !sandboxPolicyRestrictionContains(payload.Plan.Restrictions, "native process isolation unavailable on windows") {
t.Fatalf("expected JSON plan to document Windows fallback, got %#v", payload.Plan.Restrictions)
}
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows native sandbox adapter is not implemented") {
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows sandbox command runner is not available") {
t.Fatalf("expected JSON warnings to document Windows fallback, got %#v", payload.Plan.Warnings)
}
} else {
@@ -275,12 +319,16 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
for _, want := range []string{
"Zero sandbox policy",
"backend: policy-only",
"target_backend: windows-restricted-token",
"support_level: policy-only",
"command_wrapped: false",
"enforcement_level: degraded",
"downgrade_reason: policy-only fallback: Windows sandbox command runner is not available",
"backend_fallback: true",
"backend_command_wrapping: false",
"backend_native_isolation: false",
"backend_platform: windows",
"Windows native sandbox adapter is not implemented",
"Windows sandbox command runner is not available",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected policy text to contain %q, got %q", want, output)
@@ -291,6 +339,173 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
}
}
func TestRunSandboxSetupRunsWindowsHelper(t *testing.T) {
workspace := t.TempDir()
runnerDir := t.TempDir()
runnerPath := filepath.Join(runnerDir, sandbox.WindowsSandboxCommandRunnerName)
var gotPath string
var gotArgs []string
deps := appDeps{
getwd: func() (string, error) { return workspace, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
Sandbox: config.SandboxConfig{Network: "allow"},
}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendWindowsRestrictedToken,
Available: true,
Platform: "windows",
Executable: runnerPath,
CommandWrapping: true,
NativeIsolation: true,
}
},
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
gotPath = path
gotArgs = append([]string{}, args...)
return nil
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "setup", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
}
wantPath := filepath.Join(runnerDir, sandbox.WindowsSandboxSetupName)
if gotPath != wantPath {
t.Fatalf("setup helper path = %q, want %q", gotPath, wantPath)
}
config, err := sandbox.ParseWindowsSandboxSetupArgs(gotArgs)
if err != nil {
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
}
if config.CommandCWD != workspace || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != workspace {
t.Fatalf("setup args config = %#v, want workspace cwd/root", config)
}
if config.PermissionProfile.Network.Mode != sandbox.NetworkAllow {
t.Fatalf("setup network profile = %#v, want allow", config.PermissionProfile.Network)
}
var payload sandboxSetupResult
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode setup JSON: %v\n%s", err, stdout.String())
}
if !payload.Ran || payload.Helper != wantPath || payload.Backend != sandbox.BackendWindowsRestrictedToken {
t.Fatalf("setup payload = %#v, want ran Windows helper", payload)
}
}
func TestRunSandboxSetupNoopsForNonWindowsBackend(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}
},
runSandboxSetupHelper: func(string, []string, io.Writer, io.Writer) error {
t.Fatal("setup helper should not run for non-Windows backend")
return nil
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "setup"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "No native sandbox setup is required for linux") {
t.Fatalf("setup output = %q, want non-Windows no-op message", stdout.String())
}
}
func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}, appDeps{}); got != nil {
t.Fatal("linux backend should not enable the TUI sandbox setup command")
}
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendPolicyOnly, Platform: "windows", Available: false}, appDeps{}); got != nil {
t.Fatal("policy-only Windows backend should not enable the TUI sandbox setup command")
}
got := tuiSandboxSetupCommand(sandbox.Backend{
Name: sandbox.BackendWindowsRestrictedToken,
Platform: "windows",
Available: true,
Executable: filepath.Join(t.TempDir(), sandbox.WindowsSandboxCommandRunnerName),
CommandWrapping: true,
NativeIsolation: true,
}, appDeps{})
if got == nil {
t.Fatal("native Windows backend should enable the TUI sandbox setup command")
}
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
getwd: func() (string, error) { return workspace, nil },
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, nil
},
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
return sandbox.Backend{
Name: sandbox.BackendPolicyOnly,
Platform: "windows",
Fallback: true,
Message: "policy-only fallback: Windows sandbox command runner is not available",
}
},
}
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_policy_only.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)
}
}
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
t.Helper()
var wantValue any
if err := json.Unmarshal(wantBytes, &wantValue); err != nil {
t.Fatalf("decode wanted JSON: %v", err)
}
var gotValue any
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
}
return reflect.DeepEqual(wantValue, gotValue)
}
func replacePathToken(value string, path string, token string) string {
replace := func(value string, path string) string {
value = strings.ReplaceAll(value, path, token)
if encoded, err := json.Marshal(path); err == nil && len(encoded) >= 2 {
value = strings.ReplaceAll(value, string(encoded[1:len(encoded)-1]), token)
}
return value
}
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
value = replace(value, resolved)
}
return replace(value, path)
}
func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
store := newSandboxTestStore(t)
deps := appDeps{
@@ -641,24 +856,24 @@ func TestApplyConfiguredAutonomyCeiling(t *testing.T) {
}
}
func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
func TestApplyConfiguredSandboxPolicyDiagnosticsFlags(t *testing.T) {
base := sandbox.DefaultPolicy()
if base.BlockUnixSockets || base.MonitorDenials {
t.Fatalf("precondition: hardening flags must default off, got block=%v monitor=%v", base.BlockUnixSockets, base.MonitorDenials)
t.Fatalf("precondition: diagnostic flags must default off")
}
// Omitted keys leave the (off) defaults untouched.
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.BlockUnixSockets || got.MonitorDenials {
t.Fatalf("empty config must not enable hardening flags, got block=%v monitor=%v", got.BlockUnixSockets, got.MonitorDenials)
t.Fatalf("empty config must not enable diagnostic flags: %#v", got)
}
// Each flag opts in independently.
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{BlockUnixSockets: true, MonitorDenials: true})
if !got.BlockUnixSockets {
t.Fatal("BlockUnixSockets config not applied to policy")
}
if !got.MonitorDenials {
t.Fatal("MonitorDenials config not applied to policy")
// The flags opt in.
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{
BlockUnixSockets: true,
MonitorDenials: true,
})
if !got.BlockUnixSockets || !got.MonitorDenials {
t.Fatalf("diagnostic config not applied to policy: %#v", got)
}
}
+29
View File
@@ -0,0 +1,29 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// The central config->policy translation must carry scoped mode + the egress
// allow/deny lists into the engine policy (this path feeds exec, the TUI app,
// and the sandbox CLI commands).
func TestApplyConfiguredSandboxPolicyWiresScopedNetwork(t *testing.T) {
cfg := config.SandboxConfig{
Network: "scoped",
NetworkAllowedDomains: []string{"github.com", "pypi.org"},
NetworkDeniedDomains: []string{"evil.example"},
}
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
if policy.Network != sandbox.NetworkScoped {
t.Fatalf("Network = %q, want scoped", policy.Network)
}
if len(policy.AllowedDomains) != 2 || policy.AllowedDomains[0] != "github.com" {
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
}
if len(policy.DeniedDomains) != 1 || policy.DeniedDomains[0] != "evil.example" {
t.Fatalf("DeniedDomains = %#v", policy.DeniedDomains)
}
}
+104 -2
View File
@@ -3,11 +3,13 @@ package cli
import (
"fmt"
"io"
"net/url"
"strconv"
"strings"
"unicode"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/provideronboarding"
@@ -285,8 +287,108 @@ func setupRequired(resolved config.ResolvedConfig) bool {
if !config.HasProviderProfile(resolved.Provider) {
return true
}
_, missing := setupMissingCredentialEnv(resolved.Provider)
return missing
if _, missing := setupMissingCredentialEnv(resolved.Provider); !missing {
return false
}
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// though the profile has no inline key / env var — so a logged-in provider
// must not trigger onboarding.
return !providerHasOAuthLogin(resolved.Provider, oauthLoggedInProviders())
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider, keyed by its profile name or catalog id.
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
return true
}
}
return false
}
// oauthLoggedInProviders returns the set of provider names that have a stored
// OAuth token, so credential checks recognize an OAuth login (not just inline
// keys / env vars). Errors degrade to an empty set (no logins).
func oauthLoggedInProviders() map[string]bool {
out := map[string]bool{}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return out
}
statuses, err := store.Status(oauth.KeyPrefixProvider)
if err != nil {
return out
}
for _, status := range statuses {
if status.HasToken {
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
if !config.HasProviderProfile(profile) {
continue
}
// A profile whose catalog entry no longer resolves AND that has no explicit
// BaseURL has no endpoint to talk to, so it cannot become a working
// provider — skip it rather than picking a fallback that fails at first use.
// (A stale CatalogID with a BaseURL still works as a custom endpoint.)
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" && strings.TrimSpace(profile.BaseURL) == "" {
if _, err := providercatalog.Require(catalogID); err != nil {
continue
}
}
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
if !haveLocal {
localFallback = profile
haveLocal = true
}
continue
}
return profile, true
}
if haveLocal {
return localFallback, true
}
return config.ProviderProfile{}, false
}
// 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.
func providerProfileIsLocal(profile config.ProviderProfile) bool {
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
if descriptor, err := providercatalog.Require(catalogID); err == nil && descriptor.Local {
return true
}
}
base := strings.TrimSpace(profile.BaseURL)
if base == "" {
return false
}
parsed, err := url.Parse(base)
if err != nil {
return false
}
switch strings.ToLower(parsed.Hostname()) {
case "localhost", "127.0.0.1", "::1":
return true
}
return false
}
func formatSetupComplete(result tui.SetupResult) string {
+91
View File
@@ -0,0 +1,91 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable (env only, no inline key)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "moonshot" {
t.Fatalf("want remote keyed provider (moonshot), got %q ok=%v", got.Name, ok)
}
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "ollama" {
t.Fatalf("want local usable fallback (ollama), got %q ok=%v", got.Name, ok)
}
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
}
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("no provider has a credential, want ok=false, got %q", got.Name)
}
}
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want keyless local proxy fallback, got %q ok=%v", got.Name, ok)
}
}
// A profile whose catalog entry no longer resolves and that carries no explicit
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "custom" {
t.Fatalf("want custom-endpoint fallback, got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
baseURL string
want bool
}{
{"loopback name", "http://localhost:11434/v1", true},
{"loopback v4", "http://127.0.0.1:8080", true},
{"loopback v6", "http://[::1]:10531/v1", true},
{"remote", "https://api.moonshot.ai/v1", false},
{"contains-localhost-substring", "https://notlocalhost.com/v1", false},
{"host-with-127-substring", "https://api127.0.0.1.example.com", false},
{"empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := providerProfileIsLocal(config.ProviderProfile{BaseURL: tc.baseURL})
if got != tc.want {
t.Fatalf("providerProfileIsLocal(%q) = %v, want %v", tc.baseURL, got, tc.want)
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/specialist"
)
// TestSpecialistSummariesIncludesBuiltins confirms the orchestrator delegation
// prompt is fed the built-in specialists (the wiring behind auto-delegation).
func TestSpecialistSummariesIncludesBuiltins(t *testing.T) {
paths, err := specialist.DefaultPaths(t.TempDir())
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
descByName := map[string]string{}
for _, info := range specialistSummaries(paths) {
descByName[info.Name] = info.WhenToUse
}
for _, name := range []string{"worker", "explorer", "code-review"} {
if descByName[name] == "" {
t.Fatalf("specialistSummaries missing built-in %q (or empty description); got %v", name, descByName)
}
}
}
@@ -0,0 +1,112 @@
{
"policy": {
"mode": "enforce",
"network": "deny",
"enforceWorkspace": true,
"denyDestructiveShell": true,
"allowPolicyOnlyRunner": true,
"maxAutonomy": "high"
},
"backend": {
"name": "policy-only",
"available": false,
"platform": "windows",
"fallback": true,
"commandWrapping": false,
"nativeIsolation": false,
"message": "policy-only fallback: Windows sandbox command runner is not available"
},
"plan": {
"backend": {
"name": "policy-only",
"available": false,
"platform": "windows",
"fallback": true,
"commandWrapping": false,
"nativeIsolation": false,
"message": "policy-only fallback: Windows sandbox command runner is not available"
},
"targetBackend": "windows-restricted-token",
"workspaceRoot": "$WORKSPACE",
"policy": {
"mode": "enforce",
"network": "deny",
"enforceWorkspace": true,
"denyDestructiveShell": true,
"allowPolicyOnlyRunner": true,
"maxAutonomy": "high"
},
"permissionProfile": {
"fileSystem": {
"kind": "restricted",
"readRoots": [
"$WORKSPACE"
],
"writeRoots": [
{
"root": "$WORKSPACE",
"protectedMetadataNames": [
".git",
".zero",
".agents"
]
}
],
"includePlatformRoots": true,
"allowTemp": true
},
"network": {
"mode": "deny"
}
},
"commandWrapped": false,
"enforcementLevel": "degraded",
"downgradeReason": "policy-only fallback: Windows sandbox command runner is not available",
"supportLevel": "policy-only",
"requiresPlatformSandbox": true,
"capabilities": [
{
"key": "policy_evaluation",
"status": "preflight",
"detail": "tool requests are evaluated against sandbox policy before execution"
},
{
"key": "workspace_write_guard",
"status": "preflight",
"detail": "filesystem writes are checked against the workspace root before execution"
},
{
"key": "network_guard",
"status": "preflight",
"detail": "network-capable tool requests are denied before execution"
},
{
"key": "destructive_shell_guard",
"status": "preflight",
"detail": "destructive shell patterns are denied before execution"
},
{
"key": "native_process_isolation",
"status": "unavailable",
"detail": "Windows sandbox command runner is not available"
},
{
"key": "command_wrapping",
"status": "unavailable",
"detail": "shell commands are not wrapped by a native platform sandbox"
}
],
"restrictions": [
"filesystem writes must stay inside workspace",
"network access denied unless a future adapter grants it explicitly",
"destructive shell patterns denied before execution",
"native process isolation unavailable on windows; policy engine still evaluates tool requests before execution",
"shell commands are not wrapped by a native platform sandbox"
],
"warnings": [
"Windows sandbox command runner is not available; using policy-only preflight checks",
"shell commands are not wrapped by a native platform sandbox"
]
},
"grantsPath": "$GRANTS"
}
+31
View File
@@ -0,0 +1,31 @@
package config
import "strings"
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
// search and scraping work out of the box with no setup and no API key. They are
// seeded before user/project config is merged (see ResolveMCP), so a user can
// override any field — for example point firecrawl at a self-hosted instance, or
// add an API-key header to lift the free-tier limit — or disable it entirely with
// `zero mcp disable <name>` (which writes `"disabled": true`).
//
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
// use. Zero only calls it over the network, so Firecrawl's license never reaches
// into Zero's own code.
func DefaultMCPServers() map[string]MCPServerConfig {
return map[string]MCPServerConfig{
"firecrawl": {
Type: "http",
URL: "https://mcp.firecrawl.dev/v2/mcp",
},
}
}
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
// servers. The config commands use it so a default can be disabled/enabled even
// though it is not written to the user's config file until overridden.
func IsDefaultMCPServer(name string) bool {
_, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok
}
+69
View File
@@ -0,0 +1,69 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestIsDefaultMCPServer(t *testing.T) {
if !IsDefaultMCPServer("firecrawl") {
t.Fatal("firecrawl should be a built-in default")
}
if IsDefaultMCPServer(" firecrawl ") == false {
t.Fatal("IsDefaultMCPServer should trim whitespace")
}
if IsDefaultMCPServer("not-a-default") {
t.Fatal("unknown server should not be a default")
}
}
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
cfg, err := ResolveMCP(ResolveOptions{})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl, ok := cfg.Servers["firecrawl"]
if !ok {
t.Fatal("expected the firecrawl default to be seeded with no user config")
}
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
}
if firecrawl.Disabled {
t.Fatal("the firecrawl default must be enabled out of the box")
}
}
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
if !cfg.Servers["firecrawl"].Disabled {
t.Fatal("a user must be able to disable the default by writing over it")
}
}
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// Point firecrawl at a self-hosted instance; the default's Type must survive.
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.URL != "http://localhost:3002/mcp" {
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
}
if firecrawl.Type != "http" {
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
}
}
+72 -7
View File
@@ -15,7 +15,16 @@ import (
const defaultMaxTurns = 30
const defaultDeferThreshold = 10
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
// Zero collapses their full JSON schemas into compact `tool_search` reminder
// lines instead of advertising every schema on every turn. MCP tool schemas run
// 300-600 tokens each, so eagerly shipping even a small server's toolset wastes
// thousands of input tokens per message. Kept low so a typical single-server set
// (Exa-style, a handful of tools) defers and stays cheap; the only cost is one
// `tool_search` round-trip before a deferred tool's first use, after which it is
// loaded for the rest of the run. Override per-config with tools.deferThreshold
// (set 0 to always advertise every schema, e.g. for a model without tool_search).
const defaultDeferThreshold = 3
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
cfg := FileConfig{
@@ -58,6 +67,10 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
}
if cfg.Swarm.MaxTeamSize < 0 {
return ResolvedConfig{}, fmt.Errorf("invalid swarm.maxTeamSize %d: must be >= 0 (0 uses the default)", cfg.Swarm.MaxTeamSize)
}
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
// would otherwise survive Resolve untouched, reach the sandbox bridge,
@@ -70,8 +83,14 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
if network := strings.TrimSpace(cfg.Sandbox.Network); network != "" {
switch sandbox.NetworkMode(network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny:
case sandbox.NetworkScoped:
// Scoped with no allowlist would collapse to deny in the engine; reject
// it loudly so the operator isn't surprised by a silent downgrade.
if !hasNonEmptyDomain(cfg.Sandbox.NetworkAllowedDomains) {
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: scoped requires a non-empty sandbox.networkAllowedDomains", network)
}
default:
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow or deny", network)
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q: expected allow, deny, or scoped", network)
}
}
if mode := strings.TrimSpace(cfg.Notify.Mode); mode != "" {
@@ -103,12 +122,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
}, nil
}
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
cfg := FileConfig{}
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
// no-setup web search/scrape) BEFORE merging user/project config, so the user
// can override any field or disable a default by writing over it.
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
if path == "" {
@@ -154,7 +177,18 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
// Scoped egress allow/deny lists are a GRANT, layered here (global user
// config) like AdditionalWriteRoots and likewise NOT taken from project
// config (see mergeProjectConfig).
dst.Sandbox.NetworkAllowedDomains = unionStrings(dst.Sandbox.NetworkAllowedDomains, src.Sandbox.NetworkAllowedDomains)
dst.Sandbox.NetworkDeniedDomains = unionStrings(dst.Sandbox.NetworkDeniedDomains, src.Sandbox.NetworkDeniedDomains)
dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots)
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
}
if src.Sandbox.MonitorDenials {
dst.Sandbox.MonitorDenials = true
}
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
dst.Notify.Mode = mode
}
@@ -165,6 +199,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
dst.Tools.deferThresholdSet = true
}
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
if src.Preferences.FavoriteModels != nil {
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
}
@@ -185,16 +222,23 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
mergeProvider(dst, provider)
}
mergeMCPConfig(&dst.MCP, src.MCP)
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
// are the only config sources for write roots.
// Sandbox.AdditionalWriteRoots and Sandbox.NetworkAllowedDomains/
// NetworkDeniedDomains are intentionally NOT merged from project config: a
// cloned repo's .zero/config.json must not be able to grant itself write
// access outside the workspace, nor widen its own network egress allowlist.
// Global config (and CLI flags) are the only sources for those grants.
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
dst.Sandbox.MaxAutonomy = maxAutonomy
}
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
dst.Sandbox.Network = network
}
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
}
if src.Sandbox.MonitorDenials {
dst.Sandbox.MonitorDenials = true
}
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
dst.Notify.Mode = mode
}
@@ -205,6 +249,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
dst.Tools.deferThresholdSet = true
}
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
return nil
}
@@ -509,6 +556,12 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
cfg.Sandbox.MaxAutonomy = maxAutonomy
}
if overrides.Sandbox.BlockUnixSockets {
cfg.Sandbox.BlockUnixSockets = true
}
if overrides.Sandbox.MonitorDenials {
cfg.Sandbox.MonitorDenials = true
}
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
cfg.Notify.Mode = mode
}
@@ -590,6 +643,18 @@ func hasProviderFields(profile ProviderProfile) bool {
// base, preserving order. Used for additive config keys like
// sandbox.additionalWriteRoots where a later layer must not erase earlier
// grants.
// hasNonEmptyDomain reports whether domains contains at least one entry that is
// non-empty after trimming — used to reject a scoped network policy that has no
// usable allowlist.
func hasNonEmptyDomain(domains []string) bool {
for _, domain := range domains {
if strings.TrimSpace(domain) != "" {
return true
}
}
return false
}
func unionStrings(base []string, extra []string) []string {
seen := make(map[string]struct{}, len(base))
for _, value := range base {
+29
View File
@@ -1269,6 +1269,26 @@ func TestResolveSandboxMaxAutonomyFromFile(t *testing.T) {
}
}
func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
path := writeConfig(t, `{
"sandbox": {"blockUnixSockets": true},
"providers": [{
"name": "openai",
"provider": "openai",
"api_key": "sk-test",
"model": "gpt-test"
}]
}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if !resolved.Sandbox.BlockUnixSockets {
t.Fatal("resolved.Sandbox.BlockUnixSockets = false, want true")
}
}
func TestResolveSandboxMaxAutonomyPrecedence(t *testing.T) {
userPath := writeConfig(t, `{
"sandbox": {"maxAutonomy": "low"},
@@ -1417,6 +1437,15 @@ func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
}
}
// The default must stay low so a typical single MCP server's toolset defers
// instead of shipping every full schema each turn (the token-economy fix). This
// guards against a regression silently bumping it back up.
func TestDefaultDeferThresholdStaysLowForTokenEconomy(t *testing.T) {
if defaultDeferThreshold <= 0 || defaultDeferThreshold > 4 {
t.Fatalf("defaultDeferThreshold = %d, want a small positive value (<=4) so small MCP sets defer", defaultDeferThreshold)
}
}
func TestResolveDeferThresholdFromFile(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "p",
+60
View File
@@ -0,0 +1,60 @@
package config
import (
"strings"
"testing"
)
func TestResolveAcceptsScopedNetworkWithAllowlist(t *testing.T) {
userPath := writeConfig(t, `{
"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com", "pypi.org"], "networkDeniedDomains": ["evil.example"]}
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.Network != "scoped" {
t.Fatalf("Network = %q, want scoped", resolved.Sandbox.Network)
}
if len(resolved.Sandbox.NetworkAllowedDomains) != 2 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
t.Fatalf("NetworkAllowedDomains = %#v", resolved.Sandbox.NetworkAllowedDomains)
}
if len(resolved.Sandbox.NetworkDeniedDomains) != 1 {
t.Fatalf("NetworkDeniedDomains = %#v", resolved.Sandbox.NetworkDeniedDomains)
}
}
func TestResolveRejectsScopedNetworkWithoutAllowlist(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"network": "scoped"}}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err == nil || !strings.Contains(err.Error(), "scoped requires a non-empty sandbox.networkAllowedDomains") {
t.Fatalf("expected scoped-without-allowlist error, got %v", err)
}
}
func TestResolveRejectsUnknownNetworkMode(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"network": "bogus"}}`)
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
if err == nil || !strings.Contains(err.Error(), "expected allow, deny, or scoped") {
t.Fatalf("expected unknown-mode error, got %v", err)
}
}
// Security: a project config (a cloned repo) must NOT be able to add to the
// scoped egress allowlist — only the global user config can.
func TestResolveProjectCannotWidenNetworkAllowlist(t *testing.T) {
userPath := writeConfig(t, `{"sandbox": {"network": "scoped", "networkAllowedDomains": ["github.com"]}}`)
projectPath := writeConfig(t, `{"sandbox": {"networkAllowedDomains": ["exfil.example"]}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
for _, d := range resolved.Sandbox.NetworkAllowedDomains {
if d == "exfil.example" {
t.Fatalf("project config widened the egress allowlist: %#v", resolved.Sandbox.NetworkAllowedDomains)
}
}
if len(resolved.Sandbox.NetworkAllowedDomains) != 1 || resolved.Sandbox.NetworkAllowedDomains[0] != "github.com" {
t.Fatalf("allowlist = %#v, want [github.com] only", resolved.Sandbox.NetworkAllowedDomains)
}
}
+35
View File
@@ -0,0 +1,35 @@
package config
import "testing"
func TestResolveSwarmMaxTeamSize(t *testing.T) {
provider := `"activeProvider":"p","providers":[{"name":"p","provider":"openai","api_key":"sk","model_id":"m"}]`
cases := []struct {
name string
json string
wantSize int
wantError bool
}{
{"parsed from config", `{` + provider + `,"swarm":{"maxTeamSize":3}}`, 3, false},
{"unset stays 0 so the swarm applies its own default", `{` + provider + `}`, 0, false},
{"negative is rejected", `{` + provider + `,"swarm":{"maxTeamSize":-1}}`, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := writeConfig(t, tc.json)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if tc.wantError {
if err == nil {
t.Fatal("expected an error for an invalid maxTeamSize")
}
return
}
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if resolved.Swarm.MaxTeamSize != tc.wantSize {
t.Fatalf("MaxTeamSize = %d, want %d", resolved.Swarm.MaxTeamSize, tc.wantSize)
}
})
}
}
+30 -9
View File
@@ -59,10 +59,22 @@ func HasProviderProfile(profile ProviderProfile) bool {
type SandboxConfig struct {
MaxAutonomy string `json:"maxAutonomy,omitempty"`
// Network controls whether shell commands classified as network-touching
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
// Empty keeps the built-in default (deny). Without this knob the engine's
// hard-coded NetworkDeny was unreachable from any config surface.
// (curl, git push, package installs, …) are allowed: "allow", "deny", or
// "scoped". Empty keeps the built-in default (deny). "scoped" permits egress
// only to NetworkAllowedDomains (minus NetworkDeniedDomains) and REQUIRES a
// non-empty NetworkAllowedDomains; it falls closed to "deny" on a backend that
// cannot enforce scoped egress.
Network string `json:"network,omitempty"`
// NetworkAllowedDomains is the egress allowlist consulted only when Network is
// "scoped": the sandboxed process may reach only these domains (minus
// NetworkDeniedDomains) and nothing else. Ignored for "allow"/"deny". Like
// AdditionalWriteRoots this is a GRANT, so it is honored from the GLOBAL user
// config only — never project config — so a cloned repo cannot widen its own
// network egress.
NetworkAllowedDomains []string `json:"networkAllowedDomains,omitempty"`
// NetworkDeniedDomains subtracts from NetworkAllowedDomains under "scoped".
// Global-user-config only, for the same reason as NetworkAllowedDomains.
NetworkDeniedDomains []string `json:"networkDeniedDomains,omitempty"`
// AdditionalWriteRoots lists directories outside the workspace the sandbox
// allows writes in. Each entry must be an existing directory; entries are
// normalized (~-expanded, absolutized, symlink-resolved) at startup and an
@@ -70,12 +82,9 @@ type SandboxConfig struct {
// flags only — deliberately not project config, so a cloned repo cannot
// grant itself write access. Session-only grants use /add-dir instead.
AdditionalWriteRoots []string `json:"additionalWriteRoots,omitempty"`
// BlockUnixSockets, when true, installs a seccomp filter on the Linux
// (bubblewrap) backend that denies AF_UNIX socket creation in the sandboxed
// command, closing the Unix-socket channel bubblewrap's filesystem/network
// isolation leaves open. Off by default; degrades gracefully (runs without the
// filter) when the zero-seccomp helper is not installed. Ignored on other
// backends.
// BlockUnixSockets, when true, asks the Linux sandbox helper to install a
// best-effort seccomp filter that denies AF_UNIX socket creation inside the
// sandboxed command. Off by default; ignored on non-Linux backends.
BlockUnixSockets bool `json:"blockUnixSockets,omitempty"`
// MonitorDenials, when true on macOS, tails the unified log for this run's
// sandbox denials and appends them to a command's stderr so blocked operations
@@ -98,6 +107,14 @@ type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
}
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
// concurrently per team; 0 uses the built-in default (8). Spawns past the cap
// queue and launch as slots free, so lowering it bounds parallelism (and provider
// load / rate-limit pressure) without dropping work.
type SwarmConfig struct {
MaxTeamSize int `json:"maxTeamSize,omitempty"`
}
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
// threshold (including to 0, which disables deferral). Use this for programmatic
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
@@ -131,6 +148,7 @@ type FileConfig struct {
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"`
}
@@ -162,6 +180,7 @@ type ResolvedConfig struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
}
@@ -206,6 +225,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Sandbox SandboxConfig `json:"sandbox"`
Notify NotifyConfig `json:"notify"`
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
@@ -230,6 +250,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Sandbox = raw.Sandbox
cfg.Notify = raw.Notify
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
+37
View File
@@ -72,6 +72,43 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
model = strings.TrimSpace(model)
if model == "" {
return FileConfig{}, fmt.Errorf("model is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(cfg.Providers[index].Name, name) {
cfg.Providers[index].Model = model
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
+66
View File
@@ -150,6 +150,72 @@ func TestSetActiveProviderTightensExistingConfigFilePermissions(t *testing.T) {
}
}
func TestSetProviderModelUpdatesConfiguredProvider(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{
Name: "openai",
ProviderKind: ProviderKindOpenAI,
APIKey: "sk-test",
Model: "gpt-4.1",
},
{
Name: "anthropic",
ProviderKind: ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
},
},
}, 0o600)
cfg, err := SetProviderModel(path, " OpenAI ", " gpt-4.1-mini ")
if err != nil {
t.Fatalf("SetProviderModel() error = %v", err)
}
if cfg.Providers[0].Model != "gpt-4.1-mini" {
t.Fatalf("updated provider model = %q, want gpt-4.1-mini", cfg.Providers[0].Model)
}
if cfg.Providers[0].APIKey != "sk-test" {
t.Fatalf("provider credential was not preserved: %#v", cfg.Providers[0])
}
if cfg.Providers[1].Model != "claude-sonnet-4.5" {
t.Fatalf("unrelated provider changed: %#v", cfg.Providers[1])
}
persisted := readConfigFixture(t, path)
if persisted.Providers[0].Model != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", persisted.Providers[0].Model)
}
if persisted.ActiveProvider != "openai" {
t.Fatalf("active provider changed to %q", persisted.ActiveProvider)
}
}
func TestSetProviderModelRejectsUnknownProviderWithoutRewriting(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
_, err := SetProviderModel(path, "anthropic", "claude-sonnet-4.5")
if err == nil {
t.Fatal("SetProviderModel() error = nil, want error")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten for unknown provider\nbefore: %s\nafter: %s", before, after)
}
}
func TestUpsertProviderTightensExistingConfigFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose POSIX mode bits reliably")
+4 -1
View File
@@ -261,7 +261,10 @@ func estimateTextTokens(value string) int {
if value == "" {
return 0
}
tokens := len(value) / 4
// Share the agent's heuristic (non-whitespace bytes / 4) so the context
// preview matches the scale the compaction loop uses and tracks the real
// tokenizer far better than naive len/4. Non-empty text reports at least 1.
tokens := agent.ApproxTextTokens(value)
if tokens == 0 {
return 1
}
+11
View File
@@ -96,12 +96,23 @@ type noopAttestation struct{}
func (noopAttestation) Verify(map[string]string) error { return nil }
// Connection modes negotiated in the auth handshake. The default (empty) is a
// daemon session, preserving the original behavior; "bundle" requests a one-shot
// git-bundle upload instead of a session.
const (
ModeSession = "session"
ModeBundle = "bundle"
)
// authRequest is the first frame a remote client sends (before the daemon
// hello). Token is never logged.
type authRequest struct {
Token string `json:"token"`
Version int `json:"version"`
Meta map[string]string `json:"meta,omitempty"`
// Mode selects the connection's purpose: "" / "session" => a daemon session
// (default), "bundle" => a git-bundle upload. Unknown modes are rejected.
Mode string `json:"mode,omitempty"`
}
// authResponse is the bridge's reply to the auth handshake.
+44 -3
View File
@@ -16,6 +16,9 @@ const (
defaultMaxConnections = 32
defaultHandshakeTimeout = 10 * time.Second
defaultAuthFailDelay = 250 * time.Millisecond
// defaultMaxBundleBytes caps a single uploaded git bundle (256 MiB) so a
// remote peer cannot exhaust the host's disk.
defaultMaxBundleBytes = 256 << 20
)
// Bridge serves authenticated remote connections and drives the local daemon's
@@ -27,6 +30,8 @@ type Bridge struct {
minVersion int
handshakeTimeout time.Duration
authFailDelay time.Duration
bundleDir string
maxBundleBytes int64
log func(string)
sem chan struct{}
@@ -51,7 +56,13 @@ type BridgeOptions struct {
HandshakeTimeout time.Duration
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
AuthFailDelay time.Duration
Log func(string)
// BundleDir is the directory under which uploaded git bundles are extracted
// into per-link working trees. Empty disables bundle uploads entirely (a
// bundle-mode connection is then refused — opt-in, fail closed).
BundleDir string
// MaxBundleBytes caps a single uploaded bundle; 0 => default.
MaxBundleBytes int64
Log func(string)
}
// NewBridge validates options and builds a Bridge.
@@ -85,6 +96,10 @@ func NewBridge(opts BridgeOptions) (*Bridge, error) {
if attest == nil {
attest = noopAttestation{}
}
maxBundleBytes := opts.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = defaultMaxBundleBytes
}
return &Bridge{
server: opts.Server,
auth: opts.Authenticator,
@@ -92,6 +107,8 @@ func NewBridge(opts BridgeOptions) (*Bridge, error) {
minVersion: minVersion,
handshakeTimeout: handshakeTimeout,
authFailDelay: authFailDelay,
bundleDir: strings.TrimSpace(opts.BundleDir),
maxBundleBytes: maxBundleBytes,
log: opts.Log,
sem: make(chan struct{}, maxConns),
}, nil
@@ -179,13 +196,37 @@ func (b *Bridge) handle(conn net.Conn) {
b.deny(conn, "attestation failed")
return
}
// Resolve the connection mode before accepting it. Validating here (before the
// success response) lets the bridge fail closed: an unknown mode, or a bundle
// upload when bundle transfer is disabled, is denied without side effects.
mode := req.Mode
if mode == "" {
mode = ModeSession
}
switch mode {
case ModeSession:
case ModeBundle:
if b.bundleDir == "" {
b.deny(conn, "bundle transfer not enabled")
return
}
default:
b.deny(conn, "unsupported mode")
return
}
if err := writeAuthResponse(conn, authResponse{OK: true, Version: daemon.ProtoVersion}); err != nil {
_ = conn.Close()
return
}
// Clear the handshake deadline: a session may stream for a long time.
// Clear the handshake deadline: a session may stream, and a bundle upload may
// transfer many megabytes, for a long time.
_ = conn.SetDeadline(time.Time{})
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
switch mode {
case ModeBundle:
b.handleBundle(conn) // receives + extracts the bundle, then closes conn
default:
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
}
}
// deny rejects an unauthenticated connection after a small backoff (to slow
+378
View File
@@ -0,0 +1,378 @@
package remote
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/daemon"
)
// gitTimeout bounds a single git invocation (bundle create/verify, clone) so a
// hung git process cannot pin a connection or the upload path indefinitely.
const gitTimeout = 2 * time.Minute
// bundleChunkSize is the per-frame payload when streaming a bundle file. It is
// kept comfortably under daemon.MaxFrameSize (1 MiB).
const bundleChunkSize = 512 << 10
// bundleHeader is the first control frame of a bundle upload (after the auth
// handshake): it declares the link id and the exact byte size that follows.
type bundleHeader struct {
LinkID string `json:"link_id"`
Size int64 `json:"size"`
}
// bundleResult is the bridge's reply once the bundle is received and extracted.
type bundleResult struct {
OK bool `json:"ok"`
Path string `json:"path,omitempty"`
Message string `json:"message,omitempty"`
}
func writeBundleHeader(w io.Writer, h bundleHeader) error {
payload, err := json.Marshal(h)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readBundleHeader(r io.Reader) (bundleHeader, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return bundleHeader{}, err
}
if kind != daemon.KindCtrl {
return bundleHeader{}, errors.New("remote: expected bundle header frame")
}
var h bundleHeader
if err := json.Unmarshal(payload, &h); err != nil {
return bundleHeader{}, fmt.Errorf("remote: decode bundle header: %w", err)
}
return h, nil
}
func writeBundleResult(w io.Writer, res bundleResult) error {
payload, err := json.Marshal(res)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readBundleResult(r io.Reader) (bundleResult, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return bundleResult{}, err
}
if kind != daemon.KindCtrl {
return bundleResult{}, errors.New("remote: expected bundle result frame")
}
var res bundleResult
if err := json.Unmarshal(payload, &res); err != nil {
return bundleResult{}, fmt.Errorf("remote: decode bundle result: %w", err)
}
return res, nil
}
// ---- server side -----------------------------------------------------------
// handleBundle receives an uploaded bundle, extracts it into a per-link working
// tree, and reports the outcome. It always closes conn.
func (b *Bridge) handleBundle(conn net.Conn) {
defer func() { _ = conn.Close() }()
res := b.receiveBundle(conn)
if !res.OK {
b.logf("remote: bundle upload rejected: %s", res.Message)
}
_ = writeBundleResult(conn, res)
}
// receiveBundle reads the header + framed bundle bytes, verifies the bundle, and
// extracts it under bundleDir. Every failure returns a non-OK result rather than
// panicking, and the staged temp file is always removed.
func (b *Bridge) receiveBundle(conn net.Conn) bundleResult {
hdr, err := readBundleHeader(conn)
if err != nil {
return bundleResult{Message: "read bundle header: " + err.Error()}
}
id, err := sanitizeLinkID(hdr.LinkID)
if err != nil {
return bundleResult{Message: err.Error()}
}
if hdr.Size <= 0 || hdr.Size > b.maxBundleBytes {
return bundleResult{Message: fmt.Sprintf("invalid bundle size %d (max %d)", hdr.Size, b.maxBundleBytes)}
}
tmp, err := os.CreateTemp("", "zero-remote-*.bundle")
if err != nil {
return bundleResult{Message: "stage bundle: " + err.Error()}
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if err := streamFramesToFile(conn, tmp, hdr.Size); err != nil {
_ = tmp.Close()
return bundleResult{Message: "receive bundle: " + err.Error()}
}
if err := tmp.Close(); err != nil {
return bundleResult{Message: "stage bundle: " + err.Error()}
}
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
if err := gitBundleVerify(ctx, tmpName); err != nil {
return bundleResult{Message: "bundle verify: " + err.Error()}
}
dest := filepath.Join(b.bundleDir, id)
if !withinDir(b.bundleDir, dest) {
return bundleResult{Message: "invalid link id"}
}
if err := extractBundle(ctx, tmpName, dest); err != nil {
return bundleResult{Message: "extract bundle: " + err.Error()}
}
return bundleResult{OK: true, Path: dest}
}
// streamFramesToFile copies exactly size bytes from KindData frames on r into w.
// A non-data frame, or any frame that would overrun the declared size, is an
// error (fail closed) so a peer cannot write past the cap.
func streamFramesToFile(r io.Reader, w io.Writer, size int64) error {
remaining := size
for remaining > 0 {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return err
}
if kind != daemon.KindData {
return errors.New("expected bundle data frame")
}
if int64(len(payload)) > remaining {
return errors.New("bundle exceeds declared size")
}
if _, err := w.Write(payload); err != nil {
return err
}
remaining -= int64(len(payload))
}
return nil
}
// extractBundle clones bundleFile into a staging dir, then atomically renames it
// over dest (replacing any prior extraction for this link id). git clone needs a
// non-existent target, so the staging+rename keeps the live dest intact on error.
func extractBundle(ctx context.Context, bundleFile, dest string) error {
parent := filepath.Dir(dest)
if err := os.MkdirAll(parent, 0o700); err != nil {
return err
}
staging, err := os.MkdirTemp(parent, ".staging-*")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(staging) }()
cloneDest := filepath.Join(staging, "repo")
if err := gitClone(ctx, bundleFile, cloneDest); err != nil {
return err
}
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(cloneDest, dest)
}
// ---- client side -----------------------------------------------------------
// UploadRepoBundle creates a git bundle of repoDir's full history and uploads it
// to the remote bridge over an authenticated, bundle-mode TLS connection. The
// bridge extracts it into a per-link working tree and returns its path, captured
// in the returned SessionLink. repoDir must be a git work tree.
func UploadRepoBundle(cfg RemoteConfig, repoDir, linkID string) (*SessionLink, error) {
id, err := sanitizeLinkID(linkID)
if err != nil {
return nil, err
}
repoDir = strings.TrimSpace(repoDir)
if repoDir == "" {
return nil, errors.New("remote: repo dir is required")
}
if !isGitWorktree(repoDir) {
return nil, fmt.Errorf("remote: %s is not a git repository", repoDir)
}
// Reserve a unique temp name, then let git create the bundle fresh at it.
tmp, err := os.CreateTemp("", "zero-bundle-*.bundle")
if err != nil {
return nil, fmt.Errorf("remote: stage bundle: %w", err)
}
tmpName := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpName)
defer func() { _ = os.Remove(tmpName) }()
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
if err := gitBundleCreate(ctx, repoDir, tmpName); err != nil {
return nil, fmt.Errorf("remote: create bundle: %w", err)
}
sum, size, err := hashFile(tmpName)
if err != nil {
return nil, fmt.Errorf("remote: hash bundle: %w", err)
}
conn, err := dialAuthenticated(cfg, ModeBundle)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if err := writeBundleHeader(conn, bundleHeader{LinkID: id, Size: size}); err != nil {
return nil, fmt.Errorf("remote: send bundle header: %w", err)
}
if err := streamFileFrames(conn, tmpName); err != nil {
return nil, fmt.Errorf("remote: send bundle: %w", err)
}
res, err := readBundleResult(conn)
if err != nil {
return nil, fmt.Errorf("remote: bundle result: %w", err)
}
if !res.OK {
return nil, fmt.Errorf("remote: bundle rejected: %s", res.Message)
}
return &SessionLink{
Address: strings.TrimSpace(cfg.Address),
ServerName: strings.TrimSpace(cfg.ServerName),
CACertFile: strings.TrimSpace(cfg.CACertFile),
LinkID: id,
RemotePath: res.Path,
BundleSHA256: sum,
}, nil
}
// streamFileFrames writes the file at path to w as a sequence of KindData frames.
func streamFileFrames(w io.Writer, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
buf := make([]byte, bundleChunkSize)
for {
n, err := f.Read(buf)
if n > 0 {
if werr := daemon.WriteFrame(w, daemon.KindData, buf[:n]); werr != nil {
return werr
}
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
// ---- git + path helpers ----------------------------------------------------
func gitBundleCreate(ctx context.Context, repoDir, outFile string) error {
return runGit(ctx, repoDir, "bundle", "create", outFile, "--all")
}
func gitBundleVerify(ctx context.Context, bundleFile string) error {
return runGit(ctx, "", "bundle", "verify", bundleFile)
}
func gitClone(ctx context.Context, bundleFile, destDir string) error {
return runGit(ctx, "", "clone", "--quiet", bundleFile, destDir)
}
// isGitWorktree reports whether dir is inside a git work tree.
func isGitWorktree(dir string) bool {
cmd := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
out, err := cmd.CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) == "true"
}
// runGit runs a git subcommand, returning a concise single-line error on failure.
func runGit(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
msg := firstLine(strings.TrimSpace(string(out)))
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("git %s: %s", args[0], msg)
}
return nil
}
// sanitizeLinkID validates a link id used as a single path component under the
// bundle dir. It allows letters, digits, '-', '_', '.', forbids the traversal
// names, and caps the length — so it can never escape the bundle dir.
func sanitizeLinkID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", errors.New("remote: link id is required")
}
if len(id) > 128 {
return "", errors.New("remote: link id too long (max 128)")
}
if id == "." || id == ".." {
return "", errors.New("remote: invalid link id")
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
default:
return "", errors.New("remote: link id may only contain letters, digits, '-', '_', '.'")
}
}
return id, nil
}
// withinDir reports whether target resolves to a path inside root.
func withinDir(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// hashFile returns the hex SHA-256 and byte size of the file at path.
func hashFile(path string) (sum string, size int64, err error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer func() { _ = f.Close() }()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
+193
View File
@@ -0,0 +1,193 @@
package remote
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// initTestRepo creates a temp git work tree with one committed file and returns
// its path. It sets a deterministic identity so it does not depend on global git
// config.
func initTestRepo(t *testing.T, file, content string) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("init", "-q")
run("config", "user.email", "[email protected]")
run("config", "user.name", "t")
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
run("add", ".")
run("commit", "-q", "-m", "init")
return dir
}
func TestGitBundleRoundTrip(t *testing.T) {
repo := initTestRepo(t, "a.txt", "content")
ctx := context.Background()
bundle := filepath.Join(t.TempDir(), "x.bundle")
if err := gitBundleCreate(ctx, repo, bundle); err != nil {
t.Fatalf("create: %v", err)
}
if err := gitBundleVerify(ctx, bundle); err != nil {
t.Fatalf("verify: %v", err)
}
dest := filepath.Join(t.TempDir(), "clone")
if err := gitClone(ctx, bundle, dest); err != nil {
t.Fatalf("clone: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "a.txt")); err != nil {
t.Fatalf("cloned tree missing file: %v", err)
}
}
func TestIsGitWorktree(t *testing.T) {
repo := initTestRepo(t, "f", "x")
if !isGitWorktree(repo) {
t.Fatal("a git repo should be detected")
}
if isGitWorktree(t.TempDir()) {
t.Fatal("a plain dir should not be detected as a git repo")
}
}
func TestSanitizeLinkID(t *testing.T) {
for _, ok := range []string{"proj", "proj-1", "a_b.c", "ABC123"} {
if _, err := sanitizeLinkID(ok); err != nil {
t.Fatalf("sanitizeLinkID(%q) unexpected error: %v", ok, err)
}
}
for _, bad := range []string{"", " ", ".", "..", "a/b", "a\\b", "a b", "a$b", string(make([]byte, 200))} {
if _, err := sanitizeLinkID(bad); err == nil {
t.Fatalf("sanitizeLinkID(%q) should error", bad)
}
}
}
func TestWithinDir(t *testing.T) {
root := t.TempDir()
if !withinDir(root, filepath.Join(root, "child")) {
t.Fatal("child should be within root")
}
if withinDir(root, filepath.Dir(root)) {
t.Fatal("parent should not be within root")
}
}
func TestSessionLinkSaveLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "link.json")
link := SessionLink{Address: "host:9000", ServerName: "host", LinkID: "proj-1", RemotePath: "/bundles/proj-1", BundleSHA256: "deadbeef"}
if err := link.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("link file perm = %v, want 0600", perm)
}
}
got, err := LoadSessionLink(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if *got != link {
t.Fatalf("roundtrip mismatch: %+v != %+v", *got, link)
}
}
func TestSessionLinkValidate(t *testing.T) {
for _, bad := range []SessionLink{
{LinkID: "p", RemotePath: "/r"}, // no address
{Address: "h:1", RemotePath: "/r"}, // no link id
{Address: "h:1", LinkID: "p"}, // no remote path
} {
if err := bad.Validate(); err == nil {
t.Fatalf("Validate(%+v) should error", bad)
}
if err := bad.Save(filepath.Join(t.TempDir(), "x.json")); err == nil {
t.Fatalf("Save of invalid link %+v should error", bad)
}
}
}
func TestUploadRepoBundleRejectsNonRepo(t *testing.T) {
// A non-git dir is rejected before any dial, so the bogus address is never used.
_, err := UploadRepoBundle(RemoteConfig{Address: "127.0.0.1:1", Token: "t"}, t.TempDir(), "p")
if err == nil {
t.Fatal("a non-git dir should be rejected")
}
}
func TestBridgeBundleUploadRoundTrip(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
bundleRoot := t.TempDir()
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: bundleRoot})
repo := initTestRepo(t, "hello.txt", "hi there")
link, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1")
if err != nil {
t.Fatalf("UploadRepoBundle: %v", err)
}
wantPath := filepath.Join(bundleRoot, "proj-1")
if link.RemotePath != wantPath {
t.Fatalf("remote path = %q, want %q", link.RemotePath, wantPath)
}
if link.BundleSHA256 == "" {
t.Fatal("link should carry a bundle sha256")
}
// The extracted work tree should contain the committed file.
data, err := os.ReadFile(filepath.Join(link.RemotePath, "hello.txt"))
if err != nil {
t.Fatalf("extracted tree missing file: %v", err)
}
if string(data) != "hi there" {
t.Fatalf("extracted file content = %q", data)
}
// A second upload to the same link id replaces the prior extraction.
if _, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1"); err != nil {
t.Fatalf("re-upload: %v", err)
}
}
func TestBridgeBundleDisabledByDefault(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1}) // no BundleDir
repo := initTestRepo(t, "f", "x")
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "p")
if err == nil {
t.Fatal("bundle upload must be refused when --bundle-dir is unset")
}
}
func TestBridgeBundleRejectsBadToken(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("correct")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: t.TempDir(), AuthFailDelay: -1})
repo := initTestRepo(t, "f", "x")
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca}, repo, "p")
if err == nil {
t.Fatal("bundle upload with a bad token must be refused")
}
}
+19 -2
View File
@@ -37,6 +37,23 @@ type RemoteConfig struct {
// share one protocol. The server certificate is always verified (never
// InsecureSkipVerify).
func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
conn, err := dialAuthenticated(cfg, ModeSession)
if err != nil {
return nil, err
}
client, err := daemon.NewClientConn(conn)
if err != nil {
_ = conn.Close()
return nil, err
}
return client, nil
}
// dialAuthenticated establishes a verified TLS connection, performs the
// bearer-token auth handshake for the given mode, and returns the live conn with
// its deadline cleared (ready for the subsequent daemon handshake or bundle
// stream). The server certificate is always verified (never InsecureSkipVerify).
func dialAuthenticated(cfg RemoteConfig, mode string) (net.Conn, error) {
address := strings.TrimSpace(cfg.Address)
if address == "" {
return nil, errors.New("remote: address is required")
@@ -65,7 +82,7 @@ func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
}
// Bound the auth handshake; the daemon handshake + stream run without a deadline.
_ = conn.SetDeadline(time.Now().Add(timeout))
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion}); err != nil {
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion, Mode: mode}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("remote: send auth: %w", err)
}
@@ -79,7 +96,7 @@ func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
}
_ = conn.SetDeadline(time.Time{})
return daemon.NewClientConn(conn)
return conn, nil
}
// clientTLSConfig builds a verifying client TLS config. It never disables
+107
View File
@@ -0,0 +1,107 @@
package remote
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// SessionLink records the association between a local repo and the remote
// working tree a bundle upload produced. It carries everything needed to reach
// the linked repo again — the bridge address, TLS verification details, the link
// id, and the extracted remote path — except the bearer token, which is always
// supplied separately (never persisted to the link file).
type SessionLink struct {
// Address is host:port of the remote bridge.
Address string `json:"address"`
// ServerName overrides TLS/SNI verification; empty => host of Address.
ServerName string `json:"server_name,omitempty"`
// CACertFile is the CA trusted for the bridge cert (for a self-signed bridge).
CACertFile string `json:"ca_cert_file,omitempty"`
// LinkID is the per-link identifier (a single path component on the remote).
LinkID string `json:"link_id"`
// RemotePath is the extracted working tree on the remote; use it as --cwd for
// a remote run/attach against the linked repo.
RemotePath string `json:"remote_path"`
// BundleSHA256 is the hex SHA-256 of the uploaded bundle, for verification.
BundleSHA256 string `json:"bundle_sha256,omitempty"`
}
// Validate checks the fields required to use a link.
func (l SessionLink) Validate() error {
if strings.TrimSpace(l.Address) == "" {
return errors.New("remote: session link address is required")
}
if strings.TrimSpace(l.LinkID) == "" {
return errors.New("remote: session link id is required")
}
if strings.TrimSpace(l.RemotePath) == "" {
return errors.New("remote: session link remote path is required")
}
return nil
}
// Save writes the link to path as pretty JSON with 0600 permissions, atomically
// (write-temp-then-rename) so a reader never sees a partial file.
func (l SessionLink) Save(path string) error {
if err := l.Validate(); err != nil {
return err
}
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return atomicWriteFile(path, append(data, '\n'), 0o600)
}
// LoadSessionLink reads and validates a link file written by Save.
func LoadSessionLink(path string) (*SessionLink, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var l SessionLink
if err := json.Unmarshal(data, &l); err != nil {
return nil, fmt.Errorf("remote: decode session link: %w", err)
}
if err := l.Validate(); err != nil {
return nil, err
}
return &l, nil
}
// atomicWriteFile writes data to path via a temp file in the same directory,
// chmod-ed to perm, then renamed into place.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".link-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
cleanup = false
return nil
}
+3 -1
View File
@@ -43,6 +43,8 @@ type Options struct {
UserConfig string
ProjectConfig string
Provider config.ProviderProfile
WorkspaceRoot string
Sandbox config.SandboxConfig
Connectivity bool
ProviderHealth *providerhealth.Result
// GOOS overrides the platform used to resolve the sandbox backend. Empty
@@ -69,7 +71,7 @@ func Run(options Options) Report {
modelCheck := providerModelCheck(options.Provider)
checks = append(checks, modelCheck)
checks = append(checks, connectivityCheck(options.Provider, options.Connectivity, modelCheck.Status, options.ProviderHealth))
checks = append(checks, sandboxBackendCheck(options.GOOS, options.LookupExecutable))
checks = append(checks, sandboxBackendCheck(options.GOOS, options.LookupExecutable, options.WorkspaceRoot, options.Sandbox))
checks = append(checks, lspServersCheck(options.LookupExecutable))
report := Report{
+101 -18
View File
@@ -4,17 +4,18 @@ import (
"fmt"
"os/exec"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/sandbox"
)
// sandboxBackendCheck reports whether a native sandbox backend (bubblewrap on
// Linux, sandbox-exec on macOS) is available. A missing backend is a WARN, not a
// FAIL: ZERO still evaluates its policy engine before every tool call, it just
// loses native process isolation. The remedy line is the fix — the platform's
// install/enable command — not a restatement of the diagnosis.
func sandboxBackendCheck(goos string, lookup func(string) (string, error)) Check {
// sandboxBackendCheck reports whether the selected platform has a native
// sandbox backend ready. A missing backend is a WARN, not a FAIL: Zero still
// evaluates policy before tool calls, but shell execution is degraded to
// preflight checks instead of native process isolation.
func sandboxBackendCheck(goos string, lookup func(string) (string, error), workspaceRoot string, sandboxConfig config.SandboxConfig) Check {
if goos == "" {
goos = runtime.GOOS
}
@@ -23,34 +24,116 @@ func sandboxBackendCheck(goos string, lookup func(string) (string, error)) Check
}
backend := sandbox.SelectBackend(sandbox.BackendOptions{GOOS: goos, LookupExecutable: lookup})
if backend.Available {
if setupCheck := windowsSandboxSetupCheck(goos, backend, workspaceRoot, sandboxConfig); setupCheck != nil {
return *setupCheck
}
return check("sandbox.backend", "Sandbox backend", StatusPass, fmt.Sprintf("Native sandbox backend %s is available.", backend.Name), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
})
}
remedy := sandboxRemedy(goos)
return check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("No native sandbox backend on %s; ZERO falls back to policy-only preflight checks.", goos), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"remedy": remedy,
remedy := sandboxRemedy(goos, backend)
return check("sandbox.backend", "Sandbox backend", StatusWarn, sandboxBackendWarning(goos, backend), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
"downgradeReason": backend.DowngradeReason(sandbox.DefaultPolicy()),
"remedy": remedy,
})
}
// sandboxRemedy returns the platform-specific, actionable command to obtain a
// native sandbox backend.
func sandboxRemedy(goos string) string {
func sandboxBackendWarning(goos string, backend sandbox.Backend) string {
if backend.Message != "" {
return fmt.Sprintf("Native sandbox backend unavailable on %s: %s.", goos, backend.Message)
}
return fmt.Sprintf("Native sandbox backend unavailable on %s; shell commands use degraded policy-only preflight checks.", goos)
}
// sandboxRemedy returns the platform-specific, actionable step to obtain a
// native sandbox backend or complete required setup.
func sandboxRemedy(goos string, backend sandbox.Backend) string {
switch goos {
case "linux":
return "install bubblewrap (e.g. `apt-get install bubblewrap` or `dnf install bubblewrap`) so `bwrap` is on PATH"
return "install the Linux sandbox helper or bubblewrap so native command wrapping is available"
case "darwin":
return "sandbox-exec ships with macOS; ensure /usr/bin is on PATH so `sandbox-exec` resolves"
case "windows":
return "native sandboxing is not yet available on Windows; run inside WSL2 or a Linux container for native isolation"
if backend.Message != "" {
return "install the Windows sandbox command runner and setup helper together, then run `zero sandbox setup`"
}
return "run `zero sandbox setup` to prepare the Windows native sandbox"
default:
return "no native sandbox adapter exists for " + goos + "; run inside Linux (bubblewrap) or macOS (sandbox-exec) for native isolation"
}
}
func windowsSandboxSetupCheck(goos string, backend sandbox.Backend, workspaceRoot string, sandboxConfig config.SandboxConfig) *Check {
if goos != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken {
return nil
}
workspaceRoot = strings.TrimSpace(workspaceRoot)
if workspaceRoot == "" {
return nil
}
scope, err := sandbox.NewScope(workspaceRoot, sandboxConfig.AdditionalWriteRoots)
if err != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, "Windows sandbox setup could not be checked because configured write roots are invalid: "+err.Error(), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
"setupStatus": "invalid-config",
"remedy": "fix sandbox.additionalWriteRoots, then run `zero sandbox setup`",
})
return &result
}
sandboxHome, err := sandbox.ResolveWindowsSandboxHome(nil)
if err != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, "Windows sandbox setup could not be checked: "+err.Error(), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
"setupStatus": "unknown",
"remedy": "run `zero sandbox setup` after fixing the sandbox home path",
})
return &result
}
profile := sandbox.PermissionProfileFromPolicy(workspaceRoot, doctorSandboxPolicy(sandboxConfig), scope)
setupConfig := sandbox.WindowsSandboxSetupConfig{
SandboxHome: sandboxHome,
CommandCWD: workspaceRoot,
WorkspaceRoots: []string{workspaceRoot},
PermissionProfile: profile,
}
if err := sandbox.ValidateWindowsSandboxSetupMarker(setupConfig); err != nil {
result := check("sandbox.backend", "Sandbox backend", StatusWarn, fmt.Sprintf("Native sandbox backend %s is installed, but Windows sandbox setup is missing or out of date: %v.", backend.Name, err), map[string]any{
"backend": string(backend.Name),
"platform": goos,
"supportLevel": string(backend.SupportLevel()),
"setupStatus": "missing-or-out-of-date",
"remedy": "run `zero sandbox setup` to prepare the Windows native sandbox",
})
return &result
}
return nil
}
func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
policy := sandbox.DefaultPolicy()
switch sandbox.NetworkMode(cfg.Network) {
case sandbox.NetworkAllow, sandbox.NetworkDeny, sandbox.NetworkScoped:
policy.Network = sandbox.NetworkMode(cfg.Network)
}
if len(cfg.NetworkAllowedDomains) > 0 {
policy.AllowedDomains = append([]string(nil), cfg.NetworkAllowedDomains...)
}
if len(cfg.NetworkDeniedDomains) > 0 {
policy.DeniedDomains = append([]string(nil), cfg.NetworkDeniedDomains...)
}
policy.MonitorDenials = cfg.MonitorDenials
return policy
}
// lspServersCheck reports which language servers ZERO would use are present on
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
// those languages — so the worst status is WARN, and each missing server gets an
+72 -3
View File
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
// validProvider is a fully-formed provider profile so the report's overall OK
@@ -39,15 +40,15 @@ func TestSandboxCheckPassesWhenBackendPresent(t *testing.T) {
Now: fixedDoctorClock("2026-06-12T10:00:00Z"),
Runtime: "go",
GOOS: "linux",
LookupExecutable: stubLookup("bwrap"),
LookupExecutable: stubLookup(sandbox.LinuxSandboxHelperName, "bwrap"),
})
check := report.Check("sandbox.backend")
if check == nil || check.Status != StatusPass {
t.Fatalf("expected sandbox.backend pass, got %#v", report.Checks)
}
if !strings.Contains(strings.ToLower(check.Message), "bubblewrap") {
t.Fatalf("expected bubblewrap named in message, got %q", check.Message)
if !strings.Contains(check.Message, string(sandbox.BackendLinuxBwrap)) {
t.Fatalf("expected Linux sandbox backend named in message, got %q", check.Message)
}
}
@@ -101,6 +102,74 @@ func TestSandboxCheckRemedyIsPlatformSpecific(t *testing.T) {
}
}
func TestSandboxCheckReportsWindowsNativeSetupStates(t *testing.T) {
native := Run(Options{
Now: fixedDoctorClock("2026-06-12T10:08:00Z"),
Runtime: "go",
GOOS: "windows",
LookupExecutable: stubLookup(
sandbox.WindowsSandboxCommandRunnerName,
sandbox.WindowsSandboxSetupName,
),
}).Check("sandbox.backend")
if native == nil || native.Status != StatusPass {
t.Fatalf("expected windows native sandbox pass when helpers are present, got %#v", native)
}
if !strings.Contains(native.Message, string(sandbox.BackendWindowsRestrictedToken)) {
t.Fatalf("expected native Windows backend in message, got %q", native.Message)
}
t.Setenv("ZERO_WINDOWS_SANDBOX_HOME", t.TempDir())
needsSetup := Run(Options{
Now: fixedDoctorClock("2026-06-12T10:08:30Z"),
Runtime: "go",
GOOS: "windows",
WorkspaceRoot: t.TempDir(),
LookupExecutable: stubLookup(
sandbox.WindowsSandboxCommandRunnerName,
sandbox.WindowsSandboxSetupName,
),
}).Check("sandbox.backend")
if needsSetup == nil || needsSetup.Status != StatusWarn {
t.Fatalf("expected setup-missing warning when marker is absent, got %#v", needsSetup)
}
setupStatus, _ := needsSetup.Details["setupStatus"].(string)
setupRemedy, _ := needsSetup.Details["remedy"].(string)
if setupStatus != "missing-or-out-of-date" || !strings.Contains(setupRemedy, "zero sandbox setup") {
t.Fatalf("setup warning details = %#v, want missing/out-of-date with setup remedy", needsSetup.Details)
}
setupMissing := Run(Options{
Now: fixedDoctorClock("2026-06-12T10:09:00Z"),
Runtime: "go",
GOOS: "windows",
LookupExecutable: stubLookup(sandbox.WindowsSandboxCommandRunnerName),
}).Check("sandbox.backend")
if setupMissing == nil || setupMissing.Status != StatusWarn {
t.Fatalf("expected setup-missing warning, got %#v", setupMissing)
}
remedy, _ := setupMissing.Details["remedy"].(string)
if !strings.Contains(setupMissing.Message, "setup helper") || !strings.Contains(remedy, "zero sandbox setup") {
t.Fatalf("setup-missing check message=%q remedy=%q details=%#v", setupMissing.Message, remedy, setupMissing.Details)
}
if strings.Contains(setupMissing.Message, "not yet available") || strings.Contains(remedy, "WSL2") {
t.Fatalf("windows remedy should not claim native sandboxing is unavailable: message=%q remedy=%q", setupMissing.Message, remedy)
}
runnerMissing := Run(Options{
Now: fixedDoctorClock("2026-06-12T10:10:00Z"),
Runtime: "go",
GOOS: "windows",
LookupExecutable: stubLookup(),
}).Check("sandbox.backend")
if runnerMissing == nil || runnerMissing.Status != StatusWarn {
t.Fatalf("expected runner-missing warning, got %#v", runnerMissing)
}
if !strings.Contains(runnerMissing.Message, "command runner") {
t.Fatalf("runner-missing message = %q, want command runner guidance", runnerMissing.Message)
}
}
func TestLSPCheckWarnsForMissingServersWithRemedy(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-12T10:10:00Z"),
+19
View File
@@ -0,0 +1,19 @@
package doctor
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestDoctorSandboxPolicyWiresScopedNetwork(t *testing.T) {
cfg := config.SandboxConfig{Network: "scoped", NetworkAllowedDomains: []string{"github.com"}}
policy := doctorSandboxPolicy(cfg)
if policy.Network != sandbox.NetworkScoped {
t.Fatalf("Network = %q, want scoped", policy.Network)
}
if len(policy.AllowedDomains) != 1 || policy.AllowedDomains[0] != "github.com" {
t.Fatalf("AllowedDomains = %#v", policy.AllowedDomains)
}
}
+57 -4
View File
@@ -10,6 +10,8 @@ import (
"testing"
)
const minimalPDFTextChunkSize = 80
// buildMinimalPDF assembles a tiny, single-page PDF whose content stream draws
// the given text. It computes a real cross-reference table and trailer so a
// pure-Go PDF parser (ledongthuc/pdf) accepts it. Generating the fixture in-test
@@ -28,10 +30,11 @@ func buildMinimalPDF(text string) []byte {
startObj() // object 2: page tree
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
pageHeight := minimalPDFPageHeight(text)
startObj() // object 3: page
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 " + strconv.Itoa(pageHeight) + "] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
content := minimalPDFTextContent(text, pageHeight-92)
startObj() // object 4: content stream
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
buf.WriteString(content)
@@ -52,6 +55,56 @@ func buildMinimalPDF(text string) []byte {
return buf.Bytes()
}
func minimalPDFPageHeight(text string) int {
lines := (len(text) + minimalPDFTextChunkSize - 1) / minimalPDFTextChunkSize
if lines < 1 {
lines = 1
}
height := 184 + lines*10
if height < 792 {
return 792
}
return height
}
func minimalPDFTextContent(text string, startY int) string {
var content strings.Builder
content.WriteString("BT /F1 8 Tf 10 TL 72 ")
content.WriteString(strconv.Itoa(startY))
content.WriteString(" Td ")
for index := 0; len(text) > 0; index++ {
if index > 0 {
content.WriteString(" T* ")
}
chunk := text
if len(chunk) > minimalPDFTextChunkSize {
cut := minimalPDFTextChunkSize
for cut > 0 && !utf8RuneStart(chunk[cut]) {
cut--
}
if cut == 0 {
cut = minimalPDFTextChunkSize
}
chunk = text[:cut]
}
content.WriteString("(")
content.WriteString(escapePDFLiteral(chunk))
content.WriteString(") Tj")
text = text[len(chunk):]
}
content.WriteString(" ET")
return content.String()
}
func escapePDFLiteral(text string) string {
text = strings.ReplaceAll(text, `\`, `\\`)
text = strings.ReplaceAll(text, `(`, `\(`)
text = strings.ReplaceAll(text, `)`, `\)`)
text = strings.ReplaceAll(text, "\r", `\r`)
text = strings.ReplaceAll(text, "\n", `\n`)
return text
}
func TestIsPDF(t *testing.T) {
if !isPDF(buildMinimalPDF("hi")) {
t.Fatal("isPDF should accept real %PDF- bytes")
@@ -159,7 +212,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
// Many short lines so the *extracted text* (not the file) exceeds the cap.
var body strings.Builder
line := "The quick brown fox jumps over the lazy dog. "
for body.Len() < MaxDocumentTextBytes+4096 {
for body.Len() < MaxDocumentTextBytes+65536 {
body.WriteString(line)
}
if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil {
@@ -173,7 +226,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
t.Fatalf("capped text length %d exceeds cap %d (marker must be counted against the cap)", len(doc.Text), MaxDocumentTextBytes)
}
if !doc.Truncated {
t.Fatal("Truncated should be set when extracted text is capped")
t.Fatalf("Truncated should be set when extracted text is capped; extracted len=%d pages=%d", len(doc.Text), doc.Pages)
}
if !strings.Contains(doc.Text, documentTruncatedMarker) {
t.Fatal("truncated text should carry the truncation marker")
+225
View File
@@ -0,0 +1,225 @@
// Package keyring provides a small, dependency-free secret store backed by the
// operating system's native credential tooling: the `security` keychain CLI on
// macOS and `secret-tool` (libsecret) on Linux. It stores a single secret string
// per (service, account). Windows and other platforms report unsupported.
//
// It shells out to the OS tools rather than taking a third-party dependency. On
// macOS the secret is passed to `security` as an argument, so it is briefly
// visible to other processes via the process list; on Linux the secret is passed
// over stdin and is not exposed in the argument vector. Callers that need to keep
// a secret out of the process list on macOS should prefer the file backend.
package keyring
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
)
// commandTimeout bounds a single keyring tool invocation.
const commandTimeout = 10 * time.Second
// ErrUnsupported is returned when no OS keyring backend is available for the
// current platform.
var ErrUnsupported = errors.New("keyring: no OS keyring backend on this platform")
// runner executes name with args and optional stdin, returning stdout. It is the
// single seam tests replace to drive the platform command logic without touching
// a real keychain.
type runner func(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error)
// Keyring is an OS-native secret store.
type Keyring struct {
run runner
goos string
}
// New returns a Keyring for the current platform.
func New() *Keyring {
return &Keyring{run: execRunner, goos: runtime.GOOS}
}
// Available reports whether this platform has a supported keyring backend. The
// backing tool (`secret-tool` on Linux) must also be installed; a missing tool
// surfaces as an error from Get/Set/Delete.
func (k *Keyring) Available() bool {
switch k.goos {
case "darwin", "linux":
return true
default:
return false
}
}
// Set stores secret under (service, account), replacing any existing value.
func (k *Keyring) Set(service, account, secret string) error {
if err := validate(service, account); err != nil {
return err
}
switch k.goos {
case "darwin":
// -U updates the item if it already exists rather than failing.
_, err := k.exec(nil, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", secret)
return wrap("set", err)
case "linux":
// secret-tool reads the secret from stdin, keeping it out of the argv.
_, err := k.exec([]byte(secret), "secret-tool", "store", "--label", "zero", "service", service, "account", account)
return wrap("set", err)
default:
return ErrUnsupported
}
}
// Get returns the secret stored under (service, account). The bool is false when
// no entry exists (which is not an error).
func (k *Keyring) Get(service, account string) (string, bool, error) {
if err := validate(service, account); err != nil {
return "", false, err
}
switch k.goos {
case "darwin":
out, err := k.exec(nil, "security", "find-generic-password", "-s", service, "-a", account, "-w")
if err != nil {
if isNotFound(err, securityNotFoundExit) {
return "", false, nil
}
return "", false, wrap("get", err)
}
return strings.TrimRight(string(out), "\r\n"), true, nil
case "linux":
out, err := k.exec(nil, "secret-tool", "lookup", "service", service, "account", account)
if err != nil {
if isNotFound(err, secretToolNotFoundExit) {
return "", false, nil
}
return "", false, wrap("get", err)
}
value := strings.TrimRight(string(out), "\r\n")
if value == "" {
return "", false, nil
}
return value, true, nil
default:
return "", false, ErrUnsupported
}
}
// Delete removes the entry under (service, account), reporting whether one
// existed.
func (k *Keyring) Delete(service, account string) (bool, error) {
if err := validate(service, account); err != nil {
return false, err
}
switch k.goos {
case "darwin":
_, err := k.exec(nil, "security", "delete-generic-password", "-s", service, "-a", account)
if err != nil {
if isNotFound(err, securityNotFoundExit) {
return false, nil
}
return false, wrap("delete", err)
}
return true, nil
case "linux":
// `secret-tool clear` always exits 0, so probe existence first.
_, existed, err := k.Get(service, account)
if err != nil {
return false, err
}
if _, err := k.exec(nil, "secret-tool", "clear", "service", service, "account", account); err != nil {
return false, wrap("delete", err)
}
return existed, nil
default:
return false, ErrUnsupported
}
}
func (k *Keyring) exec(stdin []byte, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
return k.run(ctx, name, stdin, args...)
}
func execRunner(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
if len(stdin) > 0 {
cmd.Stdin = bytes.NewReader(stdin)
}
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return out.Bytes(), &runError{err: err, stderr: strings.TrimSpace(errBuf.String())}
}
return out.Bytes(), nil
}
// runError carries a tool failure with its stderr and preserves the underlying
// error for errors.As (so exit-code / not-found detection still works).
type runError struct {
err error
stderr string
}
func (e *runError) Error() string {
if e.stderr != "" {
return e.stderr
}
return e.err.Error()
}
func (e *runError) Unwrap() error { return e.err }
// Not-found exit codes for the OS tools: macOS `security` exits 44
// (errSecItemNotFound) when no matching item exists; `secret-tool` exits 1 when a
// lookup finds nothing. Any other non-zero exit is a real failure.
const (
securityNotFoundExit = 44
secretToolNotFoundExit = 1
)
// isNotFound reports whether err is a tool exit whose code is one of the given
// "no such entry" codes (as opposed to a missing binary or a genuine failure,
// which must not be masked). It matches on the ExitCode behavior (satisfied by
// *exec.ExitError) so the logic is testable without spawning a real process.
func isNotFound(err error, codes ...int) bool {
var coder interface{ ExitCode() int }
if !errors.As(err, &coder) {
return false
}
code := coder.ExitCode()
for _, c := range codes {
if code == c {
return true
}
}
return false
}
// wrap adds operation context to a tool error, leaving nil untouched.
func wrap(op string, err error) error {
if err == nil {
return nil
}
var execErr *exec.Error
if errors.As(err, &execErr) {
return fmt.Errorf("keyring: %s: %q not found (install the OS keyring tool): %w", op, execErr.Name, err)
}
return fmt.Errorf("keyring: %s: %w", op, err)
}
func validate(service, account string) error {
if strings.TrimSpace(service) == "" {
return errors.New("keyring: service is required")
}
if strings.TrimSpace(account) == "" {
return errors.New("keyring: account is required")
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package keyring
import (
"context"
"os/exec"
"strings"
"testing"
)
// fakeExit is a not-found exit error: it satisfies the ExitCode() seam used by
// isNotFound without spawning a real process.
type fakeExit struct{ code int }
func (e fakeExit) Error() string { return "exit status" }
func (e fakeExit) ExitCode() int { return e.code }
// fakeKeyring is an in-memory simulation of the OS tools driven through the
// runner seam. It records the last stdin so tests can assert the secret never
// travels via argv on Linux.
type fakeKeyring struct {
goos string
data map[string]string
lastStdin string
lastArgs []string
}
func newFake(goos string) *fakeKeyring {
return &fakeKeyring{goos: goos, data: map[string]string{}}
}
func (f *fakeKeyring) keyring() *Keyring { return &Keyring{run: f.run, goos: f.goos} }
func flagValue(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func attrValue(args []string, attr string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == attr {
return args[i+1]
}
}
return ""
}
func key(service, account string) string { return service + "\x00" + account }
func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
f.lastStdin = string(stdin)
f.lastArgs = append([]string{name}, args...)
if len(args) == 0 {
return nil, fakeExit{1}
}
switch f.goos {
case "darwin":
svc, acct := flagValue(args, "-s"), flagValue(args, "-a")
switch args[0] {
case "add-generic-password":
f.data[key(svc, acct)] = flagValue(args, "-w")
return nil, nil
case "find-generic-password":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v + "\n"), nil // security prints a trailing newline
}
return nil, fakeExit{44}
case "delete-generic-password":
if _, ok := f.data[key(svc, acct)]; ok {
delete(f.data, key(svc, acct))
return nil, nil
}
return nil, fakeExit{44}
}
case "linux":
svc, acct := attrValue(args, "service"), attrValue(args, "account")
switch args[0] {
case "store":
f.data[key(svc, acct)] = string(stdin)
return nil, nil
case "lookup":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v), nil // secret-tool prints no trailing newline
}
return nil, fakeExit{1}
case "clear":
delete(f.data, key(svc, acct))
return nil, nil
}
}
return nil, fakeExit{1}
}
func TestKeyringGetSurfacesNonNotFoundError(t *testing.T) {
// On macOS only exit 44 (errSecItemNotFound) means "no entry"; any other
// non-zero exit is a real failure that must surface, not be masked as absent.
k := &Keyring{
goos: "darwin",
run: func(_ context.Context, _ string, _ []byte, _ ...string) ([]byte, error) {
return nil, fakeExit{1}
},
}
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("a non-44 exit must surface as an error, got ok=%v err=%v", ok, err)
}
}
func TestKeyringRoundTripDarwin(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("zero", "tokens", "blob-AAA"); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
if got != "blob-AAA" {
t.Fatalf("Get = %q, want blob-AAA", got)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
if _, ok, _ := k.Get("zero", "tokens"); ok {
t.Fatal("token should be gone after delete")
}
}
func TestKeyringRoundTripLinuxUsesStdin(t *testing.T) {
f := newFake("linux")
k := f.keyring()
if err := k.Set("zero", "tokens", "blob-BBB"); err != nil {
t.Fatalf("Set: %v", err)
}
// The secret must travel via stdin, never the argument vector.
if f.lastStdin != "blob-BBB" {
t.Fatalf("secret not sent via stdin: stdin=%q", f.lastStdin)
}
for _, a := range f.lastArgs {
if strings.Contains(a, "blob-BBB") {
t.Fatalf("secret leaked into argv: %v", f.lastArgs)
}
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != "blob-BBB" {
t.Fatalf("Get = %q ok=%v err=%v", got, ok, err)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
}
func TestKeyringGetMissingIsNotError(t *testing.T) {
for _, goos := range []string{"darwin", "linux"} {
k := newFake(goos).keyring()
if _, ok, err := k.Get("zero", "absent"); err != nil || ok {
t.Fatalf("[%s] Get(absent) = ok=%v err=%v, want false/nil", goos, ok, err)
}
if existed, err := k.Delete("zero", "absent"); err != nil || existed {
t.Fatalf("[%s] Delete(absent) = existed=%v err=%v, want false/nil", goos, existed, err)
}
}
}
func TestKeyringUnsupportedPlatform(t *testing.T) {
k := &Keyring{run: newFake("windows").run, goos: "windows"}
if k.Available() {
t.Fatal("windows should report unavailable")
}
if err := k.Set("zero", "tokens", "x"); err == nil {
t.Fatal("Set on unsupported platform should error")
}
if _, _, err := k.Get("zero", "tokens"); err == nil {
t.Fatal("Get on unsupported platform should error")
}
if _, err := k.Delete("zero", "tokens"); err == nil {
t.Fatal("Delete on unsupported platform should error")
}
}
func TestKeyringValidation(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("", "a", "s"); err == nil {
t.Fatal("empty service should error")
}
if err := k.Set("svc", "", "s"); err == nil {
t.Fatal("empty account should error")
}
}
func TestKeyringMissingBinaryError(t *testing.T) {
// A missing tool surfaces as a wrapped, descriptive error (not not-found).
k := &Keyring{goos: "linux", run: func(context.Context, string, []byte, ...string) ([]byte, error) {
return nil, &exec.Error{Name: "secret-tool", Err: exec.ErrNotFound}
}}
if err := k.Set("zero", "tokens", "x"); err == nil || !strings.Contains(err.Error(), "secret-tool") {
t.Fatalf("missing-binary Set error = %v, want mention of secret-tool", err)
}
// A missing binary on Get must not be misread as not-found.
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("missing-binary Get = ok=%v err=%v, want error", ok, err)
}
}
func TestAvailable(t *testing.T) {
if !(newFake("darwin").keyring().Available()) {
t.Fatal("darwin should be available")
}
if !(newFake("linux").keyring().Available()) {
t.Fatal("linux should be available")
}
}
+132 -173
View File
@@ -6,17 +6,15 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/oauth"
)
const (
tokenStoreSchemaVersion = 1
tokenStoreLockTimeout = 5 * time.Second
tokenStoreLockRetry = 10 * time.Millisecond
)
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
// retained so migration can recognize a file it understands.
const tokenStoreSchemaVersion = 1
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
// for a single MCP server. The token fields are sensitive: they are tagged so
@@ -42,29 +40,37 @@ type TokenStatus struct {
Expired bool `json:"expired"`
}
// TokenStoreOptions configures where OAuth tokens are persisted. When FilePath
// is empty the path is resolved under the user config dir, mirroring how MCP
// permissions are stored.
// TokenStoreOptions configures the unified token store backing MCP OAuth tokens.
// FilePath overrides the store path (default: the shared oauth store path).
// LegacyPath overrides the pre-unification file migrated on construction; when
// empty it defaults to the conventional mcp-oauth-tokens.json only for the
// default (FilePath-unset) store, so an explicit FilePath never triggers an
// unexpected migration from the real user config.
type TokenStoreOptions struct {
FilePath string
Env map[string]string
Now func() time.Time
FilePath string
LegacyPath string
Env map[string]string
Now func() time.Time
}
// TokenStore persists OAuth tokens per MCP server in a 0600 JSON file.
// TokenStore persists MCP OAuth tokens in the unified oauth store
// (internal/oauth) under the "mcp:" namespace, sharing one file with provider
// logins. On construction it transparently and non-destructively migrates a
// legacy mcp-oauth-tokens.json into the unified store.
type TokenStore struct {
filePath string
now func() time.Time
mu sync.Mutex
store *oauth.Store
}
// tokenFile is the legacy on-disk format, retained only to read a
// pre-unification mcp-oauth-tokens.json during migration.
type tokenFile struct {
SchemaVersion int `json:"schemaVersion"`
Tokens map[string]StoredToken `json:"tokens"`
}
// ResolveTokenStorePath determines the on-disk location for OAuth tokens,
// honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
// ResolveTokenStorePath determines the on-disk location of the LEGACY OAuth token
// file, honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
// It is used to locate a pre-unification file for migration.
func ResolveTokenStorePath(env map[string]string) (string, error) {
override := strings.TrimSpace(envValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"))
if override != "" {
@@ -95,215 +101,168 @@ func ResolveTokenStorePath(env map[string]string) (string, error) {
return filepath.Join(configHome, "zero", "mcp-oauth-tokens.json"), nil
}
// NewTokenStore builds a file-backed token store.
// NewTokenStore builds the unified-store-backed token store and runs a one-time
// migration from a legacy file when applicable.
func NewTokenStore(options TokenStoreOptions) (*TokenStore, error) {
filePath := options.FilePath
var err error
if strings.TrimSpace(filePath) == "" {
filePath, err = ResolveTokenStorePath(options.Env)
if err != nil {
return nil, err
}
}
if !filepath.IsAbs(filePath) {
filePath, err = filepath.Abs(filePath)
if err != nil {
return nil, err
}
}
now := options.Now
if now == nil {
now = time.Now
}
return &TokenStore{filePath: filepath.Clean(filePath), now: now}, nil
unified, err := oauth.NewStore(oauth.StoreOptions{
FilePath: options.FilePath,
Env: options.Env,
Now: now,
})
if err != nil {
return nil, err
}
store := &TokenStore{store: unified}
legacyPath := strings.TrimSpace(options.LegacyPath)
if legacyPath == "" && strings.TrimSpace(options.FilePath) == "" {
// Default (production) construction: migrate from the conventional path.
legacyPath, err = ResolveTokenStorePath(options.Env)
if err != nil {
return nil, err
}
}
if legacyPath != "" {
if err := store.migrateLegacy(legacyPath); err != nil {
return nil, err
}
}
return store, nil
}
// FilePath returns the resolved token store path.
// FilePath returns the resolved unified store path.
func (store *TokenStore) FilePath() string {
return store.filePath
return store.store.FilePath()
}
// Save persists the token for a server, replacing any existing entry.
func (store *TokenStore) Save(serverName string, token StoredToken) error {
if err := ValidateServerName(serverName); err != nil {
return err
}
store.mu.Lock()
defer store.mu.Unlock()
unlock, err := store.lockStateFile()
key, err := mcpKey(serverName)
if err != nil {
return err
}
defer unlock()
state, err := store.readState()
if err != nil {
return err
}
state.Tokens[serverName] = token
return store.writeState(state)
return store.store.Save(key, storedToOAuth(token))
}
// Load returns the stored token for a server. The second return value is false
// when no token has been stored for the server.
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
if err := ValidateServerName(serverName); err != nil {
return StoredToken{}, false, err
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
key, err := mcpKey(serverName)
if err != nil {
return StoredToken{}, false, err
}
token, ok := state.Tokens[serverName]
return token, ok, nil
token, ok, err := store.store.Load(key)
if err != nil || !ok {
return StoredToken{}, ok, err
}
return tokenToStored(token), true, nil
}
// Delete removes the stored token for a server. It reports whether an entry was
// present before deletion.
func (store *TokenStore) Delete(serverName string) (bool, error) {
if err := ValidateServerName(serverName); err != nil {
return false, err
}
store.mu.Lock()
defer store.mu.Unlock()
unlock, err := store.lockStateFile()
key, err := mcpKey(serverName)
if err != nil {
return false, err
}
defer unlock()
state, err := store.readState()
if err != nil {
return false, err
}
if _, ok := state.Tokens[serverName]; !ok {
return false, nil
}
delete(state.Tokens, serverName)
if err := store.writeState(state); err != nil {
return false, err
}
return true, nil
return store.store.Delete(key)
}
// Status returns a redaction-safe summary of every stored token, sorted by
// Status returns a redaction-safe summary of every stored MCP token, sorted by
// server name. It never includes the token material.
func (store *TokenStore) Status() ([]TokenStatus, error) {
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
if err != nil {
return nil, err
}
names := make([]string, 0, len(state.Tokens))
for name := range state.Tokens {
names = append(names, name)
out := make([]TokenStatus, 0, len(statuses))
for _, s := range statuses {
out = append(out, TokenStatus{
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
HasToken: s.HasToken,
HasRefreshToken: s.HasRefreshToken,
TokenType: s.TokenType,
Scopes: s.Scopes,
ExpiresAt: s.ExpiresAt,
Expired: s.Expired,
})
}
sort.Strings(names)
now := store.now()
statuses := make([]TokenStatus, 0, len(names))
for _, name := range names {
token := state.Tokens[name]
status := TokenStatus{
ServerName: name,
HasToken: strings.TrimSpace(token.AccessToken) != "",
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
TokenType: token.TokenType,
Scopes: token.Scopes,
ExpiresAt: token.ExpiresAt,
}
if !token.ExpiresAt.IsZero() && !token.ExpiresAt.After(now) {
status.Expired = true
}
statuses = append(statuses, status)
}
return statuses, nil
return out, nil
}
func (store *TokenStore) readState() (tokenFile, error) {
data, err := os.ReadFile(store.filePath)
// migrateLegacy imports tokens from a legacy mcp-oauth-tokens.json into the
// unified store (under "mcp:" keys), then renames the legacy file to a
// ".migrated" backup. It is non-destructive and idempotent: a newer unified
// entry is never overwritten, a missing/unreadable/foreign-schema legacy file is
// left untouched, and the rename ensures it is imported at most once.
func (store *TokenStore) migrateLegacy(legacyPath string) error {
legacyPath = filepath.Clean(legacyPath)
// FilePath() is an absolute path; resolve a relative LegacyPath so the
// same-file guard can't be bypassed by a relative spelling of the same file.
if !filepath.IsAbs(legacyPath) {
abs, err := filepath.Abs(legacyPath)
if err != nil {
return err
}
legacyPath = filepath.Clean(abs)
}
if legacyPath == store.store.FilePath() {
return nil // legacy and unified resolve to the same file; nothing to migrate
}
data, err := os.ReadFile(legacyPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return emptyTokenFile(), nil
return nil
}
return tokenFile{}, err
}
var state tokenFile
if err := json.Unmarshal(data, &state); err != nil {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
}
if state.SchemaVersion != tokenStoreSchemaVersion {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: unsupported schemaVersion", store.filePath)
}
if state.Tokens == nil {
state.Tokens = map[string]StoredToken{}
}
for serverName := range state.Tokens {
if err := ValidateServerName(serverName); err != nil {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
}
}
return state, nil
}
func (store *TokenStore) writeState(state tokenFile) error {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
var legacy tokenFile
if err := json.Unmarshal(data, &legacy); err != nil {
return nil // unreadable legacy file: leave it in place, don't block startup
}
tempPath := fmt.Sprintf("%s.tmp-%d-%d", store.filePath, os.Getpid(), store.now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
return err
if legacy.SchemaVersion != tokenStoreSchemaVersion {
return nil // unknown legacy schema: leave it untouched
}
if err := os.Rename(tempPath, store.filePath); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func (store *TokenStore) lockStateFile() (func(), error) {
lockPath := store.filePath + ".lockfile"
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return nil, err
}
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
deadline := time.Now().Add(tokenStoreLockTimeout)
for {
locked, err := tryLockPermissionFile(file)
for serverName, token := range legacy.Tokens {
key, err := mcpKey(serverName)
if err != nil {
_ = file.Close()
return nil, err
continue // a name that cannot form a valid unified key is skipped
}
if locked {
return func() {
_ = unlockPermissionFile(file)
_ = file.Close()
}, nil
if _, ok, loadErr := store.store.Load(key); loadErr == nil && ok {
continue // a unified entry already exists; never overwrite
}
if time.Now().After(deadline) {
_ = file.Close()
return nil, fmt.Errorf("timed out waiting for MCP OAuth token lock at %s", lockPath)
if err := store.store.Save(key, storedToOAuth(token)); err != nil {
return err
}
time.Sleep(tokenStoreLockRetry)
}
return os.Rename(legacyPath, legacyPath+".migrated")
}
func emptyTokenFile() tokenFile {
return tokenFile{
SchemaVersion: tokenStoreSchemaVersion,
Tokens: map[string]StoredToken{},
// mcpKey builds and validates the unified store key for an MCP server token.
func mcpKey(serverName string) (string, error) {
if err := ValidateServerName(serverName); err != nil {
return "", err
}
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
// inverse of tokenToStored). MCP never sets the oauth Account field.
func storedToOAuth(s StoredToken) oauth.Token {
return oauth.Token{
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
TokenType: s.TokenType,
Scopes: s.Scopes,
ExpiresAt: s.ExpiresAt,
}
}
+95
View File
@@ -158,6 +158,101 @@ func TestTokenStoreStatusReportsPresenceWithoutToken(t *testing.T) {
}
}
func TestTokenStoreMigratesLegacyFile(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
unified := filepath.Join(dir, "oauth-tokens.json")
legacyData := `{"schemaVersion":1,"tokens":{"demo":{"access_token":"a","refresh_token":"r","token_type":"Bearer"}}}`
if err := os.WriteFile(legacy, []byte(legacyData), 0o600); err != nil {
t.Fatal(err)
}
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatalf("NewTokenStore: %v", err)
}
tok, ok, err := store.Load("demo")
if err != nil || !ok {
t.Fatalf("migrated token not loadable: ok=%v err=%v", ok, err)
}
if tok.AccessToken != "a" || tok.RefreshToken != "r" {
t.Fatalf("migrated token = %#v", tok)
}
// The unified file keys the token under the mcp: namespace.
raw, err := os.ReadFile(unified)
if err != nil {
t.Fatalf("read unified: %v", err)
}
if !contains(string(raw), "mcp:demo") {
t.Fatalf("unified file should key under mcp: namespace:\n%s", raw)
}
// The legacy file is renamed to a .migrated backup (non-destructive, one-time).
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("legacy file should be renamed away; stat err = %v", err)
}
if _, err := os.Stat(legacy + ".migrated"); err != nil {
t.Fatalf("legacy backup missing: %v", err)
}
// Idempotent: a second construction (legacy now absent) keeps the token.
store2, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatalf("NewTokenStore#2: %v", err)
}
if _, ok, _ := store2.Load("demo"); !ok {
t.Fatal("token lost after second construction")
}
}
func TestTokenStoreMigrationPreservesNewerUnified(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
unified := filepath.Join(dir, "oauth-tokens.json")
if err := os.WriteFile(legacy, []byte(`{"schemaVersion":1,"tokens":{"demo":{"access_token":"OLD"}}}`), 0o600); err != nil {
t.Fatal(err)
}
// Pre-seed the unified store with a newer token (no migration: FilePath set, no LegacyPath).
pre, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
if err != nil {
t.Fatal(err)
}
if err := pre.Save("demo", StoredToken{AccessToken: "NEW"}); err != nil {
t.Fatal(err)
}
// Migrating must not overwrite the newer unified entry.
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatal(err)
}
tok, _, _ := store.Load("demo")
if tok.AccessToken != "NEW" {
t.Fatalf("migration overwrote a newer token: %q", tok.AccessToken)
}
}
func TestTokenStoreNamespacedFromProvider(t *testing.T) {
// An MCP token and a provider login of the same name coexist in one file.
dir := t.TempDir()
unified := filepath.Join(dir, "oauth-tokens.json")
mcpStore, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
if err != nil {
t.Fatal(err)
}
if err := mcpStore.Save("shared", StoredToken{AccessToken: "mcp-token"}); err != nil {
t.Fatal(err)
}
statuses, err := mcpStore.Status()
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 || statuses[0].ServerName != "shared" {
t.Fatalf("status = %#v, want one entry for 'shared'", statuses)
}
}
func TestResolveTokenStorePathUsesXDG(t *testing.T) {
// Use a real temp dir so the base is absolute on every OS (a literal
// "/tmp/..." isn't absolute on Windows, where ResolveTokenStorePath would
+166 -34
View File
@@ -6,23 +6,58 @@ import (
"regexp"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
)
// defaultConnectTimeout bounds how long startup waits for ONE MCP server to
// connect and list its tools. A server that exceeds it is abandoned and skipped
// so a slow or unreachable server (e.g. a hosted endpoint blocked by the local
// network) cannot delay the first model response. Servers connect concurrently,
// so total startup cost is the slowest reachable server, not the sum.
const defaultConnectTimeout = 8 * time.Second
type RegisterOptions struct {
PermissionStore *PermissionStore
Autonomy PermissionAutonomy
ClientFactory func(context.Context, Server) (ToolClient, error)
// ConnectTimeout bounds the per-server connect+list at startup. Zero uses
// defaultConnectTimeout.
ConnectTimeout time.Duration
}
// SkippedServer records an MCP server that was not registered because it could
// not be reached or its tools could not be validated. Registration is
// best-effort per server: one unreachable server is skipped (and reported here)
// rather than aborting startup or disabling the others.
type SkippedServer struct {
Name string
Err error
}
type Runtime struct {
clients []ToolClient
// cancels releases the per-server connect contexts of the clients we KEPT.
// A stdio server's subprocess is tied to its context, so the context must
// stay live for the session and be cancelled only at Close (after the client
// is closed). Same length/order as clients is not required.
cancels []context.CancelFunc
skipped []SkippedServer
once sync.Once
err error
}
// Skipped returns the servers that were skipped during registration (unreachable
// or invalid), so the caller can warn the user without failing the launch.
func (runtime *Runtime) Skipped() []SkippedServer {
if runtime == nil {
return nil
}
return runtime.skipped
}
var unsafeToolNameChars = regexp.MustCompile(`[^A-Za-z0-9_]+`)
func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options RegisterOptions) (*Runtime, error) {
@@ -45,54 +80,145 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
}
}
// Registration is atomic per call: validate and stage every server's tools
// first, then commit to the caller's registry only once they all succeed. If a
// LATER server fails mid-registration, nothing was committed, so the registry
// never ends up holding dead tools that point at a (now-closed) client for an
// earlier server.
timeout := options.ConnectTimeout
if timeout <= 0 {
timeout = defaultConnectTimeout
}
// Connect every server CONCURRENTLY: connect + list-tools is network/process
// I/O, so connecting serially makes startup wait for the SUM of all servers —
// one slow or unreachable server would block every other server AND the first
// model response. Each server gets its own cancelable context bounded by the
// startup timeout; a server that does not connect + list in time is abandoned
// (its context cancelled to tear down the half-open connection/subprocess) and
// recorded as skipped. The concurrent phase does ONLY I/O and touches no shared
// state; all validation, conflict detection, and registration happen in the
// deterministic serial phase below, so the result is identical regardless of
// completion order.
type connectResult struct {
client ToolClient
remote []RemoteTool
cancel context.CancelFunc
err error
}
results := make([]connectResult, len(servers))
var wg sync.WaitGroup
for index := range servers {
wg.Add(1)
go func(index int) {
defer wg.Done()
server := servers[index]
serverCtx, cancel := context.WithCancel(ctx)
done := make(chan connectResult, 1)
go func() {
client, remote, err := connectAndList(serverCtx, factory, server)
done <- connectResult{client: client, remote: remote, err: err}
}()
select {
case res := <-done:
if res.err != nil {
cancel() // failed: nothing to keep
} else {
res.cancel = cancel // keep the context alive; released at Close
}
results[index] = res
case <-time.After(timeout):
cancel() // abandon the slow connect: tears down the conn/subprocess
// Reap the goroutine + any partial client in the background so a
// slow server never blocks startup.
go func() {
if res := <-done; res.client != nil {
_ = res.client.Close()
}
}()
results[index] = connectResult{err: fmt.Errorf("connect timed out after %s", timeout)}
}
}(index)
}
wg.Wait()
// Serial, deterministic commit in server order. Building tools reads the
// permission store and the registry, so it stays single-goroutine. A server is
// best-effort: one that failed to connect, timed out, returned a nameless tool,
// or conflicts with an already-committed tool is SKIPPED (recorded, not fatal),
// and still contributes its tools all-or-none. The conflict check spans the
// registry plus every tool committed by an earlier server.
staged := make([]registryTool, 0)
stagedNames := make(map[string]struct{})
for _, server := range servers {
client, err := factory(ctx, server)
if err != nil {
_ = runtime.Close()
return nil, err
for index, server := range servers {
res := results[index]
if res.err != nil {
runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: res.err})
continue
}
runtime.clients = append(runtime.clients, client)
remoteTools, err := client.ListTools(ctx)
if err != nil {
_ = runtime.Close()
return nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
serverTools, validateErr := buildServerTools(registry, server, res.remote, res.client, options, stagedNames)
if validateErr != nil {
if res.cancel != nil {
res.cancel()
}
_ = res.client.Close()
runtime.skipped = append(runtime.skipped, SkippedServer{Name: server.Name, Err: validateErr})
continue
}
for _, remote := range remoteTools {
if strings.TrimSpace(remote.Name) == "" {
_ = runtime.Close()
return nil, fmt.Errorf("MCP server %s returned a tool without a name", server.Name)
}
tool := newRegistryTool(server, remote, client, options)
// Conflict detection spans both the existing registry and tools staged so
// far in this call (two MCP tools whose names collapse to the same
// sanitized name conflict even though neither is in the registry yet).
if existing, ok := registry.Get(tool.Name()); ok {
_ = runtime.Close()
return nil, fmt.Errorf("MCP tool %s from %s conflicts with existing tool %s", remote.Name, server.Name, existing.Name())
}
if _, ok := stagedNames[tool.Name()]; ok {
_ = runtime.Close()
return nil, fmt.Errorf("MCP tool %s from %s conflicts with another MCP tool named %s", remote.Name, server.Name, tool.Name())
}
runtime.clients = append(runtime.clients, res.client)
if res.cancel != nil {
runtime.cancels = append(runtime.cancels, res.cancel)
}
for _, tool := range serverTools {
stagedNames[tool.Name()] = struct{}{}
staged = append(staged, tool)
}
}
// Every server succeeded — commit the staged tools to the registry.
for _, tool := range staged {
registry.Register(tool)
}
return runtime, nil
}
// connectAndList connects to one server and lists its tools. It does ONLY I/O
// (no registry, permission-store, or other shared state), so it is safe to run
// concurrently for every server. On a list error it closes the client.
func connectAndList(ctx context.Context, factory func(context.Context, Server) (ToolClient, error), server Server) (ToolClient, []RemoteTool, error) {
client, err := factory(ctx, server)
if err != nil {
return nil, nil, err
}
remoteTools, err := client.ListTools(ctx)
if err != nil {
_ = client.Close()
return nil, nil, fmt.Errorf("list MCP tools for %s: %w", server.Name, err)
}
return client, remoteTools, nil
}
// buildServerTools validates a server's remote tools against the registry and the
// names already committed by earlier servers, returning the server's tools only
// when every one is named and conflict-free. It runs in the serial commit phase
// (single goroutine), so its registry and permission-store reads are race-free.
// On error the caller closes the client (it owns the result), so this never does.
func buildServerTools(registry *tools.Registry, server Server, remoteTools []RemoteTool, client ToolClient, options RegisterOptions, stagedNames map[string]struct{}) ([]registryTool, error) {
serverTools := make([]registryTool, 0, len(remoteTools))
localNames := make(map[string]struct{})
for _, remote := range remoteTools {
if strings.TrimSpace(remote.Name) == "" {
return nil, fmt.Errorf("MCP server %s returned a tool without a name", server.Name)
}
tool := newRegistryTool(server, remote, client, options)
if existing, ok := registry.Get(tool.Name()); ok {
return nil, fmt.Errorf("MCP tool %s from %s conflicts with existing tool %s", remote.Name, server.Name, existing.Name())
}
if _, ok := stagedNames[tool.Name()]; ok {
return nil, fmt.Errorf("MCP tool %s from %s conflicts with another MCP tool named %s", remote.Name, server.Name, tool.Name())
}
if _, ok := localNames[tool.Name()]; ok {
return nil, fmt.Errorf("MCP tool %s from %s conflicts with another tool from the same server", remote.Name, server.Name)
}
localNames[tool.Name()] = struct{}{}
serverTools = append(serverTools, tool)
}
return serverTools, nil
}
func (runtime *Runtime) Close() error {
if runtime == nil {
return nil
@@ -103,6 +229,12 @@ func (runtime *Runtime) Close() error {
runtime.err = err
}
}
// Release the kept servers' connect contexts AFTER closing the clients: a
// stdio subprocess is already terminated by Close, so cancelling is then a
// no-op; it frees the context (and any tied subprocess) either way.
for _, cancel := range runtime.cancels {
cancel()
}
})
return runtime.err
}
+65 -17
View File
@@ -118,14 +118,15 @@ func TestRegisterToolsMarksPersistentlyApprovedToolsAllow(t *testing.T) {
}
}
func TestRegisterToolsRollsBackEarlierServerToolsWhenLaterServerFails(t *testing.T) {
func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
// NormalizeConfig sorts server names, so "alpha" registers before "zebra".
// "zebra" fails mid-registration; registration must be atomic, so "alpha"'s
// tools must NOT be left dangling in the caller's registry.
// "zebra" is unreachable; it must be SKIPPED (recorded in Skipped()), not fatal,
// and "alpha"'s tools must still register — one bad server can't disable the rest
// or abort startup. A server still contributes its tools all-or-none.
registry := tools.NewRegistry()
alphaClient := &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "Lookup documentation"}}}
_, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"alpha": {Type: "stdio", Command: "alpha-mcp"},
"zebra": {Type: "stdio", Command: "zebra-mcp"},
}}, RegisterOptions{
@@ -136,21 +137,29 @@ func TestRegisterToolsRollsBackEarlierServerToolsWhenLaterServerFails(t *testing
return alphaClient, nil
},
})
if err == nil {
t.Fatal("expected RegisterTools to fail when a later server fails")
if err != nil {
t.Fatalf("RegisterTools returned error, want a skip: %v", err)
}
if _, ok := registry.Get("mcp_alpha_lookup"); ok {
t.Fatal("expected earlier server's tools to be rolled back when a later server fails")
defer runtime.Close()
if _, ok := registry.Get("mcp_alpha_lookup"); !ok {
t.Fatal("expected the reachable server's tools to register when another is skipped")
}
skipped := runtime.Skipped()
if len(skipped) != 1 || skipped[0].Name != "zebra" {
t.Fatalf("Skipped() = %#v, want exactly one entry for zebra", skipped)
}
if skipped[0].Err == nil {
t.Fatal("expected the skipped server to record why it was skipped")
}
}
func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
// A tool that predates the MCP registration must survive a failed RegisterTools
// call: rollback removes only what this call added.
func TestRegisterToolsKeepsPriorStateAndReachableServersWhenOneIsSkipped(t *testing.T) {
// A tool that predates registration must survive, the reachable server's tools
// must register, and the unreachable server is skipped (recorded), not fatal.
registry := tools.NewRegistry()
registry.Register(&fakePreexistingTool{name: "preexisting"})
_, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"alpha": {Type: "stdio", Command: "alpha-mcp"},
"zebra": {Type: "stdio", Command: "zebra-mcp"},
}}, RegisterOptions{
@@ -161,14 +170,53 @@ func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
return &fakeToolClient{listed: []RemoteTool{{Name: "lookup"}}}, nil
},
})
if err == nil {
t.Fatal("expected RegisterTools to fail when a later server fails")
if err != nil {
t.Fatalf("RegisterTools returned error, want a skip: %v", err)
}
defer runtime.Close()
if _, ok := registry.Get("preexisting"); !ok {
t.Fatal("expected pre-existing tool to survive a failed MCP registration")
t.Fatal("expected the pre-existing tool to survive registration")
}
if _, ok := registry.Get("mcp_alpha_lookup"); ok {
t.Fatal("expected the failed call to add no MCP tools")
if _, ok := registry.Get("mcp_alpha_lookup"); !ok {
t.Fatal("expected the reachable server's tools to register")
}
if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "zebra" {
t.Fatalf("Skipped() = %#v, want exactly one entry for zebra", skipped)
}
}
func TestRegisterToolsSkipsServerThatExceedsConnectTimeout(t *testing.T) {
// A slow server must not block startup: it is abandoned after ConnectTimeout
// and skipped, while a fast server registers normally. This is the latency fix
// — one unreachable server can't hold up the first response.
registry := tools.NewRegistry()
fastClient := &fakeToolClient{listed: []RemoteTool{{Name: "lookup", Description: "fast"}}}
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"fast": {Type: "stdio", Command: "fast-mcp"},
"slow": {Type: "stdio", Command: "slow-mcp"},
}}, RegisterOptions{
ConnectTimeout: 50 * time.Millisecond,
ClientFactory: func(ctx context.Context, server Server) (ToolClient, error) {
if server.Name == "slow" {
// Block past the timeout, honoring ctx cancellation so the abandoned
// connect tears down cleanly instead of leaking.
<-ctx.Done()
return nil, ctx.Err()
}
return fastClient, nil
},
})
if err != nil {
t.Fatalf("RegisterTools error: %v", err)
}
defer runtime.Close()
if _, ok := registry.Get("mcp_fast_lookup"); !ok {
t.Fatal("expected the fast server's tools to register despite the slow one timing out")
}
skipped := runtime.Skipped()
if len(skipped) != 1 || skipped[0].Name != "slow" {
t.Fatalf("Skipped() = %#v, want exactly one entry for slow", skipped)
}
}
+72 -6
View File
@@ -1,15 +1,81 @@
package modelregistry
// SupportsVision reports whether the model identified by modelID is known to the
// registry and advertises the vision capability. It returns false when the model
// cannot be resolved (e.g. a custom or openai-compatible id absent from the
// catalog): an unknown model is treated as "cannot confirm", so callers drop
// images rather than send them to a model that may reject them.
import "strings"
// SupportsVision reports whether the model identified by modelID accepts image
// input. A model in the curated catalog is authoritative (its declared
// capability wins). For a model the catalog does NOT know — a custom /
// openai-compatible / ollama id that will never be in the catalog — it falls
// back to recognizing well-known multimodal families by name, so a real vision
// model still accepts images. A name outside those families stays refused (the
// previous "cannot confirm → drop" behavior), so text-only models are unaffected.
//
// modelID is resolved through the registry's normal alias/pattern matching, so
// any spelling that Get accepts works here too. This helper is the single
// capability check shared by the headless (exec) and interactive (TUI) input
// surfaces; the warn-and-drop behavior lives at those call sites.
func SupportsVision(registry Registry, modelID string) bool {
return registry.SupportsCapability(modelID, ModelCapabilityVision)
if registry.SupportsCapability(modelID, ModelCapabilityVision) {
return true
}
// The catalog knows this model and it lacks vision: trust that — never let the
// name heuristic override an authoritative "no".
if _, known := registry.Get(modelID); known {
return false
}
return visionCapableByName(modelID)
}
// visionCapableByName reports whether modelID names a known multimodal family.
// It is conservative: it matches the established vision families and leaves
// everything else (including text-only models like gpt-oss, kimi, and coder
// models) refused, so a false "supported" is unlikely. Used only as a fallback
// for models absent from the curated catalog.
func visionCapableByName(modelID string) bool {
id := strings.ToLower(strings.TrimSpace(modelID))
if slash := strings.LastIndexByte(id, '/'); slash >= 0 {
id = id[slash+1:] // drop a "provider/" prefix
}
switch {
case strings.Contains(id, "gemini"):
return true // every Gemini model is multimodal
case strings.Contains(id, "claude-3"), strings.Contains(id, "claude-4"),
strings.Contains(id, "claude-opus"), strings.Contains(id, "claude-sonnet"),
strings.Contains(id, "claude-haiku"):
return true // Claude 3+ are multimodal
case strings.Contains(id, "gpt-4o"), strings.Contains(id, "gpt-4.1"),
strings.Contains(id, "gpt-4-turbo"), strings.Contains(id, "gpt-4-vision"),
strings.Contains(id, "gpt-5"):
return true // GPT-4o / 4.1 / 4-turbo / 5 are multimodal (gpt-oss is NOT matched)
case id == "o1" || id == "o3" || strings.HasPrefix(id, "o1-") ||
strings.HasPrefix(id, "o3-") || strings.HasPrefix(id, "o4-"):
return true // OpenAI o-series accept images
case strings.Contains(id, "grok-4"),
strings.Contains(id, "grok") && mentionsVision(id):
return true // Grok 4 (and grok vision variants) are multimodal
case strings.Contains(id, "llava"), strings.Contains(id, "pixtral"),
strings.Contains(id, "internvl"), strings.Contains(id, "minicpm-v"),
strings.Contains(id, "moondream"), strings.Contains(id, "bakllava"),
strings.Contains(id, "-vl"), strings.Contains(id, "vl-"),
mentionsVision(id):
return true // common open multimodal families + *-vl / *-vision
default:
return false
}
}
// mentionsVision reports whether id advertises vision via the word "vision",
// excluding negated forms (vision-less / no-vision): a model named for LACKING
// vision must not be treated as multimodal.
func mentionsVision(id string) bool {
if !strings.Contains(id, "vision") {
return false
}
switch {
case strings.Contains(id, "vision-less"), strings.Contains(id, "visionless"),
strings.Contains(id, "no-vision"), strings.Contains(id, "non-vision"),
strings.Contains(id, "novision"), strings.Contains(id, "nonvision"):
return false
}
return true
}
@@ -0,0 +1,52 @@
package modelregistry
import "testing"
func TestVisionCapableByName(t *testing.T) {
vision := []string{
"gemini-3-flash-preview", "gemini-2.5-flash", "ollama-cloud/gemini-3-flash-preview",
"grok-4.3", "grok-4-fast", "grok-2-vision",
"gpt-4o", "gpt-4.1-mini", "gpt-4-turbo", "gpt-5", "o3-mini", "o1",
"claude-sonnet-4.5", "claude-3-haiku",
"llava:13b", "qwen2.5-vl-7b", "llama3.2-vision", "pixtral-12b", "moondream",
}
textOnly := []string{
"gpt-oss:120b", "kimi-for-coding", "deepseek-coder", "qwen2.5-coder",
"codestral", "llama3.1-8b", "grok-text-only", "mistral-large",
// Negated "vision" names must NOT match the bare-"vision" fallback.
"my-custom-vision-less-model", "no-vision-model", "grok-vision-less",
}
for _, m := range vision {
if !visionCapableByName(m) {
t.Errorf("expected %q to be recognized as vision-capable", m)
}
}
for _, m := range textOnly {
if visionCapableByName(m) {
t.Errorf("expected %q to NOT be vision-capable", m)
}
}
}
func TestSupportsVisionFallbackForUnknownModels(t *testing.T) {
reg, err := DefaultRegistry()
if err != nil {
t.Fatal(err)
}
// Catalog model: authoritative vision flag.
if !SupportsVision(reg, "gpt-4o") {
t.Error("gpt-4o should be vision (catalog)")
}
// Unknown-to-catalog but real vision models now pass via the name fallback.
for _, m := range []string{"gemini-3-flash-preview", "grok-4.3"} {
if !SupportsVision(reg, m) {
t.Errorf("%q should be vision via name fallback", m)
}
}
// Unknown text-only models stay refused.
for _, m := range []string{"gpt-oss:120b", "kimi-for-coding"} {
if SupportsVision(reg, m) {
t.Errorf("%q should NOT be vision", m)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package notify
import "strings"
// Webhook delivery is configured entirely from the environment. A webhook URL
// typically embeds a secret token, so sourcing it from the environment keeps it
// out of any on-disk config file. The sink is strictly opt-in: with
// EnvWebhookURL unset the wiring helper attaches nothing and the notifier
// behaves exactly as before.
const (
// EnvWebhookURL holds the destination webhook/Slack URL. Empty disables it.
EnvWebhookURL = "ZERO_NOTIFY_WEBHOOK_URL"
// EnvWebhookSummary is an optional one-line run summary attached to every
// payload (for example "nightly audit run").
EnvWebhookSummary = "ZERO_NOTIFY_WEBHOOK_SUMMARY"
)
// MaybeAddWebhookSink attaches a webhook sink to n when a webhook URL is present
// in the environment, and is otherwise a no-op. env resolves an environment
// variable (pass os.Getenv); logf records one redacted line per failed delivery
// (pass nil to stay silent — for example a TUI that owns the screen). It is safe
// to call unconditionally: configuration alone decides whether the sink exists.
//
// The attached sink is still subject to the notifier's Mode/focus policy, so a
// webhook only delivers when notifications are enabled (for example
// `--notify both`), matching the rest of the notification surface.
func MaybeAddWebhookSink(n *Notifier, env func(string) string, logf func(format string, args ...any)) {
if n == nil || env == nil {
return
}
url := strings.TrimSpace(env(EnvWebhookURL))
if url == "" {
return
}
n.AddSink(NewWebhookSink(WebhookConfig{
URL: url,
Summary: strings.TrimSpace(env(EnvWebhookSummary)),
Logf: logf,
}))
}
+71
View File
@@ -0,0 +1,71 @@
package notify
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// envFunc builds a deterministic env resolver from a map for the wiring tests.
func envFunc(values map[string]string) func(string) string {
return func(key string) string { return values[key] }
}
func TestMaybeAddWebhookSinkAttachesAndDelivers(t *testing.T) {
var hits int32
var gotSummary string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
var payload webhookPayload
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&payload)
gotSummary = payload.Summary
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
MaybeAddWebhookSink(n, envFunc(map[string]string{
EnvWebhookURL: server.URL,
EnvWebhookSummary: "nightly audit",
}), nil)
if got := len(n.sinks); got != 1 {
t.Fatalf("expected 1 sink attached, got %d", got)
}
n.Notify(Completion, "Zero: ready")
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("webhook hit %d times, want 1", got)
}
if gotSummary != "nightly audit" {
t.Fatalf("summary = %q, want %q", gotSummary, "nightly audit")
}
}
func TestMaybeAddWebhookSinkNoopWhenURLBlank(t *testing.T) {
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
// Unset.
MaybeAddWebhookSink(n, envFunc(nil), nil)
// Set but blank / whitespace only.
MaybeAddWebhookSink(n, envFunc(map[string]string{EnvWebhookURL: " "}), nil)
if got := len(n.sinks); got != 0 {
t.Fatalf("expected no sink attached, got %d", got)
}
}
func TestMaybeAddWebhookSinkNilGuards(t *testing.T) {
// Must not panic on a nil notifier or nil env resolver.
MaybeAddWebhookSink(nil, envFunc(map[string]string{EnvWebhookURL: "https://example.test"}), nil)
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
MaybeAddWebhookSink(n, nil, nil)
if got := len(n.sinks); got != 0 {
t.Fatalf("nil env must attach nothing, got %d sinks", got)
}
}
+189
View File
@@ -0,0 +1,189 @@
package oauth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
const (
// secretBytes is the AES-256 key length kept in the per-user secret file.
secretBytes = 32
secretRetryAttempts = 500
secretRetryDelay = 2 * time.Millisecond
)
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
// per-user random secret persisted (0600) beside the token file. The on-disk
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
// detection, so a corrupted/forged file fails closed on open. This is the
// opt-in "encrypted-file" storage backend; the default backend writes the
// 0600 plaintext JSON unchanged.
type aesGCMCrypter struct {
secretPath string
}
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
return &aesGCMCrypter{secretPath: secretPath}
}
// aead loads (or, when create is set, generates) the secret and returns the GCM
// AEAD. open passes create=false so a missing secret is a hard error rather than
// silently minting a new key that could never decrypt the existing file.
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
secret, err := loadOrCreateSecret(c.secretPath, create)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(secret)
if err != nil {
return nil, fmt.Errorf("oauth: build cipher: %w", err)
}
return cipher.NewGCM(block)
}
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
// on first use.
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
gcm, err := c.aead(true)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
// a short blob, or a failed authentication tag (tampering / wrong key).
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
gcm, err := c.aead(false)
if err != nil {
return nil, err
}
if len(blob) < gcm.NonceSize() {
return nil, errors.New("oauth: encrypted token file is too short")
}
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
}
return plaintext, nil
}
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
// the file is absent, it generates a random secret and creates the file
// atomically (0600). A wrong-sized existing secret fails closed (corruption).
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
if data, err := readSecretFileRetry(path); err == nil {
return data, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if !create {
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
return createSecretFile(path)
}
func createSecretFile(path string) ([]byte, error) {
lockPath := path + ".lock"
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_ = lock.Close()
defer os.Remove(lockPath)
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else if !errors.Is(rerr, os.ErrNotExist) {
return nil, rerr
}
return writeNewSecretFile(path)
}
if !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
if data, rerr := readSecretFileRetry(path); rerr == nil {
return data, nil
} else {
lastErr = rerr
}
time.Sleep(secretRetryDelay)
}
if lastErr != nil {
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret lock %s", lockPath)
}
func writeNewSecretFile(path string) ([]byte, error) {
secret := make([]byte, secretBytes)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return nil, fmt.Errorf("oauth: create token secret temp file: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return nil, fmt.Errorf("oauth: chmod token secret temp file: %w", err)
}
if _, werr := tmp.Write(secret); werr != nil {
_ = tmp.Close()
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
}
if err := os.Rename(tmpPath, path); err != nil {
return nil, fmt.Errorf("oauth: publish token secret: %w", err)
}
return secret, nil
}
func readSecretFileRetry(path string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
data, err := readSecretFile(path)
if !isTransientSecretAccessError(err) {
return data, err
}
lastErr = err
time.Sleep(secretRetryDelay)
}
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
}
// readSecretFile reads and validates the secret at path, returning a wrapped
// os.ErrNotExist when it is absent so callers can branch on creation.
func readSecretFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, err
}
return nil, fmt.Errorf("oauth: read token secret: %w", err)
}
if len(data) != secretBytes {
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
}
return data, nil
}
+204
View File
@@ -0,0 +1,204 @@
package oauth
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
const n = 16
secrets := make([][]byte, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
secrets[i], errs[i] = loadOrCreateSecret(path, true)
}(i)
}
wg.Wait()
// Exactly one creator wins; every racer must converge on the same on-disk
// secret rather than reading a half-published file or orphaning its own.
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("goroutine %d: %v", i, errs[i])
}
if !bytes.Equal(secrets[i], secrets[0]) {
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
}
}
}
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
c := newAESGCMCrypter(secretPath)
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
blob, err := c.seal(plaintext)
if err != nil {
t.Fatalf("seal: %v", err)
}
if bytes.Contains(blob, plaintext) {
t.Fatal("sealed blob must not contain the plaintext")
}
got, err := c.open(blob)
if err != nil {
t.Fatalf("open: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("round-trip mismatch: %q", got)
}
// Tamper: flipping any byte breaks the GCM tag.
tampered := append([]byte(nil), blob...)
tampered[len(tampered)-1] ^= 0xff
if _, err := c.open(tampered); err == nil {
t.Fatal("open must reject a tampered blob (GCM auth)")
}
}
func TestLoadOrCreateSecret(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
// Missing + create=false => fail closed (can't decrypt without the secret).
if _, err := loadOrCreateSecret(path, false); err == nil {
t.Fatal("missing secret with create=false must error")
}
secret, err := loadOrCreateSecret(path, true)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
// Stable across reads.
again, err := loadOrCreateSecret(path, false)
if err != nil || !bytes.Equal(again, secret) {
t.Fatalf("secret not stable: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat secret: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("secret file mode = %o, want 600", perm)
}
}
// Wrong-sized secret fails closed.
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
t.Fatalf("corrupt secret: %v", err)
}
if _, err := loadOrCreateSecret(path, true); err == nil {
t.Fatal("wrong-sized secret must error")
}
}
func newEncryptedStore(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore(encrypted): %v", err)
}
return s, path
}
// The unified Storage="encrypted-file" selector must encrypt at rest, the same as
// the legacy Encrypted:true alias (the file/keyring/encrypted-file merge).
func TestNewStoreEncryptedFileStorageSelector(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"})
if err != nil {
t.Fatalf("NewStore(encrypted-file): %v", err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "super-secret-access"}); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
t.Fatalf("Storage=encrypted-file did not encrypt at rest:\n%s", raw)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "super-secret-access" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
}
func TestEncryptedStoreRoundTripAndCiphertextOnDisk(t *testing.T) {
s, path := newEncryptedStore(t)
tok := Token{AccessToken: "super-secret-access", RefreshToken: "super-secret-refresh", Account: "me@x"}
if err := s.Save(ProviderKey("demo"), tok); err != nil {
t.Fatalf("Save: %v", err)
}
// On-disk file must be ciphertext: not valid JSON, no plaintext token.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
t.Fatalf("token file is not encrypted at rest:\n%s", raw)
}
// The secret file exists beside it.
if _, err := os.Stat(path + ".secret"); err != nil {
t.Fatalf("secret file missing: %v", err)
}
// A fresh Store (same path) decrypts and reads it back.
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore 2: %v", err)
}
got, ok, err := s2.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "super-secret-access" || got.Account != "me@x" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
// Delete + Status work through the encrypted backend too.
statuses, err := s2.Status(KeyPrefixProvider)
if err != nil || len(statuses) != 1 {
t.Fatalf("Status = %+v err=%v", statuses, err)
}
if removed, err := s2.Delete(ProviderKey("demo")); err != nil || !removed {
t.Fatalf("Delete = %v %v", removed, err)
}
}
func TestEncryptedStoreTamperFailsClosed(t *testing.T) {
s, path := newEncryptedStore(t)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
if len(raw) == 0 {
t.Fatal("encrypted token file is empty")
}
raw[len(raw)-1] ^= 0xff
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("rewrite: %v", err)
}
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
t.Fatal("a tampered encrypted store must fail closed")
}
}
func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
s, path := newEncryptedStore(t)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
if err := os.Remove(path + ".secret"); err != nil {
t.Fatalf("remove secret: %v", err)
}
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
t.Fatal("missing secret must fail closed, not return empty")
}
}
+98 -10
View File
@@ -2,6 +2,7 @@ package oauth
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -9,6 +10,11 @@ import (
"time"
)
// ErrNoToken reports that no token is stored for a key. Callers (e.g. a request
// path that prefers OAuth but can fall back to an API key) match it with
// errors.Is to distinguish "not logged in" from a real refresh failure.
var ErrNoToken = errors.New("oauth: no stored token")
// defaultRefreshBuffer refreshes a token this long before its hard expiry.
const defaultRefreshBuffer = 60 * time.Second
@@ -126,6 +132,62 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error)
return m.statusFor(key)
}
// PrepareDeviceLogin resolves the provider config and requests an RFC 8628
// device code, returning the user-facing DeviceAuth (verification URI + user
// code) and the resolved config. It is split from the token poll
// (CompleteDeviceLogin) so a UI can display the code to the user while waiting
// for authorization. The CLI's monolithic Login(Device:true) is unaffected.
func (m *Manager) PrepareDeviceLogin(ctx context.Context, opts LoginOptions) (DeviceAuth, Config, error) {
cfg, _, err := m.registry.ResolveConfig(opts.Provider, m.env)
if err != nil {
return DeviceAuth{}, Config{}, err
}
if len(opts.ExtraScopes) > 0 {
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
}
// Self-bound the network prepare (endpoint discovery + device-code request) the
// same way Login does, so a caller that hands in an unbounded context still gets
// the timeout guarantee instead of a hang.
timeout := opts.Timeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
prepCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cfg, err = m.resolveEndpoints(prepCtx, cfg)
if err != nil {
return DeviceAuth{}, Config{}, err
}
auth, err := RequestDeviceCode(prepCtx, m.client, cfg, m.now)
if err != nil {
return DeviceAuth{}, Config{}, err
}
return auth, cfg, nil
}
// CompleteDeviceLogin polls for the token authorized via PrepareDeviceLogin and
// stores it under "provider:<name>", returning a redaction-safe status. Pass the
// cfg and auth returned by PrepareDeviceLogin.
func (m *Manager) CompleteDeviceLogin(ctx context.Context, provider string, cfg Config, auth DeviceAuth) (Status, error) {
// Bound the poll by the device code's own expiry so an unbounded caller context
// cannot leave an in-flight request hanging past the code's lifetime. PollDeviceToken
// re-checks ExpiresAt between polls; the deadline also caps each individual request.
if !auth.ExpiresAt.IsZero() {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, auth.ExpiresAt)
defer cancel()
}
token, err := PollDeviceToken(ctx, m.client, cfg, auth, m.now)
if err != nil {
return Status{}, err
}
key := ProviderKey(provider)
if err := m.store.Save(key, token); err != nil {
return Status{}, err
}
return m.statusFor(key)
}
// resolveEndpoints fills missing authorize/token/device endpoints from issuer
// discovery (RFC 8414 then OIDC), leaving any explicitly-pinned endpoint intact.
func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, error) {
@@ -214,20 +276,31 @@ func (m *Manager) loginDevice(ctx context.Context, cfg Config) (Token, error) {
// stored token is expired or within the refresh buffer. Mirrors
// checkAndRefreshOAuthTokenIfNeeded.
func (m *Manager) GetFresh(ctx context.Context, key string) (string, error) {
token, cfg, err := m.loadForKey(key)
token, err := m.loadToken(key)
if err != nil {
return "", err
}
// A still-valid token is returned without resolving the provider config, so a
// readable login never depends on config that is only needed to refresh (e.g.
// an opt-in preset client_id). Only an actual refresh resolves endpoints.
if !token.NeedsRefresh(m.now(), m.buffer) {
return token.AccessToken, nil
}
cfg, err := m.resolveConfigForKey(ctx, key)
if err != nil {
return "", err
}
return m.refreshAndSave(ctx, key, cfg, token)
}
// Handle401 forces a refresh after an upstream 401, returning the new access
// token. Mirrors handleOAuth401Error.
func (m *Manager) Handle401(ctx context.Context, key string) (string, error) {
token, cfg, err := m.loadForKey(key)
token, err := m.loadToken(key)
if err != nil {
return "", err
}
cfg, err := m.resolveConfigForKey(ctx, key)
if err != nil {
return "", err
}
@@ -245,27 +318,42 @@ func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, cu
return refreshed.AccessToken, nil
}
// loadForKey loads a stored token and resolves the provider config for its key.
func (m *Manager) loadForKey(key string) (Token, Config, error) {
// loadToken loads a stored token for key without resolving any provider config,
// so reading a still-valid login never depends on refresh-only configuration.
func (m *Manager) loadToken(key string) (Token, error) {
if err := ValidateKey(key); err != nil {
return Token{}, Config{}, err
return Token{}, err
}
token, ok, err := m.store.Load(key)
if err != nil {
return Token{}, Config{}, err
return Token{}, err
}
if !ok {
return Token{}, Config{}, fmt.Errorf("oauth: no stored token for %q", key)
return Token{}, fmt.Errorf("%w for %q", ErrNoToken, key)
}
return token, nil
}
// resolveConfigForKey resolves the provider OAuth config for a provider-token key.
// It is only needed to refresh a token (the endpoints + client identity), not to
// read one.
func (m *Manager) resolveConfigForKey(ctx context.Context, key string) (Config, error) {
name := strings.TrimPrefix(key, KeyPrefixProvider)
if name == key {
return Token{}, Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
return Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
}
cfg, _, err := m.registry.ResolveConfig(name, m.env)
if err != nil {
return Token{}, Config{}, err
return Config{}, err
}
return token, cfg, nil
// Fill any missing token/authorize/device endpoints from issuer discovery so a
// provider configured with only ZERO_OAUTH_<NAME>_ISSUER_URL can still refresh
// (refreshAndSave requires the token endpoint).
cfg, err = m.resolveEndpoints(ctx, cfg)
if err != nil {
return Config{}, err
}
return cfg, nil
}
// Logout removes a provider's stored token, reporting whether one was present.
+70
View File
@@ -106,6 +106,76 @@ func TestManagerLoginDevice(t *testing.T) {
}
}
func TestManagerPrepareAndCompleteDeviceLogin(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"two-phase-at","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
"ZERO_OAUTH_DEMO2_CLIENT_ID": "client",
"ZERO_OAUTH_DEMO2_TOKEN_URL": fp.server.URL + "/token",
"ZERO_OAUTH_DEMO2_DEVICE_URL": fp.server.URL + "/device",
}
m := managerFor(t, env, nil)
// Phase 1: request the device code (what the UI displays to the user).
auth, cfg, err := m.PrepareDeviceLogin(context.Background(), LoginOptions{Provider: "demo2"})
if err != nil {
t.Fatalf("PrepareDeviceLogin: %v", err)
}
if auth.UserCode != "U-1" || auth.VerificationURI == "" {
t.Fatalf("device auth missing user code/uri: %+v", auth)
}
// Phase 2: poll for the token and persist it.
status, err := m.CompleteDeviceLogin(context.Background(), "demo2", cfg, auth)
if err != nil {
t.Fatalf("CompleteDeviceLogin: %v", err)
}
if !status.HasToken || status.Key != ProviderKey("demo2") {
t.Fatalf("status = %+v", status)
}
access, err := m.GetFresh(context.Background(), ProviderKey("demo2"))
if err != nil {
t.Fatalf("GetFresh: %v", err)
}
if access != "two-phase-at" {
t.Fatalf("access = %q, want two-phase-at", access)
}
}
// A provider configured with only an issuer URL (no TOKEN_URL) must still be
// refreshable: GetFresh resolves the token endpoint via discovery before
// refreshing. Guards resolveConfigForKey calling resolveEndpoints.
func TestManagerGetFreshDiscoversIssuerEndpoints(t *testing.T) {
var tokenHits int32
mux := http.NewServeMux()
server := httptest.NewServer(mux)
defer server.Close()
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"issuer":"`+server.URL+`","token_endpoint":"`+server.URL+`/token","authorization_endpoint":"`+server.URL+`/authorize"}`)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&tokenHits, 1)
_, _ = io.WriteString(w, `{"access_token":"refreshed-at","token_type":"Bearer","expires_in":3600}`)
})
env := map[string]string{
"ZERO_OAUTH_ISSUERONLY_CLIENT_ID": "client",
"ZERO_OAUTH_ISSUERONLY_ISSUER_URL": server.URL, // no TOKEN_URL → discovered
}
m := managerFor(t, env, nil)
if err := m.store.Save(ProviderKey("issueronly"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
t.Fatalf("seed: %v", err)
}
got, err := m.GetFresh(context.Background(), ProviderKey("issueronly"))
if err != nil {
t.Fatalf("GetFresh (issuer-only) must discover the token endpoint and refresh: %v", err)
}
if got != "refreshed-at" {
t.Fatalf("access = %q, want refreshed-at", got)
}
if atomic.LoadInt32(&tokenHits) == 0 {
t.Fatal("token endpoint never hit — discovery/refresh did not run")
}
}
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
env := map[string]string{
+5 -3
View File
@@ -14,9 +14,11 @@
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
// - Token files are 0600, base-confined, and file-locked.
//
// This package holds NO vendor secrets: provider client IDs and any overridable
// endpoints come from config/env, so no third-party OAuth client identity is
// baked into the binary.
// This package holds NO vendor secrets: provider client IDs and endpoints come
// from config/env by default, so no third-party OAuth client identity is used
// unless the operator opts in. A small set of built-in presets (e.g. xAI's public
// client) exists for convenience but is OFF by default and only consulted when
// ZERO_OAUTH_ALLOW_PRESETS is set; env always overrides a preset.
package oauth
import (
+74
View File
@@ -0,0 +1,74 @@
package oauth
import "strings"
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
// env vars (env wins). Only providers whose OAuth flow is verified to yield a
// credential usable for model calls are listed here.
type providerPreset struct {
ClientID string
ClientSecret string
AuthorizationEndpoint string
TokenEndpoint string
DeviceAuthorizationEndpoint string
IssuerURL string
Scopes []string
Flow Flow
}
// builtinOAuthPresets maps a provider name to its default OAuth config.
//
// These presets are OFF by default and only consulted when the operator opts in
// with ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed). A preset carries a
// third-party OAuth client identity, and the engine keeps such identities out of
// the default credential path (see the package doc) — opting in is an explicit
// acknowledgement that the binary's preset client_id will be used when no
// ZERO_OAUTH_<NAME>_* override is set.
//
// xAI (Grok): the client_id is a PUBLIC client (no secret) used by several Grok
// CLIs; its access token is accepted directly as a bearer on api.x.ai/v1 (an
// OpenAI-compatible endpoint), so no header/identity spoofing is involved.
// CAVEATS: it is NOT formally documented by xAI as a public developer API and may
// change without notice (override via ZERO_OAUTH_XAI_*), and using it requires a
// SuperGrok / X Premium+ subscription. Pay-as-you-go users should use a console
// API key instead.
var builtinOAuthPresets = map[string]providerPreset{
"xai": {
ClientID: "b1a00492-073a-47ea-816f-4c329264a828",
AuthorizationEndpoint: "https://auth.x.ai/oauth2/authorize",
TokenEndpoint: "https://auth.x.ai/oauth2/token",
DeviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
IssuerURL: "https://auth.x.ai",
Scopes: []string{"openid", "profile", "email", "offline_access", "grok-cli:access", "api:access"},
Flow: FlowLoopback,
},
}
// lookupOAuthPreset returns the baked-in preset for a provider name (if any).
func lookupOAuthPreset(name string) (providerPreset, bool) {
preset, ok := builtinOAuthPresets[strings.ToLower(strings.TrimSpace(name))]
return preset, ok
}
// presetsAllowed reports whether baked-in OAuth presets may supply defaults. They
// are OFF unless the operator opts in with ZERO_OAUTH_ALLOW_PRESETS set to a
// truthy value, keeping any third-party OAuth client identity out of the default
// credential path (a preset client_id is only ever used after explicit opt-in).
func presetsAllowed(env map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(envValue(env, "ZERO_OAUTH_ALLOW_PRESETS"))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// scopesOrPreset returns the env scopes (space-separated) when set, else the
// preset's scopes.
func scopesOrPreset(envScopes string, preset []string) []string {
if fields := strings.Fields(envScopes); len(fields) > 0 {
return fields
}
// Copy so a caller appending to cfg.Scopes can't mutate the shared preset slice.
return append([]string(nil), preset...)
}
+95
View File
@@ -0,0 +1,95 @@
package oauth
import (
"strings"
"testing"
)
func TestScopesOrPresetReturnsACopy(t *testing.T) {
preset := []string{"openid", "profile"}
got := scopesOrPreset("", preset)
if len(got) != 2 || got[0] != "openid" {
t.Fatalf("got %v, want the preset scopes", got)
}
// Mutating the result must not bleed into the shared preset slice.
got[0] = "MUTATED"
if preset[0] != "openid" {
t.Fatalf("scopesOrPreset aliased the shared preset slice: %v", preset)
}
}
func TestResolveConfigUsesXAIPreset(t *testing.T) {
r := NewRegistry()
// Presets are opt-in; with ZERO_OAUTH_ALLOW_PRESETS set and no other env vars
// the preset supplies everything.
cfg, flow, err := r.ResolveConfig("xai", map[string]string{"ZERO_OAUTH_ALLOW_PRESETS": "1"})
if err != nil {
t.Fatalf("ResolveConfig(xai): %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
if cfg.AuthorizationEndpoint != "https://auth.x.ai/oauth2/authorize" {
t.Fatalf("authorize = %q", cfg.AuthorizationEndpoint)
}
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
t.Fatalf("token = %q", cfg.TokenEndpoint)
}
if cfg.DeviceAuthorizationEndpoint != "https://auth.x.ai/oauth2/device/code" {
t.Fatalf("device = %q", cfg.DeviceAuthorizationEndpoint)
}
if flow != FlowLoopback {
t.Fatalf("flow = %q, want loopback", flow)
}
if len(cfg.Scopes) == 0 {
t.Fatal("preset scopes should be populated")
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
"ZERO_OAUTH_ALLOW_PRESETS": "1",
"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id",
"ZERO_OAUTH_XAI_SCOPES": "alpha beta",
"ZERO_OAUTH_XAI_FLOW": "device",
}
cfg, flow, err := r.ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig(xai, env): %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("env should override client_id, got %q", cfg.ClientID)
}
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "alpha" {
t.Fatalf("env should override scopes, got %v", cfg.Scopes)
}
if flow != FlowDevice {
t.Fatalf("env should override flow, got %q", flow)
}
// A field not overridden still comes from the preset.
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
t.Fatalf("non-overridden token endpoint = %q", cfg.TokenEndpoint)
}
}
func TestResolveConfigNoPresetStillRequiresEnv(t *testing.T) {
r := NewRegistry()
if _, _, err := r.ResolveConfig("acme-no-preset", map[string]string{}); err == nil {
t.Fatal("a provider with neither preset nor env config should error")
}
}
// Without the opt-in flag the xAI preset must stay inert: no third-party client
// identity is baked into the default credential path, and the error points the
// user at the opt-in.
func TestResolveConfigPresetInertWithoutOptIn(t *testing.T) {
r := NewRegistry()
_, _, err := r.ResolveConfig("xai", map[string]string{})
if err == nil {
t.Fatal("xai must not resolve from the preset unless ZERO_OAUTH_ALLOW_PRESETS is set")
}
if !strings.Contains(err.Error(), "ZERO_OAUTH_ALLOW_PRESETS") {
t.Fatalf("error should point at the opt-in, got: %v", err)
}
}
+30 -13
View File
@@ -28,10 +28,11 @@ func ValidateProviderName(name string) error {
return nil
}
// Registry resolves a provider's Config from env/config. It ships NO built-in
// providers, endpoints, or client identities: every provider is defined entirely
// by the operator via ZERO_OAUTH_<NAME>_* variables, so no third-party brand or
// OAuth client identity is baked into the binary.
// Registry resolves a provider's Config from env/config. By default every provider
// is defined entirely by the operator via ZERO_OAUTH_<NAME>_* variables, so no
// third-party OAuth client identity is used. A small set of built-in presets
// exists for convenience but stays inert unless the operator opts in with
// ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed); env always overrides a preset.
type Registry struct{}
// NewRegistry returns the (stateless) env-driven registry.
@@ -61,21 +62,37 @@ func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Fl
if err := ValidateProviderName(name); err != nil {
return Config{}, "", err
}
// A baked-in preset (if any) supplies defaults, but ONLY when the operator opts
// in with ZERO_OAUTH_ALLOW_PRESETS — otherwise no third-party client identity is
// used and every field must come from a ZERO_OAUTH_<NAME>_* env var (env wins).
var preset providerPreset
if presetsAllowed(env) {
preset, _ = lookupOAuthPreset(name)
}
cfg := Config{
ClientID: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))),
ClientSecret: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))),
AuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))),
TokenEndpoint: strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))),
DeviceAuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))),
IssuerURL: strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))),
Scopes: strings.Fields(envValue(env, envKey(name, "SCOPES"))),
ClientID: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))), preset.ClientID),
ClientSecret: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))), preset.ClientSecret),
AuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))), preset.AuthorizationEndpoint),
TokenEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))), preset.TokenEndpoint),
DeviceAuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))), preset.DeviceAuthorizationEndpoint),
IssuerURL: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))), preset.IssuerURL),
Scopes: scopesOrPreset(envValue(env, envKey(name, "SCOPES")), preset.Scopes),
}
if cfg.ClientID == "" {
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)", name, envKey(name, "CLIENT_ID"))
hint := ""
if _, ok := lookupOAuthPreset(name); ok {
hint = " (or set ZERO_OAUTH_ALLOW_PRESETS=1 to use the built-in preset)"
}
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)%s", name, envKey(name, "CLIENT_ID"), hint)
}
var flow Flow
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
case "", string(FlowLoopback):
case "":
flow = preset.Flow
if flow == "" {
flow = FlowLoopback
}
case string(FlowLoopback):
flow = FlowLoopback
case string(FlowDevice):
flow = FlowDevice
+1 -1
View File
@@ -50,7 +50,7 @@ func (s *RefreshScheduler) loop(ctx context.Context, m *Manager, key string, don
defer close(done)
loadErrors := 0
for {
token, _, err := m.loadForKey(key)
token, err := m.loadToken(key)
if err != nil {
// A load error may be transient (a concurrent store write, a brief I/O
// hiccup): back off and retry rather than permanently stopping proactive
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package oauth
func isTransientSecretAccessError(error) bool {
return false
}
+14
View File
@@ -0,0 +1,14 @@
//go:build windows
package oauth
import (
"errors"
"golang.org/x/sys/windows"
)
func isTransientSecretAccessError(err error) bool {
return errors.Is(err, windows.ERROR_SHARING_VIOLATION) ||
errors.Is(err, windows.ERROR_LOCK_VIOLATION)
}
+237 -56
View File
@@ -1,16 +1,20 @@
package oauth
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/keyring"
)
const (
@@ -54,14 +58,41 @@ type StoreOptions struct {
FilePath string
Env map[string]string
Now func() time.Time
// Storage selects the backend: "" / "file" => a 0600 JSON file (default);
// "encrypted-file" => an AES-256-GCM encrypted file; "keyring" => the OS
// keyring. When empty it falls back to ZERO_OAUTH_STORAGE.
Storage string
// Encrypted is a legacy alias for Storage=="encrypted-file" (AES-256-GCM at
// rest). Ignored when Storage is set.
Encrypted bool
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
// Injected by tests to avoid touching a real keychain.
Keyring KeyringClient
}
// Store persists OAuth tokens (provider + MCP namespaces) in a 0600 JSON file,
// guarded by a cross-process lock and written atomically.
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
// satisfies it; tests inject a fake.
type KeyringClient interface {
Get(service, account string) (string, bool, error)
Set(service, account, secret string) error
Delete(service, account string) (bool, error)
}
// Keyring storage stores the whole token blob under one fixed entry.
const (
keyringService = "zero"
keyringAccount = "oauth-tokens"
)
// Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob,
// written atomically through a pluggable backend (a 0600 file guarded by a
// cross-process lock, or the OS keyring). When crypter is non-nil the file blob
// is AES-256-GCM ciphertext at rest.
type Store struct {
filePath string
now func() time.Time
mu sync.Mutex
blob blobStore
crypter *aesGCMCrypter // nil => plaintext blob
now func() time.Time
mu sync.Mutex
}
type storeFile struct {
@@ -99,29 +130,80 @@ func ResolveStorePath(env map[string]string) (string, error) {
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
}
// NewStore builds a file-backed token store.
// NewStore builds a token store with the configured backend (file by default,
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
func NewStore(options StoreOptions) (*Store, error) {
now := options.Now
if now == nil {
now = time.Now
}
storage := strings.TrimSpace(options.Storage)
if storage == "" {
storage = strings.TrimSpace(envValue(options.Env, "ZERO_OAUTH_STORAGE"))
}
if storage == "" && options.Encrypted {
storage = "encrypted-file" // legacy alias
}
switch storage {
case "", "file":
path, err := resolveStoreFilePath(options)
if err != nil {
return nil, err
}
return &Store{blob: fileBlob{path: path}, now: now}, nil
case "encrypted-file":
path, err := resolveStoreFilePath(options)
if err != nil {
return nil, err
}
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
// a sibling ".secret" file (see encrypt.go).
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
case "keyring":
kr := options.Keyring
if kr == nil {
osKeyring := keyring.New()
if !osKeyring.Available() {
return nil, fmt.Errorf("oauth: keyring storage requested but not available on %s; use file storage", runtime.GOOS)
}
kr = osKeyring
}
// Serialize the keyring's read-modify-write across processes with a lock
// file beside where the file backend would live. Best-effort: if no config
// location resolves, fall back to in-process serialization only.
lockPath := ""
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
}
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
default:
return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage)
}
}
// resolveStoreFilePath resolves the absolute file path for the file backend.
func resolveStoreFilePath(options StoreOptions) (string, error) {
filePath := options.FilePath
var err error
if strings.TrimSpace(filePath) == "" {
filePath, err = ResolveStorePath(options.Env)
if err != nil {
return nil, err
return "", err
}
}
if !filepath.IsAbs(filePath) {
filePath, err = filepath.Abs(filePath)
if err != nil {
return nil, err
return "", err
}
}
now := options.Now
if now == nil {
now = time.Now
}
return &Store{filePath: filepath.Clean(filePath), now: now}, nil
return filepath.Clean(filePath), nil
}
// FilePath returns the resolved token store location (a path for the file
// backend, or a "keyring:..." identifier for the keyring backend).
func (s *Store) FilePath() string { return s.blob.location() }
// Save persists a token under key, replacing any existing entry.
func (s *Store) Save(key string, token Token) error {
if err := ValidateKey(key); err != nil {
@@ -129,17 +211,14 @@ func (s *Store) Save(key string, token Token) error {
}
s.mu.Lock()
defer s.mu.Unlock()
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
if err != nil {
return err
}
defer unlock()
state, err := s.readState()
if err != nil {
return err
}
state.Tokens[key] = token
return s.writeState(state)
return s.blob.withLock(s.now, func() error {
state, err := s.readState()
if err != nil {
return err
}
state.Tokens[key] = token
return s.writeState(state)
})
}
// Load returns the token for key; the bool is false when none is stored.
@@ -164,23 +243,20 @@ func (s *Store) Delete(key string) (bool, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
if err != nil {
return false, err
}
defer unlock()
state, err := s.readState()
if err != nil {
return false, err
}
if _, ok := state.Tokens[key]; !ok {
return false, nil
}
delete(state.Tokens, key)
if err := s.writeState(state); err != nil {
return false, err
}
return true, nil
var removed bool
err := s.blob.withLock(s.now, func() error {
state, err := s.readState()
if err != nil {
return err
}
if _, ok := state.Tokens[key]; !ok {
return nil
}
delete(state.Tokens, key)
removed = true
return s.writeState(state)
})
return removed, err
}
// Status returns redaction-safe summaries of every stored token, sorted by key.
@@ -218,54 +294,159 @@ func (s *Store) Status(prefix string) ([]Status, error) {
}
func (s *Store) readState() (storeFile, error) {
data, err := os.ReadFile(s.filePath)
data, ok, err := s.blob.read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return emptyStoreFile(), nil
}
return storeFile{}, err
}
if !ok {
return emptyStoreFile(), nil
}
if s.crypter != nil {
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
data, err = s.crypter.open(data)
if err != nil {
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
}
}
var state storeFile
if err := json.Unmarshal(data, &state); err != nil {
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
}
if state.SchemaVersion != storeSchemaVersion {
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: unsupported schemaVersion", s.filePath)
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: unsupported schemaVersion", s.blob.location())
}
if state.Tokens == nil {
state.Tokens = map[string]Token{}
}
for key := range state.Tokens {
if err := ValidateKey(key); err != nil {
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
}
}
return state, nil
}
func (s *Store) writeState(state storeFile) error {
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
tempPath := fmt.Sprintf("%s.tmp-%d-%d", s.filePath, os.Getpid(), s.now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
// Plaintext keeps the trailing newline for a tidy file; the encrypted backend
// writes opaque ciphertext instead.
payload := append(data, '\n')
if s.crypter != nil {
payload, err = s.crypter.seal(data)
if err != nil {
return err
}
}
return s.blob.write(payload)
}
func emptyStoreFile() storeFile {
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
}
// blobStore abstracts the persistence of the whole token blob behind the Store,
// so the same store logic backs either a 0600 file or the OS keyring.
type blobStore interface {
// read returns the stored blob; ok is false when nothing is stored yet.
read() (data []byte, ok bool, err error)
// write replaces the stored blob.
write(data []byte) error
// withLock runs fn under whatever cross-process exclusion the backend offers
// (a lock file for the file backend; none for the keyring, which is the
// authoritative store and is serialized within the process by Store.mu).
withLock(now func() time.Time, fn func() error) error
// location is a human-readable identifier for diagnostics/errors.
location() string
}
// fileBlob persists the blob as a 0600 JSON file, written atomically and guarded
// by a cross-process lock file. Behavior matches the original file store.
type fileBlob struct{ path string }
func (b fileBlob) read() ([]byte, bool, error) {
data, err := os.ReadFile(b.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
return nil, false, err
}
return data, true, nil
}
func (b fileBlob) write(data []byte) error {
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
return err
}
if err := os.Rename(tempPath, s.filePath); err != nil {
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
return err
}
if err := os.Rename(tempPath, b.path); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func emptyStoreFile() storeFile {
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
unlock, err := acquireFileLock(b.path+".lockfile", now)
if err != nil {
return err
}
defer unlock()
return fn()
}
func (b fileBlob) location() string { return b.path }
// keyringBlob persists the blob in the OS keyring as a single base64 entry
// (base64 keeps the multi-line JSON a single, control-character-free value).
type keyringBlob struct {
kr KeyringClient
service string
account string
// lockPath, when set, is a cross-process lock file serializing the keyring's
// read-modify-write so concurrent processes don't clobber each other's tokens.
lockPath string
}
func (b keyringBlob) read() ([]byte, bool, error) {
enc, ok, err := b.kr.Get(b.service, b.account)
if err != nil || !ok {
return nil, ok, err
}
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
if err != nil {
return nil, false, fmt.Errorf("oauth: decode keyring token blob: %w", err)
}
return data, true, nil
}
func (b keyringBlob) write(data []byte) error {
return b.kr.Set(b.service, b.account, base64.StdEncoding.EncodeToString(data))
}
// withLock serializes the keyring's read-modify-write. Store.mu covers the
// in-process case; lockPath (when set) adds cross-process exclusion so two
// processes can't both read the blob, modify, and write — dropping a token.
func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
if b.lockPath == "" {
return fn()
}
unlock, err := acquireFileLock(b.lockPath, now)
if err != nil {
return err
}
defer unlock()
return fn()
}
func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.account }
// FormatStatuses renders a human-readable status table without leaking token
// material.
func FormatStatuses(statuses []Status) string {
+113
View File
@@ -0,0 +1,113 @@
package oauth
import (
"strings"
"testing"
)
// fakeKR is an in-memory KeyringClient for exercising the keyring backend
// without touching a real OS keychain.
type fakeKR struct{ data map[string]string }
func newFakeKR() *fakeKR { return &fakeKR{data: map[string]string{}} }
func (f *fakeKR) Get(service, account string) (string, bool, error) {
v, ok := f.data[service+"/"+account]
return v, ok, nil
}
func (f *fakeKR) Set(service, account, secret string) error {
f.data[service+"/"+account] = secret
return nil
}
func (f *fakeKR) Delete(service, account string) (bool, error) {
key := service + "/" + account
_, ok := f.data[key]
delete(f.data, key)
return ok, nil
}
func TestStoreKeyringBackendRoundTrip(t *testing.T) {
// Keep the cross-process keyring lock file inside a temp config dir.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
kr := newFakeKR()
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
if err != nil {
t.Fatalf("NewStore(keyring): %v", err)
}
if !strings.HasPrefix(s.FilePath(), "keyring:") {
t.Fatalf("FilePath = %q, want keyring identifier", s.FilePath())
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r"}); err != nil {
t.Fatalf("Save: %v", err)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok {
t.Fatalf("Load: ok=%v err=%v", ok, err)
}
if got.AccessToken != "a" || got.RefreshToken != "r" {
t.Fatalf("Load = %#v", got)
}
// The blob is stored base64-encoded, so the raw JSON field names never appear.
raw := kr.data[keyringService+"/"+keyringAccount]
if raw == "" {
t.Fatal("nothing stored in keyring")
}
if strings.Contains(raw, "access_token") {
t.Fatalf("keyring blob is not encoded: %s", raw)
}
removed, err := s.Delete(ProviderKey("demo"))
if err != nil || !removed {
t.Fatalf("Delete: removed=%v err=%v", removed, err)
}
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
t.Fatal("token still present after delete")
}
}
func TestNewStoreStorageSelection(t *testing.T) {
// Unknown storage is rejected (fail closed).
if _, err := NewStore(StoreOptions{Storage: "bogus"}); err == nil {
t.Fatal("unknown storage should error")
}
// ZERO_OAUTH_STORAGE selects the keyring (with an injected client).
s, err := NewStore(StoreOptions{
Env: map[string]string{"ZERO_OAUTH_STORAGE": "keyring"},
Keyring: newFakeKR(),
})
if err != nil {
t.Fatalf("NewStore(env keyring): %v", err)
}
if !strings.HasPrefix(s.FilePath(), "keyring:") {
t.Fatalf("env did not select keyring backend: %q", s.FilePath())
}
// Default is the file backend.
fileStore, err := NewStore(StoreOptions{FilePath: t.TempDir() + "/oauth-tokens.json"})
if err != nil {
t.Fatalf("NewStore(file): %v", err)
}
if strings.HasPrefix(fileStore.FilePath(), "keyring:") {
t.Fatalf("default backend should be file, got %q", fileStore.FilePath())
}
}
func TestStoreKeyringStatus(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
kr := newFakeKR()
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
if err != nil {
t.Fatal(err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatal(err)
}
statuses, err := s.Status(KeyPrefixProvider)
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") || !statuses[0].HasToken {
t.Fatalf("status = %#v", statuses)
}
}
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more