diff --git a/CHANGELOG.md b/CHANGELOG.md index 929bc55..4106024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-08-04 + +### Fixed + +- Receiver configs that omit the `autonomy` block, such as signing-only + configs, now preserve the same conservative `always_pause` behavior as an + absent config file. Doctor reports the config as valid, and the autonomy + evaluator records the normal `admission_paused` / `mode_always_pause` + fingerprint instead of treating the config as malformed. +- The Claude envelope hook now splits compound Bash commands only at shell + operators outside quoted arguments and command substitutions, so read-only + patterns containing literal pipes and backslash-newline continuations no + longer produce false escalation while later mutating segments remain gated. + Its conservative parser fallback recognizes only a small, redirect-free + read-only command set. Every emitted ask or deny reason now starts with the + stable `[oacp-envelope]` source tag and includes the active task message id + when available, making hook prompts distinguishable from harness prompts. +- Envelope enforcement is now session-scoped. The compiled envelope + records the harness session that compiled it (via a hook-observed + session claim consumed by the compiler), and the Claude adapter + no-ops for tool calls from any other session — a concurrent + interactive session in the same repository no longer inherits a + dispatched task's constraints or consumes its `files_touched` + budget. Envelopes compiled without a resolvable session (un-hooked + runtimes, or callers the harness gave no session id) stay unbound + and keep the historical whole-workspace enforcement scope, and + same-task recompiles — including a post-re-authorization + `--extend` from outside the bound session — inherit the existing + binding. +- The autonomy gate evaluator now persists complete, lock-serialized + audit records to the `--audit-dir` destination; previously an + evaluation could finish without writing the durable record. +- `oacp doctor` now reports trust-pin completeness per receiver and + gates enforce-mode signing readiness on it: every catalog identity + must carry an active pin before a receiver flips to enforce, with + gaps broken down per receiver in doctor output. + ## [0.4.0] - 2026-07-17 ### Added diff --git a/docs/guides/runtime_capability_matrix.md b/docs/guides/runtime_capability_matrix.md index 83db092..14f8440 100644 --- a/docs/guides/runtime_capability_matrix.md +++ b/docs/guides/runtime_capability_matrix.md @@ -1,12 +1,12 @@ # Cross-Runtime Parity Matrix -**Date**: 2026-06-26 +**Date**: 2026-08-03 This is a capability comparison across the currently profiled agent runtimes (Claude Code, Codex, Gemini), compiled from each runtime's self-report and current runtime changelogs. Cursor support is scaffold-only until Cursor-owned onboarding lands, so Cursor is intentionally excluded from this comparison table; see `docs/protocol/runtime_capabilities.md` for its conservative scaffold defaults. -Claude was last checked against Claude Code `v2.1.170` with Claude Fable 5 (`claude-fable-5`, serving model verified in-session on the 1M-context variant). Fable 5 (released 2026-06-09, first Mythos-class model) is included at no extra cost on Pro/Max/Team/Enterprise plans Jun 9–22, 2026, with usage credits required after; Opus 4.8 remains available and serves as Fable 5's safeguard-fallback model. +Claude was last checked against Claude Code `v2.1.220` (verified in-session 2026-08-03 via `claude --version`) with Claude Fable 5 (`claude-fable-5`, serving model verified in-session on the 1M-context variant). Fable 5 (released 2026-06-09, first Mythos-class model) is now included in Max and Team Premium plan usage as part of the shared weekly limit pool (the launch-window free-inclusion/credit period has ended); the API rate is $10/$50 per MTok. Claude Opus 5 (`claude-opus-5`, released 2026-07-24) is the current Opus tier at $5/$25 per MTok with the same 1M context; Opus 4.8 remains available at the same price and remains the safeguard-fallback target for Fable 5's classifier fallbacks (unchanged by the Opus 5 release). -Codex was last checked against app update `26.616`, CLI `0.142.3`, the local desktop app `26.623.31921`, OpenAI's GPT-5.5 launch note from 2026-04-23, and the June 2026 Codex/API entries for Sites, Amazon Bedrock, Remote, and Record & Replay. +Codex was last checked against app update `26.727`, stable CLI `0.146.0`, GPT-5.6, and the OpenAI Codex/API changelog entries through 2026-07-30. Runtime availability remains configuration-dependent: standard multi-agent support and memories are stable, while multi-agent V2, token budgets, current-time reminders, and remote Code Mode may still require explicit enablement or experimental configuration. --- @@ -15,18 +15,19 @@ Codex was last checked against app update `26.616`, CLI `0.142.3`, the local des | Capability | Claude (Claude Code CLI) | Codex (Desktop App) | Gemini | | ---------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Spawn background tasks | Yes — Task tool + Bash `run_in_background` | Yes — shell background processes | Yes — `run_command` async mode | -| Spawn subagents | Yes — typed agents (Explore, Plan, general-purpose, code-reviewer, etc.) | Yes — native multi-agent lifecycle with runtime metadata and follow-up defaults | Partial — `browser_subagent` only | -| Parallel agent teams | Yes — TeamCreate, task lists, SendMessage, broadcast | Partial — parallel spawned agents are supported, but there is no team/task-list primitive | No — parallel tool calls but no independent agent instances | +| Spawn subagents | Yes — typed agents (Explore, Plan, general-purpose, code-reviewer, etc.) | Yes — stable native multi-agent lifecycle with runtime metadata and follow-up controls | Partial — `browser_subagent` only | +| Parallel agent teams | Yes — TeamCreate, task lists, SendMessage, broadcast | Partial — parallel delegation is supported; opt-in multi-agent V2 adds configurable models, reasoning, concurrency, and roles, but there is no shared team/task-list primitive | No — parallel tool calls but no independent agent instances | | MCP tools | Yes — extensible via MCP servers | Yes — MCP/plugin support with `/mcp verbose`, per-server environment targeting, read-only MCP parallelism, scriptable plugin inventory, and default tool-search exposure where supported | Yes — MCP server support | | Web search | Yes — native WebSearch tool | Yes — web search/fetch tools; hosted web tools are expanding in code-mode flows | Yes — native `search_web` tool | -| Browser interaction | Partial — WebFetch (read-only, HTML→markdown) | Partial — in-app browser and Chrome extension can inspect local/public and approved browser contexts, with faster asset extraction and read-only JS structured-data extraction; not full browser automation | Yes — full browser control (click, type, navigate, screenshot, video) | +| Browser interaction | Partial — WebFetch (read-only, HTML→markdown) | Configuration-dependent — Browser and Chrome plugins can navigate, click, type, inspect, and capture approved contexts; history search remains separately permissioned | Yes — full browser control (click, type, navigate, screenshot, video) | | File system access | Sandboxed — configurable read/write allowlists | Policy-dependent per session; named permission profiles can include deny-read rules and managed requirements | Full — unrestricted | -| Git operations | Yes — via Bash (may need sandbox configuration) | Yes — native | Yes — via shell | +| Multi-folder projects | Manual workspace composition | Yes — the primary folder controls new chats, Git, and automatic instruction/skill/config discovery; secondary folders provide file access | Manual workspace composition | +| Git operations | Yes — via Bash (may need sandbox configuration) | Yes — native; in a multi-folder project, operations are rooted in the primary folder | Yes — via shell | | GitHub CLI (gh) | Yes — via Bash (may need sandbox configuration) | Yes — authenticated | Yes — native | -| Session memory | Strong — auto-loaded MEMORY.md + optional MCP memory | Partial — app memories where available plus OACP file memory; app memories are not protocol SSOT | Partial — Knowledge Items (not directly writable), conversation logs | -| Interactive mode | Yes — CLI chat with permissions, plan mode | Yes — desktop app and CLI/TUI, including Plan Mode, Goal mode, side conversations, archive/unarchive/delete flows, and token-budget-aware long-running work | Yes — chat with task UI, artifacts | -| Context window | ~1M with Fable 5 or Opus 4.8 (auto-compaction extends indefinitely) | Model-dependent; GPT-5.5 in Codex is documented at 400K, with no auto-compaction guarantee | ~1M tokens | -| Cost model | Token-based, visible in statusline; Fable 5 API rate is $10/$50 per MTok (2× Opus 4.8's $5/$25) | Not surfaced per session; GPT-5.5 Fast mode trades 2.5x cost for 1.5x token generation speed, and newer CLI builds add explicit token budgets for bounded work | Token-based | +| Session memory | Strong — auto-loaded MEMORY.md + optional MCP memory | Partial — stable generated app memories plus OACP file memory; app memories are not protocol SSOT | Partial — Knowledge Items (not directly writable), conversation logs | +| Interactive mode | Yes — CLI chat with permissions, plan mode | Yes — desktop app and CLI/TUI, including Plan and Goal modes, named and pinned sessions, side-chat switching, forks, and archive/unarchive/delete flows | Yes — chat with task UI, artifacts | +| Context window | ~1M across the current lineup (Fable 5, Opus 5, Opus 4.8); auto-compaction extends indefinitely | Model-dependent; GPT-5.6 Sol, Terra, and Luna are documented at 272K | ~1M tokens | +| Cost model | Token-based, visible in statusline; Fable 5 API rate is $10/$50 per MTok (2× the $5/$25 shared by Opus 5 and Opus 4.8) | ChatGPT sessions do not surface per-session cost; API Fast mode for Sol trades 2× price for up to 2.5× speed, while Terra and Luna target lower-cost work | Token-based | | Sandbox restrictions | Yes — configurable allowlists | Session-dependent; supports deny-read policies, isolated `codex exec`, named permission profiles, managed requirements, and explicit approval policies | None — full system access | --- @@ -41,8 +42,8 @@ Codex was last checked against app update `26.616`, CLI `0.142.3`, the local des | Plan mode | Claude, Codex | Claude has structured explore → plan → approve → implement; Codex CLI can move from planning into fresh-context implementation | | Auto-compaction | Claude | Context auto-compresses, enabling unlimited session length | | Cross-session semantic search | Claude | MCP-based searchable memory (optional) | -| Browser automation (full) | Gemini | Click, type, navigate, screenshot, WebP video recording | -| GPT-5.5 model availability | Codex | Available in Codex for Plus, Pro, Business, Enterprise, Edu, and Go plans with a 400K context window | +| Browser automation | Codex, Gemini | Codex Browser/Chrome plugins can click, type, navigate, inspect, and capture approved contexts; Gemini also supports WebP video recording | +| GPT-5.6 model family | Codex | Sol, Terra, and Luna provide task-tier choices with a documented 272K context window | | Image generation | Codex, Gemini | Codex CLI image generation is enabled by default; Gemini has native `generate_image` | | URL content reading (no browser) | Gemini | `read_url_content` fetches HTML→markdown or PDF directly | | Code outline navigation | Gemini | `view_file_outline`, `view_code_item` for structured exploration | @@ -53,12 +54,14 @@ Codex was last checked against app update `26.616`, CLI `0.142.3`, the local des | Remote host control | Codex | Mobile or desktop remote control can run work on connected Mac or Windows hosts with host-local files, credentials, plugins, skills, and config | | App-level artifact review | Codex | Sidebar preview for generated PDFs, spreadsheets, documents, and presentations | | App-level PR review | Codex | PR sidebar can inspect changed files, review comments, and follow-up fixes | -| App-server automation | Codex | JSON-RPC app-server, SDK, schema generation, thread APIs, and websocket/Unix-socket transports for custom clients | +| Multi-folder projects | Codex | One project can span repositories; the primary folder owns Git and automatic instruction, skill, and config discovery, while secondary folders are file-access-only | +| Session organization | Codex | CLI/TUI supports named and pinned sessions, side-chat switching, archive/unarchive, deletion, and temporary or persisted forks | +| App-server automation | Codex | JSON-RPC app-server, SDK, schema generation, thread APIs, and authenticated WebSocket/Unix-socket/stdio transports; remote Code Mode remains experimental | | Hosted site deployment | Codex | Sites preview can create, deploy, inspect, and manage hosted websites or internal tools through the Codex app | -| Plugin marketplace inventory | Codex | Plugin directory plus `codex plugin list --json` for installed plugin inventory and marketplace-aware diagnostics | +| Plugin marketplace inventory | Codex | Agent Plugin manifests, workspace publishing, additional marketplaces, and `codex plugin list --json`; availability and trust remain configuration-dependent | | Goal mode | Codex | Stable long-running objective mode with dedicated state; candidate for OACP wait/review-loop experiments | | Record & Replay | Codex | Mac desktop workflows can be recorded and converted into reusable skills; candidate for private skill capture after privacy review | -| Current-time reminders | Codex | CLI can remind agents of current time/date context; useful for protocol work with relative dates and scheduled waits | +| Current-time reminders | Codex | Announced CLI surface for relative-date work; feature availability must be checked before workflow dependence | --- @@ -80,9 +83,9 @@ Scope: skills shipped in [`kiloloop/oacp-skills`](https://github.com/kiloloop/oa | Dimension | Claude | Codex | Gemini | | --------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Best at | Orchestration, multi-agent teams, persistent memory, plan-then-execute | Terminal-native execution, GPT-5.5 agentic coding, fast iterative patching, plan-to-implementation handoff, app-assisted PR/artifact review, app-server automation, protocol discipline | Web research, browser automation, visual verification, large context | +| Best at | Orchestration, multi-agent teams, persistent memory, plan-then-execute | Terminal-native execution, GPT-5.6 agentic coding, fast iterative patching, native delegation, plan-to-implementation handoff, app-assisted review, app-server automation, protocol discipline | Web research, browser automation, visual verification, large context | | Ideal task type | Team coordination, complex multi-file refactors, long-running sessions | Shell-heavy workflows, long-horizon coding, targeted file edits, deterministic scripts, PR follow-up, artifact review, CLI planning passes, plugin/app-server automation prototypes | External research, UI testing, document review, MCP integrations | -| Cost profile | Flexible (haiku subagents for cheap tasks, opus for complex, Fable 5 at 2× Opus API rates for the hardest work) | Per-session cost not visible; GPT-5.5 is described as more token-efficient than GPT-5.4 for Codex tasks | Token-based, web search has additional costs | +| Cost profile | Flexible (haiku subagents for cheap tasks, opus for complex, Fable 5 at 2× Opus API rates for the hardest work) | Per-session ChatGPT cost not visible; Sol, Terra, and Luna provide quality/speed/cost tiers, with API Fast mode available for Sol | Token-based, web search has additional costs | --- @@ -90,17 +93,17 @@ Scope: skills shipped in [`kiloloop/oacp-skills`](https://github.com/kiloloop/oa | Limitation | Claude | Codex | Gemini | | ----------------------------- | ----------------------------- | -------------------------------------------------------------- | ---------------------- | -| No subagents | — | Yes | Partial (browser only) | -| No browser automation | Yes (read-only) | Partial (in-app browser and Chrome extension are useful for review/verification, but not a general full-browser automation substitute) | — | +| No subagents | — | — | Partial (browser only) | +| No browser automation | Yes (read-only) | Configuration-dependent — Browser/Chrome plugins and an approved context are required | — | | No image generation | Yes | — | — | | No persistent writable memory | — | Partial (app memories are not a replacement for OACP durable memory) | Yes | | Sandbox friction | Yes (configurable) | Session-dependent | — | -| No team primitive | — | Yes | Yes | -| Context limits | Auto-compaction mitigates | Model-dependent; GPT-5.5 in Codex is 400K, but there is no documented auto-compaction behavior | Large but finite | +| No team primitive | — | Partial — native parallel delegation exists, but not a shared task-list/broadcast primitive | Yes | +| Context limits | Auto-compaction mitigates | GPT-5.6 Sol, Terra, and Luna are 272K; compaction behavior is runtime-dependent | Large but finite | | No terminal stdin | Yes | — | — | | Cost not surfaced | — | Yes | — | | Permanent session delete | — | `codex delete` is available; use archive/unarchive for routine cleanup and reserve delete for explicit destructive cleanup | — | -| Serving model can change mid-session | Yes (Fable 5 only — cyber/bio-chem/distillation classifiers fall back to Opus 4.8; default and non-configurable in Claude interfaces incl. Claude Code, with a session event emitted; <5% of sessions — system card §1.5) | — | — | +| Serving model can change mid-session | Yes (Fable 5 only — cyber/bio-chem/distillation classifiers fall back to Opus 4.8, a target unchanged by the Opus 5 release; enabled by default and user-configurable in Claude interfaces — off-toggle in Settings > Capabilities, or Config > MODEL & OUTPUT in Claude Code, after which a flagged request pauses instead of switching; a session event is emitted on switch; <5% of sessions) | — | — | --- @@ -110,10 +113,10 @@ These are the highest-impact gaps where one runtime's limitation blocks effectiv | Gap | Affected Runtime(s) | Impact | Proposed Fix | | --------------------------- | ---------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| No team orchestration | Codex, Gemini | Cannot run parallel agent teams | Agent cards — let runtimes discover and delegate to capable peers | +| No shared team/task-list primitive | Codex, Gemini | Codex can delegate in parallel but lacks Claude-style shared task lists and broadcast; Gemini lacks independent general agents | Agent cards — let runtimes discover and delegate to capable peers | | Memory asymmetry | Codex (partial), Gemini (KIs only) | Cross-session context degrades without MEMORY.md equivalent | Standardize memory protocol; each runtime implements its own persistence layer | | Sandbox blocks git/gh | Claude | Every git/gh call needs sandbox configuration | Configure sandbox allowlists or disable sandbox for specific commands | -| Full browser automation gap | Claude, Codex | Claude is read-only; Codex has stronger browser review and Chrome-extension support but not full autonomous browser automation | Delegate full browser tasks to Gemini; use Codex browser/Chrome workflows for local, public, or approved signed-in page review | +| Full browser automation gap | Claude; Codex without Browser/Chrome plugins | Claude is read-only; Codex browser control depends on installed plugins and an approved browser context | Delegate to a browser-capable runtime or enable the scoped Codex browser surface after privacy review | | Reviewer cost | All (especially Claude) | High cost for single PR review with polling pattern | Stateless reviewer rounds — one round per invocation | | Public skill coverage | Gemini | `kiloloop/oacp-skills` ships `claude/` and `codex/` variants for all 5 public skills; no `gemini/` variants — Gemini users must rely on convention-based adoption | Add `gemini/` variants to each public skill, or document the convention-based pattern as a first-class install path | @@ -124,7 +127,7 @@ These are the highest-impact gaps where one runtime's limitation blocks effectiv | Dimension | Claude | Codex | Gemini | | ---------------------------- | -------------------------------- | ------------------------------------------ | --------------------------------------------- | | Max parallel tool calls | ~10+ | Yes (parallel independent calls) | ~10 (practical) | -| Side conversations | No | Yes (`/side` in CLI/TUI) | No | +| Side conversations | No | Yes — named/pinned sessions and side-chat switching in CLI/TUI | No | | Hooks system | Yes (pre/post tool call hooks) | Yes (stable hooks and extension lifecycle hooks; plugin-bundled hooks are configuration-dependent) | No | | Automation scheduling | Yes (`CronCreate`, `ScheduleWakeup`, `/loop`, `/schedule` skills) | Yes (desktop app thread automations, Goal mode, and app-server/SDK automation surfaces) | No | | Notebook editing | Yes (NotebookEdit tool) | No | No | @@ -138,7 +141,8 @@ These are the highest-impact gaps where one runtime's limitation blocks effectiv | Workflow file format | SKILL.md with YAML frontmatter | SKILL.md with YAML frontmatter | Markdown with YAML frontmatter | | Policy visibility at runtime | Partial (sandbox config visible) | Yes (session policy, approval policy, sandbox, and named permission profiles) | Yes (`SafeToAutoRun` flags) | | Long-running shell sessions | Bash tool (no stdin) | Yes (PTY + stdin; multiple terminals in app) | Yes (`send_command_input`) | -| App-server / SDK | No | Yes (JSON-RPC app-server, Python SDK, archive/thread APIs, schema generation, remote execution improvements, and multi-agent continuation surfaces) | No | +| App-server / SDK | No | Yes (JSON-RPC app-server, Python SDK, thread/fork APIs, authenticated WebSocket/Unix/stdio transports, and experimental remote Code Mode) | No | +| Multi-folder projects | No native primary-folder model | Yes (primary folder owns Git and automatic instruction/config discovery; secondary folders are file-access-only) | No native primary-folder model | | Hosted site deployment | No | Yes (Sites preview, app-only/cloud-hosted with separate secret management) | No | | Desktop workflow capture | No | Yes (Record & Replay on Mac; privacy-sensitive and best treated as private-skill capture until reviewed) | No | @@ -146,10 +150,11 @@ These are the highest-impact gaps where one runtime's limitation blocks effectiv ## 8. Source Notes -- GPT-5.5 Codex availability, 400K context, Fast mode, token-efficiency, and API timing come from OpenAI's 2026-04-23 release note: . -- Codex app/CLI capability changes through app `26.616`, CLI `0.142.3`, and the local desktop app `26.623.31921` come from OpenAI's Codex changelog and local Codex runtime checks: . +- GPT-5.6 model availability, the corrected 272K context window, stable memories and multi-agent support, session organization, Agent Plugins, multi-folder behavior, browser/Chrome changes, and app-server transports come from OpenAI's Codex changelog: . +- Codex app/CLI capability changes are reviewed through app `26.727` and stable CLI `0.146.0`; workstation-specific alpha versions are intentionally kept out of this public matrix. +- GPT-5.6 API model and pricing changes through 2026-07-30 come from the OpenAI API changelog: . - Sites, Amazon Bedrock, Remote, Record & Replay, app-server, plugin, and permissions details come from the official Codex docs under . -- Claude Fable 5 release date, pricing, and plan-inclusion window come from Anthropic's 2026-06-09 announcement: . Safeguard-fallback behavior comes from the Fable 5 / Mythos 5 system card §1.5 ("Novel safeguards"): client apps and Claude interfaces auto-fall back to Opus 4.8 (default and non-configurable in interfaces, session event emitted), while the Messages API blocks by default with a structured refusal category and offers opt-in server-side fallback. The serving model in the header was verified in-session by the Claude runtime. +- Claude Fable 5 release date and pricing come from Anthropic's 2026-06-09 announcement: ; the current plan-inclusion posture (Max/Team Premium shared weekly pool, launch promo ended) reflects Anthropic's plan policy as of 2026-07-20. Claude Opus 5 model ID, pricing ($5/$25 per MTok), 1M context, and its release as the current Opus tier were re-verified 2026-08-03 against Anthropic's model documentation (models overview + migration guide at ): Opus 5 is a drop-in at Opus 4.8's pricing, and Opus 4.8 stays available. Present-day interface fallback behavior comes from Anthropic's Help Center article "Why Claude switched models in your conversation with Fable 5" (2026-07-01, verified 2026-08-04): automatic switching to Opus 4.8 is enabled by default and user-configurable — Settings > Capabilities in the apps, Config > MODEL & OUTPUT in Claude Code ("Switch models when a message is flagged" toggle); with it off, a flagged request pauses the conversation. The Fable 5 / Mythos 5 system card §1.5 ("Novel safeguards") remains the source for the safeguard design, the observed fallback-rate/session-event facts, and the API posture: the Messages API blocks by default with a structured refusal category and offers opt-in server-side fallback — whose category-routed default likewise targets Opus 4.8 for cyber-class refusals, so the fallback target is unchanged post-Opus-5. The serving model (claude-fable-5) and Claude Code version (v2.1.220) in the header were verified in-session by the Claude runtime on 2026-08-03. --- diff --git a/docs/protocol/autonomy.md b/docs/protocol/autonomy.md index f07f327..1376b76 100644 --- a/docs/protocol/autonomy.md +++ b/docs/protocol/autonomy.md @@ -41,8 +41,11 @@ autonomy: enabled: false ``` -When config is absent, receivers behave as `always_pause`. Malformed config -causes a pause and should be surfaced by `oacp doctor`. +When config is absent, receivers behave as `always_pause`. A present config +that omits the `autonomy` block entirely, such as a signing-only config, +resolves the same way: `always_pause`, not `config_malformed`. An explicit but +malformed `autonomy` value still causes a pause and should be surfaced by +`oacp doctor`. `external_side_effects` accepts three policy actions: @@ -222,7 +225,7 @@ Every autonomy decision writes one YAML file: ```yaml schema_version: 2 -spec_version: "0.4.0" +spec_version: "0.4.1" created_at_utc: "2026-05-12T13:23:25Z" receiver: codex sender: iris @@ -313,9 +316,12 @@ missing or malformed config. `sender` is normally traceability metadata and also binds an enabled standing grant to the sender that received approval. `policy_sha256` is the SHA-256 of a canonical, key-sorted serialization of the parsed policy, so comments and YAML formatting do not produce false drift. -`spec_version: "0.4.0"` pins Gate 1 integrity enforcement plus the recalibrated -Gate 2/3 policy, full task-profile capture, explicit `breached` list, and the -outcome block shown above. Audit `schema_version: 2` adds thread identity and +`spec_version: "0.4.1"` pins everything 0.4.0 pinned — Gate 1 integrity +enforcement, the recalibrated Gate 2/3 policy, full task-profile capture, the +explicit `breached` list, and the outcome block shown above — plus +session-scoped envelope enforcement, the enforce-mode trust-pin completeness +gate, and preserved `always_pause` defaults for configs without an `autonomy` +block. Audit `schema_version: 2` adds thread identity and the structured `result.human_outcome` block. Recorders may upgrade a v1 audit to v2 when the first human outcome is written; standing grants trust only v2 records. @@ -623,7 +629,7 @@ The envelope is written to ```json { "envelope_version": 1, - "spec_version": "0.4.0", + "spec_version": "0.4.1", "compiler": "envelope_compiler.py", "compiled_at_utc": "2026-07-12T02:00:00Z", "project": "my-project", @@ -649,7 +655,8 @@ The envelope is written to "private_repo_allowlist": ["example-org/private-repo"] }, "counters": {"files_touched": []}, - "enforcement": "hooks" + "enforcement": "hooks", + "session_id": "sess-…" } ``` @@ -664,6 +671,18 @@ Compilation rules: runtime enforcement never trusts sender declarations alone. - Granular side-effect fields absent from a legacy profile compile to `false`. `counters` are runtime state and always start empty. +- **Session binding.** The envelope records which harness session it + belongs to. The runtime hook — the only party that sees the harness + session id (it is not present in the command environment) — records a + short-lived session claim when it observes the compile command; the + compiler consumes the claim (matching it against the message file it + actually compiles, within a freshness window) and stamps `session_id`. + A recompile for the same task — including a post-re-authorization + `--extend` run from outside the bound session — inherits the existing + binding. When no valid claim exists (an un-hooked runtime compiled, or + the harness supplied no session id) the envelope compiles unbound + (`session_id: null`) and enforcement keeps the historical + (project, agent) scope for every session. ### Delivery: static shim, dynamic envelope @@ -678,6 +697,16 @@ completion clear against the task's audit record (see below), so the enforcement window can be exited from inside the session exactly once the task lifecycle is over. +Enforcement is **session-scoped** when the envelope carries a session +binding: a tool call from a different harness session gets pre-envelope +behavior — no classification, no `files_touched` accounting — so a +concurrent interactive session in the same repository neither inherits the +dispatched task's constraints nor consumes its file budget. The scope never +silently narrows: an unbound envelope enforces every session in the +(project, agent) workspace exactly as before, and a caller the harness gave +no session id is enforced even under a bound envelope (it cannot be proven +foreign). + Runtime decisions: - **deny** — the call breaches a declared-false capability (destructive @@ -715,6 +744,18 @@ Runtime decisions: review; unenforceable never silently degrades to allowed. - **allow** — emitted as *no output*: the envelope can only narrow the harness's own permission surface, never widen or bypass it. +- Every emitted **ask** or **deny** reason starts with the stable + `[oacp-envelope]` source tag. When an active envelope supplies a message id, + the reason also includes `[task ]`; operators can therefore + distinguish an envelope decision from the harness's native permission + prompt and identify the task that caused it. +- Compound Bash classification is quote-aware: separators inside quoted + arguments remain argument data, backslash-newline continuations stay in the + same segment, and top-level or command-substitution segments are still + inspected independently so a read-only head cannot conceal a later + mutation. If argv parsing degrades, only a small known read-only command set + with no shell control, redirection, expansion, or substitution syntax can + pass; everything else escalates to **ask**. - `oacp send` is never denied; it is the checkpoint notification pipe. The exemption is exactly that wide: read-only oacp subcommands pass, all other oacp mutations are classified. @@ -784,11 +825,12 @@ unconditionally — completion sanctions the exit, never recompilation. ### Envelope drift A tool call that would exceed `expected_files_touched` is denied with the -canonical checkpoint opener (`Blocked: autonomy threshold exceeded — -files_touched expected N, now M`), which forces the Threshold-Exceeded -Checkpoint protocol above: the deny fires once, the session stops, notifies -the sender, and awaits re-authorization. A revised profile is recompiled -with `oacp envelope compile --extend`, which preserves accumulated counters. +canonical tagged checkpoint opener (`[oacp-envelope] Blocked: autonomy +threshold exceeded — files_touched expected N, now M`), followed by the +active task id when available. This forces the Threshold-Exceeded Checkpoint +protocol above: the deny fires once, the session stops, notifies the sender, +and awaits re-authorization. A revised profile is recompiled with `oacp +envelope compile --extend`, which preserves accumulated counters. ### Enforcement recording diff --git a/docs/protocol/message_signing.md b/docs/protocol/message_signing.md index c1be872..a4ee33a 100644 --- a/docs/protocol/message_signing.md +++ b/docs/protocol/message_signing.md @@ -16,13 +16,38 @@ outcome — `unsigned`, `signed-verified`, `signed-unknown-kid`, `message_auth` audit block; none of them rejects, quarantines-as-rejection, or changes how a message is processed. A verified signature is a recorded fact about who signed, not a permission. Enforce mode (rejection, receipt -ledger, quarantine activation) is **v0.4.1**, activated only after a warn -soak on live fleet traffic; the seams exist in v0.4.0 and none activate. +ledger, quarantine activation) lands in **a later release**, activated only +after a warn soak on live fleet traffic and an explicit enforcement ruling; +the seams exist in v0.4.0 and none activate. Receivers opt in per-agent via `signing.verify_mode: off | warn` in `agents//config.yaml`. An early `enforce` value degrades to `warn`; anything else degrades to `off`. +## Enforce-mode preparation + +Before any receiver flips to `signing.verify_mode: enforce`, run a fleet-wide +re-pin sweep and then `oacp doctor --project `. Every receiver must pin +every peer identity in the project catalog (a receiver's own catalog identity +is exempt), and every active receiver pin must refer to an identity recorded +in the catalog. Revoked pins remain as audit history and are excluded from the +pin-to-catalog completeness direction. Re-import the applicable public stub +for each missing relationship: + +```bash +oacp trust import /path/to/.pub.json --project --agent +oacp doctor --project +``` + +Doctor reports one aggregate `pin completeness` result with counts in both +directions across all receiver profiles. Gaps are warnings while the project +remains in warn mode. If any receiver explicitly configures enforce mode, the +same aggregate result becomes a blocking error so a missing pin cannot turn +into silent message rejection after the flip. Unlike the advisory +`catalog-not-pinned` drift signal, pre-enforce completeness intentionally has +no liveness exemption: every cataloged peer relationship must be ready before +any profile enforces. + ## Trust root Two files, deliberately asymmetric in authority: @@ -182,7 +207,7 @@ telemetry only. attests to a key, and key custody on-machine is only as strong as the file permissions. This is an accepted, documented limitation of the warn-mode rollout. -- **Keystore hardening is planned follow-up work** for v0.4.1+: OS +- **Keystore hardening is planned follow-up work** for a later release: OS keychain / vault-backed signer backends behind the same `kid` seam. ## Rotation and revocation diff --git a/pyproject.toml b/pyproject.toml index 68e4652..0d35359 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oacp-cli" -version = "0.4.0" +version = "0.4.1" description = "Open Agent Coordination Protocol CLI for file-based multi-agent workflows" readme = "README.md" license = "Apache-2.0" diff --git a/scripts/_oacp_constants.py b/scripts/_oacp_constants.py index 905ba15..1a08334 100644 --- a/scripts/_oacp_constants.py +++ b/scripts/_oacp_constants.py @@ -15,7 +15,7 @@ # The protocol spec version the tooling implements. Stamped into audit # records, compiled envelopes, and workspace.json at init so every artifact # names the contract it was produced under. -SPEC_VERSION = "0.4.0" +SPEC_VERSION = "0.4.1" CREATABLE_RUNTIMES = ("claude", "codex", "cursor", "gemini") ALL_RUNTIMES = ("claude", "codex", "cursor", "gemini", "human", "unknown") CANONICAL_CAPABILITIES = { diff --git a/scripts/autonomy_gate.py b/scripts/autonomy_gate.py index 19ddef1..d4b2260 100644 --- a/scripts/autonomy_gate.py +++ b/scripts/autonomy_gate.py @@ -14,14 +14,16 @@ import datetime as dt import hashlib import json +import os import re import sys +import tempfile from pathlib import Path from typing import Any, Dict, FrozenSet, List, Optional, Sequence, Tuple import yaml -from _oacp_constants import REPO_SLUG_RE, SPEC_VERSION, utc_now_iso +from _oacp_constants import REPO_SLUG_RE, SPEC_VERSION, locked_audit, utc_now_iso from validate_message import validate_message_dict @@ -249,9 +251,81 @@ def load_yaml_file(path: Path) -> Dict[str, Any]: return data +def write_audit_record( + audit_dir: Path, + decision: Dict[str, Any], + *, + config: Dict[str, Any], + message: Dict[str, Any], + message_path: Path, + policy_path: Path, + receiver: str, + now_utc: Optional[dt.datetime] = None, +) -> Path: + """Persist a documented audit event without mutating evaluator stdout. + + The evaluator's result block is admission-time state. Receivers still own + terminal result updates, human outcomes, and message-auth attachment. + """ + audit_dir.mkdir(parents=True, exist_ok=True) + created_at = utc_now_iso(now_utc) + autonomy = config.get("autonomy") + raw_thresholds = ( + autonomy.get("auto_review_thresholds") + if isinstance(autonomy, dict) + else None + ) + thresholds = { + key: raw_thresholds.get(key) if isinstance(raw_thresholds, dict) else None + for key in NUMERIC_THRESHOLD_KEYS + } + audit_record = dict(decision) + audit_record.setdefault("created_at_utc", created_at) + audit_record.setdefault("message_subject", message.get("subject")) + audit_record.setdefault("message_path", str(message_path)) + audit_record.setdefault("policy_path", str(policy_path)) + audit_record.setdefault("thresholds", thresholds) + audit_record.setdefault("runtime", {"agent": receiver, "model": None}) + + message_id = str(decision.get("message_id") or "missing-message-id") + safe_message_id = re.sub(r"[^A-Za-z0-9._-]", "_", message_id).strip("._") + safe_message_id = safe_message_id[:200] or "missing-message-id" + stamp = created_at.replace(":", "").replace("-", "") + audit_path = audit_dir / f"{stamp}_{safe_message_id}.yaml" + content = yaml.safe_dump(audit_record, sort_keys=False, allow_unicode=True) + temp_path: Optional[Path] = None + + with locked_audit(audit_path): + if audit_path.exists(): + raise FileExistsError(f"audit record already exists: {audit_path}") + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=audit_dir, + prefix=f".{audit_path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temp_path = Path(handle.name) + os.chmod(temp_path, 0o600) + os.replace(temp_path, audit_path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + return audit_path + + def validate_receiver_config(config: Dict[str, Any]) -> List[str]: errors: List[str] = [] - autonomy = config.get("autonomy") + if "autonomy" not in config: + return [] + + autonomy = config["autonomy"] if not isinstance(autonomy, dict): return ["field 'autonomy' must be a mapping"] @@ -310,7 +384,8 @@ def receiver_policy(config: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: if errors: raise AutonomyConfigError("; ".join(errors)) - autonomy = config["autonomy"] + autonomy = config.get("autonomy", {"default_mode": "always_pause"}) + assert isinstance(autonomy, dict) # Guaranteed by validation above. thresholds = autonomy.get("auto_review_thresholds") or {} continuation = autonomy.get("continuation_grants") or {} return str(autonomy.get("default_mode")), { @@ -1580,17 +1655,29 @@ def main(argv: Optional[Sequence[str]] = None) -> int: config = load_yaml_file(args.config) message = load_yaml_file(args.message) actuals = load_yaml_file(args.actuals) if args.actuals else None - print(json.dumps( - evaluate_autonomy( - message, - config, - actuals, + decision = evaluate_autonomy( + message, + config, + actuals, + message_path=args.message, + audit_dir=args.audit_dir, + receiver=args.receiver, + ) + if args.audit_dir is not None and decision.get("reason_codes") != [ + "message_replayed" + ]: + write_audit_record( + args.audit_dir, + decision, + config=config, + message=message, message_path=args.message, - audit_dir=args.audit_dir, + policy_path=args.config, receiver=args.receiver, - ), - indent=2, - )) + ) + elif args.audit_dir is not None: + print("NOTE: replay detected; audit record not written", file=sys.stderr) + print(json.dumps(decision, indent=2)) return 0 except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) diff --git a/scripts/claude_envelope_hook.py b/scripts/claude_envelope_hook.py index 1b6b316..fc0a973 100644 --- a/scripts/claude_envelope_hook.py +++ b/scripts/claude_envelope_hook.py @@ -14,8 +14,8 @@ - ``deny`` — the call breaches a declared-false capability, targets a repo outside the receiver allowlist, or drifts past ``expected_files_touched`` - (denied with the canonical ``Blocked: autonomy threshold exceeded`` opener - so the session pivots to the §E checkpoint protocol). + (denied with the canonical ``[oacp-envelope] Blocked: autonomy threshold + exceeded`` opener so the session pivots to the §E checkpoint protocol). - ``ask`` — the hook cannot confidently classify the call (exotic compound command, unresolvable repo). The exact command is escalated for just-in-time human review instead of blanket-denied or silently allowed. @@ -56,11 +56,19 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple from autonomy_gate import DESTRUCTIVE_PATTERNS, load_yaml_file -from envelope_compiler import envelope_path, load_envelope, write_envelope +from envelope_compiler import ( + ENVELOPE_FILENAME, + SESSION_CLAIM_FILENAME, + envelope_path, + load_envelope, + write_envelope, + write_session_claim, +) -BLOCKED_OPENER = "Blocked: autonomy threshold exceeded" +SESSION_CLAIM_STEM = SESSION_CLAIM_FILENAME.rsplit(".", 1)[0] -SEGMENT_SPLIT_RE = re.compile(r"[;|&\n]+") +REASON_PREFIX = "[oacp-envelope]" +BLOCKED_OPENER = f"{REASON_PREFIX} Blocked: autonomy threshold exceeded" # Shell expansion syntax the classifier cannot statically resolve: the shell # expands globs/braces AFTER classification, so a pattern operand can reach a # protected path while its literal spelling does not. Such operands escalate. @@ -222,11 +230,29 @@ def __init__( def _deny(reason: str) -> Decision: - return Decision("deny", reason) + return Decision("deny", _prefixed_reason(reason)) def _ask(reason: str) -> Decision: - return Decision("ask", reason) + return Decision("ask", _prefixed_reason(reason)) + + +def _prefixed_reason(reason: str) -> str: + """Return one operator-visible reason with the stable OACP tag.""" + if reason.startswith(REASON_PREFIX): + return reason + return f"{REASON_PREFIX} {reason}" + + +def _with_message_id(decision: Decision, message_id: str) -> Decision: + """Add the active task id to a non-allow decision when it is known.""" + if decision.action == "allow" or not message_id: + return decision + return Decision( + decision.action, + f"{_prefixed_reason(decision.reason)} [task {message_id}]", + decision.new_files, + ) class WorkspaceContext: @@ -1122,20 +1148,169 @@ def _gate_write_paths( return ALLOW -def _segments_of(command: str) -> List[str]: - """Split a command into classifiable segments (F-002). +def _split_shell_segments(command: str) -> List[str]: + """Split on shell operators outside quotes and substitutions. - Separators cover `;`, `|`, `||`, `&&`, single `&`, and newlines. Command - substitution bodies (`$(...)`, backticks) are appended as additional - segments so a nested mutation is classified like a top-level one. + This is deliberately a small classifier scanner, not a shell parser. It + preserves raw segment text for the existing redirect checks, joins shell + line continuations, and rejects unterminated quoting/substitution instead + of guessing. Separator characters inside quoted arguments stay data. """ - segments = [s.strip() for s in SEGMENT_SPLIT_RE.split(command) if s.strip()] - for match in SUBSTITUTION_RE.finditer(command): + segments: List[str] = [] + current: List[str] = [] + quote: Optional[str] = None + in_backticks = False + substitution_depth = 0 + index = 0 + + def flush() -> None: + segment = "".join(current).strip() + if segment: + segments.append(segment) + current.clear() + + while index < len(command): + char = command[index] + next_char = command[index + 1] if index + 1 < len(command) else "" + + if char == "\\" and quote != "'": + if next_char == "\n": + index += 2 + continue + current.append(char) + if next_char: + current.append(next_char) + index += 2 + else: + index += 1 + continue + + if quote is not None: + current.append(char) + if char == quote: + quote = None + index += 1 + continue + + if in_backticks: + current.append(char) + if char == "`": + in_backticks = False + index += 1 + continue + + if char in ("'", '"'): + quote = char + current.append(char) + index += 1 + continue + if char == "`": + in_backticks = True + current.append(char) + index += 1 + continue + if char == "$" and next_char == "(": + substitution_depth += 1 + current.extend((char, next_char)) + index += 2 + continue + if substitution_depth: + if char == "(": + substitution_depth += 1 + elif char == ")": + substitution_depth -= 1 + current.append(char) + index += 1 + continue + + if char in (";", "\n", "|"): + flush() + index += 1 + if char == "|" and index < len(command) and command[index] in ("|", "&"): + index += 1 + continue + if char == "&": + # `2>&1`, `<&0`, and `&>file` are redirections, not command + # boundaries. Their targets remain visible to the redirect gate. + if (current and current[-1] in ("<", ">")) or next_char == ">": + current.append(char) + index += 1 + continue + flush() + index += 1 + if index < len(command) and command[index] == "&": + index += 1 + continue + + current.append(char) + index += 1 + + if quote is not None or in_backticks or substitution_depth: + raise ValueError("unterminated shell quote or command substitution") + flush() + return segments + + +def _segments_of(command: str) -> List[str]: + """Return top-level and command-substitution segments (F-002).""" + segments = _split_shell_segments(command) + substitutions = list(SUBSTITUTION_RE.finditer(command)) + simple_dollar_substitutions = sum( + match.group(1) is not None for match in substitutions + ) + if command.count("$(") != simple_dollar_substitutions: + # The deliberately small extraction regex cannot prove the contents + # of nested or parenthesized substitutions. Escalate the whole call + # instead of letting an uninspected inner mutation hide under a + # read-only outer command. + raise ValueError("nested or parenthesized command substitution") + for match in substitutions: inner = match.group(1) or match.group(2) or "" - segments.extend(s.strip() for s in SEGMENT_SPLIT_RE.split(inner) if s.strip()) + segments.extend(_split_shell_segments(inner)) return segments +DEGRADED_READONLY_COMMANDS = { + "cat", + "cmp", + "diff", + "grep", + "head", + "jq", + "ls", + "pwd", + "rg", + "stat", + "tail", + "test", + "wc", +} + + +def _degraded_readonly_segment(segment: str) -> bool: + """Conservatively recognize a direct read-only head after argv failure. + + No shell control, redirect, expansion, or substitution syntax is allowed + on this fallback. Ripgrep's command-executing ``--pre`` mode is excluded. + The quote-aware scanner has already rejected unbalanced shell syntax. + """ + if re.search(r"[;|&<>()`$\n]", segment) or EXPANSION_SYNTAX_RE.search(segment): + return False + words = segment.strip().split() + while words and ENV_ASSIGNMENT_RE.match(words[0]): + words.pop(0) + if words and words[0] in ("command", "builtin"): + words.pop(0) + if not words or words[0].startswith("-"): + return False + program = Path(words[0]).name + if program == "rg" and any( + word == "--pre" or word.startswith("--pre=") for word in words[1:] + ): + return False + return program in DEGRADED_READONLY_COMMANDS + + def _classify_segment( tokens: List[str], segment: str, @@ -1308,7 +1483,10 @@ def classify_bash( ) write_targets: List[str] = [] - segments = _segments_of(command) + try: + segments = _segments_of(command) + except ValueError: + return _ask(f"cannot segment shell command: {command!r}") # A command with exactly one segment has no earlier shell state (cd, # export) that could retarget a target-sensitive subcommand after # validation — the completion clear is sanctioned only in that form. @@ -1317,6 +1495,8 @@ def classify_bash( try: tokens = shlex.split(segment, posix=True) except ValueError: + if _degraded_readonly_segment(segment): + continue return _ask(f"cannot classify shell segment: {segment!r}") decision = _classify_segment( tokens, @@ -1408,24 +1588,316 @@ def emit(decision: Decision) -> None: ) +def _compile_message_name(payload: Dict[str, Any]) -> Optional[str]: + """Return the message filename when this tool call runs an envelope compile. + + Recognizes ``oacp envelope compile ...`` (and the script-path + spelling ``... envelope_compiler.py compile ...``) anywhere in a + Bash command. Best-effort by design: a miss degrades to an unbound + envelope, never to a wrong binding — the compiler still checks the + claimed filename against the message it actually compiles. + """ + if str(payload.get("tool_name") or "") != "Bash": + return None + command = str((payload.get("tool_input") or {}).get("command") or "") + if "compile" not in command: + return None + try: + tokens = shlex.split(command, posix=True) + except ValueError: + return None + # Compile options that consume a value: their value token must never be + # mistaken for the positional message (mirrors the compiler's argparse + # surface — keep in sync with envelope_compiler.py's compile subparser). + value_options = {"--receiver", "--project", "--oacp-dir", "--config"} + for index, token in enumerate(tokens[:-1]): + # Front ends for the same compile operation: the `oacp` executable + # (any path spelling), the module CLI (`python3 -m oacp.cli`), and + # the compiler script invoked directly. + is_cli = ( + token == "envelope" + and index > 0 + and tokens[index - 1].endswith(("oacp", "oacp.cli")) + ) + is_script = token.endswith("envelope_compiler.py") + if not (is_cli or is_script): + continue + rest = tokens[index + 1 :] + if not rest or rest[0] != "compile": + continue + skip_next = False + for operand in rest[1:]: + if skip_next: + skip_next = False + continue + if operand.startswith("-"): + if operand in value_options: + skip_next = True + continue + return Path(operand).name + return None + + +def _shared_state_affinity( + payload: Dict[str, Any], state_root: str, cwd: str +) -> str: + """Classify a foreign-session tool call's relation to shared envelope state. + + Returns ``"affine"`` when the call names the shared envelope state that + protects the bound session (an ``oacp envelope`` invocation, a protected + state filename anywhere in the command, or an operand resolving into the + receiver's ``state/`` directory — through any symlinked spelling), + ``"uncertain"`` when the target cannot be determined statically + (unresolved shell expansion, substitution, unparseable input), and + ``"clear"`` for ordinary foreign work. Callers deny mutation-capable + affine calls outright — the classifier itself allows some mutation + spellings (e.g. plain ``rm`` with a ``$VAR`` operand), so falling + through is not a safe backstop — but keep pre-envelope behavior for + provably read-only inspection (``_readonly_state_inspection``), keep + uncertain calls enforced without budget accounting, and bypass only + clear ones. + """ + tool_name = str(payload.get("tool_name") or "") + tool_input = payload.get("tool_input") or {} + # Compare against the canonical spelling of the state root: OACP homes + # reached through a symlink otherwise never match a canonicalized + # candidate, and the guard would wave the real path through. + state_root = os.path.realpath(state_root) + if tool_name in ("Write", "Edit", "NotebookEdit"): + file_path = str( + tool_input.get("file_path") or tool_input.get("notebook_path") or "" + ) + if not file_path: + return "uncertain" + canonical = os.path.realpath(_normalize_file_path(file_path, cwd)) + return "affine" if _within(canonical, state_root) else "clear" + if tool_name != "Bash": + return "clear" + command = str(tool_input.get("command") or "") + lowered = command.lower() + if "envelope" in lowered and ( + "oacp" in lowered or "envelope_compiler" in lowered or "oacp-envelope" in lowered + ): + return "affine" + # Protected state filenames anywhere in the raw command — catches bare + # names after a `cd`, operands inside substitutions, and mv/rm targets. + if ENVELOPE_FILENAME in command or SESSION_CLAIM_STEM in command: + return "affine" + if state_root in command: + return "affine" + parse_failed = False + try: + tokens = shlex.split(command, posix=True) + except ValueError: + tokens = [] + parse_failed = True + for token in tokens: + # An env-assignment token can smuggle the path in its value part. + value = token.split("=", 1)[1] if ENV_ASSIGNMENT_RE.match(token) else token + candidate = os.path.realpath(_normalize_file_path(value, cwd)) + if _within(candidate, state_root): + return "affine" + if ( + parse_failed + or "$" in command + or "`" in command + or EXPANSION_SYNTAX_RE.search(command) + ): + return "uncertain" + return "clear" + + +# Commands that cannot mutate or execute anything when output redirection, +# process substitution, and shell expansion are all absent (each is ruled +# out before this set is consulted). Membership requires the tool to have +# no write mode AND no command-execution flag in any common implementation: +# `sed` (-i), `sort` (-o), `find` (-delete/-exec), `tee`, `rg` (--pre), +# `less`/`more` (shell escapes, `less -o`), and `file` (-C writes a magic +# cache) all stay out. +READONLY_INSPECTION_COMMANDS = { + "cat", + "cd", + "cmp", + "diff", + "echo", + "grep", + "head", + "jq", + "ls", + "pwd", + "stat", + "tail", + "test", + "wc", +} + + +def _readonly_state_inspection(payload: Dict[str, Any]) -> bool: + """True when an affine Bash call is provably read-only inspection. + + State relation is not the same as mutation capability: a foreign + session reading shared state (``oacp envelope show``, ``cat``/``stat`` + on state files) cannot disturb the bound session, so it keeps + pre-envelope behavior. Conservative by construction — any parse + failure, unresolved expansion, redirection, process substitution, or + command outside a small inspection allowlist means the affine deny + stands: the exemption must prove the complete command cannot spawn, + embed, or execute a mutation. + """ + if str(payload.get("tool_name") or "") != "Bash": + return False + command = str((payload.get("tool_input") or {}).get("command") or "") + if not command.strip(): + return False + if "$" in command or "`" in command or ">" in command: + return False + # Process substitution executes its body: `cat <(rm state)` mutates + # under a read-only head. (`>(...)` is already caught by the `>` check.) + if "<(" in command: + return False + if EXPANSION_SYNTAX_RE.search(command): + return False + try: + segments = _segments_of(command) + except ValueError: + return False + for segment in segments: + try: + tokens = shlex.split(segment, posix=True) + except ValueError: + return False + while tokens and ENV_ASSIGNMENT_RE.match(tokens[0]): + tokens.pop(0) + if not tokens: + return False + if Path(tokens[0]).name in READONLY_INSPECTION_COMMANDS: + continue + if _is_envelope_show_invocation(tokens): + continue + return False + return True + + +# `envelope show` takes exactly these value-carrying options (keep in sync +# with envelope_compiler.py's common parser). Anything else denies, so a +# grammar change fails safe. +SHOW_VALUE_OPTIONS = {"--receiver", "--project", "--oacp-dir"} + + +def _is_envelope_show_invocation(tokens: List[str]) -> bool: + """Anchored recognition of the read-only ``envelope show`` form. + + The front end must be the command head — a subsequence scan would let + trailing spoof tokens bless a different leading command (`rm state + oacp envelope show`) — and the remaining argv must fit show's option + grammar exactly. + """ + head = Path(tokens[0]).name + if head == "oacp": + if tokens[1:3] != ["envelope", "show"]: + return False + rest = tokens[3:] + elif head.startswith("python"): + if len(tokens) >= 3 and tokens[1] == "-m": + # Module names are exact — an endswith match would bless + # look-alike modules. + if tokens[2] not in ("oacp.cli", "oacp"): + return False + if tokens[3:5] != ["envelope", "show"]: + return False + rest = tokens[5:] + elif len(tokens) >= 2 and tokens[1].endswith("envelope_compiler.py"): + if tokens[2:3] != ["show"]: + return False + rest = tokens[3:] + else: + return False + elif tokens[0].endswith("envelope_compiler.py"): + if tokens[1:2] != ["show"]: + return False + rest = tokens[2:] + else: + return False + expect_value = False + for token in rest: + if expect_value: + expect_value = False + continue + if token in SHOW_VALUE_OPTIONS: + expect_value = True + continue + return False + return not expect_value + + def process(payload: Dict[str, Any], receiver: str = "claude") -> Decision: from _oacp_env import resolve_oacp_home cwd = str(payload.get("cwd") or os.getcwd()) + session_id = str(payload.get("session_id") or "") project = find_project(Path(cwd)) if project is None: return ALLOW oacp_root = resolve_oacp_home(cwd=Path(cwd)) target = envelope_path(oacp_root, project, receiver) - if not target.is_file(): - return ALLOW from envelope_compiler import envelope_lock + if not target.is_file(): + # No active envelope: if this very call is about to compile one, + # record which session it belongs to. Only the hook sees the harness + # session id (it is absent from the Bash environment), so this + # observation is the binding's sole source. Best-effort: a failed + # claim write must never affect the call's outcome. + if session_id: + message_name = _compile_message_name(payload) + if message_name: + try: + with envelope_lock(target): + if not target.is_file(): + write_session_claim(target, session_id, message_name) + except OSError: + pass + return ALLOW + with envelope_lock(target): envelope = load_envelope(target) if envelope is None: return ALLOW + bound_session = str(envelope.get("session_id") or "") + foreign_session = bool( + bound_session and session_id and bound_session != session_id + ) + if foreign_session: + # Session-bound envelope, different session: pre-envelope + # behavior — no classification, no files_touched accounting. + # An unbound envelope (legacy compile, or a caller the harness + # gave no session id) keeps the historical (project, agent) + # scope: enforcement never silently narrows. + affinity = _shared_state_affinity(payload, str(target.parent), cwd) + if affinity == "affine": + # Pure inspection of shared state cannot disturb the bound + # session — only mutation capability loses the bypass. + if _readonly_state_inspection(payload): + return ALLOW + # A foreign session has no legitimate mutation of the state + # protecting the bound session — deny outright rather than + # trusting classification, which allows some mutation + # spellings (e.g. plain rm with a $VAR operand). + return _with_message_id( + _deny( + "this call can modify envelope state bound to another " + "session; a concurrent session must not compile over, " + "clear, or edit the active envelope (read-only " + "inspection is exempt)" + ), + str(envelope.get("message_id") or ""), + ) + if affinity == "clear": + return ALLOW + # Uncertain target (unresolved expansion / unparseable): stay + # enforced through full classification, but never charge the + # bound session's files_touched budget for foreign work. context = WorkspaceContext( oacp_root=oacp_root, project=str(envelope.get("project") or project), @@ -1439,12 +1911,12 @@ def process(payload: Dict[str, Any], receiver: str = "claude") -> Decision: envelope, context, ) - if decision.action == "allow" and decision.new_files: + if decision.action == "allow" and decision.new_files and not foreign_session: counters = envelope.setdefault("counters", {}) touched = list(counters.get("files_touched") or []) counters["files_touched"] = sorted(set(touched) | set(decision.new_files)) write_envelope(target, envelope) - return decision + return _with_message_id(decision, context.message_id) def main(argv: Optional[Sequence[str]] = None) -> int: diff --git a/scripts/envelope_compiler.py b/scripts/envelope_compiler.py index db65efa..ef15e8e 100644 --- a/scripts/envelope_compiler.py +++ b/scripts/envelope_compiler.py @@ -20,7 +20,9 @@ from __future__ import annotations import argparse +import datetime as dt import fcntl +import hashlib import json import os import re @@ -28,7 +30,7 @@ import tempfile from contextlib import contextmanager from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Sequence +from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple from _oacp_constants import SPEC_VERSION, utc_now_iso from autonomy_gate import ( @@ -46,6 +48,15 @@ ENVELOPE_FILENAME = "active_envelope.json" ENVELOPE_COMPILE_ERROR = "envelope_compile_error" +# Session-claim sidecar: the runtime hook records the compiling session's +# identity here (it alone sees the harness session id, on the tool call that +# runs the compile); the compiler consumes it and stamps the envelope. A +# claim older than this window, or naming a different message file, is +# ignored — the envelope then compiles unbound (session_id null) and the +# hook enforces for every session, the pre-session-binding behavior. +SESSION_CLAIM_FILENAME = "pending_session_claim.json" +SESSION_CLAIM_MAX_AGE_SECONDS = 120 + # Safe-ID grammar for the message id embedded in the envelope. The runtime # adapter compares this id against audit-record content, and it must never # be able to act as a glob/path metacharacter anywhere downstream. @@ -87,6 +98,7 @@ def build_envelope( project: str, message_path: Optional[Path] = None, now_iso: Optional[str] = None, + session_id: Optional[str] = None, ) -> Dict[str, Any]: """Return an envelope dict for an admitted message, or raise :class:`EnvelopeCompileError`. @@ -138,6 +150,7 @@ def build_envelope( "files_touched": [], }, "enforcement": "hooks", + "session_id": session_id or None, } @@ -148,6 +161,100 @@ def envelope_path(oacp_root: Path, project: str, receiver: str) -> Path: return oacp_root / "projects" / project / "agents" / receiver / "state" / ENVELOPE_FILENAME +def session_claim_path(envelope_target: Path, session_id: str) -> Path: + """Per-session claim file: concurrent sessions never overwrite each other. + + Distinct files are what makes a same-message claim race *detectable* — + with one shared file, last-writer-wins would silently bind the compile + to whichever session claimed last. + """ + digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:12] + stem, suffix = SESSION_CLAIM_FILENAME.rsplit(".", 1) + return envelope_target.parent / f"{stem}.{digest}.{suffix}" + + +def _iter_session_claim_paths(envelope_target: Path) -> List[Path]: + stem, suffix = SESSION_CLAIM_FILENAME.rsplit(".", 1) + return sorted(envelope_target.parent.glob(f"{stem}*.{suffix}")) + + +def write_session_claim( + envelope_target: Path, session_id: str, message_name: str +) -> None: + """Record the compiling session's identity for the compiler to consume. + + Callers (the runtime hook) must hold ``envelope_lock(envelope_target)``. + The claim is advisory: losing or skipping it degrades to an unbound + envelope, never to a wrong binding. + """ + claim = { + "session_id": session_id, + "message_name": message_name, + "claimed_at_utc": utc_now_iso(), + } + path = session_claim_path(envelope_target, session_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(claim, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _parse_session_claim(raw: str, message_name: str) -> Tuple[str, Optional[str]]: + """Classify a claim against ``message_name``. + + Returns ``("match", session_id)`` for a fresh claim naming the same + message, ``("other", None)`` for a fresh claim naming a different + message (a concurrent compile in flight — it belongs to that compile), + and ``("discard", None)`` for malformed or stale claims. + """ + try: + claim = json.loads(raw) + if not isinstance(claim, dict): + return ("discard", None) + claimed_at = dt.datetime.strptime( + str(claim.get("claimed_at_utc") or ""), "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=dt.timezone.utc) + age = (dt.datetime.now(dt.timezone.utc) - claimed_at).total_seconds() + if not (0 <= age <= SESSION_CLAIM_MAX_AGE_SECONDS): + return ("discard", None) + if str(claim.get("message_name") or "") != message_name: + return ("other", None) + session_id = str(claim.get("session_id") or "") + return ("match", session_id) if session_id else ("discard", None) + except (ValueError, TypeError): + return ("discard", None) + + +def consume_session_claim(envelope_target: Path, message_name: str) -> Optional[str]: + """Read, validate, and delete the pending claims for ``message_name``. + + Returns a session id only when exactly one session holds a fresh claim + naming the same message file. Two *different* sessions with fresh claims + for the same message are indistinguishable to the compiler — the compile + could belong to either — so ambiguity degrades to an unbound envelope, + never to a wrong binding. Malformed and stale claims are garbage-collected; + a fresh claim naming a *different* message survives untouched so the + concurrent compile it belongs to can still bind. Callers must hold + ``envelope_lock``. + """ + claimed_sessions = set() + for path in _iter_session_claim_paths(envelope_target): + try: + raw = path.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + continue + verdict, session_id = _parse_session_claim(raw, message_name) + if verdict == "other": + continue + try: + path.unlink() + except (FileNotFoundError, OSError): + pass + if verdict == "match" and session_id: + claimed_sessions.add(session_id) + if len(claimed_sessions) == 1: + return claimed_sessions.pop() + return None + + @contextmanager def envelope_lock(path: Path) -> Iterator[None]: """Serialize envelope read-modify-write cycles across processes. @@ -248,6 +355,7 @@ def _cmd_compile(args: argparse.Namespace, oacp_root: Path) -> int: target = envelope_path(oacp_root, project, args.receiver) with envelope_lock(target): + claimed_session = consume_session_claim(target, message_path.name) existing = load_envelope(target) if existing is not None: same_message = existing.get("message_id") == envelope["message_id"] @@ -266,6 +374,14 @@ def _cmd_compile(args: argparse.Namespace, oacp_root: Path) -> int: envelope["counters"]["files_touched"] = list( prior["files_touched"] ) + # A recompile for the same task keeps its session binding: + # the documented --extend runs outside the bound session + # (post-re-auth, often a human terminal with no hook to + # write a fresh claim), and dropping the binding there + # would silently re-expose peer sessions to enforcement. + if claimed_session is None: + claimed_session = str(existing.get("session_id") or "") or None + envelope["session_id"] = claimed_session write_envelope(target, envelope) if args.json: diff --git a/scripts/message_verify.py b/scripts/message_verify.py index 40fe1e1..a87d735 100644 --- a/scripts/message_verify.py +++ b/scripts/message_verify.py @@ -23,7 +23,8 @@ - **Warn mode records identity and grants no authority**: every outcome — unsigned / signed-unknown-kid / signed-verified / signed-INVALID — produces an annotation and a ``message_auth`` audit block, never a rejection. - Enforce/quarantine-as-rejection is v0.4.1; the seams exist, none activate. + Enforce/quarantine-as-rejection lands in a later release; the seams + exist, none activate. - **No-clobber quarantine**: byte-tamper cases (a present signature that fails verification) write an evidence copy aside into ``dead_letter/`` with exclusive-create semantics. The original message file is never @@ -75,7 +76,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 # Pinned message_auth status enum (v0.4.0 warn subset; replayed/id_collision -# join with the v0.4.1 receipt ledger). +# join with the future receipt ledger). STATUS_UNSIGNED = "unsigned" STATUS_VERIFIED = "verified" STATUS_INVALID = "invalid" diff --git a/scripts/oacp_doctor.py b/scripts/oacp_doctor.py index 9461e2c..1237991 100644 --- a/scripts/oacp_doctor.py +++ b/scripts/oacp_doctor.py @@ -495,14 +495,21 @@ def _validate_status_data(data: Any, agent_name: str) -> List[str]: def validate_autonomy_config_data(data: Any) -> List[str]: - """Validate agents//config.yaml autonomy settings.""" + """Validate agents//config.yaml autonomy settings. + + A config with no ``autonomy`` block preserves the same conservative + ``always_pause`` default as an absent config file. This lets independent + settings such as message signing be enabled without changing receiver + autonomy. + """ errors: List[str] = [] if not isinstance(data, dict): return ["root must be a YAML mapping"] - autonomy = data.get("autonomy") - if autonomy is None: - return ["missing required field 'autonomy'"] + if "autonomy" not in data: + return [] + + autonomy = data["autonomy"] if not isinstance(autonomy, dict): return ["field 'autonomy' must be a mapping"] @@ -659,10 +666,17 @@ def check_autonomy( message=f"{agent_name}/config.yaml — {'; '.join(errs)}", )) else: + autonomy_suffix = ( + "; autonomy absent, defaults to always_pause" + if isinstance(data, dict) and "autonomy" not in data + else "" + ) cat.results.append(DoctorResult( name=f"{agent_name}/config.yaml", severity=Severity.ok, - message=f"{agent_name}/config.yaml — valid", + message=( + f"{agent_name}/config.yaml — valid{autonomy_suffix}" + ), )) orphaned: List[str] = [] @@ -772,7 +786,112 @@ def check_agent_status( # ── Category 6: Trust Root ─────────────────────────────────────────────── -def check_trust(project_dir: Path) -> DoctorCategory: +def _configured_enforce_receivers( + project_dir: Path, + *, + yaml_loader: Optional[Any] = None, +) -> List[str]: + """Return receivers that explicitly configure signing enforcement.""" + loader = yaml_loader + if loader is None: + yaml_mod = _try_yaml_import() + if yaml_mod is not None: + loader = yaml_mod.safe_load + if loader is None: + return [] + + agents_dir = project_dir / "agents" + if not agents_dir.is_dir(): + return [] + + receivers: List[str] = [] + for agent_dir in sorted(agents_dir.iterdir()): + config_path = agent_dir / "config.yaml" + if not agent_dir.is_dir() or not config_path.is_file(): + continue + try: + config = loader(config_path.read_text(encoding="utf-8")) + except Exception: + continue # Schema checks report malformed configs separately. + if not isinstance(config, dict): + continue + signing = config.get("signing") + if not isinstance(signing, dict): + continue + mode = signing.get("verify_mode") + if isinstance(mode, str) and mode.strip().lower() == "enforce": + receivers.append(agent_dir.name) + return receivers + + +def _trust_completeness(project_dir: Path) -> Optional[Dict[str, Any]]: + """Count both gap directions for every locally profiled receiver.""" + try: + from message_verify import ALLOWED_SIGNERS_RELPATH, load_allowed_signers + from trust_root import ( + CATALOG_RELPATH, + TrustRootError, + load_catalog, + receiver_pin_gaps, + ) + except ImportError: # pragma: no cover - packaging guard + return None + + agents_dir = project_dir / "agents" + if not agents_dir.is_dir(): + return None + + try: + catalog = load_catalog(project_dir / CATALOG_RELPATH) + except TrustRootError: + return None # The canonical drift report emits the blocking detail. + + receiver_count = 0 + catalog_to_pin = 0 + pin_to_catalog = 0 + receiver_gaps: Dict[str, Dict[str, int]] = {} + for agent_dir in sorted(agents_dir.iterdir()): + if not agent_dir.is_dir(): + continue + pins_path = agent_dir / ALLOWED_SIGNERS_RELPATH + # Legacy workspaces may retain bare agent directories that were never + # configured as receivers. A receiver config or trust pins is the + # local profile signal; do not turn dormant directory stubs into + # enforce-readiness requirements. + if not (agent_dir / "config.yaml").is_file() and not pins_path.is_file(): + continue + try: + pins = load_allowed_signers(pins_path) + except TrustRootError: + continue # The canonical drift report emits the blocking detail. + + receiver_count += 1 + catalog_gap_kids, pin_gap_kids = receiver_pin_gaps( + catalog, + pins, + agent_dir.name, + ) + receiver_catalog_to_pin = len(catalog_gap_kids) + receiver_pin_to_catalog = len(pin_gap_kids) + catalog_to_pin += receiver_catalog_to_pin + pin_to_catalog += receiver_pin_to_catalog + receiver_gaps[agent_dir.name] = { + "catalog_to_pin": receiver_catalog_to_pin, + "pin_to_catalog": receiver_pin_to_catalog, + } + + return { + "receiver_count": receiver_count, + "catalog_to_pin": catalog_to_pin, + "pin_to_catalog": pin_to_catalog, + "receiver_gaps": receiver_gaps, + } + + +def check_trust( + project_dir: Path, + yaml_loader: Optional[Any] = None, +) -> DoctorCategory: """Check catalog-vs-pins drift for the project's signing trust root. Zero-authority semantics: an unpinned catalog @@ -823,6 +942,51 @@ def check_trust(project_dir: Path) -> DoctorCategory: message=finding["message"], fix_hint=fix_hint, )) + + completeness = _trust_completeness(project_dir) + if completeness is not None: + catalog_to_pin = completeness["catalog_to_pin"] + pin_to_catalog = completeness["pin_to_catalog"] + gap_count = catalog_to_pin + pin_to_catalog + enforce_receivers = _configured_enforce_receivers( + project_dir, + yaml_loader=yaml_loader, + ) + severity = Severity.ok + if gap_count: + severity = Severity.error if enforce_receivers else Severity.warn + enforcement_note = "" + if gap_count and enforce_receivers: + enforcement_note = ( + "; blocking because verify_mode=enforce is configured for " + + ", ".join(enforce_receivers) + ) + receiver_note = "" + receiver_gaps = completeness["receiver_gaps"] + if receiver_gaps: + receiver_note = "; per receiver: " + "; ".join( + f"{receiver}={counts['catalog_to_pin'] + counts['pin_to_catalog']} " + f"gap(s) ({counts['catalog_to_pin']} catalog-to-pin, " + f"{counts['pin_to_catalog']} pin-to-catalog)" + for receiver, counts in receiver_gaps.items() + ) + cat.results.append(DoctorResult( + name="trust-pin-completeness", + severity=severity, + message=( + f"pin completeness — {gap_count} gap(s) across " + f"{completeness['receiver_count']} receiver(s): " + f"{catalog_to_pin} catalog-to-pin, " + f"{pin_to_catalog} pin-to-catalog{enforcement_note}" + f"{receiver_note}" + ), + fix_hint=( + "Run the pre-enforce re-pin sweep for peer catalog identities; " + "re-import or revoke active pins absent from the catalog" + if gap_count + else "" + ), + )) return cat @@ -1276,7 +1440,7 @@ def run_doctor( categories.append(check_schemas(project_dir, yaml_loader=yaml_loader)) categories.append(check_autonomy(project_dir, yaml_loader=yaml_loader)) categories.append(check_agent_status(project_dir, yaml_loader=yaml_loader, now_fn=now_fn)) - categories.append(check_trust(project_dir)) + categories.append(check_trust(project_dir, yaml_loader=yaml_loader)) if include_memory: categories.append(check_memory_sync(oacp_dir, runner=runner, now_fn=now_fn)) diff --git a/scripts/trust_root.py b/scripts/trust_root.py index 409d2bf..3f6d635 100644 --- a/scripts/trust_root.py +++ b/scripts/trust_root.py @@ -607,6 +607,31 @@ def revoke_pin( # Catalog-vs-pins drift (consumed by `oacp doctor`) # --------------------------------------------------------------------------- + +def receiver_pin_gaps( + catalog: Dict[str, Dict[str, Any]], + pins: Dict[str, Dict[str, Any]], + receiver: str, +) -> Tuple[List[str], List[str]]: + """Return completeness gaps for one receiver in both directions. + + A receiver never needs to pin its own catalog identity. Revoked pins are + retained as an audit trail and therefore do not become pin-to-catalog + gaps when their catalog identity is decommissioned. + """ + catalog_to_pin = sorted( + kid + for kid, entry in catalog.items() + if entry.get("agent") != receiver and kid not in pins + ) + pin_to_catalog = sorted( + kid + for kid, pin in pins.items() + if pin.get("status") == PIN_STATUS_ACTIVE and kid not in catalog + ) + return catalog_to_pin, pin_to_catalog + + def _drift(severity: str, code: str, message: str) -> Dict[str, str]: return {"severity": severity, "code": code, "message": message} @@ -672,12 +697,14 @@ def drift_report(project_dir: Path) -> List[Dict[str, str]]: for agent in sorted(loaded_pins): pins = loaded_pins[agent] - active = 0 - for kid, pin in pins.items(): - if pin.get("status") != PIN_STATUS_ACTIVE: - continue - active += 1 - if catalog_usable and kid not in catalog: + active = sum( + 1 for pin in pins.values() + if pin.get("status") == PIN_STATUS_ACTIVE + ) + _, pin_to_catalog = receiver_pin_gaps(catalog, pins, agent) + if catalog_usable: + for kid in pin_to_catalog: + pin = pins[kid] findings.append( _drift( DRIFT_WARN, @@ -704,10 +731,10 @@ def drift_report(project_dir: Path) -> List[Dict[str, str]]: # receiver's inbox has traffic from that agent); otherwise note it. for agent in sorted(loaded_pins): pins = loaded_pins[agent] - for kid, entry in catalog.items(): + catalog_to_pin, _ = receiver_pin_gaps(catalog, pins, agent) + for kid in catalog_to_pin: + entry = catalog[kid] catalog_agent = entry.get("agent") - if catalog_agent == agent or kid in pins: - continue pinned_elsewhere = any( other != agent and other_pins.get(kid, {}).get("status") == PIN_STATUS_ACTIVE diff --git a/tests/test_autonomy_gate.py b/tests/test_autonomy_gate.py index cbf71ca..f7daac8 100644 --- a/tests/test_autonomy_gate.py +++ b/tests/test_autonomy_gate.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib +import json import re import shutil import sys @@ -24,6 +25,7 @@ canonical_policy_sha256, evaluate_autonomy, evaluate_threshold_checkpoint, + main as autonomy_main, normalize_scope_envelope, ) @@ -137,6 +139,70 @@ def test_autonomy_gate_records_raw_message_hash_when_path_provided() -> None: assert "message_hash_recorded" in decision["reason_codes"] +def test_autonomy_gate_cli_writes_audit_and_preserves_stdout( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config_path = FIXTURE_ROOT / "configs" / "auto_review_standard.yaml" + message_path = FIXTURE_ROOT / "messages" / "clean_task.yaml" + audit_dir = tmp_path / "audit" / "autonomy_decisions" + + assert autonomy_main([ + "--config", + str(config_path), + "--message", + str(message_path), + "--audit-dir", + str(audit_dir), + "--receiver", + "codex", + ]) == 0 + + first_output = capsys.readouterr() + stdout_decision = json.loads(first_output.out) + assert first_output.err == "" + audit_files = list(audit_dir.glob("*.yaml")) + assert len(audit_files) == 1 + assert re.fullmatch( + r"\d{8}T\d{6}Z_msg-20260512120000-iris-clean1\.yaml", + audit_files[0].name, + ) + audit_record = _load_yaml(audit_files[0]) + for key, value in stdout_decision.items(): + assert audit_record[key] == value + assert re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", + audit_record["created_at_utc"], + ) + assert audit_record["message_subject"] == "Small docs cleanup" + assert audit_record["message_path"] == str(message_path) + assert audit_record["policy_path"] == str(config_path) + assert audit_record["thresholds"] == { + "max_estimated_minutes": 45, + "max_expected_files_touched": 5, + } + assert audit_record["runtime"] == {"agent": "codex", "model": None} + assert audit_files[0].with_name(audit_files[0].name + ".lock").is_file() + + assert autonomy_main([ + "--config", + str(config_path), + "--message", + str(message_path), + "--audit-dir", + str(audit_dir), + "--receiver", + "codex", + ]) == 0 + + replay_output = capsys.readouterr() + replay_decision = json.loads(replay_output.out) + assert replay_decision["decision"] == "paused" + assert replay_decision["reason_codes"] == ["message_replayed"] + assert replay_output.err == "NOTE: replay detected; audit record not written\n" + assert list(audit_dir.glob("*.yaml")) == audit_files + + def test_autonomy_gate_self_stamps_evaluator_provenance() -> None: import autonomy_gate diff --git a/tests/test_claude_envelope_hook.py b/tests/test_claude_envelope_hook.py index 8c44360..0436c27 100644 --- a/tests/test_claude_envelope_hook.py +++ b/tests/test_claude_envelope_hook.py @@ -17,7 +17,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) import claude_envelope_hook as hook # noqa: E402 -from envelope_compiler import envelope_path, load_envelope, write_envelope # noqa: E402 +from envelope_compiler import ( # noqa: E402 + envelope_path, + load_envelope, + session_claim_path, + write_envelope, +) CONSTRAINTS: Dict[str, Any] = { @@ -620,6 +625,72 @@ def test_background_separated_mutation_denied() -> None: assert bash("true & gh pr merge 162 --squash").action == "deny" +def test_quoted_pipe_pattern_stays_in_readonly_segment() -> None: + command = ( + "oacp envelope show --project test-proj --receiver claude 2>&1 " + '| head -25 && command grep -n "expires\\|ttl\\|estimated_minutes" ' + "config.yaml | head -8" + ) + + assert bash(command).action == "allow" + + +@pytest.mark.parametrize( + "command", + [ + 'rg -n "foo|bar" README.md', + "awk '{print $1 \"|\" $2}' data.txt", + "git log --oneline \\\n --max-count=5", + ], +) +def test_quoted_separators_and_line_continuations_stay_in_segment( + command: str, +) -> None: + assert bash(command).action == "allow" + + +@pytest.mark.parametrize( + "command", + [ + 'grep -n "safe|pattern" file | tee .env', + 'grep -n "safe|pattern" file && gh pr merge 12 --squash', + 'command grep -n "safe|pattern" file > pyproject.toml', + 'grep "$(gh pr merge 12 --squash)" file', + ], +) +def test_quoted_readonly_head_does_not_hide_mutation(command: str) -> None: + assert bash(command).action == "deny" + + +def test_nested_command_substitution_fails_closed() -> None: + command = 'echo "$(printf \'(safe)\'; gh pr merge 12 --squash)"' + + assert bash(command).action == "ask" + + +def test_readonly_head_fallback_is_narrow_when_argument_parser_degrades( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_split = hook.shlex.split + + def degraded_split(command: str, *args: Any, **kwargs: Any): + if command.startswith(("grep ", "command grep ", "rg ")): + raise ValueError("simulated parser degradation") + return original_split(command, *args, **kwargs) + + monkeypatch.setattr(hook.shlex, "split", degraded_split) + + assert bash('grep -n "safe pattern" file').action == "allow" + assert bash('command grep -n "safe pattern" file').action == "allow" + assert bash('grep -n "safe pattern" file > .env').action == "ask" + assert bash("grep *.txt file").action == "ask" + assert bash("rg --pre='rm -f .env' needle file").action == "ask" + + +def test_natural_readonly_fallback_allows_trailing_backslash() -> None: + assert bash("cat safe-file\\").action == "allow" + + def test_shell_indirection_asks() -> None: assert bash("bash -c 'gh pr merge 162 --squash'").action == "ask" assert bash("xargs -I{} sh -c '{}'").action == "ask" @@ -898,6 +969,448 @@ def test_process_enforces_and_persists_counters(tmp_path: Path) -> None: assert stored["counters"]["files_touched"] == [str(Path(repo) / "a.py")] +def test_process_foreign_session_noop_and_no_budget_pool(tmp_path: Path) -> None: + """The two-process scenario: a concurrent session must get pre-envelope + behavior under another session's envelope, and its writes must not + consume the dispatched task's files_touched budget.""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-a" + target = _install_envelope(tmp_path, envelope) + + foreign_merge = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "gh pr merge 1"}, + "cwd": str(repo), + "session_id": "sess-b", + } + ) + assert foreign_merge.action == "allow" + + foreign_write = hook.process( + { + "tool_name": "Write", + "tool_input": {"file_path": "b.py"}, + "cwd": str(repo), + "session_id": "sess-b", + } + ) + assert foreign_write.action == "allow" + assert load_envelope(target)["counters"]["files_touched"] == [] + + owner_merge = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "gh pr merge 1"}, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert owner_merge.action == "deny" + + owner_write = hook.process( + { + "tool_name": "Write", + "tool_input": {"file_path": "a.py"}, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert owner_write.action == "allow" + assert load_envelope(target)["counters"]["files_touched"] == [ + str(Path(repo) / "a.py") + ] + + +def test_foreign_session_cannot_touch_shared_envelope_state(tmp_path: Path) -> None: + """The foreign-session bypass must not extend to the envelope state that + protects the bound session: clear/compile and direct state-file writes + keep full classification (and its self-modification denials).""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-owner" + target = _install_envelope(tmp_path, envelope) + + foreign_clear = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": "oacp envelope clear --project test-proj --oacp-dir /home" + }, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert foreign_clear.action != "allow" + assert target.is_file() + + foreign_compile = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": "oacp envelope compile /inbox/other.yaml --receiver claude" + }, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert foreign_compile.action != "allow" + + foreign_write = hook.process( + { + "tool_name": "Write", + "tool_input": {"file_path": str(target)}, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert foreign_write.action != "allow" + + foreign_rm = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": f"rm {target}"}, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert foreign_rm.action != "allow" + + # Ordinary foreign work is still bypassed — the guard is state-scoped. + ordinary = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "gh pr merge 1"}, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert ordinary.action == "allow" + + +def test_foreign_session_readonly_state_inspection_keeps_bypass( + tmp_path: Path, +) -> None: + """State relation is not mutation capability: a foreign session reading + shared state gets pre-envelope ALLOW with no counter updates, while + mutation-capable spellings of the same surfaces keep the deny.""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-owner" + target = _install_envelope(tmp_path, envelope) + + readonly_commands = [ + f"cat {target}", + "oacp envelope show --project test-proj --oacp-dir /home", + "python3 -m oacp.cli envelope show --project test-proj", + f"cd {target.parent} && cat active_envelope.json | grep session_id", + f"stat {target}; wc -l {target}", + ] + for command in readonly_commands: + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": command}, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert decision.action == "allow", command + assert load_envelope(target)["counters"]["files_touched"] == [] + + mutating_commands = [ + "oacp envelope clear --project test-proj --oacp-dir /home", + # Redirection loses the exemption even under a read-only command. + f"cat {target} > /tmp/copy.json", + # A read segment cannot launder a mutating one in the compound. + f"cat {target} && rm {target}", + f"echo extra >> {target}", + # Process substitution executes its body under a read-only head. + f"cat <(rm {target})", + # Allowlisted-by-name tools with execution flags stay excluded. + f"rg --pre rm needle {target}", + # Trailing show tokens must not bless a different command head. + f"rm {target} oacp envelope show", + # Interactive pagers (shell escapes) are not inspection tools. + f"less {target}", + # A look-alike module must not pass for the real CLI. + "python3 -m evil_oacp envelope show --project test-proj", + # Unknown option grammar on show fails safe. + "oacp envelope show --project test-proj --unknown-flag", + ] + for command in mutating_commands: + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": command}, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert decision.action != "allow", command + assert target.is_file() + + +def test_foreign_session_variable_state_mutation_stays_enforced( + tmp_path: Path, +) -> None: + """Unresolved shell expansion hides the target from the guard: an env + assignment plus a $VAR operand must stay enforced, not bypassed.""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-owner" + target = _install_envelope(tmp_path, envelope) + + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": ( + f"STATE={target.parent}; rm $STATE/active_envelope.json" + ) + }, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert decision.action != "allow" + assert target.is_file() + + # A bare protected filename after a cd also stays enforced. + cd_form = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": f"cd {target.parent} && rm active_envelope.json" + }, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert cd_form.action != "allow" + + +def test_foreign_session_guard_canonicalizes_symlinked_state_root( + tmp_path: Path, +) -> None: + """A state root reached through a symlink must still contain the real + path: candidates are canonicalized, so the root must be too.""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-owner" + target = _install_envelope(tmp_path, envelope) + + link_home = tmp_path / "home_link" + link_home.symlink_to(tmp_path / "home") + symlinked_state_root = str( + link_home / "projects" / "test-proj" / "agents" / "claude" / "state" + ) + assert hook._shared_state_affinity( + { + "tool_name": "Write", + "tool_input": {"file_path": str(target)}, + }, + symlinked_state_root, + str(repo), + ) == "affine" + assert hook._shared_state_affinity( + { + "tool_name": "Bash", + "tool_input": {"command": f"mv {target} /tmp/stolen.json"}, + }, + symlinked_state_root, + str(repo), + ) == "affine" + + +def test_foreign_session_uncertain_command_enforced_without_pooling( + tmp_path: Path, +) -> None: + """Expansion-bearing foreign commands with no state affinity stay + enforced through classification, and foreign work never charges the + bound session's files_touched budget.""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-owner" + target = _install_envelope(tmp_path, envelope) + + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "echo $HOME"}, + "cwd": str(repo), + "session_id": "sess-foreign", + } + ) + assert decision.action == "allow" + assert load_envelope(target)["counters"]["files_touched"] == [] + + +def test_process_unbound_envelope_enforces_every_session(tmp_path: Path) -> None: + """An envelope without a session binding keeps the historical + (project, agent) scope even for callers that identify themselves.""" + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "gh pr merge 1"}, + "cwd": str(repo), + "session_id": "sess-b", + } + ) + assert decision.action == "deny" + + +def test_process_bound_envelope_enforces_unidentified_caller(tmp_path: Path) -> None: + """A caller the harness gave no session id cannot be proven foreign, so + a bound envelope still enforces it — scope never silently narrows.""" + repo = _make_workspace(tmp_path) + envelope = make_envelope() + envelope["session_id"] = "sess-a" + _install_envelope(tmp_path, envelope) + decision = hook.process( + {"tool_name": "Bash", "tool_input": {"command": "gh pr merge 1"}, "cwd": str(repo)} + ) + assert decision.action == "deny" + + +def test_process_records_session_claim_for_compile_command(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + target = envelope_path(tmp_path / "home", "test-proj", "claude") + claim_file = session_claim_path(target, "sess-a") + + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": ( + "oacp envelope compile /inbox/msg_x.yaml " + "--receiver claude --oacp-dir /home" + ) + }, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert decision.action == "allow" + claim = json.loads(claim_file.read_text(encoding="utf-8")) + assert claim["session_id"] == "sess-a" + assert claim["message_name"] == "msg_x.yaml" + + claim_file.unlink() + + script_spelling = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": "python3 scripts/envelope_compiler.py compile /inbox/msg_y.yaml" + }, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert script_spelling.action == "allow" + assert json.loads(claim_file.read_text(encoding="utf-8"))["message_name"] == "msg_y.yaml" + + +def test_compile_claim_recorded_for_module_cli_spelling(tmp_path: Path) -> None: + """`python3 -m oacp.cli envelope compile …` is a supported front end and + must record the claim like the executable spelling; end-to-end, the + compiled envelope binds to the hook session.""" + repo = _make_workspace(tmp_path) + home = tmp_path / "home" + target = envelope_path(home, "test-proj", "claude") + + agent_dir = home / "projects" / "test-proj" / "agents" / "claude" + agent_dir.mkdir(parents=True, exist_ok=True) + conformance = Path(__file__).resolve().parent / "conformance" / "autonomy" + (agent_dir / "config.yaml").write_bytes( + (conformance / "configs" / "auto_review_standard.yaml").read_bytes() + ) + inbox = agent_dir / "inbox" + inbox.mkdir(exist_ok=True) + message_path = inbox / "msg_m.yaml" + message_path.write_bytes( + (conformance / "messages" / "clean_task.yaml").read_bytes() + ) + + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": ( + f"python3 -m oacp.cli envelope compile {message_path} " + f"--oacp-dir {home}" + ) + }, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert decision.action == "allow" + claim_file = session_claim_path(target, "sess-a") + assert json.loads(claim_file.read_text(encoding="utf-8"))["message_name"] == "msg_m.yaml" + + from envelope_compiler import main as compiler_main + + assert compiler_main( + ["compile", str(message_path), "--oacp-dir", str(home)] + ) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + + +def test_compile_claim_survives_option_before_positional(tmp_path: Path) -> None: + """Value options must not be mistaken for the positional message.""" + repo = _make_workspace(tmp_path) + target = envelope_path(tmp_path / "home", "test-proj", "claude") + + decision = hook.process( + { + "tool_name": "Bash", + "tool_input": { + "command": ( + "oacp envelope compile --receiver claude " + "--oacp-dir /home /inbox/msg_z.yaml" + ) + }, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert decision.action == "allow" + claim_file = session_claim_path(target, "sess-a") + assert json.loads(claim_file.read_text(encoding="utf-8"))["message_name"] == "msg_z.yaml" + + +def test_process_writes_no_claim_without_session_or_compile(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + target = envelope_path(tmp_path / "home", "test-proj", "claude") + claim_file = session_claim_path(target, "sess-a") + + no_session = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "oacp envelope compile /inbox/msg_x.yaml"}, + "cwd": str(repo), + } + ) + assert no_session.action == "allow" + assert not claim_file.exists() + + not_compile = hook.process( + { + "tool_name": "Bash", + "tool_input": {"command": "oacp envelope show --project test-proj"}, + "cwd": str(repo), + "session_id": "sess-a", + } + ) + assert not_compile.action == "allow" + assert not claim_file.exists() + + def test_main_emits_deny_json(tmp_path: Path, monkeypatch, capsys) -> None: repo = _make_workspace(tmp_path) _install_envelope(tmp_path, make_envelope()) @@ -910,6 +1423,9 @@ def test_main_emits_deny_json(tmp_path: Path, monkeypatch, capsys) -> None: assert hook.main([]) == 0 output = json.loads(capsys.readouterr().out) assert output["hookSpecificOutput"]["permissionDecision"] == "deny" + reason = output["hookSpecificOutput"]["permissionDecisionReason"] + assert reason.startswith("[oacp-envelope]") + assert "msg-1" in reason def test_main_allow_is_silent(tmp_path: Path, monkeypatch, capsys) -> None: @@ -939,6 +1455,9 @@ def test_main_corrupt_envelope_asks(tmp_path: Path, monkeypatch, capsys) -> None assert hook.main([]) == 0 output = json.loads(capsys.readouterr().out) assert output["hookSpecificOutput"]["permissionDecision"] == "ask" + assert output["hookSpecificOutput"]["permissionDecisionReason"].startswith( + "[oacp-envelope]" + ) def test_main_malformed_stdin_asks(monkeypatch, capsys) -> None: @@ -946,6 +1465,9 @@ def test_main_malformed_stdin_asks(monkeypatch, capsys) -> None: assert hook.main([]) == 0 output = json.loads(capsys.readouterr().out) assert output["hookSpecificOutput"]["permissionDecision"] == "ask" + assert output["hookSpecificOutput"]["permissionDecisionReason"].startswith( + "[oacp-envelope]" + ) # ── Completion clear: audit-sanctioned envelope exit ───────────────────────── diff --git a/tests/test_envelope_compiler.py b/tests/test_envelope_compiler.py index 4db3081..0451dee 100644 --- a/tests/test_envelope_compiler.py +++ b/tests/test_envelope_compiler.py @@ -22,6 +22,8 @@ envelope_path, load_envelope, main, + session_claim_path, + write_session_claim, ) @@ -298,6 +300,154 @@ def test_cli_force_resets_counters_for_new_message(tmp_path: Path, capsys) -> No assert updated["counters"]["files_touched"] == [] +def test_cli_compile_consumes_fresh_session_claim(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", message_path.name) + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + envelope = load_envelope(target) + assert envelope["session_id"] == "sess-a" + assert not session_claim_path(target, "sess-a").exists() + + +def test_cli_compile_binds_with_options_before_positional( + tmp_path: Path, capsys +) -> None: + """End-to-end for the option-order form: claim written, options-first + compile invocation, binding preserved.""" + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", message_path.name) + + assert main([ + "compile", "--receiver", "claude", "--oacp-dir", str(tmp_path), + str(message_path), + ]) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + + +def test_cli_compile_racing_claims_from_two_sessions_compile_unbound( + tmp_path: Path, capsys +) -> None: + """The reviewed interleaving: session A claims, session B claims the same + message before A's compiler runs. The compile must not bind to either — + ambiguity degrades to unbound, never to a wrong binding.""" + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", message_path.name) + write_session_claim(target, "sess-b", message_path.name) + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] is None + assert not session_claim_path(target, "sess-a").exists() + assert not session_claim_path(target, "sess-b").exists() + + +def test_cli_compile_same_session_double_claim_still_binds( + tmp_path: Path, capsys +) -> None: + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", message_path.name) + write_session_claim(target, "sess-a", message_path.name) + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + + +def test_cli_compile_without_claim_is_unbound(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + envelope = load_envelope(envelope_path(tmp_path, "test-proj", "claude")) + assert envelope["session_id"] is None + + +def test_cli_compile_ignores_mismatched_claim(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", "some-other-message.yaml") + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] is None + # A fresh claim for a different message belongs to the compile in + # flight for that message — it must survive this consumption. + assert session_claim_path(target, "sess-a").is_file() + + +def test_cli_compile_other_messages_claim_survives_and_binds_its_own( + tmp_path: Path, capsys +) -> None: + """Two sessions claim two different messages: each compile binds its own + claimant, and neither consumption destroys the other's pending claim.""" + inbox = _workspace(tmp_path) + first = _write_message(inbox, "msg-1") + second = _write_message(inbox, "msg-2") + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", first.name) + write_session_claim(target, "sess-b", second.name) + + assert main(["compile", str(first), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + assert session_claim_path(target, "sess-b").is_file() + + assert main([ + "compile", str(second), "--oacp-dir", str(tmp_path), "--force", + ]) == 0 + assert load_envelope(target)["session_id"] == "sess-b" + assert not session_claim_path(target, "sess-b").exists() + + +def test_cli_compile_ignores_stale_claim(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + message_path = _write_message(inbox) + target = envelope_path(tmp_path, "test-proj", "claude") + claim_file = session_claim_path(target, "sess-a") + claim_file.parent.mkdir(parents=True, exist_ok=True) + claim_file.write_text( + json.dumps( + { + "session_id": "sess-a", + "message_name": message_path.name, + "claimed_at_utc": "2026-01-01T00:00:00Z", + } + ), + encoding="utf-8", + ) + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] is None + + +def test_cli_recompile_and_extend_preserve_session_binding( + tmp_path: Path, capsys +) -> None: + inbox = _workspace(tmp_path) + first = _write_message(inbox, "msg-1") + second = _write_message(inbox, "msg-2") + target = envelope_path(tmp_path, "test-proj", "claude") + write_session_claim(target, "sess-a", first.name) + assert main(["compile", str(first), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + + # Same-message recompile without a fresh claim (e.g. a human-run + # --extend after re-authorization) keeps the binding. + assert main(["compile", str(first), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + + assert main([ + "compile", str(second), "--oacp-dir", str(tmp_path), "--extend", + ]) == 0 + assert load_envelope(target)["session_id"] == "sess-a" + + def test_cli_compile_missing_profile_exits_3(tmp_path: Path, capsys) -> None: inbox = _workspace(tmp_path) path = inbox / "msg-np.yaml" diff --git a/tests/test_oacp_doctor.py b/tests/test_oacp_doctor.py index f0bb5a6..3e3fea7 100644 --- a/tests/test_oacp_doctor.py +++ b/tests/test_oacp_doctor.py @@ -38,6 +38,7 @@ run_doctor, validate_autonomy_config_data, ) +from autonomy_gate import evaluate_autonomy # noqa: E402 from memory_sync import CANONICAL_MEMORY_GITIGNORE # noqa: E402 @@ -252,6 +253,66 @@ def test_valid_autonomy_config(self) -> None: } self.assertEqual(validate_autonomy_config_data(data), []) + def test_signing_only_config_preserves_absent_autonomy_defaults(self) -> None: + config_data = { + "signing": { + "sign_messages": True, + "verify_mode": "warn", + } + } + + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) + agent_dir = project_dir / "agents" / "codex" + (agent_dir / "audit" / "autonomy_decisions").mkdir(parents=True) + + before = check_autonomy(project_dir) + before_config = next( + result + for result in before.results + if result.name == "codex/config.yaml" + ) + self.assertEqual(before_config.severity, Severity.ok) + self.assertIn("defaults to always_pause", before_config.message) + + _write( + agent_dir / "config.yaml", + "signing:\n sign_messages: true\n verify_mode: warn\n", + ) + import yaml + + after = check_autonomy(project_dir, yaml_loader=yaml.safe_load) + after_config = next( + result + for result in after.results + if result.name == "codex/config.yaml" + ) + self.assertEqual(after_config.severity, Severity.ok) + self.assertIn("defaults to always_pause", after_config.message) + + self.assertEqual(validate_autonomy_config_data(config_data), []) + self.assertNotEqual( + validate_autonomy_config_data({"autonomy": None}), + [], + ) + + decision = evaluate_autonomy( + { + "id": "msg-signing-only", + "from": "iris", + "to": "codex", + "type": "task_request", + "priority": "P2", + "created_at_utc": "2026-08-03T00:00:00Z", + "subject": "Signing-only config regression", + "body": "No action should auto-run.", + }, + config_data, + ) + self.assertEqual(decision["decision"], "paused") + self.assertEqual(decision["mode"], "always_pause") + self.assertEqual(decision["reason_codes"], ["mode_always_pause"]) + def test_rejects_malformed_continuation_grants(self) -> None: data = { "autonomy": { @@ -610,12 +671,41 @@ class TestCheckTrust(unittest.TestCase): KID = "a" * 43 GOLDEN_X = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" - def _pins(self, project_dir: Path, agent: str, kid: str, x: str) -> None: + def _catalog(self, project_dir: Path, agent: str = "alice") -> str: + from message_signing import jwk_thumbprint + from trust_root import import_public_stub + + project_dir.mkdir(parents=True, exist_ok=True) + jwk = {"kty": "OKP", "crv": "Ed25519", "x": self.GOLDEN_X} + kid = jwk_thumbprint(jwk) + stub = { + "kid": kid, + "jwk": jwk, + "agent": agent, + "agent_urn": ( + f"urn:oacp:agent:123e4567-e89b-42d3-a456-426614174000:{agent}" + ), + "instance_urn": "urn:uuid:123e4567-e89b-42d3-a456-426614174111", + } + stub_path = project_dir / f"{agent}.pub.json" + stub_path.write_text(json.dumps(stub), encoding="utf-8") + import_public_stub(stub_path, project_dir, catalog_only=True) + return kid + + def _pins( + self, + project_dir: Path, + agent: str, + kid: str, + x: str, + *, + status: str = "active", + ) -> None: pins = project_dir / "agents" / agent / "trust" / "allowed_signers.yaml" pins.parent.mkdir(parents=True, exist_ok=True) pins.write_text( "version: 1\nsigners:\n" - f" - {{agent: iris, kid: {kid}, status: active, " + f" - {{agent: alice, kid: {kid}, status: {status}, " f"jwk: {{kty: OKP, crv: Ed25519, x: {x}}}}}\n", encoding="utf-8", ) @@ -663,6 +753,179 @@ def test_pinned_but_not_cataloged_warns(self) -> None: names = [r.name for r in cat.results if r.severity == Severity.warn] self.assertIn("trust-pin-not-in-catalog", names) + def test_revoked_pin_absent_from_catalog_is_not_completeness_gap(self) -> None: + from message_signing import jwk_thumbprint + + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + agent_dir = project_dir / "agents" / "claude" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text( + "signing:\n verify_mode: enforce\n", + encoding="utf-8", + ) + jwk = {"kty": "OKP", "crv": "Ed25519", "x": self.GOLDEN_X} + self._pins( + project_dir, + "claude", + jwk_thumbprint(jwk), + self.GOLDEN_X, + status="revoked", + ) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.ok) + self.assertIn("0 gap(s) across 1 receiver(s)", result.message) + self.assertIn("0 catalog-to-pin, 0 pin-to-catalog", result.message) + + def test_completeness_warns_without_liveness_signal(self) -> None: + """Reproduce the live cataloged kid + missing receiver pin shape.""" + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + agent_dir = project_dir / "agents" / "claude" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text( + "signing:\n verify_mode: warn\n", + encoding="utf-8", + ) + self._catalog(project_dir) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.warn) + self.assertIn("1 gap(s) across 1 receiver(s)", result.message) + self.assertIn("1 catalog-to-pin, 0 pin-to-catalog", result.message) + + def test_completeness_summarizes_both_directions(self) -> None: + from message_signing import b64url_encode, jwk_thumbprint + + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + (project_dir / "agents" / "claude").mkdir(parents=True) + codex_dir = project_dir / "agents" / "codex" + codex_dir.mkdir(parents=True) + (codex_dir / "config.yaml").write_text( + "signing:\n verify_mode: warn\n", + encoding="utf-8", + ) + self._catalog(project_dir) + other_x = b64url_encode(bytes([7]) * 32) + other_jwk = {"kty": "OKP", "crv": "Ed25519", "x": other_x} + self._pins( + project_dir, + "claude", + jwk_thumbprint(other_jwk), + other_x, + ) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.warn) + self.assertIn("3 gap(s) across 2 receiver(s)", result.message) + self.assertIn("2 catalog-to-pin, 1 pin-to-catalog", result.message) + + def test_enforce_mode_promotes_completeness_gap_to_error(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + agent_dir = project_dir / "agents" / "claude" + agent_dir.mkdir(parents=True) + self._catalog(project_dir) + (agent_dir / "config.yaml").write_text( + "signing:\n verify_mode: enforce\n", + encoding="utf-8", + ) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.error) + self.assertIn("blocking because verify_mode=enforce", result.message) + self.assertIn("claude", result.message) + + def test_enforce_error_localizes_gap_to_warn_mode_receiver(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + kid = self._catalog(project_dir) + for receiver, mode in (("claude", "enforce"), ("codex", "warn")): + agent_dir = project_dir / "agents" / receiver + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text( + f"signing:\n verify_mode: {mode}\n", + encoding="utf-8", + ) + self._pins(project_dir, "claude", kid, self.GOLDEN_X) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.error) + self.assertIn("claude=0 gap(s)", result.message) + self.assertIn( + "codex=1 gap(s) (1 catalog-to-pin, 0 pin-to-catalog)", + result.message, + ) + + def test_self_catalog_identity_does_not_require_a_pin(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + agent_dir = project_dir / "agents" / "alice" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text( + "signing:\n verify_mode: warn\n", + encoding="utf-8", + ) + self._catalog(project_dir) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.ok) + self.assertIn("0 gap(s) across 1 receiver(s)", result.message) + + def test_unprofiled_agent_directory_is_not_counted(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) / "proj" + claude_dir = project_dir / "agents" / "claude" + claude_dir.mkdir(parents=True) + (claude_dir / "config.yaml").write_text( + "signing:\n verify_mode: warn\n", + encoding="utf-8", + ) + (project_dir / "agents" / "gemini").mkdir(parents=True) + kid = self._catalog(project_dir) + self._pins(project_dir, "claude", kid, self.GOLDEN_X) + + cat = check_trust(project_dir) + result = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + + self.assertEqual(result.severity, Severity.ok) + self.assertIn("0 gap(s) across 1 receiver(s)", result.message) + def test_integrity_failure_is_error(self) -> None: with tempfile.TemporaryDirectory() as td: project_dir = Path(td) @@ -673,6 +936,11 @@ def test_integrity_failure_is_error(self) -> None: # the reader enforces integrity, so the bad kid surfaces as an # unreadable pins file carrying the thumbprint detail self.assertIn("trust-pins-unreadable", names) + completeness = next( + item for item in cat.results + if item.name == "trust-pin-completeness" + ) + self.assertIn("0 gap(s) across 0 receiver(s)", completeness.message) class TestCheckMemorySync(unittest.TestCase):