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):