diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8cd5b..ca75eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,68 @@ 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.3.5] - 2026-07-11 + +### Added + +- Receiver autonomy supports sender-marked `oacp-guardrails` fences, negation + fallback, declaration-aware lexical advisories, and a pinned reason-code + taxonomy backed by executable conformance fixtures. +- `external_side_effects: allow_pr_artifacts` permits declared PR, review, and + issue-comment artifacts only for receiver-allowlisted private repositories, + while retaining pre-approval for public/unlisted artifacts, direct main + pushes, merges, deploys, and publishes. +- Autonomy audit output records the full declared task profile, an explicit + breach list, a semantic policy hash, and a shared outcome block including + completion time and materialized-risk telemetry. +- Default-off standing continuation grants now resolve from an explicit prior + human approval in the same conversation thread; sender-declared grant data + alone cannot authorize follow-up scope. +- `oacp autonomy-outcome` atomically records structured approval, modification, + decline, latency, and grant-decision telemetry in schema-v2 autonomy audits. + The subcommand is exposed by the installed CLI starting with this release. +- Envelope compilation: `oacp envelope compile|show|clear` + turns an admitted message's `task_profile` plus receiver config into a + runtime envelope (`active_envelope.json`), enforced at the tool-call layer + by a static Claude PreToolUse hook (`oacp-envelope-hook`, registered once + by `oacp setup claude`). Compilation is fail-closed + (`envelope_compile_error`), envelope drift denies with the canonical + threshold-checkpoint opener, unclassifiable calls escalate to `ask`, and + the audit outcome block records `envelope_enforcement: hooks | none`. + Contract pinned by executable fixtures under `tests/conformance/envelope/`. +- The security policy documents the Tier-1.5 trust ceiling for single-OS-user + hosts: `from` fields are unauthenticated traceability today, future optional + signing would add tamper-evidence and provenance but not same-host + anti-impersonation, and hard isolation requires separate OS users, + containers, or hosts. + +### Changed + +- The versioning guide documents the staged release pipeline end to end, + replacing the retired single-repo flow. +- Documentation examples and conformance fixtures use neutral example + repository slugs. +- The standard `auto_review` estimate cap is 45 minutes; estimates above 45 + still pause and the five-file cap remains unchanged. +- Pricing and commercial matches remain hard pauses but now use the separate + `hard_stop_content_sensitivity` category. +- Follow-up declarations outside a standing grant's time, file, or side-effect + scope re-pause before work begins. + +### Fixed + +- `doctor --memory` now forwards bounded network timeouts to remote Git fetches + without breaking custom runners that implement the original command interface. +- The MCP stdio coordinator now converts unexpected request exceptions into + structured `-32603` responses instead of crashing the server. +- Undeclared runtime side effects now produce `declaration_error` at the + threshold checkpoint instead of silently completing outside the profile. +- Message validation now accepts and type-checks the six documented + review-loop telemetry fields: `model`, `turns`, `input_tokens`, + `output_tokens`, `wall_time_s`, and `est_cost_usd`. +- Receiver config and doctor validation share one repository-slug regex instead + of maintaining duplicate definitions. + ## [0.3.4] - 2026-06-11 ### Fixed @@ -192,6 +254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Checkout step in github-release workflow job (#19) - Pre-release audit fixes: SHA-pinned actions, dangling doc refs (#15, #16) +[0.3.5]: https://github.com/kiloloop/oacp/compare/v0.3.4...v0.3.5 [0.3.4]: https://github.com/kiloloop/oacp/compare/v0.3.3...v0.3.4 [0.3.3]: https://github.com/kiloloop/oacp/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/kiloloop/oacp/compare/v0.3.1...v0.3.2 diff --git a/README.md b/README.md index 10bd19f..3c7e92b 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ When running inside a configured agent runtime, `--from` can be omitted — OACP ## Coming from `claude -p`? -`claude -p` runs an agent synchronously and headless — your script blocks while it works, and as of June 15 programmatic usage draws from a separate metered credit. +`claude -p` runs an agent synchronously and headless — your script blocks while it works, and there's no session to glance at, attach to, or steer mid-run. -OACP routes the same work to a **standing interactive Claude Code session** instead: `oacp send` queues the task, the session picks it up and runs it async, you're not blocked — and because it's a real interactive session, it stays on your subscription. +OACP routes the same work to a **standing interactive Claude Code session** instead: `oacp send` queues the task, the session picks it up and runs it async, you're not blocked — and the session is right there to watch and steer. Once tasks are messages, review loops, handoffs, and multi-agent coordination come with the shape. **[From `claude -p` to an interactive Claude Code session →](docs/from-claude-p.md)** — paste-into-your-agent setup, the honest tradeoffs, and where it fits. diff --git a/SECURITY.md b/SECURITY.md index 44f05ed..862fa49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,6 +29,21 @@ OACP is a file-based coordination protocol. Security considerations include: - **Script injection** — Report any case where user-controlled input in message fields can lead to command injection via OACP scripts. - **Path traversal** — Report any case where message fields or script arguments can read or write files outside the intended directories. +### Trust ceiling on shared hosts (Tier 1.5) + +On a single-OS-user host, OACP cannot *cryptographically prevent* one agent +from impersonating another, because key-read access and inbox-write access +collapse to the same uid. Today, `from` fields are unauthenticated and provide +traceability only — not identity assurance. Message signing (an optional +extension under design, not yet implemented) would add tamper-evidence and +third-party-verifiable identity provenance for honest agents; it would still +not provide same-host anti-impersonation. Hard inter-agent isolation requires +one OS user per agent (Tier-2), containers, or separate hosts with enforced +access controls. Hardware-backed keys can strengthen future signing identity +assurance, but they do not isolate a shared filesystem or process space. Treat +any `from` field on a shared host as **provenance, not an anti-spoofing +guarantee.** + ## Acknowledgments We appreciate responsible disclosure and will credit reporters in release notes (unless you prefer to remain anonymous). diff --git a/SPEC.md b/SPEC.md index 7784c69..c8557ce 100644 --- a/SPEC.md +++ b/SPEC.md @@ -79,7 +79,10 @@ Messages are YAML files. Filename convention: `__.yaml` | `subject` | string | Subject line | | `body` | string | Message content (multi-line markdown) | -**Optional fields:** `expires_at`, `channel`, `autonomy_hint`, `related_packet`, `related_pr`, `conversation_id`, `parent_message_id`, `context_keys` +**Optional fields:** `expires_at`, `channel`, `autonomy_hint`, `related_packet`, +`related_pr`, `conversation_id`, `parent_message_id`, `context_keys`, plus the +review-loop telemetry fields `model`, `turns`, `input_tokens`, `output_tokens`, +`wall_time_s`, and `est_cost_usd` `autonomy_hint` is an advisory sender hint, such as `auto_proceed`. Receivers remain authoritative: local autonomy config, message content, safety defaults, @@ -88,6 +91,8 @@ interactive human confirmation. Receiver autonomy is defined by `agents//config.yaml` and the scope-envelope contract in [`docs/protocol/autonomy.md`](docs/protocol/autonomy.md). +Sender-marked `oacp-guardrails` body fences and the 45-minute standard +`auto_review` cap are defined there as well. ### Message Types diff --git a/docs/from-claude-p.md b/docs/from-claude-p.md index a7635e9..cb517eb 100644 --- a/docs/from-claude-p.md +++ b/docs/from-claude-p.md @@ -1,12 +1,12 @@ # From `claude -p` to an interactive Claude Code session -On June 15, programmatic Claude usage — `claude -p`, the Agent SDK, Claude Code GitHub Actions, third-party tools — moves onto a separate metered monthly credit. Interactive Claude Code and chat stay on your subscription, unchanged. +`claude -p` is how most people first script a Claude agent. It works — but if you run agents in automation, it's worth asking a question the one-liner never forces: **why is the agent headless and synchronous in the first place?** -It's a reasonable change: programmatic usage is heavier and burstier than a person typing. But if you run agents in automation, it's worth asking a different question than "how do I dodge the meter" — **why is the agent headless and synchronous in the first place?** +(A pricing footnote, since earlier versions of this page led with it: Anthropic explored moving programmatic usage — `claude -p`, the Agent SDK, Claude Code GitHub Actions — onto a separate metered credit, but paused that change before it took effect; per the June 15 announcement no such credit exists, subscription limits are unchanged, and advance notice is promised before any future version.) This guide is built to be handed to your agent. Read the first half for the *why*; paste the setup block into your Claude Code session for the *how*. -> **Setup is a one-time ~5 minutes. Every task after that is one `oacp send` — async, non-blocking, on your subscription.** +> **Setup is a one-time ~5 minutes. Every task after that is one `oacp send` — async, non-blocking, observable.** ## The shape of `claude -p` @@ -14,7 +14,7 @@ This guide is built to be handed to your agent. Read the first half for the *why - it **blocks** — your script waits for the agent to finish - it runs **headless** — no session to glance at, attach to, or steer mid-run -- after June 15, it's **metered** — programmatic usage draws from the separate credit pool +- it's **one-shot** — one prompt in, one result out; there's no queue behind it, and coordinating several calls is your script's problem That shape was always a compromise. You wanted to script an agent, and `claude -p` was the way to do it from a shell. Synchronous-and-headless came along for the ride. @@ -24,10 +24,10 @@ Run the agent as a **standing interactive Claude Code session**, and *send* it w ``` Before: claude -p "do X" - blocks · headless · metered after Jun 15 + blocks · headless · one-shot After: oacp send → a standing interactive session picks it up - async · you're not blocked · on your subscription + async · you're not blocked · a session you can watch ``` Concretely — the call that replaces `claude -p "do X"`: @@ -39,23 +39,23 @@ oacp send my-project --from sender --to claude --type task_request \ Same prompt, different delivery. The text you'd have handed to `claude -p` becomes the message `--body`; `oacp send` drops it in the interactive session's inbox, and the session picks it up and runs it. Sending the prompt as an OACP message is the whole trick — it routes the work into a session running in interactive mode instead of a headless `claude -p` call. -The session is a real interactive Claude Code session — so it stays on your subscription. You're not spoofing interactive mode; you're using it, and feeding it a queue. +The session is a real interactive Claude Code session — you're not spoofing interactive mode; you're using it, and feeding it a queue. -This isn't only about the credit. Async is the right shape for most automation — a build, an overnight refactor, a research job — none of it needs your script to sit and wait. And once tasks are messages in an inbox, you get real multi-agent coordination — review loops, handoffs, several agents on one project — instead of a pile of blocking shell calls. +Async is the right shape for most automation — a build, an overnight refactor, a research job — none of it needs your script to sit and wait. And once tasks are messages in an inbox, you get real multi-agent coordination — review loops, handoffs, several agents on one project — instead of a pile of blocking shell calls. ## Your options, honestly -Two real choices if June 15 affects you. +Two real choices. -**1. Pay the metered credit (or API rates).** Simplest. Low programmatic volume — the included credit may cover you; past that it's API pricing. Already on the API — nothing changes. No new tooling; it just costs money at scale. +**1. Keep `claude -p`.** Simplest. It's one line, it works, and nothing about your setup changes. If your automation is a single blocking call and you never need to see inside it, this is fine. -**2. OACP — run interactive for real, feed it a queue.** Run a real interactive Claude Code session and send it work. It stays on your subscription because it genuinely *is* an interactive session. Not a one-liner — it's a workflow change. In exchange you get real agent coordination, not just a credit workaround. +**2. OACP — run interactive for real, feed it a queue.** Run a real interactive Claude Code session and send it work. Not a one-liner — it's a workflow change. In exchange you get async dispatch, a session you can watch and steer, and real agent coordination. -Option 1 is the no-effort path that costs money at volume. Option 2 is a one-time workflow change that doesn't. Pick on that. +Option 1 is zero effort and stays synchronous, headless, one-shot. Option 2 is a one-time workflow change that removes all three. Pick on that. ## What OACP is -OACP isn't a June-15 tool. It's a file-based protocol for coordinating AI agents — inbox/outbox messaging, structured review loops, shared memory — no server, no daemon, just files in a directory. It was built to run a multi-agent fleet; the `claude -p` transition is **one usage** of it. Full picture: the [README](../README.md) and [SPEC](../SPEC.md). The companion [oacp-skills](https://github.com/kiloloop/oacp-skills) repo packages the runtime guidance — skills that teach Claude, Codex, and other agents to operate the protocol. +OACP isn't a `claude -p` replacement tool. It's a file-based protocol for coordinating AI agents — inbox/outbox messaging, structured review loops, shared memory — no server, no daemon, just files in a directory. It was built to run a multi-agent fleet; the `claude -p` swap is **one usage** of it. Full picture: the [README](../README.md) and [SPEC](../SPEC.md). The companion [oacp-skills](https://github.com/kiloloop/oacp-skills) repo packages the runtime guidance — skills that teach Claude, Codex, and other agents to operate the protocol. ## Set it up — paste this into your agent @@ -142,7 +142,7 @@ oacp-do() { **Request-response callers** — CI doing `RESULT=$(claude -p ...)` and using the output inline. Works too, but not a one-liner: the caller also arms `oacp watch --agent sender` to catch the reply. Worth it for a real pipeline — just know it's more than a swap. -**If you just want zero workflow change** and don't care about coordination — keep `claude -p` and pay the meter (option 1). OACP earns its setup cost when you have more than one agent, recurring work, or you want to see and steer what the agent is doing. If that's not you, we'd rather say so. +**If you just want zero workflow change** and don't care about coordination — keep `claude -p` (option 1). OACP earns its setup cost when you have more than one agent, recurring work, or you want to see and steer what the agent is doing. If that's not you, we'd rather say so. ## Try it diff --git a/docs/guides/doctor.md b/docs/guides/doctor.md index a6bf319..6298b52 100644 --- a/docs/guides/doctor.md +++ b/docs/guides/doctor.md @@ -13,7 +13,7 @@ No project, no config, no commit required — just install and run. ## What doctor checks -Doctor organizes its output into five categories. When run without `--project`, only the first category (Environment) runs. With `--project `, all five categories are evaluated. +Doctor organizes its output into six categories. When run without `--project`, only the first category (Environment) runs. With `--project `, all six categories are evaluated. ### 1. Environment @@ -50,7 +50,19 @@ Validates YAML files against protocol expectations. - **packets/** — parses all `.yaml`/`.yml` files and reports syntax errors - **status.yaml** — checks required fields (`runtime`, `status`, `capabilities`, `updated_at`), validates enum values, and verifies timestamp format -### 5. Agent Status +### 5. Autonomy + +Validates each agent's receiver-autonomy setup (see `docs/protocol/autonomy.md`). + +- **config.yaml** — validates `autonomy.default_mode`, `auto_review_thresholds` + (numeric + policy keys, including `external_side_effects: pause | + allow_pr_artifacts | allow`), `allow_without_task_profile` entries, and + receiver-controlled `private_repo_allowlist` entries, and + `continuation_grants`; an absent config is fine (defaults to `always_pause`) +- **audit directory** — warns if `agents//audit/autonomy_decisions/` is missing +- **orphaned policy refs** — warns when an audit event's `policy_path` points at a config file that no longer exists + +### 6. Agent Status Checks each agent's `status.yaml` for presence and freshness. @@ -104,6 +116,14 @@ $ oacp doctor --project my-project [+] claude/status.yaml — valid [+] codex/status.yaml — valid +[!] Autonomy + [+] claude/audit/autonomy_decisions — present + [+] claude/config.yaml — valid + [+] claude/autonomy audit — no orphaned policy refs + [!] codex/audit/autonomy_decisions — missing + mkdir -p $OACP_HOME/projects/my-project/agents/codex/audit/autonomy_decisions + [+] codex/config.yaml — absent; defaults to always_pause + [!] Agent Status [+] claude/status.yaml — present [!] codex/status.yaml — stale (updated 26h ago) @@ -179,6 +199,8 @@ oacp doctor # environment checks only oacp doctor --project # full workspace + agent checks oacp doctor --json # machine-readable JSON output oacp doctor --project --json # full checks in JSON format +oacp doctor --project --fix # auto-fix safe issues (missing inbox dirs, missing/stale status.yaml) +oacp doctor --memory # advisory checks for OACP_HOME memory git sync oacp doctor -o report.txt # save report to file ``` diff --git a/docs/guides/prompt_caching.md b/docs/guides/prompt_caching.md index 722222a..8fad998 100644 --- a/docs/guides/prompt_caching.md +++ b/docs/guides/prompt_caching.md @@ -94,7 +94,6 @@ Codex CLI sends the full prompt to the Codex API on each invocation. There is no 4. **Lean on diff-only context** - For PR fix loops, pass the diff + findings rather than full file contents - - `run_agent_task.sh` already scopes context to changed files ## Gemini diff --git a/docs/guides/runtime_capability_matrix.md b/docs/guides/runtime_capability_matrix.md index 71a8e65..83db092 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-09 +**Date**: 2026-06-26 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. -Codex was last checked against app update `26.602`, CLI `0.137.0`, OpenAI's GPT-5.5 launch note from 2026-04-23, and the June 2026 Codex/API entries for Sites and Amazon Bedrock. +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. --- @@ -17,16 +17,16 @@ Codex was last checked against app update `26.602`, CLI `0.137.0`, OpenAI's GPT- | 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 | -| MCP tools | Yes — extensible via MCP servers | Yes — MCP/plugin support with `/mcp verbose`, per-server environment targeting, read-only MCP parallelism, and scriptable plugin inventory | Yes — MCP server support | +| 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) | | 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 | | 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, and archive/unarchive flows | Yes — chat with task UI, artifacts | +| 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 | Token-based | +| 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 | | 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 | --- @@ -57,6 +57,8 @@ Codex was last checked against app update `26.602`, CLI `0.137.0`, OpenAI's GPT- | 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 | | 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 | --- @@ -97,6 +99,7 @@ Scope: skills shipped in [`kiloloop/oacp-skills`](https://github.com/kiloloop/oa | 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 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) | — | — | --- @@ -135,16 +138,17 @@ 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) | No | +| 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 | | 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 | --- ## 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.602` and CLI `0.137.0` come from OpenAI's Codex changelog: . -- Sites, Amazon Bedrock, app-server, plugin, and permissions details come from the official Codex docs under . +- 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: . +- 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. --- diff --git a/docs/guides/setup.md b/docs/guides/setup.md index 2a489e1..a303174 100644 --- a/docs/guides/setup.md +++ b/docs/guides/setup.md @@ -80,7 +80,6 @@ For Claude Code projects, copy templates into the repo's `.claude/` directory: # From the oacp repo root: cp templates/claude/agents/role_agent.template.md /path/to/repo/.claude/agents/.md cp templates/claude/rules/guardrail.template.md /path/to/repo/.claude/rules/guardrails.md -cp templates/claude/skills/session_lifecycle.template.md /path/to/repo/.claude/skills/lifecycle/SKILL.md ``` ## 5) Initialize packet files (source checkout only) diff --git a/docs/guides/unified_skill_spec.md b/docs/guides/unified_skill_spec.md index 3e8fa6c..03aa651 100644 --- a/docs/guides/unified_skill_spec.md +++ b/docs/guides/unified_skill_spec.md @@ -179,7 +179,7 @@ Once both runtimes can resolve a shared skill path (e.g., `shared/skills// ## Example: check-inbox (unified) -See the current Claude and Codex implementations: +See the current Claude and Codex implementations in the [oacp-skills](https://github.com/kiloloop/oacp-skills) repo: - `claude/skills/check-inbox/SKILL.md` - `codex/skills/check-inbox/SKILL.md` diff --git a/docs/guides/versioning.md b/docs/guides/versioning.md index a159e61..5c69d3a 100644 --- a/docs/guides/versioning.md +++ b/docs/guides/versioning.md @@ -1,8 +1,8 @@ # Versioning Guide -Use semantic-style tags for standards evolution: +The `oacp-cli` package follows semantic versioning: -- `v0.x.y` while process is still stabilizing. +- `v0.x.y` while the CLI surface and protocol are still stabilizing. - `v1.0.0` once format fields and loop controls are stable. ## Change Types @@ -11,24 +11,55 @@ Use semantic-style tags for standards evolution: - Minor (`x.Y.0`): new optional fields/templates/scripts. - Major (`X.0.0`): breaking template/protocol changes. -## Release Checklist +## Where the Version Lives -1. Update `CHANGELOG.md`. -2. Validate scripts on at least one project. -3. Tag release and announce migration notes for breaking changes. +`pyproject.toml` is the single source of truth. `oacp/__init__.py` resolves +`__version__` at runtime via `importlib.metadata`, so `oacp --version` always +reports the installed package version — the version number itself is edited in +exactly one file (the release-bump PR also adds the matching `CHANGELOG.md` +section, see below). -## Project Pin Bump Checklist +## Release Pipeline -When a project pins standards via `.oacp_version`, use this flow: +Releases flow through a staged promotion: development repo → private staging +repo (soak) → public repo (`kiloloop/oacp`) → PyPI. Every repository content +change lands via a reviewed pull request (branch protection enforces squash +merges and rejects direct pushes); the one non-PR step is the release tag +itself, which triggers the publish workflow. -1. Confirm target standards tag/commit in this repo. -2. Update project pin file values: - - `STANDARDS_TAG=` - - `STANDARDS_COMMIT=` - - `UPDATED_AT=` -3. Run smoke validation in the target project: - - packet bootstrap (`scripts/shared/init_packet.sh`) - - any updated standards scripts used by that project -4. Update project memory if the standards change introduces operational behavior changes. -5. Include pin bump details in project commit/PR notes (old -> new tag/commit). -6. If multiple projects are pinned, track rollout status and only retire old guidance after all critical projects migrate. +1. **Pre-export audit** — run `make preflight ARGS="--full"` (conflict-marker + scan, Makefile checks, YAML validation, ruff, shellcheck, full test suite), + then scan for leaks: hardcoded user paths, private repository references, + secret patterns, and SPDX headers on all tracked Python files. All checks + must pass before anything is exported. +2. **Version bump PR** — update the `version` in `pyproject.toml` and add a + `CHANGELOG.md` section in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) + format. Gather the changes with `git log --oneline ..HEAD`. +3. **Export to staging** — open a staging PR mirroring the development branch. + The export copies the tracked tree only (`git archive`), so gitignored + local files never leave the development machine; rerun the leak audit on + the staged tree before merging. Non-urgent releases soak here before + promotion; urgent fixes may skip the soak. +4. **Promote to public** — open a promotion PR into `kiloloop/oacp` under the + release automation identity; a human reviews and merges it. The squash + commit message becomes permanent public history — keep it free of internal + references (the PR description alone is not what gets published). +5. **Tag and publish** — push an annotated `vX.Y.Z` tag to the public repo + (a direct tag push, not a PR). The tag-triggered release workflow reruns + the checks, builds the sdist/wheel, publishes `oacp-cli` to PyPI via + [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC — no + long-lived API tokens), and creates the GitHub Release with notes from the + CHANGELOG section. +6. **Verify** — check the version-pinned endpoint + `https://pypi.org/pypi/oacp-cli//json` first; the unversioned + "latest" endpoint and the pip simple index can lag a few minutes behind it. + Then install and confirm `oacp --version` prints the new version. On a + machine with an existing uv-managed install, replace it explicitly — + `uv cache prune`, `uv tool uninstall oacp-cli`, then + `uv tool install oacp-cli== --no-cache`; the short + `uv tool install` form is only reliable in a clean tool environment. + +## Doc-Only Changes + +Documentation-only updates promote through the same two-PR staging path +(staging PR plus public PR) with no version bump, no tag, and no PyPI publish. diff --git a/docs/protocol/autonomy.md b/docs/protocol/autonomy.md index d536515..9853d1f 100644 --- a/docs/protocol/autonomy.md +++ b/docs/protocol/autonomy.md @@ -25,16 +25,18 @@ Receiver policy lives at `agents//config.yaml`: autonomy: default_mode: always_pause auto_review_thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 destructive_ops: pause - external_side_effects: pause + external_side_effects: allow_pr_artifacts auth_config_or_secrets: pause dependency_changes: pause public_visibility: pause git_push_or_deploy: pause allow_without_task_profile: - brainstorm_request + private_repo_allowlist: + - example-org/private-repo continuation_grants: enabled: false ``` @@ -42,6 +44,29 @@ autonomy: When config is absent, receivers behave as `always_pause`. Malformed config causes a pause and should be surfaced by `oacp doctor`. +`external_side_effects` accepts three policy actions: + +| Action | Behavior | +|---|---| +| `pause` | Pause every declared external side effect. | +| `allow_pr_artifacts` | Allow PR creation/update, review comments, and issue comments only when `target_repo` appears in the receiver-controlled `private_repo_allowlist`; direct main pushes, merges, deploys, and publishes still pause. | +| `allow` | Allow declared ordinary external side effects; non-demotable hard stops still pause. | + +All other policy actions remain `pause`. A private PR-artifact profile must set +`target_repo: owner/repo`, `public_visibility: false`, +`external_side_effects: true`, and at least one of `creates_or_updates_pr` or +`comments_on_github` to `true`. The sender declaration is necessary but not +sufficient: `target_repo` must also match the receiver's independent +`private_repo_allowlist`. Branch commits and pushes needed to create that +artifact are folded into `external_side_effects`; direct pushes to `main` are +not. + +The legacy `git_push_or_deploy: pause` policy is enforced at Gate 3 for direct +main pushes and other non-demotable action phrases. There is intentionally no +separate task-profile field: ordinary branch pushes supporting an allowlisted +private PR artifact are part of `external_side_effects`, while direct main +pushes remain hard stops. + ## Message Fields Messages may include top-level `autonomy_hint: auto_proceed`. This is advisory @@ -60,6 +85,7 @@ task_profile: touches_auth_config_or_secrets: false touches_dependencies: false public_visibility: false + target_repo: "" creates_or_updates_pr: false comments_on_github: false commits_changes: false @@ -72,6 +98,30 @@ schema-invalid profile pauses with `task_profile_unparsable`; it is not a fatal message-schema error. Message types listed in `allow_without_task_profile`, such as `brainstorm_request`, may auto-accept without the block. +The core declaration is complete only when it includes the two numeric fields, +`risk_tier`, and all five legacy risk booleans shown above. Granular side-effect +booleans are optional but must agree with `external_side_effects`; a profile +that declares a PR/comment/commit while declaring no external side effects +pauses with `declaration_error`. + +### Sender-marked guardrails + +Senders may isolate non-operative safety language in a fenced body section: + +````markdown +```oacp-guardrails +Do not merge, deploy, publish, or touch credentials. +``` +```` + +Gate 3 excludes well-formed `oacp-guardrails` fence contents from ordinary +side-effect, auth/config/secrets, and ambiguous-scope pause classification, but +records every matching term as a `lexical_advisory`; fenced text is never +invisible to the audit. Destructive commands, direct main pushes, credential +rotation, dependency installation, public-repository text, memory SSOT text, +and pricing/commercial content are scanned across the raw body and remain hard +even inside the fence. An unclosed or differently labeled fence is not skipped. + ## Four-Gate Evaluator If any required gate is missing or uncertain, the receiver pauses. @@ -86,8 +136,14 @@ If any required gate is missing or uncertain, the receiver pauses. - Required for `task_request` and `question`. - Normalizes the profile into a scope envelope with time, files, risk booleans, side-effect booleans, and optional continuation grants. - - Cross-checks `estimated_minutes`, `expected_files_touched`, destructive - scope, sensitive scope, and side-effect scope against receiver policy. + - Cross-checks `estimated_minutes` (45-minute standard cap), + `expected_files_touched` (5-file standard cap), destructive scope, + sensitive scope, and side-effect scope against receiver policy. + - Applies `allow_pr_artifacts` only to the declared private-repository + artifact class when `target_repo` also appears in the receiver-controlled + allowlist; public or unlisted repository artifacts and every other + external side-effect class pause. + - Pauses contradictory profile fields with `declaration_error`. 3. **Receiver classification** - Pause unconditionally on destructive command tokens: `rm -rf`, `--force`, `--no-verify`, `--dangerously-skip-permissions`. @@ -97,8 +153,22 @@ If any required gate is missing or uncertain, the receiver pauses. as deploy/publish/merge are logged as notes instead of hard stops. Destructive tokens still pause. - Path-like tokens such as `packets/deploy/` are not deploy verbs. - - Pause when the body touches auth, config, secrets, credentials, - dependencies, public repos, pricing/commercial content, or memory SSOT. + - Exclude sender-marked `oacp-guardrails` fences from demotable pause + classification while logging their matches as advisories. Suppress + demotable matches in clauses headed by `no`, `not`, `never`, or `do not`. + - With a complete profile, demote side-effect or sensitive-scope lexical + matches to a logged `lexical_advisory` when the corresponding declaration + is `false`. Missing/unparsable profiles and contradictory declarations do + not receive this demotion. + - When policy explicitly uses `external_side_effects: allow`, declared + ordinary external side-effect verbs are also advisory; non-demotable hard + stops remain hard. + - Pause when the body touches declared auth/config/secrets/credentials or + public-repository scope, or any memory SSOT scope. + - Keep `commercial`, `pricing`, public-repository text, and memory SSOT text + hard with no fence or negation demotion. Pricing/commercial matches are + reported separately as `hard_stop_content_sensitivity` rather than action + risk. - Pause when file scope is ambiguous or broader than the declared profile. 4. **Runtime/workspace** - Worktree is clean or the task can be isolated to a fresh branch. @@ -110,13 +180,13 @@ cannot override hard stops. ## Hard-Stop Override -Regardless of autonomy mode, receivers must pause on any of: destructive command -tokens (`rm -rf`, `--force`, `--no-verify`, -`--dangerously-skip-permissions`), external side effects -(push/deploy/merge/publish/rotate/install), or modifications to auth, config, -secrets, credentials, dependencies, public repos, pricing/commercial content, or -memory SSOT, unless explicitly authorized by a separate safety-default -exception. +Regardless of autonomy mode, receivers must pause on destructive command tokens +(`rm -rf`, `--force`, `--no-verify`, +`--dangerously-skip-permissions`), direct main pushes, credential rotation, +dependency installation, memory SSOT scope, and pricing/commercial content. +Declared auth/config/secrets/dependencies/public scope still pauses through Gate +2. The only standard external-side-effect exception is the configured +`allow_pr_artifacts` private-repository class described above. Continuation grants do not override destructive tokens, auth/secrets/credentials, dependency, public-scope, pricing/commercial, config, or memory-SSOT hard stops. @@ -131,14 +201,16 @@ Every autonomy decision writes one YAML file: `agents//audit/autonomy_decisions/YYYYMMDDTHHMMSSZ_.yaml` ```yaml -schema_version: 1 -spec_version: "0.3.1" +schema_version: 2 +spec_version: "0.3.5" created_at_utc: "2026-05-12T13:23:25Z" receiver: codex sender: iris message_id: msg-20260512132325-iris-de62 message_type: task_request message_subject: "Small docs cleanup" +conversation_id: conv-20260512-iris-001 +parent_message_id: null message_path: agents/codex/inbox/20260512132325_iris_task_request.yaml message_sha256: "..." decision: auto_accepted @@ -149,12 +221,24 @@ reason_codes: - task_profile_present - risk_threshold_passed thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 task_profile: estimated_minutes: 20 + risk_tier: P3 expected_files_touched: 3 destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: "" + creates_or_updates_pr: false + comments_on_github: false + commits_changes: false + sends_oacp_reply_only: true + continuation_grants: {} +breached: [] runtime: agent: codex model: gpt-5 @@ -164,22 +248,119 @@ result: actual_minutes: null actual_files_touched: null predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none threshold_checkpoint: evaluated: false actual_minutes: null actual_files_touched: null side_effects_actual: {} breached: false + breached_fields: [] + declaration_errors: [] action: not_evaluated + predicted_risk_materialized: false + completed_at_utc: null + human_outcome: + recorded: false + actor: null + decision: null + decided_at_utc: null + decision_latency_seconds: null + pause_reason_codes: [] + grant: + decision: not_recorded + request_present: false + request_error: null + requested_scope: null + granted_scope: null reply_message_id: msg-... artifacts: [] ``` `policy_path` and `policy_sha256` may be null when the pause is caused by -missing or malformed config. `sender` is logged only for traceability. -`spec_version: "0.3.1"` pins Gate 1 integrity enforcement: schema validation, -expiry rejection, raw YAML SHA-256 recording, and same-receiver replay -detection. +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.3.5"` 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 +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. + +`breached` is always an ordered list, but its entries intentionally reflect the +evaluation phase. Admission-time pauses record pinned gate reason codes (for +example `estimated_minutes_exceeds_threshold`); post-accept checkpoint pauses +record the concrete declared/actual field paths that exceeded the envelope +(for example `side_effects_actual.creates_or_updates_pr`). `completion_kind` +distinguishes those two phases; `reason_codes` remains the canonical taxonomy +for the decision itself. + +### Human approval and decline outcomes + +When a paused task is approved, modified, or declined, record the decision in +the same audit file: + +```bash +oacp autonomy-outcome \ + --decision approved \ + --decided-at 2026-05-12T13:25:00Z +``` + +The recorder copies the pause reason codes, computes decision latency from the +audit's `created_at_utc`, and locks the full read-modify-write sequence before +an atomic replacement. It refuses to overwrite a recorded outcome unless +`--replace` is explicit. `decision` is `approved`, `modified`, or `declined`. +Grant handling is separate so task approval never silently creates a standing +grant: + +- `--grant-decision not_requested` (default): no valid grant request or grant + decision was involved; a valid recorded request requires an explicit grant + decision, while a malformed request is preserved as `request_error`; +- `approved`: approve the requested grant scope, or an explicit scope supplied + with `--grant-scope-file`; +- `modified`: require an explicit replacement scope file; +- `denied`: record that the task may proceed or decline without granting a + standing continuation. + +An approved or modified standing grant is valid only when the task decision is +also approved or modified. A declined task cannot approve a grant. A malformed +grant request never prevents recording the task-level outcome; the recorder +sets `grant.request_error` and requires an explicit replacement scope before +that malformed request can be approved or modified. + +### Pinned reason-code taxonomy + +Evaluator implementations must reject unpinned reason codes. The canonical +families are: + +- integrity/config: `config_malformed`, `mode_always_pause`, + `message_invalid`, `message_expired`, `message_replayed`, + `task_profile_missing`, `task_profile_unparsable`, + `risk_obvious_no_profile`, `envelope_compile_error`; +- declaration/threshold: `declaration_error`, + `estimated_minutes_exceeds_threshold`, + `expected_files_touched_exceeds_threshold`, `destructive_ops_pause`, + `auth_config_or_secrets_pause`, `dependency_changes_pause`, + `public_visibility_pause`, `external_side_effects_pause`, + `external_side_effects_not_pr_artifact`, and the granular side-effect pause + codes; +- classification: `hard_stop_destructive_command`, + `hard_stop_external_side_effect`, `hard_stop_sensitive_scope`, + `hard_stop_content_sensitivity`, `file_scope_ambiguous`, and + `lexical_advisory`; +- continuation/checkpoint and success codes pinned by the executable fixtures + under `tests/conformance/autonomy/`: `continuation_grant_accepted`, + `continuation_grant_denied`, `continuation_grant_ignored_disabled`, + `continuation_grant_missing_approval`, + `continuation_grant_missing_scope`, `continuation_grant_missing_thread`, + `continuation_grant_scope_exceeded`, `threshold_checkpoint_breached`, + `message_valid`, `message_not_expired`, `message_hash_recorded`, + `task_profile_present`, `task_profile_not_required`, `task_type_allowed`, + `risk_threshold_passed`, `hard_stops_clear`, and + `workspace_check_required`. ## State Transition Metadata @@ -250,6 +431,7 @@ result: actual_minutes: 25 actual_files_touched: 4 predicted_risk_materialized: true + completed_at_utc: "2026-05-12T13:48:25Z" threshold_checkpoint: evaluated: true actual_minutes: 25 @@ -259,9 +441,134 @@ result: comments_on_github: true commits_changes: true breached: true + breached_fields: + - actual_files_touched action: paused_for_reauthorization ``` +If an undeclared side effect materializes, the receiver pauses with +`declaration_error`; `threshold_checkpoint.declaration_errors` identifies the +actual side-effect field. This checkpoint is mandatory before performing any +newly discovered capability or outward action. + +## Envelope Compilation (Phase 2) + +Phase 2 turns the declared `task_profile` from reviewed intent into enforced +runtime constraints. After a message is admitted (auto-accepted, or paused +and then human-approved), the receiver compiles the profile plus its own +autonomy config into a runtime envelope: + +``` +oacp envelope compile --receiver +``` + +The envelope is written to +`agents//state/active_envelope.json`: + +```json +{ + "envelope_version": 1, + "spec_version": "0.3.5", + "compiler": "envelope_compiler.py", + "compiled_at_utc": "2026-07-12T02:00:00Z", + "project": "my-project", + "receiver": "claude", + "message_id": "msg-...", + "message_sha256": "...", + "constraints": { + "estimated_minutes": 30, + "expected_files_touched": 4, + "risk_tier": "P2", + "target_repo": "example-org/private-repo", + "destructive_ops": false, + "external_side_effects": true, + "creates_or_updates_pr": true, + "comments_on_github": false, + "commits_changes": true, + "sends_oacp_reply_only": false, + "touches_auth_config_or_secrets": false, + "touches_dependencies": false, + "public_visibility": false, + "private_repo_allowlist": ["example-org/private-repo"] + }, + "counters": {"files_touched": []}, + "enforcement": "hooks" +} +``` + +Compilation rules: + +- **Fail closed.** A missing, unparsable, or invalid profile — or a malformed + receiver config — fails compilation, and the receiver pauses the task with + reason code `envelope_compile_error` instead of executing unenforced. +- The compiler reuses the gate evaluator's normalization and pattern + constants directly, so admission spec and runtime enforcement cannot drift. +- The receiver-side `private_repo_allowlist` is embedded at compile time; + 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. + +### Delivery: static shim, dynamic envelope + +Runtime adapters enforce the envelope at the tool-call layer. The Claude +adapter is a PreToolUse hook (`oacp-envelope-hook`, matcher +`Bash|Edit|Write|NotebookEdit`) registered **once** by `oacp setup claude`. +Per-task constraints live only in the compiled envelope file — no per-task +settings mutation, effective mid-session, and a strict no-op while no +envelope is active. The receiver compiles the envelope at task pickup and +clears it (`oacp envelope clear`) at completion. + +Runtime decisions: + +- **deny** — the call breaches a declared-false capability (destructive + tokens, undeclared commits/pushes/PR mutations, secret-class or + dependency-manifest writes — including determinable Bash write targets + such as redirects and common writer programs), targets a repo outside the + embedded allowlist or pinned `target_repo`, pushes to a protected branch + or with bulk-ref flags (`--mirror`, `--all`, `--delete`), is a GitHub + mutation outside every allow class (merges, releases, issue creation), or + attempts envelope self-modification (`oacp envelope compile|clear` from + inside the enveloped session). +- **ask** — the adapter cannot confidently classify the call (shell + indirection like `bash -c`, wrapper flags, unknown GitHub or oacp + mutations, implicit `gh api` writes, unresolvable repository). The exact + command is escalated for just-in-time 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. +- `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. +- Determinable Bash write targets feed the same distinct-file counter as + Edit/Write calls (`/dev/*` excluded), so shell writes cannot bypass + `expected_files_touched`. + +### 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. + +### Enforcement recording + +The audit `result` block records `envelope_enforcement: hooks | none`. +Receivers set `hooks` after a successful compile on an adapter-equipped +runtime; `none` means pickup-gate-only enforcement (no adapter for the +runtime yet). Degradation must never be silent. + +Enforcement boundary: hooks constrain every tool call inside the session, +including subagent tool calls, and fire before sandbox/permission +evaluation. They do not constrain the human operator, and a runtime with the +shim stripped from settings is unenforced — provisioning integrity is an ops +concern, verifiable via the settings entry. Envelope discovery is scoped to +tool calls whose working directory resolves into the project workspace; +session-keyed multi-envelope concurrency and non-Claude adapters are v0.4 +extensions. The compilation contract is pinned by the executable fixtures +under `tests/conformance/envelope/`. + ## Continuation Grants `continuation_grants` are default-off. Receivers ignore grants unless their @@ -273,7 +580,7 @@ autonomy: enabled: true ``` -The supported grant kind is `approved_thread_continuation` under +The supported request kind is `approved_thread_continuation` under `task_profile.continuation_grants`: ```yaml @@ -294,17 +601,36 @@ task_profile: commits_changes: true ``` -A grant may be considered only when: +A sender-declared block is a grant request, not proof of approval. A standing +grant may be honored only when: - receiver config enables continuation grants; - the message has same-thread evidence via `parent_message_id` or `conversation_id`; -- the grant includes an explicit `scope`; -- actual work remains inside the granted scope. - -If disabled, receivers log `continuation_grant_ignored_disabled` and evaluate -the message as normal. If enabled but actual work drifts outside the grant, -receivers pause with `threshold_checkpoint_breached`. +- a prior schema-v2 audit in that thread records a human task decision of + `approved` or `modified` plus a grant decision of `approved` or `modified`; +- the follow-up sender matches the sender recorded by that prior audit; +- the current declared minutes, files, and side-effect classes stay inside the + prior audit's `granted_scope`; +- actual work stays inside that same granted scope at the checkpoint. + +The most recent explicit grant decision in the matching thread is +authoritative. A later denial revokes the standing grant for subsequent +follow-ups. A self-declared grant with no prior human approval pauses with +`continuation_grant_missing_approval`. A follow-up whose declared scope exceeds +the prior grant pauses with `continuation_grant_scope_exceeded`; actual drift +after acceptance pauses with `threshold_checkpoint_breached`. If the feature is +disabled, receivers log `continuation_grant_ignored_disabled` and evaluate the +message under normal policy. + +`parent_message_id` is sender-declared protocol metadata. For compatibility, +an immediate-parent match remains acceptable same-thread evidence when a +shared `conversation_id` is unavailable or does not match. Sender binding +prevents cross-agent reuse, but until authenticated message/thread identity is +added (a banked message-signing proposal), same-sender parent-ID reuse is an +accepted residual +trust boundary; it does not bypass hard stops or the grant's declared/actual +scope checks. ## Taxonomy Pin diff --git a/docs/protocol/inbox_outbox.md b/docs/protocol/inbox_outbox.md index 7d52ac5..2131e53 100644 --- a/docs/protocol/inbox_outbox.md +++ b/docs/protocol/inbox_outbox.md @@ -13,15 +13,15 @@ agents//outbox/ # this agent's sent messages (copies) ## Message Format -Messages are YAML files. Filename: `__.yaml` +Messages are YAML files. Filename: `___.yaml` — the 4-hex-char random suffix prevents collisions when multiple messages are sent within the same second (a broadcast generates one filename and writes it unchanged to every recipient's inbox and the sender's outbox). -Example: `20260211T1430Z_codex_task_request.yaml` +Example: `20260211143000_codex_task_request_a3f2.yaml` ```yaml -id: "msg-20260211T1430Z-codex-001" +id: "msg-20260211143000-codex-a3f2" # msg---, as generated by `oacp send` from: "codex" to: "claude" -type: "task_request" # task_request | question | notification | follow_up | handoff | handoff_complete | review_request | review_feedback | review_addressed | review_lgtm +type: "task_request" # task_request | question | notification | follow_up | handoff | handoff_complete | review_request | review_feedback | review_addressed | review_lgtm | brainstorm_request | brainstorm_followup priority: "P2" # P0 | P1 | P2 | P3 created_at_utc: "2026-02-11T14:30:00Z" autonomy_hint: "auto_proceed" # optional advisory hint; receiver policy decides @@ -31,7 +31,7 @@ conversation_id: "" # optional — conversation thread ID (see Conversati parent_message_id: "" # optional — ID of the message this replies to context_keys: | # optional — key items from prior conversation for continuity Summary of relevant prior context. -subject: "Review EdgeX spread calculation change" +subject: "Review spread calculation change" body: | Free-form message content. Can be multi-line markdown. @@ -45,6 +45,7 @@ body: | touches_auth_config_or_secrets: false touches_dependencies: false public_visibility: false + target_repo: "" # owner/repo; required for allow_pr_artifacts creates_or_updates_pr: false comments_on_github: false commits_changes: false @@ -113,7 +114,7 @@ next_owner: "claude" Use `follow_up` for non-blocking items that should be tracked after the current task/review closes. Do not use this type for blocking defects. -`body` must include these required fields: +`body` should include these tracking fields: - `source_type` (`review` | `task` | `deploy` | `incident` | `other`) - `source_ref` (PR/issue/message ID or file path) @@ -176,6 +177,11 @@ For review-loop message types (`review_feedback`, `review_lgtm`, and optionally These fields are optional and backward-compatible. Review-loop bodies may also include a nested `telemetry:` map for richer per-round detail. +`model` is a non-empty string. `turns`, `input_tokens`, and `output_tokens` are +non-negative integers. `wall_time_s` and `est_cost_usd` are non-negative +numbers. The six top-level fields are valid only on `review_feedback`, +`review_lgtm`, and `review_addressed` messages. + ### Autonomy Fields Messages may include `autonomy_hint: auto_proceed` as an advisory top-level @@ -193,8 +199,46 @@ of hard stops for those profileless types. The scope envelope may also include side-effect booleans (`creates_or_updates_pr`, `comments_on_github`, `commits_changes`) and default-off `continuation_grants.approved_thread_continuation` data for -same-thread continuations. Receivers ignore continuation grants unless -`autonomy.continuation_grants.enabled: true` is set in their config. +same-thread continuations. That sender-provided data is a request, not proof of +approval. Receivers ignore it unless `autonomy.continuation_grants.enabled: +true` is set and a prior schema-v2 autonomy audit in the same conversation (or +the immediate parent message) records an explicit human-approved or +human-modified grant scope for the same sender. The current declaration and +actual checkpoint must both remain inside that prior scope; otherwise the +receiver re-pauses. + +Paused task approvals, modifications, and declines are recorded with +`oacp autonomy-outcome --decision `. +The v2 audit outcome captures the pause reasons, human decision, decision +latency, and any separately approved/modified/denied continuation grant. + +The standard `auto_review` time cap is 45 minutes; estimates greater than 45 +pause, while the standard five-file cap is unchanged. When receiver policy sets +`external_side_effects: allow_pr_artifacts`, a complete profile may declare +private-repository PR/review/issue-comment artifacts with +`external_side_effects: true`, `public_visibility: false`, an `owner/repo` +`target_repo`, and the matching granular booleans. The target must also appear +in the receiver-controlled `private_repo_allowlist`; sender visibility claims +alone never establish private scope. Public/unlisted artifacts, direct main +pushes, merges, deploys, and publishes still pause. + +Senders may place non-operative safety language in a fenced section that Gate +3 excludes from demotable pause classification: + +````markdown +```oacp-guardrails +Do not merge, deploy, publish, or touch credentials. +``` +```` + +Matching fence terms are still logged as `lexical_advisory` events. Destructive +commands, direct main pushes, credential rotation, dependency installation, +public-repository text, memory SSOT text, and pricing/commercial content scan +the raw body and remain hard even inside the fence. For unadopted senders, the +evaluator also treats demotable matches in clauses headed by `no`, `not`, +`never`, or `do not` as advisories. A complete profile with a corresponding +risk flag declared `false` receives the same advisory demotion; runtime scope +expansion or an undeclared side effect pauses with `declaration_error`. Full receiver policy and audit rules are in [`autonomy.md`](autonomy.md). @@ -372,7 +416,7 @@ model: "" # optional telemetry (commonly for review-l turns: "" # optional telemetry input_tokens: "" # optional telemetry output_tokens: "" # optional telemetry -wall_time_s: "" # optional telemetry +wall_time_s: "" # optional telemetry est_cost_usd: "" # optional telemetry context_keys: | diff --git a/docs/protocol/mcp_integration.md b/docs/protocol/mcp_integration.md index 9a208a6..61db57d 100644 --- a/docs/protocol/mcp_integration.md +++ b/docs/protocol/mcp_integration.md @@ -49,7 +49,7 @@ findings: blocking: true status: "open" area: "data" - file: "src/asterbot/core/signal_cache.py" + file: "src/myapp/core/signal_cache.py" evidence: "Query shows 3.2% data gaps in signal stream during H19-H20" evidence_attachments: - type: "query_result" @@ -138,7 +138,7 @@ and `status` — evidence attachments are informational and don't affect the gat ### Gemini QA Workflow -When Gemini produces a findings packet (via `aster-review-pr` or similar): +When Gemini produces a findings packet (via a project review workflow): 1. Run MCP tools (BigQuery queries, browser checks) to gather evidence 2. Save outputs to `packets/evidence/_.` diff --git a/docs/protocol/review_loop.md b/docs/protocol/review_loop.md index 52ae9fa..3c863f1 100644 --- a/docs/protocol/review_loop.md +++ b/docs/protocol/review_loop.md @@ -333,7 +333,7 @@ This aligns with existing merge-readiness criteria in the shared workspace proto Integration points: - `scripts/preflight.py` — automated quality checks (conflict markers, linting, etc.) -- Future: `check_quality_gate.py` — dedicated script for full quality-gate evaluation +- `scripts/check_quality_gate.py` — dedicated script for machine-readable quality-gate evaluation (see `packet_states.yaml`) ## Comparison with background review automation diff --git a/docs/protocol/runtime_capabilities.md b/docs/protocol/runtime_capabilities.md index cb6e1d0..d8a31a9 100644 --- a/docs/protocol/runtime_capabilities.md +++ b/docs/protocol/runtime_capabilities.md @@ -5,7 +5,7 @@ Define a standard schema for declaring runtime capabilities and dynamic agent status, enabling: - **Capability discovery** — agents and tools can query what a runtime supports before dispatching work - **Dynamic status** — agents publish their current state so coordinators can route tasks to available agents -- **Health validation** — `hub doctor` checks environment, workspace, and agent status consistency +- **Health validation** — `oacp doctor` checks environment, workspace, and agent status consistency ## Static Capabilities @@ -123,7 +123,7 @@ For review tasks, prefer agents with `github_cli` capability. For parallel work, ## Health Check Contract -The `hub doctor` command validates environment and workspace health. Every check produces a result with one of four severities: +The `oacp doctor` command validates environment and workspace health. Every check produces a result with one of four severities: ### Severity Levels @@ -145,7 +145,8 @@ The `hub doctor` command validates environment and workspace health. Every check 2. **Workspace** — `workspace.json` existence and validity, agent and packet directories 3. **Inbox Health** — per-agent inbox directories, stale messages (>24h) 4. **Schemas** — YAML file validity in `packets/`, `status.yaml` validation -5. **Agent Status** — `status.yaml` presence per agent, staleness (<1h) +5. **Autonomy** — receiver `config.yaml` validity, audit directory presence, orphaned audit `policy_path` refs +6. **Agent Status** — `status.yaml` presence per agent, staleness (<1h) ### Check Details @@ -177,7 +178,7 @@ The `hub doctor` command validates environment and workspace health. Every check ### Hub Doctor -Run `hub doctor` to validate the full stack: +Run `oacp doctor` to validate the full stack: ```bash make doctor # environment checks only diff --git a/mcp_servers/oacp_coordinator.py b/mcp_servers/oacp_coordinator.py index 20fed5a..dcf391b 100755 --- a/mcp_servers/oacp_coordinator.py +++ b/mcp_servers/oacp_coordinator.py @@ -663,6 +663,10 @@ def handle_mcp_request(coordinator: OACPCoordinator, request: Dict[str, Any]) -> if request_id is None: return None return jsonrpc_error(request_id, -32000, str(exc)) + except Exception: + if request_id is None: + return None + return jsonrpc_error(request_id, -32603, "internal error") if request_id is None: return None return jsonrpc_result( diff --git a/oacp/cli.py b/oacp/cli.py index 0ba53eb..a7a2bc0 100644 --- a/oacp/cli.py +++ b/oacp/cli.py @@ -29,6 +29,8 @@ send Send a protocol-compliant inbox message org-memory Initialize org-level memory at $OACP_HOME/org-memory/ write-event Write an event to org-memory/events/ + autonomy-outcome Record a human approval/decline in an autonomy audit + envelope Compile, show, or clear the runtime envelope for a task doctor Check environment and workspace health validate Validate an inbox/outbox YAML message @@ -44,6 +46,9 @@ oacp send my-project --to iris --type notification --subject "Done" --body "Completed" oacp org-memory init oacp write-event --agent claude --project my-project --type decision --slug api-convention --body "Use REST for public APIs" + oacp autonomy-outcome /path/to/audit.yaml --decision approved + oacp envelope compile /path/to/message.yaml --receiver claude + oacp envelope show --project my-project oacp doctor oacp validate /path/to/message.yaml """ @@ -59,6 +64,8 @@ "send": "send_inbox_message.py", "org-memory": "init_org_memory.py", "write-event": "write_event.py", + "autonomy-outcome": "record_autonomy_outcome.py", + "envelope": "envelope_compiler.py", "doctor": "oacp_doctor.py", "validate": "validate_message.py", } diff --git a/oacp/envelope_hook.py b/oacp/envelope_hook.py new file mode 100644 index 0000000..57a497f --- /dev/null +++ b/oacp/envelope_hook.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Console entry point for the Claude PreToolUse envelope hook. + +``oacp setup claude`` registers this command (``oacp-envelope-hook``) once in +``.claude/settings.json``; the per-task policy lives in the compiled +``active_envelope.json``, so the settings entry never changes per dispatch. +""" + +from __future__ import annotations + +import sys +from typing import Optional, Sequence + +from oacp.cli import _run_script + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + return _run_script("claude_envelope_hook.py", args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 4439c5e..01a1529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oacp-cli" -version = "0.3.4" +version = "0.3.5" description = "Open Agent Coordination Protocol CLI for file-based multi-agent workflows" readme = "README.md" license = "Apache-2.0" @@ -38,6 +38,7 @@ Issues = "https://github.com/kiloloop/oacp/issues" [project.scripts] oacp = "oacp.cli:main" +oacp-envelope-hook = "oacp.envelope_hook:main" [dependency-groups] dev = [ @@ -55,6 +56,9 @@ packages = ["oacp"] "scripts/handoff_schema.py" = "oacp/_scripts/handoff_schema.py" "scripts/oacp_doctor.py" = "oacp/_scripts/oacp_doctor.py" "scripts/autonomy_gate.py" = "oacp/_scripts/autonomy_gate.py" +"scripts/record_autonomy_outcome.py" = "oacp/_scripts/record_autonomy_outcome.py" +"scripts/envelope_compiler.py" = "oacp/_scripts/envelope_compiler.py" +"scripts/claude_envelope_hook.py" = "oacp/_scripts/claude_envelope_hook.py" "scripts/init_project_workspace.py" = "oacp/_scripts/init_project_workspace.py" "scripts/send_inbox_message.py" = "oacp/_scripts/send_inbox_message.py" "scripts/session_lifecycle_hooks.py" = "oacp/_scripts/session_lifecycle_hooks.py" diff --git a/scripts/_oacp_constants.py b/scripts/_oacp_constants.py index a549146..2273f99 100644 --- a/scripts/_oacp_constants.py +++ b/scripts/_oacp_constants.py @@ -11,6 +11,7 @@ from pathlib import Path AGENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +REPO_SLUG_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") 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 11daeeb..149b0b4 100644 --- a/scripts/autonomy_gate.py +++ b/scripts/autonomy_gate.py @@ -21,11 +21,13 @@ import yaml +from _oacp_constants import REPO_SLUG_RE from validate_message import validate_message_dict VALID_MODES = {"always_pause", "auto_review"} -POLICY_ACTIONS = {"pause"} +POLICY_ACTIONS = {"pause", "allow_pr_artifacts", "allow"} +AUTONOMY_AUDIT_SCHEMA_VERSION = 2 NUMERIC_THRESHOLD_KEYS = ("max_estimated_minutes", "max_expected_files_touched") POLICY_THRESHOLD_KEYS = ( "destructive_ops", @@ -54,9 +56,75 @@ "comments_on_github", "commits_changes", ) +COMPLETE_PROFILE_FIELDS = ( + "estimated_minutes", + "risk_tier", + "expected_files_touched", + *LEGACY_PROFILE_BOOL_FIELDS, +) FINAL_STATES = {"done", "paused", "blocked", "superseded", "error"} - +PINNED_REASON_CODES = frozenset({ + "auth_config_or_secrets_pause", + "comments_on_github_invalid", + "comments_on_github_pause", + "commits_changes_invalid", + "commits_changes_pause", + "config_malformed", + "continuation_grant_accepted", + "continuation_grant_denied", + "continuation_grant_ignored_disabled", + "continuation_grant_missing_approval", + "continuation_grant_missing_scope", + "continuation_grant_missing_thread", + "continuation_grant_scope_exceeded", + "creates_or_updates_pr_invalid", + "creates_or_updates_pr_pause", + "declaration_error", + "dependency_changes_pause", + "destructive_ops_pause", + "envelope_compile_error", + "estimated_minutes_exceeds_threshold", + "expected_files_touched_exceeds_threshold", + "external_side_effects_not_pr_artifact", + "external_side_effects_pause", + "file_scope_ambiguous", + "hard_stop_content_sensitivity", + "hard_stop_destructive_command", + "hard_stop_external_side_effect", + "hard_stop_sensitive_scope", + "hard_stops_clear", + "lexical_advisory", + "max_actual_files_touched_invalid", + "max_actual_minutes_invalid", + "message_expired", + "message_hash_recorded", + "message_invalid", + "message_not_expired", + "message_replayed", + "message_valid", + "mode_always_pause", + "public_visibility_pause", + "risk_obvious_no_profile", + "risk_threshold_passed", + "task_profile_missing", + "task_profile_not_required", + "task_profile_present", + "task_profile_unparsable", + "task_type_allowed", + "threshold_checkpoint_breached", + "workspace_check_required", +}) + +GUARDRAILS_FENCE_RE = re.compile( + r"(?ms)^[ \t]*```oacp-guardrails[ \t]*\n" + r"(?P.*?)^[ \t]*```[ \t]*(?:\n|$)" +) +NEGATION_PREFIX_RE = re.compile( + r"\b(?:no|not|never|do\s+not|does\s+not|don't|doesn't)\b" + r"[^.!?;\n]{0,160}$", + re.IGNORECASE, +) DESTRUCTIVE_PATTERNS = ( ("rm -rf", re.compile(r"(? List[str]: if not isinstance(value, int) or isinstance(value, bool) or value < 0: errors.append(f"autonomy.auto_review_thresholds.{key} invalid") for key in POLICY_THRESHOLD_KEYS: - if str(thresholds.get(key) or "") not in POLICY_ACTIONS: - errors.append(f"autonomy.auto_review_thresholds.{key} must be pause") + value = str(thresholds.get(key) or "") + allowed = POLICY_ACTIONS if key == "external_side_effects" else {"pause"} + if value not in allowed: + choices = ", ".join(sorted(allowed)) + errors.append( + f"autonomy.auto_review_thresholds.{key} must be one of: {choices}" + ) allow_without_profile = autonomy.get("allow_without_task_profile", []) if allow_without_profile is None: @@ -169,6 +261,18 @@ def validate_receiver_config(config: Dict[str, Any]) -> List[str]: elif "enabled" in grants and not isinstance(grants.get("enabled"), bool): errors.append("autonomy.continuation_grants.enabled must be a boolean") + private_repos = autonomy.get("private_repo_allowlist", []) + if private_repos is None: + private_repos = [] + if not isinstance(private_repos, list): + errors.append("autonomy.private_repo_allowlist must be a list") + else: + for repo in private_repos: + if not isinstance(repo, str) or not REPO_SLUG_RE.fullmatch(repo): + errors.append( + "autonomy.private_repo_allowlist entries must use owner/repo" + ) + return errors @@ -186,6 +290,7 @@ def receiver_policy(config: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: autonomy.get("allow_without_task_profile") or [] ), "continuation_grants_enabled": bool(continuation.get("enabled", False)), + "private_repo_allowlist": list(autonomy.get("private_repo_allowlist") or []), } @@ -236,11 +341,28 @@ def _int_value(profile: Dict[str, Any], key: str) -> int: raise TaskProfileError(f"task_profile.{key} must be a non-negative integer") +def _risk_tier_value(profile: Dict[str, Any]) -> str: + value = profile.get("risk_tier") + if isinstance(value, str) and value in {"P0", "P1", "P2", "P3"}: + return value + raise TaskProfileError("task_profile.risk_tier must be P0, P1, P2, or P3") + + +def _target_repo_value(profile: Dict[str, Any]) -> str: + value = profile.get("target_repo", "") + if value in (None, ""): + return "" + if isinstance(value, str) and REPO_SLUG_RE.fullmatch(value): + return value + raise TaskProfileError("task_profile.target_repo must use owner/repo") + + def normalize_scope_envelope(profile: Dict[str, Any]) -> Dict[str, Any]: envelope: Dict[str, Any] = { "estimated_minutes": _int_value(profile, "estimated_minutes"), "expected_files_touched": _int_value(profile, "expected_files_touched"), - "risk_tier": str(profile.get("risk_tier") or ""), + "risk_tier": _risk_tier_value(profile), + "target_repo": _target_repo_value(profile), } for key in LEGACY_PROFILE_BOOL_FIELDS + SIDE_EFFECT_BOOL_FIELDS: envelope[key] = _bool_value(profile, key) @@ -264,6 +386,95 @@ def first_match( return None +def canonical_policy_sha256(config: Dict[str, Any]) -> str: + """Hash parsed policy data so comments and formatting do not create drift.""" + serialized = json.dumps( + config, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(serialized).hexdigest() + + +def _profile_is_complete(profile: Optional[Dict[str, Any]]) -> bool: + return isinstance(profile, dict) and all(key in profile for key in COMPLETE_PROFILE_FIELDS) + + +def _record_lexical_note( + notes: List[Dict[str, str]], + code: str, + label: str, +) -> None: + note = {"code": code, "matched_pattern": label} + if note not in notes: + notes.append(note) + + +def _match_is_negated(body: str, match: re.Match[str]) -> bool: + prefix = body[:match.start()] + boundary = max(prefix.rfind(mark) for mark in ("\n", ".", "!", "?", ";", "—", "–")) + clause_prefix = prefix[boundary + 1:] + return NEGATION_PREFIX_RE.search(clause_prefix) is not None + + +def _gate3_body(body: str, notes: List[Dict[str, str]]) -> str: + fence_matches = list(GUARDRAILS_FENCE_RE.finditer(body)) + advisory_patterns = ( + SIDE_EFFECT_VERB_PATTERNS + + tuple( + (label, pattern) + for label, pattern, _profile_field in DECLARATION_AWARE_SENSITIVE_PATTERNS + ) + + AMBIGUOUS_SCOPE_PATTERNS + ) + for fence_match in fence_matches: + _record_lexical_note(notes, "guardrails_section_skipped", "oacp-guardrails") + content = fence_match.group("content") + for label, pattern in advisory_patterns: + if pattern.search(content): + _record_lexical_note(notes, "lexical_advisory_guardrails", label) + return GUARDRAILS_FENCE_RE.sub("\n", body) + + +def _first_effective_match( + patterns: Sequence[Tuple[str, re.Pattern[str]]], + body: str, + notes: List[Dict[str, str]], + *, + demote_declared: bool = False, +) -> Optional[str]: + for label, pattern in patterns: + for match in pattern.finditer(body): + if _match_is_negated(body, match): + _record_lexical_note(notes, "lexical_advisory_negated", label) + continue + if demote_declared: + _record_lexical_note(notes, "lexical_advisory_declared", label) + continue + return label + return None + + +def _first_sensitive_match( + body: str, + notes: List[Dict[str, str]], + profile: Optional[Dict[str, Any]], + envelope: Optional[Dict[str, Any]], +) -> Optional[str]: + profile_complete = _profile_is_complete(profile) + for label, pattern, profile_field in DECLARATION_AWARE_SENSITIVE_PATTERNS: + for match in pattern.finditer(body): + if _match_is_negated(body, match): + _record_lexical_note(notes, "lexical_advisory_negated", label) + continue + if profile_complete and envelope is not None and not envelope[profile_field]: + _record_lexical_note(notes, "lexical_advisory_declared", label) + continue + return label + return None + + def message_sha256( message: Dict[str, Any], message_path: Optional[Path] = None, @@ -339,10 +550,9 @@ def obvious_no_profile_risk(body: str) -> bool: return first_match(patterns, body) is not None -def _grant_scope( - grant: Dict[str, Any], +def normalize_continuation_scope( + scope: Any, ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - scope = grant.get("scope") if not isinstance(scope, dict): return None, "continuation_grant_missing_scope" @@ -360,68 +570,259 @@ def _grant_scope( return normalized, None +def _grant_scope( + grant: Dict[str, Any], +) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + return normalize_continuation_scope(grant.get("scope")) + + +def _audit_thread_matches(message: Dict[str, Any], audit: Dict[str, Any]) -> bool: + sender = str(message.get("from") or "").strip() + audit_sender = str(audit.get("sender") or "").strip() + if not sender or audit_sender != sender: + return False + + conversation_id = str(message.get("conversation_id") or "").strip() + parent_message_id = str(message.get("parent_message_id") or "").strip() + audit_conversation_id = str(audit.get("conversation_id") or "").strip() + + if conversation_id and audit_conversation_id == conversation_id: + return True + return bool(parent_message_id and audit.get("message_id") == parent_message_id) + + +def _prior_thread_grant( + message: Dict[str, Any], + audit_dir: Optional[Path], + receiver: str, +) -> Optional[Dict[str, Any]]: + if audit_dir is None or not audit_dir.is_dir(): + return None + + candidates: List[Tuple[str, str, Dict[str, Any]]] = [] + current_message_id = str(message.get("id") or "") + message_created_at = dt.datetime.strptime( + str(message.get("created_at_utc") or ""), + "%Y-%m-%dT%H:%M:%SZ", + ) + for audit_path in audit_dir.glob("*.yaml"): + try: + audit = yaml.safe_load(audit_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + continue + if not isinstance(audit, dict): + continue + if audit.get("schema_version") != AUTONOMY_AUDIT_SCHEMA_VERSION: + continue + if audit.get("receiver") != receiver: + continue + if audit.get("decision") != "paused": + continue + if audit.get("message_id") == current_message_id: + continue + if not _audit_thread_matches(message, audit): + continue + + result = audit.get("result") + outcome = result.get("human_outcome") if isinstance(result, dict) else None + grant = outcome.get("grant") if isinstance(outcome, dict) else None + if not isinstance(outcome, dict) or outcome.get("recorded") is not True: + continue + if not isinstance(grant, dict): + continue + grant_decision = str(grant.get("decision") or "") + if grant_decision not in {"approved", "modified", "denied"}: + continue + decided_at = str(outcome.get("decided_at_utc") or "") + try: + decision_time = dt.datetime.strptime(decided_at, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + continue + if decision_time > message_created_at: + continue + candidates.append((decided_at, audit_path.name, audit)) + + if not candidates: + return None + + _decided_at, audit_name, audit = sorted(candidates)[-1] + outcome = audit["result"]["human_outcome"] + grant = outcome["grant"] + grant_decision = str(grant["decision"]) + source = { + "source_audit": audit_name, + "source_message_id": audit.get("message_id"), + "human_decision": outcome.get("decision"), + "grant_decision": grant_decision, + } + if grant_decision == "denied": + return { + **source, + "decision": "denied", + "reason_codes": ["continuation_grant_denied"], + "scope": None, + } + + if outcome.get("decision") not in {"approved", "modified"}: + return { + **source, + "decision": "denied", + "reason_codes": ["continuation_grant_denied"], + "scope": None, + } + + scope, error = normalize_continuation_scope(grant.get("granted_scope")) + if error: + return { + **source, + "decision": "invalid", + "reason_codes": [error], + "scope": None, + } + return { + **source, + "decision": "accepted", + "reason_codes": ["continuation_grant_accepted"], + "scope": scope, + } + + def evaluate_continuation_grant( message: Dict[str, Any], envelope: Dict[str, Any], continuation_enabled: bool, + audit_dir: Optional[Path] = None, + receiver: str = "codex", ) -> Dict[str, Any]: grants = envelope.get("continuation_grants") or {} grant = grants.get("approved_thread_continuation") + request_present = isinstance(grant, dict) result: Dict[str, Any] = { - "present": isinstance(grant, dict), + "present": request_present, + "request_present": request_present, + "standing_grant_found": False, "enabled": continuation_enabled, "kind": "approved_thread_continuation", "decision": "not_present", "reason_codes": [], + "requested_scope": None, "scope": None, + "source_audit": None, + "source_message_id": None, } - if not isinstance(grant, dict): - return result if not continuation_enabled: - result["decision"] = "ignored_disabled" - result["reason_codes"] = ["continuation_grant_ignored_disabled"] + if request_present: + result["decision"] = "ignored_disabled" + result["reason_codes"] = ["continuation_grant_ignored_disabled"] return result has_thread = bool(message.get("parent_message_id") or message.get("conversation_id")) if not has_thread: - result["decision"] = "invalid" - result["reason_codes"] = ["continuation_grant_missing_thread"] + if request_present: + result["decision"] = "invalid" + result["reason_codes"] = ["continuation_grant_missing_thread"] return result - scope, error = _grant_scope(grant) - if error: - result["decision"] = "invalid" - result["reason_codes"] = [error] + if request_present: + requested_scope, error = _grant_scope(grant) + if error: + result["decision"] = "invalid" + result["reason_codes"] = [error] + return result + result["requested_scope"] = requested_scope + + prior = _prior_thread_grant(message, audit_dir, receiver) + if prior is not None: + result.update(prior) + result["present"] = True + result["standing_grant_found"] = True return result - result["decision"] = "accepted" - result["reason_codes"] = ["continuation_grant_accepted"] - result["scope"] = scope + if request_present: + result["decision"] = "missing_approval" + result["reason_codes"] = ["continuation_grant_missing_approval"] return result +def continuation_scope_breaches( + envelope: Dict[str, Any], + grant_result: Dict[str, Any], +) -> List[str]: + scope = grant_result.get("scope") + if grant_result.get("decision") != "accepted" or not isinstance(scope, dict): + return [] + + breached: List[str] = [] + if envelope["estimated_minutes"] > scope["max_actual_minutes"]: + breached.append("task_profile.estimated_minutes") + if envelope["expected_files_touched"] > scope["max_actual_files_touched"]: + breached.append("task_profile.expected_files_touched") + + declared_effects = [ + key for key in COVERABLE_CONTINUATION_FIELDS if envelope[key] + ] + if envelope["external_side_effects"] and not declared_effects: + breached.append("task_profile.external_side_effects") + for key in declared_effects: + if scope.get(key) is not True: + breached.append(f"task_profile.{key}") + return breached + + def _side_effect_reasons( envelope: Dict[str, Any], grant_result: Dict[str, Any], + external_policy: str, + private_repo_allowlist: Sequence[str], ) -> List[str]: reasons: List[str] = [] grant_scope = grant_result.get("scope") if grant_result.get("decision") == "accepted" else None grant_covers_side_effect = isinstance(grant_scope, dict) and any( grant_scope.get(key) is True for key in COVERABLE_CONTINUATION_FIELDS ) + uncovered_fields = [ + key + for key in COVERABLE_CONTINUATION_FIELDS + if envelope[key] + and not (isinstance(grant_scope, dict) and grant_scope.get(key) is True) + ] + + if external_policy == "allow": + return reasons + if external_policy == "allow_pr_artifacts": + is_private_pr_artifact = ( + envelope["external_side_effects"] + and not envelope["public_visibility"] + and envelope["target_repo"] in private_repo_allowlist + and (envelope["creates_or_updates_pr"] or envelope["comments_on_github"]) + ) + if is_private_pr_artifact: + return reasons + if ( + (envelope["external_side_effects"] and not grant_covers_side_effect) + or uncovered_fields + ): + return ["external_side_effects_not_pr_artifact"] + return reasons if envelope["external_side_effects"] and not grant_covers_side_effect: reasons.append("external_side_effects_pause") - for key in COVERABLE_CONTINUATION_FIELDS: - if not envelope[key]: - continue - if isinstance(grant_scope, dict) and grant_scope.get(key) is True: - continue + for key in uncovered_fields: reasons.append(f"{key}_pause") return reasons +def _profile_declaration_errors(envelope: Dict[str, Any]) -> List[str]: + breaches: List[str] = [] + artifact_fields = [key for key in COVERABLE_CONTINUATION_FIELDS if envelope[key]] + if artifact_fields and not envelope["external_side_effects"]: + breaches.append("task_profile.external_side_effects") + if envelope["sends_oacp_reply_only"] and artifact_fields: + breaches.append("task_profile.sends_oacp_reply_only") + return breaches + + def _threshold_reasons( envelope: Dict[str, Any], thresholds: Dict[str, Any], @@ -437,8 +838,34 @@ def _threshold_reasons( def _actual_side_effects(actuals: Dict[str, Any]) -> Dict[str, bool]: side_effects = actuals.get("side_effects_actual") or {} if not isinstance(side_effects, dict): - side_effects = {} - return {key: bool(side_effects.get(key, False)) for key in COVERABLE_CONTINUATION_FIELDS} + raise ValueError("actuals.side_effects_actual must be a mapping") + normalized: Dict[str, bool] = {} + for key in COVERABLE_CONTINUATION_FIELDS: + value = side_effects.get(key, False) + if not isinstance(value, bool): + raise ValueError(f"actuals.side_effects_actual.{key} must be boolean") + normalized[key] = value + return normalized + + +def _actual_nonnegative_int(actuals: Dict[str, Any], key: str) -> int: + value = actuals.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"actuals.{key} must be a non-negative integer") + return value + + +def _completed_at_utc(actuals: Dict[str, Any]) -> Optional[str]: + value = str(actuals.get("completed_at_utc") or "") + if not value: + return None + try: + dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise ValueError( + "actuals.completed_at_utc must use YYYY-MM-DDTHH:MM:SSZ" + ) from exc + return value def evaluate_threshold_checkpoint( @@ -452,13 +879,17 @@ def evaluate_threshold_checkpoint( "actual_files_touched": None, "side_effects_actual": {}, "breached": False, + "breached_fields": [], + "declaration_errors": [], "action": "not_evaluated", + "predicted_risk_materialized": False, + "completed_at_utc": None, } if not actuals or not envelope: return checkpoint - actual_minutes = int(actuals.get("actual_minutes") or 0) - actual_files = int(actuals.get("actual_files_touched") or 0) + actual_minutes = _actual_nonnegative_int(actuals, "actual_minutes") + actual_files = _actual_nonnegative_int(actuals, "actual_files_touched") side_effects = _actual_side_effects(actuals) grant_scope = grant_result.get("scope") if grant_result.get("decision") == "accepted" else None @@ -468,7 +899,12 @@ def evaluate_threshold_checkpoint( max_minutes = grant_scope["max_actual_minutes"] max_files = grant_scope["max_actual_files_touched"] - breached = actual_minutes > max_minutes or actual_files > max_files + breached_fields: List[str] = [] + declaration_errors: List[str] = [] + if actual_minutes > max_minutes: + breached_fields.append("actual_minutes") + if actual_files > max_files: + breached_fields.append("actual_files_touched") for key, actual in side_effects.items(): if not actual: continue @@ -476,7 +912,11 @@ def evaluate_threshold_checkpoint( continue if isinstance(grant_scope, dict) and grant_scope.get(key) is True: continue - breached = True + field = f"side_effects_actual.{key}" + breached_fields.append(field) + declaration_errors.append(field) + + breached = bool(breached_fields) action = "within_declared_envelope" if breached: @@ -490,7 +930,15 @@ def evaluate_threshold_checkpoint( "actual_files_touched": actual_files, "side_effects_actual": side_effects, "breached": breached, + "breached_fields": breached_fields, + "declaration_errors": declaration_errors, "action": action, + "predicted_risk_materialized": ( + actuals["predicted_risk_materialized"] + if isinstance(actuals.get("predicted_risk_materialized"), bool) + else breached + ), + "completed_at_utc": _completed_at_utc(actuals), }) return checkpoint @@ -507,8 +955,30 @@ def _base_result( "completion_kind": completion_kind, "actual_minutes": checkpoint.get("actual_minutes"), "actual_files_touched": checkpoint.get("actual_files_touched"), - "predicted_risk_materialized": bool(checkpoint.get("breached", False)), + "predicted_risk_materialized": bool( + checkpoint.get("predicted_risk_materialized", False) + ), + "completed_at_utc": checkpoint.get("completed_at_utc"), + # Runtime adapters (envelope hooks) upgrade this to "hooks" after a + # successful `oacp envelope compile`; "none" means pickup-gate-only + # enforcement. Degradation must never be silent. + "envelope_enforcement": "none", "threshold_checkpoint": checkpoint, + "human_outcome": { + "recorded": False, + "actor": None, + "decision": None, + "decided_at_utc": None, + "decision_latency_seconds": None, + "pause_reason_codes": [], + "grant": { + "decision": "not_recorded", + "request_present": False, + "request_error": None, + "requested_scope": None, + "granted_scope": None, + }, + }, } @@ -523,109 +993,102 @@ def evaluate_autonomy( ) -> Dict[str, Any]: """Evaluate a message/config pair and return a canonical decision dict.""" msg_hash = message_sha256(message, message_path) + policy_hash = canonical_policy_sha256(config) + logged_notes: List[Dict[str, str]] = [] + profile_snapshot: Optional[Dict[str, Any]] = None + + def finish(decision: Dict[str, Any]) -> Dict[str, Any]: + reason_codes = list(decision.get("reason_codes") or []) + unknown_codes = sorted(set(reason_codes) - PINNED_REASON_CODES) + if unknown_codes: + raise ValueError(f"unpinned autonomy reason code(s): {', '.join(unknown_codes)}") + decision["message_sha256"] = msg_hash + decision["policy_sha256"] = policy_hash + decision["schema_version"] = AUTONOMY_AUDIT_SCHEMA_VERSION + decision["spec_version"] = "0.3.5" + decision["receiver"] = receiver + decision["sender"] = message.get("from") + decision["message_id"] = message.get("id") + decision["message_type"] = message.get("type") + decision["conversation_id"] = message.get("conversation_id") + decision["parent_message_id"] = message.get("parent_message_id") + decision.setdefault("task_profile", profile_snapshot) + decision.setdefault( + "breached", + reason_codes if decision.get("decision") == "paused" else [], + ) + return decision + + def paused( + mode: str, + reason_codes: List[str], + completion_kind: str, + *, + envelope: Optional[Dict[str, Any]] = None, + grant_result: Optional[Dict[str, Any]] = None, + matched_pattern: Optional[str] = None, + validation_errors: Optional[List[str]] = None, + checkpoint: Optional[Dict[str, Any]] = None, + breached: Optional[List[str]] = None, + ) -> Dict[str, Any]: + grant = grant_result or {"present": False, "enabled": False} + resolved_checkpoint = checkpoint or evaluate_threshold_checkpoint( + envelope, + grant, + actuals, + ) + decision: Dict[str, Any] = { + "decision": "paused", + "mode": mode, + "reason_codes": reason_codes, + "scope_envelope": envelope, + "logged_notes": logged_notes, + "continuation_grant": grant, + "result": _base_result("paused", completion_kind, resolved_checkpoint), + } + if matched_pattern is not None: + decision["matched_pattern"] = matched_pattern + if validation_errors is not None: + decision["message_validation_errors"] = validation_errors + if breached is not None: + decision["breached"] = breached + return finish(decision) + try: mode, policy = receiver_policy(config) except AutonomyConfigError: - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return { - "decision": "paused", - "mode": "always_pause", - "reason_codes": ["config_malformed"], - "message_sha256": msg_hash, - "scope_envelope": None, - "logged_notes": [], - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "config_malformed", checkpoint), - } + return paused("always_pause", ["config_malformed"], "config_malformed") if mode == "always_pause": - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return { - "decision": "paused", - "mode": mode, - "reason_codes": ["mode_always_pause"], - "message_sha256": msg_hash, - "scope_envelope": None, - "logged_notes": [], - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "always_pause", checkpoint), - } + return paused(mode, ["mode_always_pause"], "always_pause") message_errors = validate_message_dict(message) if message_errors: - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return { - "decision": "paused", - "mode": mode, - "reason_codes": ["message_invalid"], - "message_sha256": msg_hash, - "message_validation_errors": message_errors, - "scope_envelope": None, - "logged_notes": [], - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "message_invalid", checkpoint), - } + return paused( + mode, + ["message_invalid"], + "message_invalid", + validation_errors=message_errors, + ) if message_expired(message, now_utc): - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return { - "decision": "paused", - "mode": mode, - "reason_codes": ["message_expired"], - "message_sha256": msg_hash, - "scope_envelope": None, - "logged_notes": [], - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "message_expired", checkpoint), - } + return paused(mode, ["message_expired"], "message_expired") if prior_auto_accept_exists(str(message.get("id") or ""), receiver, audit_dir): - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return { - "decision": "paused", - "mode": mode, - "reason_codes": ["message_replayed"], - "message_sha256": msg_hash, - "scope_envelope": None, - "logged_notes": [], - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "message_replayed", checkpoint), - } + return paused(mode, ["message_replayed"], "message_replayed") body = str(message.get("body") or "") msg_type = str(message.get("type") or "") allow_without_profile = msg_type in policy["allow_without_task_profile"] - logged_notes: List[Dict[str, str]] = [] - - def with_message_hash(decision: Dict[str, Any]) -> Dict[str, Any]: - decision["message_sha256"] = msg_hash - return decision profile, profile_error = extract_task_profile(body) + profile_snapshot = profile if profile_error: - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": [profile_error], - "scope_envelope": None, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", profile_error, checkpoint), - }) + return paused(mode, [profile_error], profile_error) if profile is None and not allow_without_profile: reason = "risk_obvious_no_profile" if obvious_no_profile_risk(body) else "task_profile_missing" - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": [reason], - "scope_envelope": None, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", reason, checkpoint), - }) + return paused(mode, [reason], reason) envelope: Optional[Dict[str, Any]] = None profile_required_reason = "task_profile_present" @@ -636,75 +1099,106 @@ def with_message_hash(decision: Dict[str, Any]) -> Dict[str, Any]: try: envelope = normalize_scope_envelope(profile) except TaskProfileError: - checkpoint = evaluate_threshold_checkpoint(None, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": ["task_profile_unparsable"], - "scope_envelope": None, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "task_profile_unparsable", checkpoint), - }) + return paused( + mode, + ["task_profile_unparsable"], + "task_profile_unparsable", + ) + + gate3_body = _gate3_body(body, logged_notes) matched = first_match(DESTRUCTIVE_PATTERNS, body) if matched: - checkpoint = evaluate_threshold_checkpoint(envelope, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": ["hard_stop_destructive_command"], - "matched_pattern": matched, - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "hard_stop", checkpoint), - }) - - side_effect_patterns = NON_DEMOTABLE_SIDE_EFFECT_PATTERNS - if not allow_without_profile or profile is not None: - side_effect_patterns = SIDE_EFFECT_VERB_PATTERNS + side_effect_patterns - matched = first_match(side_effect_patterns, body) + return paused( + mode, + ["hard_stop_destructive_command"], + "hard_stop", + envelope=envelope, + matched_pattern=matched, + ) + + external_policy = str(policy["thresholds"]["external_side_effects"]) + if profile is not None: + demote_side_effect = ( + _profile_is_complete(profile) + and envelope is not None + and not envelope["external_side_effects"] + ) or ( + external_policy == "allow" + and envelope is not None + and envelope["external_side_effects"] + ) + matched = _first_effective_match( + SIDE_EFFECT_VERB_PATTERNS, + gate3_body, + logged_notes, + demote_declared=demote_side_effect, + ) + if matched: + return paused( + mode, + ["hard_stop_external_side_effect"], + "hard_stop", + envelope=envelope, + matched_pattern=matched, + ) + + git_push_or_deploy_policy = str(policy["thresholds"]["git_push_or_deploy"]) + matched = None + if git_push_or_deploy_policy == "pause": + matched = first_match(NON_DEMOTABLE_SIDE_EFFECT_PATTERNS, body) if matched: - checkpoint = evaluate_threshold_checkpoint(envelope, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": ["hard_stop_external_side_effect"], - "matched_pattern": matched, - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "hard_stop", checkpoint), - }) + return paused( + mode, + ["hard_stop_external_side_effect"], + "hard_stop", + envelope=envelope, + matched_pattern=matched, + ) - matched = first_match(SENSITIVE_SCOPE_PATTERNS, body) + matched = _first_sensitive_match(gate3_body, logged_notes, profile, envelope) if matched: - checkpoint = evaluate_threshold_checkpoint(envelope, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": ["hard_stop_sensitive_scope"], - "matched_pattern": matched, - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "hard_stop", checkpoint), - }) + return paused( + mode, + ["hard_stop_sensitive_scope"], + "hard_stop", + envelope=envelope, + matched_pattern=matched, + ) - matched = first_match(AMBIGUOUS_SCOPE_PATTERNS, body) + matched = first_match(CONTENT_SENSITIVITY_PATTERNS, body) if matched: - checkpoint = evaluate_threshold_checkpoint(envelope, {}, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": ["file_scope_ambiguous"], - "matched_pattern": matched, - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": {"present": False, "enabled": False}, - "result": _base_result("paused", "ambiguous_scope", checkpoint), - }) + return paused( + mode, + ["hard_stop_content_sensitivity"], + "content_sensitivity", + envelope=envelope, + matched_pattern=matched, + ) + + matched = first_match(NON_DEMOTABLE_SENSITIVE_PATTERNS, body) + if matched: + return paused( + mode, + ["hard_stop_sensitive_scope"], + "hard_stop", + envelope=envelope, + matched_pattern=matched, + ) + + matched = _first_effective_match( + AMBIGUOUS_SCOPE_PATTERNS, + gate3_body, + logged_notes, + ) + if matched: + return paused( + mode, + ["file_scope_ambiguous"], + "ambiguous_scope", + envelope=envelope, + matched_pattern=matched, + ) grant_result: Dict[str, Any] = {"present": False, "enabled": False} if envelope is not None: @@ -712,7 +1206,31 @@ def with_message_hash(decision: Dict[str, Any]) -> Dict[str, Any]: message, envelope, bool(policy["continuation_grants_enabled"]), + audit_dir=audit_dir, + receiver=receiver, ) + declaration_breaches = _profile_declaration_errors(envelope) + if declaration_breaches: + return paused( + mode, + ["declaration_error"], + "declaration_error", + envelope=envelope, + grant_result=grant_result, + breached=declaration_breaches, + ) + + grant_breaches = continuation_scope_breaches(envelope, grant_result) + if grant_breaches: + return paused( + mode, + ["continuation_grant_scope_exceeded"], + "continuation_grant_scope_exceeded", + envelope=envelope, + grant_result=grant_result, + breached=grant_breaches, + ) + hard_profile_reasons = [] if envelope["destructive_ops"]: hard_profile_reasons.append("destructive_ops_pause") @@ -723,45 +1241,48 @@ def with_message_hash(decision: Dict[str, Any]) -> Dict[str, Any]: if envelope["public_visibility"]: hard_profile_reasons.append("public_visibility_pause") if hard_profile_reasons: - checkpoint = evaluate_threshold_checkpoint(envelope, grant_result, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": hard_profile_reasons, - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": grant_result, - "result": _base_result("paused", "hard_stop", checkpoint), - }) - - reasons = _threshold_reasons(envelope, policy["thresholds"]) - side_effect_reasons = _side_effect_reasons(envelope, grant_result) - if grant_result.get("decision") == "ignored_disabled": - side_effect_reasons = list(grant_result["reason_codes"]) + side_effect_reasons + return paused( + mode, + hard_profile_reasons, + "hard_stop", + envelope=envelope, + grant_result=grant_result, + ) + + reasons = [] + if grant_result.get("decision") not in {"accepted", "not_present"}: + reasons.extend(grant_result.get("reason_codes") or []) + reasons.extend(_threshold_reasons(envelope, policy["thresholds"])) + side_effect_reasons = _side_effect_reasons( + envelope, + grant_result, + external_policy, + policy["private_repo_allowlist"], + ) reasons.extend(side_effect_reasons) if reasons: - checkpoint = evaluate_threshold_checkpoint(envelope, grant_result, actuals) - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": reasons, - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": grant_result, - "result": _base_result("paused", "auto_review_paused", checkpoint), - }) + return paused( + mode, + reasons, + "auto_review_paused", + envelope=envelope, + grant_result=grant_result, + ) checkpoint = evaluate_threshold_checkpoint(envelope, grant_result, actuals) if checkpoint["evaluated"] and checkpoint["breached"]: - return with_message_hash({ - "decision": "paused", - "mode": mode, - "reason_codes": ["threshold_checkpoint_breached"], - "scope_envelope": envelope, - "logged_notes": logged_notes, - "continuation_grant": grant_result, - "result": _base_result("paused", "threshold_checkpoint_breached", checkpoint), - }) + has_declaration_error = bool(checkpoint["declaration_errors"]) + reason = "declaration_error" if has_declaration_error else "threshold_checkpoint_breached" + completion = "declaration_error" if has_declaration_error else reason + return paused( + mode, + [reason], + completion, + envelope=envelope, + grant_result=grant_result, + checkpoint=checkpoint, + breached=list(checkpoint["breached_fields"]), + ) reason_codes = [ "message_valid", @@ -774,10 +1295,17 @@ def with_message_hash(decision: Dict[str, Any]) -> Dict[str, Any]: ] if envelope is not None: reason_codes.insert(5, "risk_threshold_passed") + if any( + note.get("code", "").startswith("lexical_advisory") + or note.get("code") == "guardrails_section_skipped" + or note.get("code") == "side_effect_verb_demoted_for_profileless_type" + for note in logged_notes + ): + reason_codes.insert(-1, "lexical_advisory") if grant_result.get("decision") == "accepted": reason_codes.append("continuation_grant_accepted") - return with_message_hash({ + return finish({ "decision": "auto_accepted", "mode": mode, "reason_codes": reason_codes, diff --git a/scripts/claude_envelope_hook.py b/scripts/claude_envelope_hook.py new file mode 100644 index 0000000..3926094 --- /dev/null +++ b/scripts/claude_envelope_hook.py @@ -0,0 +1,914 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Claude PreToolUse hook: enforce the active runtime envelope. + +Registered once by ``oacp setup claude`` (static shim). On every Bash / Edit / +Write / NotebookEdit call it looks for ``active_envelope.json`` in the agent +workspace of the project owning the tool call's cwd. No envelope means no +opinion: the hook exits 0 with no output and the harness's normal permission +flow proceeds — exactly today's behavior. + +With an envelope active, the hook classifies the call against the compiled +constraints and emits a PreToolUse ``permissionDecision``: + +- ``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). +- ``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. +- allow — emitted as *no output*, never as an explicit ``allow`` decision, + so the envelope can only tighten the harness's permission surface, never + bypass it. + +``oacp send`` is never denied: it is the §E notification pipe. The exemption +is exactly that wide — other oacp subcommands are classified, and envelope +self-modification (``oacp envelope compile|clear``) is denied outright. +Determinable Bash write targets (redirects and common writer programs) pass +through the same secret/dependency/file-counter gate as Edit/Write calls. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from autonomy_gate import DESTRUCTIVE_PATTERNS +from envelope_compiler import envelope_path, load_envelope, write_envelope + +BLOCKED_OPENER = "Blocked: autonomy threshold exceeded" + +SEGMENT_SPLIT_RE = re.compile(r"[;|&\n]+") +SUBSTITUTION_RE = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") +REDIRECT_TARGET_RE = re.compile(r">>?\s*([^\s;|&]+)") +ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +GIT_URL_RE = re.compile( + r"(?:[/:])(?P[A-Za-z0-9_.-]+)/(?P[A-Za-z0-9_.-]+?)(?:\.git)?/?$" +) + +WRAPPER_COMMANDS = {"command", "builtin", "nohup", "time", "env"} +# Programs that execute arbitrary nested commands the classifier cannot see +# through — always escalated, never allowed through unclassified. +SHELL_INDIRECTION = { + "sh", "bash", "zsh", "dash", "ksh", "fish", "eval", "exec", "xargs", + "source", ".", +} +# Known runners whose nested command is visible in argv: recursively +# classified instead of escalated (F-007: `uv run oacp envelope clear`). +RUNNER_VERBS = {"uv": "run", "poetry": "run", "pipx": "run", "npm": "exec", + "pnpm": "exec", "yarn": "exec"} +DIRECT_RUNNERS = {"npx", "uvx"} +# Interpreter inline-code flags execute uninspectable nested code (F-007). +INTERPRETER_CODE_FLAGS = { + "python": {"-c"}, "python2": {"-c"}, "python3": {"-c"}, + "node": {"-e", "--eval", "-p"}, "nodejs": {"-e", "--eval", "-p"}, + "ruby": {"-e"}, "perl": {"-e", "-E"}, +} +PROTECTED_BRANCHES = {"main", "master"} +# git push flags that can update refs far beyond a single PR branch. +GIT_PUSH_BULK_FLAGS = { + "--mirror", "--all", "--tags", "--follow-tags", "--delete", "-d", "--prune", +} +# oacp subcommands an enveloped session may always run (the send pipe plus +# read-only surfaces). `envelope compile|clear|--extend` is self-modification +# of the active constraints and is denied; everything else escalates. +OACP_ALWAYS_ALLOWED = {"send", "inbox", "validate", "doctor", "watch", "help", ""} + +DEPENDENCY_FILENAMES = { + "pyproject.toml", + "package.json", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "uv.lock", + "poetry.lock", + "Cargo.toml", + "Cargo.lock", + "go.mod", + "go.sum", + "Gemfile", + "Gemfile.lock", +} +PACKAGE_MANAGERS = { + "pip", + "pip3", + "uv", + "npm", + "yarn", + "pnpm", + "brew", + "poetry", + "cargo", + "gem", + "apt", + "apt-get", +} +DEPENDENCY_VERBS = {"install", "add", "remove", "uninstall"} + +GH_READONLY_ACTIONS = {"view", "list", "status", "checks", "diff", "download"} +GH_PR_CLASS = {("pr", "create"), ("pr", "edit"), ("pr", "ready"), ("pr", "update-branch")} +GH_COMMENT_CLASS = {("pr", "comment"), ("pr", "review"), ("issue", "comment")} +GH_DENY_CLASS = { + ("pr", "merge"), + ("pr", "close"), + ("pr", "reopen"), + ("pr", "lock"), + ("pr", "unlock"), + ("issue", "create"), + ("issue", "edit"), + ("issue", "close"), + ("issue", "reopen"), + ("issue", "delete"), + ("issue", "transfer"), + ("issue", "pin"), + ("issue", "unpin"), + ("issue", "lock"), + ("issue", "unlock"), +} +GH_DENY_GROUPS = {"release", "repo", "gist", "secret", "variable", "label", "ruleset"} +GH_AUTH_MUTATIONS = {"login", "logout", "refresh", "setup-git", "token"} +GH_WORKFLOW_MUTATIONS = { + ("workflow", "run"), + ("workflow", "enable"), + ("workflow", "disable"), + ("run", "rerun"), + ("run", "cancel"), + ("cache", "delete"), +} +# Value-taking gh flags: skip flag AND value when deriving group/action, so a +# flag's value can never be misread as a positional (F-003: `gh --repo X pr +# merge` must classify as pr/merge, not X/pr). Unknown flags skip only +# themselves; if that misparses an exotic value-taking flag, the resulting +# unknown group/action falls into the fail-closed ask below. +GH_VALUE_FLAGS = { + "-R", "--repo", "-X", "--method", "-H", "--header", "-q", "--jq", + "-t", "--template", "-b", "--body", "--body-file", "-B", "--base", + "--head", "--title", "-m", "--milestone", "-a", "--assignee", + "-l", "--label", "-p", "--project", "--hostname", "-A", "--app", +} +# gh api flags that implicitly switch the request from GET to POST. +GH_API_MUTATION_FLAGS = {"-f", "--field", "-F", "--raw-field", "--input"} +# Bash writer programs whose file targets are determinable from argv. +BASH_WRITERS = {"touch", "tee", "cp", "mv", "install", "truncate"} + + +class Decision: + __slots__ = ("action", "reason", "new_files") + + def __init__( + self, + action: str, + reason: str = "", + new_files: Optional[List[str]] = None, + ) -> None: + self.action = action + self.reason = reason + self.new_files = new_files or [] + + +ALLOW = Decision("allow") + + +def _deny(reason: str) -> Decision: + return Decision("deny", reason) + + +def _ask(reason: str) -> Decision: + return Decision("ask", reason) + + +# ── Workspace discovery ─────────────────────────────────────────────────────── + + +def find_project(cwd: Path) -> Optional[str]: + """Walk upward from cwd looking for an OACP workspace marker.""" + for root in (cwd, *cwd.parents): + for name in (".oacp", "workspace.json"): + marker = root / name + if not (marker.is_file() or marker.is_symlink()): + continue + try: + data = json.loads(marker.resolve().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(data, dict) and data.get("project_name"): + return str(data["project_name"]) + return None + + +# ── Path classification ─────────────────────────────────────────────────────── + + +def is_secret_path(path: str) -> bool: + normalized = path.replace("\\", "/") + name = normalized.rsplit("/", 1)[-1] + if name == ".env" or name.startswith(".env."): + return True + if "credentials" in name.lower(): + return True + if name.endswith((".pem", ".key")): + return True + if "/.ssh/" in normalized or normalized.startswith((".ssh/", "~/.ssh/")): + return True + parts = [part for part in normalized.split("/") if part] + for index in range(len(parts) - 2): + if parts[index] == "agents" and parts[index + 2] in ("config.yaml", "config.yml"): + return True + return False + + +def is_dependency_path(path: str) -> bool: + name = path.replace("\\", "/").rsplit("/", 1)[-1] + if name in DEPENDENCY_FILENAMES: + return True + return name.startswith("requirements") and name.endswith(".txt") + + +def _normalize_file_path(path: str, cwd: str) -> str: + expanded = os.path.expanduser(path) + if not os.path.isabs(expanded): + expanded = os.path.join(cwd, expanded) + return os.path.normpath(expanded) + + +# ── Repo resolution ─────────────────────────────────────────────────────────── + + +def parse_repo_from_url(url: str) -> Optional[str]: + match = GIT_URL_RE.search(url.strip()) + if not match: + return None + return f"{match.group('owner')}/{match.group('repo')}" + + +def resolve_cwd_repo(git_dir: str, remote: str = "origin") -> Optional[str]: + try: + completed = subprocess.run( + ["git", "-C", git_dir, "remote", "get-url", remote], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return parse_repo_from_url(completed.stdout) + + +def resolve_current_branch(git_dir: str) -> Optional[str]: + try: + completed = subprocess.run( + ["git", "-C", git_dir, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() or None + + +def _repo_gate( + repo: Optional[str], + constraints: Dict[str, Any], + action_label: str, +) -> Optional[Decision]: + """Shared allowlist/visibility gate for gh and git mutations.""" + if constraints.get("public_visibility"): + return _deny( + f"envelope declares public_visibility: true is not allowed at " + f"runtime; {action_label} paused for review" + ) + if repo is None: + return _ask( + f"cannot resolve the target repository for {action_label}; " + "escalating for review" + ) + target = str(constraints.get("target_repo") or "") + allowlist = constraints.get("private_repo_allowlist") or [] + if target and repo != target: + return _deny( + f"{action_label} targets {repo}, but the envelope pins " + f"target_repo {target}" + ) + if repo not in allowlist: + return _deny( + f"{action_label} targets {repo}, which is outside the receiver's " + "private_repo_allowlist" + ) + return None + + +# ── Bash classification ─────────────────────────────────────────────────────── + + +def _strip_wrappers(tokens: List[str]) -> Tuple[List[str], bool]: + """Return (remaining tokens, needs_ask). + + needs_ask is True when a wrapper carries its own flags (`env -i gh ...`): + flag/value consumption differs per wrapper, so the real program cannot be + identified reliably — escalate instead of guessing (F-002). + """ + index = 0 + saw_wrapper = False + while index < len(tokens): + token = tokens[index] + if ENV_ASSIGNMENT_RE.match(token): + index += 1 + continue + if token in WRAPPER_COMMANDS: + saw_wrapper = True + index += 1 + continue + if saw_wrapper and token.startswith("-"): + return tokens[index:], True + break + return tokens[index:], False + + +def _flag_value(tokens: List[str], *flags: str) -> Optional[str]: + for index, token in enumerate(tokens): + if token in flags and index + 1 < len(tokens): + return tokens[index + 1] + for flag in flags: + if token.startswith(flag + "="): + return token.split("=", 1)[1] + # Attached short-flag value: `-Rowner/repo` (F-006). + if ( + not flag.startswith("--") + and len(flag) == 2 + and len(token) > 2 + and token.startswith(flag) + ): + return token[2:] + return None + + +def _git_subcommand(tokens: List[str]) -> Tuple[Optional[str], List[str], Optional[str]]: + """Return (subcommand, args-after-subcommand, -C directory).""" + git_dir: Optional[str] = None + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "-C" and index + 1 < len(tokens): + git_dir = tokens[index + 1] + index += 2 + continue + if token == "-c" and index + 1 < len(tokens): + index += 2 + continue + if token.startswith("-"): + index += 1 + continue + return token, tokens[index + 1:], git_dir + return None, [], git_dir + + +def _classify_git_push( + args: List[str], + git_dir: str, + constraints: Dict[str, Any], +) -> Decision: + if not constraints.get("external_side_effects"): + return _deny( + "git push is an external side effect the envelope declares false" + ) + if not constraints.get("creates_or_updates_pr"): + return _deny( + "git push requires creates_or_updates_pr in the envelope " + "(branch pushes are folded into PR-artifact creation)" + ) + + bulk = sorted(set(args) & GIT_PUSH_BULK_FLAGS) + if bulk: + return _deny( + f"git push {bulk[0]} can update refs beyond a single PR branch " + "and is never inside a runtime envelope" + ) + + positional = [arg for arg in args if not arg.startswith("-")] + remote = positional[0] if positional else "origin" + refspecs = positional[1:] + needs_current_branch = not refspecs + for refspec in refspecs: + # Wildcard refspecs can update many branches at once, including + # protected ones (F-006: `refs/heads/*:refs/heads/*`). + if "*" in refspec: + return _deny( + f"git push with wildcard refspec {refspec!r} can update " + "multiple branches and is never inside a runtime envelope" + ) + # `+refspec` is a per-refspec force marker — strip it before parsing + # so `+main` is still recognized as a protected branch (F-003). + destination = refspec.lstrip("+").split(":", 1)[-1] + branch = destination.rsplit("/", 1)[-1] + if branch == "HEAD": + needs_current_branch = True + continue + if branch in PROTECTED_BRANCHES: + return _deny( + f"git push targeting protected branch {branch!r} is never " + "inside a runtime envelope" + ) + if needs_current_branch: + branch = resolve_current_branch(git_dir) + if branch is None: + return _ask("cannot resolve the branch for a bare git push") + if branch in PROTECTED_BRANCHES: + return _deny( + f"git push from protected branch {branch!r} is never inside " + "a runtime envelope" + ) + + repo = resolve_cwd_repo(git_dir, remote) + gate = _repo_gate(repo, constraints, "git push") + if gate is not None: + return gate + return ALLOW + + +def _gh_positionals(tokens: List[str]) -> Tuple[List[str], bool]: + """Extract gh positionals with flag/value pairs consumed (F-003). + + Returns (positionals, api_mutation_flags): value-taking flags skip their + value so it can never be misread as group/action; `-f`/`--field`-style + flags are recorded because they implicitly switch `gh api` to POST. + """ + words: List[str] = [] + api_mutation = False + index = 1 + while index < len(tokens): + token = tokens[index] + if token in GH_API_MUTATION_FLAGS: + api_mutation = True + index += 2 + continue + if token in GH_VALUE_FLAGS: + index += 2 + continue + if token.startswith("--"): + if "=" in token and token.split("=", 1)[0] in GH_API_MUTATION_FLAGS: + api_mutation = True + index += 1 + continue + if token.startswith("-") and len(token) > 2: + # Attached short-flag value (`-Rowner/repo`, `-fkey=val`): the + # whole token is flag+value — never a positional (F-006). + if token[:2] in GH_API_MUTATION_FLAGS: + api_mutation = True + index += 1 + continue + if token.startswith("-"): + index += 1 + continue + words.append(token) + index += 1 + return words, api_mutation + + +def _classify_gh(tokens: List[str], cwd: str, constraints: Dict[str, Any]) -> Decision: + words, api_mutation = _gh_positionals(tokens) + group = words[0] if words else "" + action = words[1] if len(words) > 1 else "" + label = f"gh {group} {action}".strip() + + if group == "api": + explicit = _flag_value(tokens, "-X", "--method") + method = (explicit or ("POST" if api_mutation else "GET")).upper() + if method == "GET": + return ALLOW + return _ask(f"gh api with method {method} cannot be classified") + + if group == "auth": + # Allowlist, not blocklist: `status` is the only read-only auth verb + # (F-006 — `gh auth switch` mutates authentication configuration). + if action == "status": + return ALLOW + if not constraints.get("touches_auth_config_or_secrets"): + return _deny( + f"{label} mutates auth state the envelope declares false" + ) + return ALLOW + + if action in GH_READONLY_ACTIONS: + return ALLOW + + key = (group, action) + if key in GH_DENY_CLASS or group in GH_DENY_GROUPS or key in GH_WORKFLOW_MUTATIONS: + return _deny( + f"{label} is outside every envelope allow class " + "(PR create/update and GitHub comments only)" + ) + + if key in GH_PR_CLASS or key in GH_COMMENT_CLASS: + if not constraints.get("external_side_effects"): + return _deny( + f"{label} is an external side effect the envelope declares false" + ) + required = ( + "creates_or_updates_pr" if key in GH_PR_CLASS else "comments_on_github" + ) + if not constraints.get(required): + return _deny(f"{label} requires {required} in the envelope") + repo = _flag_value(tokens, "-R", "--repo") or resolve_cwd_repo(cwd) + gate = _repo_gate(repo, constraints, label) + if gate is not None: + return gate + return ALLOW + + # Fail closed: gh verbs outside the known read-only and mutation classes + # escalate rather than pass (F-003 — e.g. `gh run delete`). + return _ask(f"cannot classify {label} under an active envelope") + + +def _classify_oacp(tokens: List[str]) -> Decision: + """Scope the oacp exemption to what the protocol actually promises. + + Only `oacp send` (the §E notification pipe) plus read-only surfaces are + exempt. `oacp envelope compile|clear` from inside an enveloped session is + self-modification of the active constraints (F-001); other mutating + subcommands escalate. + """ + words = [token for token in tokens[1:] if not token.startswith("-")] + sub = words[0] if words else "" + action = words[1] if len(words) > 1 else "" + + if sub in OACP_ALWAYS_ALLOWED: + return ALLOW + if sub == "envelope": + if action == "show": + return ALLOW + return _deny( + f"oacp envelope {action} modifies the active envelope from inside " + "the enveloped session; re-authorization goes through the §E " + "checkpoint, not self-service recompilation" + ) + if sub == "agent" and action in ("show", "list"): + return ALLOW + return _ask( + f"cannot classify oacp {sub} under an active envelope; only send and " + "read-only subcommands are exempt" + ) + + +def _classify_dependency_command( + prog: str, + tokens: List[str], + constraints: Dict[str, Any], +) -> Optional[Decision]: + if prog.startswith("python"): + if "-m" in tokens: + module_index = tokens.index("-m") + 1 + if module_index < len(tokens) and tokens[module_index] in ("pip", "pip3"): + prog = "pip" + tokens = tokens[module_index:] + else: + return None + else: + return None + if prog not in PACKAGE_MANAGERS: + return None + verbs = [token for token in tokens[1:] if not token.startswith("-")] + # `uv pip install`, `uv tool install`: scan the first two positionals. + for verb in verbs[:2]: + if verb in DEPENDENCY_VERBS: + if constraints.get("touches_dependencies"): + return ALLOW + return _deny( + f"{prog} {verb} changes dependencies the envelope declares false" + ) + return None + + +def _segment_write_targets(tokens: List[str], segment: str) -> List[str]: + """Determinable file-write targets of one shell segment (F-004). + + Covers redirects plus common writer programs whose destinations are + parseable from argv. Read-only or unrecognized programs contribute no + targets — the secret/dependency/counter gate then simply does not fire. + """ + targets = [ + match.group(1).strip("'\"") + for match in REDIRECT_TARGET_RE.finditer(segment) + ] + if not tokens: + return targets + prog = tokens[0].rsplit("/", 1)[-1] + positional = [token for token in tokens[1:] if not token.startswith("-")] + if prog in ("touch", "tee"): + targets.extend(positional) + elif prog in ("cp", "mv", "install") and len(positional) >= 2: + targets.append(positional[-1]) + elif prog == "truncate": + targets.extend(positional) + elif prog == "sed" and any(token.startswith("-i") for token in tokens[1:]): + # First positional is the script; the rest are edited in place. + targets.extend(positional[1:]) + elif prog == "dd": + targets.extend( + token[len("of="):] for token in tokens[1:] if token.startswith("of=") + ) + return targets + + +def _gate_write_paths( + paths: List[str], + cwd: str, + constraints: Dict[str, Any], + counters: Dict[str, Any], + verb: str, +) -> Decision: + """Shared secret/dependency/file-counter gate for file tools and Bash + writes. Counts distinct paths cumulatively so a single call cannot jump + the `expected_files_touched` ceiling (F-004: `touch a b c`).""" + touched = list(counters.get("files_touched") or []) + expected = int(constraints.get("expected_files_touched") or 0) + new_files: List[str] = [] + for path in paths: + normalized = _normalize_file_path(path, cwd) + if normalized.startswith("/dev/"): + continue + if not constraints.get("touches_auth_config_or_secrets") and is_secret_path( + normalized + ): + return _deny( + f"{verb} of secret-class path {normalized!r} is outside the " + "envelope (touches_auth_config_or_secrets: false)" + ) + if not constraints.get("touches_dependencies") and is_dependency_path( + normalized + ): + return _deny( + f"{verb} of dependency manifest {normalized!r} is outside the " + "envelope (touches_dependencies: false)" + ) + if normalized in touched or normalized in new_files: + continue + if len(touched) + len(new_files) >= expected: + return _deny( + f"{BLOCKED_OPENER} — files_touched expected {expected}, " + f"now {len(touched) + len(new_files) + 1}" + ) + new_files.append(normalized) + if new_files: + return Decision("allow", new_files=new_files) + return ALLOW + + +def _segments_of(command: str) -> List[str]: + """Split a command into classifiable segments (F-002). + + 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. + """ + segments = [s.strip() for s in SEGMENT_SPLIT_RE.split(command) if s.strip()] + for match in SUBSTITUTION_RE.finditer(command): + inner = match.group(1) or match.group(2) or "" + segments.extend(s.strip() for s in SEGMENT_SPLIT_RE.split(inner) if s.strip()) + return segments + + +def _classify_segment( + tokens: List[str], + segment: str, + cwd: str, + constraints: Dict[str, Any], + write_targets: List[str], + depth: int = 0, +) -> Optional[Decision]: + """Classify one segment's tokens; return a non-allow Decision or None. + + Write targets are collected BEFORE program dispatch so a redirect on a + recognized program (`gh pr view > .env`) still reaches the shared write + gate (F-005). Known runners recurse into their nested command (F-007). + """ + if depth > 3: + return _ask(f"cannot classify deeply nested command: {segment!r}") + + tokens, wrapper_needs_ask = _strip_wrappers(tokens) + if wrapper_needs_ask: + return _ask(f"cannot classify wrapper invocation: {segment!r}") + if not tokens: + return None + + # Redirect targets are a property of the segment, writer-program targets + # of the argv — both must be gated regardless of which branch handles + # the program below (only collect segment redirects once, at depth 0). + write_targets.extend( + _segment_write_targets(tokens, segment if depth == 0 else "") + ) + + prog = tokens[0].rsplit("/", 1)[-1] + + if prog == "sudo": + return _ask(f"cannot classify privileged command: {segment!r}") + if prog in SHELL_INDIRECTION: + return _ask(f"cannot classify shell indirection: {segment!r}") + + code_flags = INTERPRETER_CODE_FLAGS.get(prog) + if code_flags and any(token in code_flags for token in tokens[1:]): + return _ask(f"cannot classify inline interpreter code: {segment!r}") + + runner_verb = RUNNER_VERBS.get(prog) + nested: Optional[List[str]] = None + if prog in DIRECT_RUNNERS: + nested = tokens[1:] + elif runner_verb is not None and len(tokens) > 1 and tokens[1] == runner_verb: + nested = tokens[2:] + if nested is not None: + if not nested or nested[0].startswith("-"): + return _ask(f"cannot classify runner invocation: {segment!r}") + return _classify_segment( + nested, segment, cwd, constraints, write_targets, depth + 1 + ) + + if prog == "oacp": + decision = _classify_oacp(tokens) + if decision.action != "allow": + return decision + return None + if prog in ("cp", "mv", "install") and not constraints.get( + "touches_auth_config_or_secrets" + ): + # Sources too: copying a secret out is as bad as writing one. + for arg in tokens[1:]: + if not arg.startswith("-") and is_secret_path(arg): + return _deny( + f"command touching secret-class path {arg!r} is outside " + "the envelope (touches_auth_config_or_secrets: false)" + ) + if prog == "git": + subcommand, args, git_dir = _git_subcommand(tokens) + base_dir = git_dir or cwd + if not os.path.isabs(base_dir): + base_dir = os.path.normpath(os.path.join(cwd, base_dir)) + if subcommand == "push": + decision = _classify_git_push(args, base_dir, constraints) + if decision.action != "allow": + return decision + elif subcommand == "commit": + if not constraints.get("commits_changes"): + return _deny( + "git commit is outside the envelope " + "(commits_changes: false)" + ) + return None + if prog == "gh": + decision = _classify_gh(tokens, cwd, constraints) + if decision.action != "allow": + return decision + return None + + dependency = _classify_dependency_command(prog, tokens, constraints) + if dependency is not None and dependency.action != "allow": + return dependency + return None + + +def classify_bash( + command: str, + cwd: str, + constraints: Dict[str, Any], + counters: Dict[str, Any], +) -> Decision: + if not constraints.get("destructive_ops"): + for label, pattern in DESTRUCTIVE_PATTERNS: + if pattern.search(command): + return _deny( + f"destructive command token {label!r} is outside the " + "envelope (destructive_ops: false)" + ) + + write_targets: List[str] = [] + for segment in _segments_of(command): + try: + tokens = shlex.split(segment, posix=True) + except ValueError: + return _ask(f"cannot classify shell segment: {segment!r}") + decision = _classify_segment(tokens, segment, cwd, constraints, write_targets) + if decision is not None: + return decision + + return _gate_write_paths(write_targets, cwd, constraints, counters, "write") + + +# ── File-tool classification ────────────────────────────────────────────────── + + +def classify_file_write( + file_path: str, + cwd: str, + constraints: Dict[str, Any], + counters: Dict[str, Any], +) -> Decision: + return _gate_write_paths([file_path], cwd, constraints, counters, "edit") + + +# ── Dispatch + I/O ──────────────────────────────────────────────────────────── + + +def classify( + tool_name: str, + tool_input: Dict[str, Any], + cwd: str, + envelope: Dict[str, Any], +) -> Decision: + constraints = envelope.get("constraints") or {} + counters = envelope.get("counters") or {} + + if tool_name == "Bash": + return classify_bash( + str(tool_input.get("command") or ""), cwd, constraints, counters + ) + if tool_name in ("Edit", "Write"): + file_path = str(tool_input.get("file_path") or "") + if not file_path: + return ALLOW + return classify_file_write(file_path, cwd, constraints, counters) + if tool_name == "NotebookEdit": + notebook = str(tool_input.get("notebook_path") or "") + if not notebook: + return ALLOW + return classify_file_write(notebook, cwd, constraints, counters) + return ALLOW + + +def emit(decision: Decision) -> None: + if decision.action == "allow": + # Silence keeps the harness's own permission flow authoritative; the + # envelope must never widen it. + return + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": decision.action, + "permissionDecisionReason": decision.reason, + } + } + ) + ) + + +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()) + 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 + + with envelope_lock(target): + envelope = load_envelope(target) + if envelope is None: + return ALLOW + decision = classify( + str(payload.get("tool_name") or ""), + payload.get("tool_input") or {}, + cwd, + envelope, + ) + if decision.action == "allow" and decision.new_files: + 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 + + +def main(argv: Optional[Sequence[str]] = None) -> int: + receiver = os.environ.get("OACP_ENVELOPE_RECEIVER", "claude") + try: + payload = json.loads(sys.stdin.read() or "{}") + if not isinstance(payload, dict): + raise ValueError("hook payload must be a JSON object") + decision = process(payload, receiver=receiver) + except Exception as exc: # fail closed in the interior: escalate, never allow + decision = _ask(f"envelope hook could not evaluate this call: {exc}") + emit(decision) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/envelope_compiler.py b/scripts/envelope_compiler.py new file mode 100644 index 0000000..06aef22 --- /dev/null +++ b/scripts/envelope_compiler.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Compile a task_profile into a runtime envelope. + +Turns an admitted inbox message's declared ``task_profile`` plus the +receiver's autonomy config into ``active_envelope.json`` under the agent's +workspace ``state/`` directory. The Claude PreToolUse hook shim +(``claude_envelope_hook.py``) reads that file on every tool call and enforces +the declared constraints at the action layer. + +The compiler deliberately imports the autonomy gate's normalization and +pattern constants so the admission spec and the runtime enforcement cannot +drift: they are the same code. + +Compile failures are fail-closed: the receiver must pause the task with +reason code ``envelope_compile_error`` instead of executing unenforced. +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, Iterator, Optional, Sequence + +from _oacp_constants import utc_now_iso +from autonomy_gate import ( + AutonomyConfigError, + TaskProfileError, + extract_task_profile, + load_yaml_file, + message_sha256, + normalize_scope_envelope, + receiver_policy, +) + +ENVELOPE_VERSION = 1 +ENVELOPE_SPEC_VERSION = "0.3.5" +ENVELOPE_FILENAME = "active_envelope.json" +ENVELOPE_COMPILE_ERROR = "envelope_compile_error" + +# Constraint keys enforced by runtime adapters. ``estimated_minutes`` and +# ``risk_tier`` are recorded for the §E self-check but carry no hook +# semantics in the MVP. +CONSTRAINT_KEYS = ( + "estimated_minutes", + "expected_files_touched", + "risk_tier", + "target_repo", + "destructive_ops", + "external_side_effects", + "creates_or_updates_pr", + "comments_on_github", + "commits_changes", + "sends_oacp_reply_only", + "touches_auth_config_or_secrets", + "touches_dependencies", + "public_visibility", +) + + +class EnvelopeCompileError(ValueError): + """Raised when a task_profile cannot be compiled into an envelope.""" + + reason_code = ENVELOPE_COMPILE_ERROR + + +def build_envelope( + message: Dict[str, Any], + config: Dict[str, Any], + *, + receiver: str, + project: str, + message_path: Optional[Path] = None, + now_iso: Optional[str] = None, +) -> Dict[str, Any]: + """Return an envelope dict for an admitted message, or raise + :class:`EnvelopeCompileError`. + + The compiler does not re-run admission gates — it normalizes the declared + profile with the gate's own functions and embeds the receiver-side + allowlist so the hook can enforce repo boundaries without re-reading + config. + """ + try: + _mode, policy = receiver_policy(config) + except AutonomyConfigError as exc: + raise EnvelopeCompileError(f"receiver config malformed: {exc}") from exc + + body = str(message.get("body") or "") + profile, profile_error = extract_task_profile(body) + if profile_error: + raise EnvelopeCompileError("task_profile is unparsable") + if profile is None: + raise EnvelopeCompileError("message has no task_profile block") + + try: + scope = normalize_scope_envelope(profile) + except TaskProfileError as exc: + raise EnvelopeCompileError(str(exc)) from exc + + constraints = {key: scope[key] for key in CONSTRAINT_KEYS} + constraints["private_repo_allowlist"] = list(policy["private_repo_allowlist"]) + + message_id = str(message.get("id") or "") + if not message_id: + raise EnvelopeCompileError("message has no id") + + return { + "envelope_version": ENVELOPE_VERSION, + "spec_version": ENVELOPE_SPEC_VERSION, + "compiler": "envelope_compiler.py", + "compiled_at_utc": now_iso or utc_now_iso(), + "project": project, + "receiver": receiver, + "message_id": message_id, + "message_sha256": message_sha256(message, message_path), + "constraints": constraints, + "counters": { + "files_touched": [], + }, + "enforcement": "hooks", + } + + +# ── Envelope state I/O ──────────────────────────────────────────────────────── + + +def envelope_path(oacp_root: Path, project: str, receiver: str) -> Path: + return oacp_root / "projects" / project / "agents" / receiver / "state" / ENVELOPE_FILENAME + + +@contextmanager +def envelope_lock(path: Path) -> Iterator[None]: + """Serialize envelope read-modify-write cycles across processes. + + A sibling lockfile survives the atomic replace of the envelope itself, + so flocks always target a stable inode. + """ + lock_path = path.parent / (path.name + ".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a") as handle: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def load_envelope(path: Path) -> Optional[Dict[str, Any]]: + """Return the parsed envelope, or None when no envelope is active.""" + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object") + return data + + +def write_envelope(path: Path, envelope: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + json.dump(envelope, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temp_path = Path(handle.name) + os.replace(temp_path, path) + temp_path = None + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + + +def _infer_project_from_message_path(message_path: Path) -> Optional[str]: + parts = message_path.resolve().parts + for index, part in enumerate(parts): + if part == "projects" and index + 2 < len(parts) and parts[index + 2] == "agents": + return parts[index + 1] + return None + + +def _resolve_project(args: argparse.Namespace, message_path: Optional[Path]) -> str: + if args.project: + return str(args.project) + if message_path is not None: + inferred = _infer_project_from_message_path(message_path) + if inferred: + return inferred + raise EnvelopeCompileError( + "cannot infer project; pass --project or a message path inside " + "$OACP_HOME/projects//" + ) + + +def _cmd_compile(args: argparse.Namespace, oacp_root: Path) -> int: + message_path = Path(args.message) + message = load_yaml_file(message_path) + project = _resolve_project(args, message_path) + + if args.config: + config_path = Path(args.config) + else: + config_path = oacp_root / "projects" / project / "agents" / args.receiver / "config.yaml" + if not config_path.is_file(): + raise EnvelopeCompileError(f"receiver config not found: {config_path}") + config = load_yaml_file(config_path) + + envelope = build_envelope( + message, + config, + receiver=args.receiver, + project=project, + message_path=message_path, + ) + + target = envelope_path(oacp_root, project, args.receiver) + with envelope_lock(target): + existing = load_envelope(target) + if existing is not None: + same_message = existing.get("message_id") == envelope["message_id"] + if not (same_message or args.extend or args.force): + raise EnvelopeCompileError( + f"an active envelope for message " + f"{existing.get('message_id')!r} already exists at {target}; " + "run `oacp envelope clear`, or pass --extend to recompile " + "with prior counters preserved" + ) + if same_message or args.extend: + prior = existing.get("counters") + if isinstance(prior, dict) and isinstance( + prior.get("files_touched"), list + ): + envelope["counters"]["files_touched"] = list( + prior["files_touched"] + ) + write_envelope(target, envelope) + + if args.json: + print(json.dumps(envelope, indent=2, sort_keys=True)) + else: + print(f"OK: envelope compiled for {envelope['message_id']} -> {target}") + return 0 + + +def _cmd_show(args: argparse.Namespace, oacp_root: Path) -> int: + project = _resolve_project(args, None) + target = envelope_path(oacp_root, project, args.receiver) + envelope = load_envelope(target) + if envelope is None: + print(f"No active envelope at {target}") + return 1 + print(json.dumps(envelope, indent=2, sort_keys=True)) + return 0 + + +def _cmd_clear(args: argparse.Namespace, oacp_root: Path) -> int: + project = _resolve_project(args, None) + target = envelope_path(oacp_root, project, args.receiver) + with envelope_lock(target): + try: + target.unlink() + except FileNotFoundError: + print(f"No active envelope at {target}") + return 0 + print(f"OK: cleared envelope at {target}") + return 0 + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="oacp envelope", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = parser.add_subparsers(dest="command", required=True) + + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--receiver", default="claude") + common.add_argument("--project", default=None) + common.add_argument( + "--oacp-dir", + default=None, + help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", + ) + + compile_parser = sub.add_parser( + "compile", + parents=[common], + help="Compile a message's task_profile into active_envelope.json", + ) + compile_parser.add_argument("message", help="Path to the admitted inbox message") + compile_parser.add_argument( + "--config", + default=None, + help="Receiver config path (default: agents//config.yaml)", + ) + compile_parser.add_argument( + "--extend", + action="store_true", + help="Recompile over an existing envelope, preserving its counters " + "(re-authorization flow)", + ) + compile_parser.add_argument( + "--force", + action="store_true", + help="Overwrite an existing envelope for a different message, " + "resetting counters", + ) + compile_parser.add_argument("--json", action="store_true") + + sub.add_parser("show", parents=[common], help="Print the active envelope") + sub.add_parser("clear", parents=[common], help="Remove the active envelope") + + return parser.parse_args(list(argv)) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + + from _oacp_env import resolve_oacp_home + + oacp_root = resolve_oacp_home(explicit=args.oacp_dir) + + handlers = { + "compile": _cmd_compile, + "show": _cmd_show, + "clear": _cmd_clear, + } + try: + return handlers[args.command](args, oacp_root) + except EnvelopeCompileError as exc: + print(f"ERROR ({ENVELOPE_COMPILE_ERROR}): {exc}", file=sys.stderr) + return 3 + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/memory_sync.py b/scripts/memory_sync.py index 4d216c3..a70b5b3 100644 --- a/scripts/memory_sync.py +++ b/scripts/memory_sync.py @@ -12,12 +12,20 @@ import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple +from typing import Dict, Iterable, List, Optional, Protocol, Sequence, Tuple + + +class Runner(Protocol): + def __call__( + self, + command: Sequence[str], + *, + timeout: Optional[int] = None, + ) -> Tuple[int, str]: ... -Runner = Callable[[Sequence[str]], Tuple[int, str]] MARKER_FILE = ".oacp-memory-repo" -GIT_FETCH_TIMEOUT_SECONDS = 30 +GIT_NETWORK_TIMEOUT_SECONDS = 30 CANONICAL_MEMORY_GITIGNORE = """\ * !*/ @@ -82,7 +90,9 @@ def _git( command = ["git", *args] if runner is None: return run_command(command, cwd=cwd, timeout=timeout) - return runner(command) + if timeout is None: + return runner(command) + return runner(command, timeout=timeout) def marker_path(oacp_root: Path) -> Path: @@ -196,7 +206,7 @@ def fetch_remote(oacp_root: Path, runner: Optional[Runner] = None) -> Tuple[bool oacp_root, ["fetch", "--quiet"], runner, - timeout=GIT_FETCH_TIMEOUT_SECONDS, + timeout=GIT_NETWORK_TIMEOUT_SECONDS, ) return rc == 0, output @@ -298,10 +308,20 @@ def push_remote(oacp_root: Path, runner: Optional[Runner] = None) -> Tuple[int, return 0, "No memory remote configured; commit remains local." upstream = configured_upstream(oacp_root, runner) if upstream: - return _git(oacp_root, ["push"], runner) + return _git( + oacp_root, + ["push"], + runner, + timeout=GIT_NETWORK_TIMEOUT_SECONDS, + ) remote = default_remote(oacp_root, runner) branch = current_branch(oacp_root, runner) - return _git(oacp_root, ["push", "-u", remote, branch], runner) + return _git( + oacp_root, + ["push", "-u", remote, branch], + runner, + timeout=GIT_NETWORK_TIMEOUT_SECONDS, + ) def pull_memory(oacp_root: Path, runner: Optional[Runner] = None) -> List[str]: @@ -319,7 +339,12 @@ def pull_memory(oacp_root: Path, runner: Optional[Runner] = None) -> List[str]: return ["OACP memory pull: local-only memory repo; no remote to pull."] if state.behind: behind = state.behind - rc, output = _git(oacp_root, ["pull", "--ff-only"], runner) + rc, output = _git( + oacp_root, + ["pull", "--ff-only"], + runner, + timeout=GIT_NETWORK_TIMEOUT_SECONDS, + ) if rc != 0: return [*lines, f"WARNING: memory pull --ff-only failed: {output}"] return [f"OACP memory pull: synced {behind} commit(s)."] @@ -438,7 +463,12 @@ def clone_memory_repo( if not oacp_root.exists() or not _is_non_empty(oacp_root): oacp_root.parent.mkdir(parents=True, exist_ok=True) - rc, output = _git(oacp_root.parent, ["clone", url, str(oacp_root)], runner) + rc, output = _git( + oacp_root.parent, + ["clone", url, str(oacp_root)], + runner, + timeout=GIT_NETWORK_TIMEOUT_SECONDS, + ) if rc != 0: raise MemorySyncError(f"git clone failed: {output}") return [f"Cloned OACP memory repo into {oacp_root}."] @@ -448,7 +478,12 @@ def clone_memory_repo( ) shutil.move(str(oacp_root), str(backup)) try: - rc, output = _git(oacp_root.parent, ["clone", url, str(oacp_root)], runner) + rc, output = _git( + oacp_root.parent, + ["clone", url, str(oacp_root)], + runner, + timeout=GIT_NETWORK_TIMEOUT_SECONDS, + ) except Exception: if not oacp_root.exists(): shutil.move(str(backup), str(oacp_root)) diff --git a/scripts/oacp_doctor.py b/scripts/oacp_doctor.py index eca5a25..7e1789a 100644 --- a/scripts/oacp_doctor.py +++ b/scripts/oacp_doctor.py @@ -33,10 +33,16 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple -from _oacp_constants import ALL_RUNTIMES, CANONICAL_CAPABILITIES, utc_now_iso +from _oacp_constants import ( + ALL_RUNTIMES, + CANONICAL_CAPABILITIES, + REPO_SLUG_RE, + utc_now_iso, +) from memory_sync import ( CANONICAL_MEMORY_GITIGNORE, MARKER_FILE, + Runner as MemorySyncRunner, STALE_MEMORY_DAYS, MemorySyncError, compute_git_state, @@ -53,7 +59,7 @@ untracked_files, ) -Runner = Callable[[Sequence[str]], Tuple[int, str]] +DoctorRunner = Callable[[Sequence[str]], Tuple[int, str]] WhichFn = Callable[[str], Optional[str]] VALID_RUNTIMES = set(ALL_RUNTIMES) @@ -72,6 +78,7 @@ "git_push_or_deploy", } AUTONOMY_POLICY_ACTIONS = {"pause"} +AUTONOMY_EXTERNAL_SIDE_EFFECT_ACTIONS = {"pause", "allow_pr_artifacts", "allow"} STALE_STATUS_HOURS = 1 STALE_INBOX_HOURS = 24 YAML_EXTENSIONS = {".yaml", ".yml"} @@ -141,7 +148,7 @@ def run_command(command: Sequence[str]) -> Tuple[int, str]: return completed.returncode, combined.strip() -def _get_version(tool: str, runner: Runner) -> Optional[str]: +def _get_version(tool: str, runner: DoctorRunner) -> Optional[str]: """Try to get a version string from a tool.""" rc, output = runner([tool, "--version"]) if rc != 0: @@ -164,7 +171,7 @@ def _try_yaml_import() -> Optional[Any]: def check_environment( - runner: Runner = run_command, + runner: DoctorRunner = run_command, which_fn: WhichFn = shutil.which, ) -> DoctorCategory: """Check required and optional CLI tools.""" @@ -531,9 +538,15 @@ def validate_autonomy_config_data(data: Any) -> List[str]: if value is None: errors.append(f"autonomy.auto_review_thresholds.{key} is required") continue - if str(value) not in AUTONOMY_POLICY_ACTIONS: + allowed = ( + AUTONOMY_EXTERNAL_SIDE_EFFECT_ACTIONS + if key == "external_side_effects" + else AUTONOMY_POLICY_ACTIONS + ) + if str(value) not in allowed: errors.append( - f"autonomy.auto_review_thresholds.{key} must be 'pause' in Phase 1" + f"autonomy.auto_review_thresholds.{key} must be one of: " + f"{', '.join(sorted(allowed))}" ) allow_without_profile = autonomy.get("allow_without_task_profile", []) @@ -559,6 +572,18 @@ def validate_autonomy_config_data(data: Any) -> List[str]: "autonomy.trusted_senders is not supported; sender trust is messenger-bound" ) + private_repos = autonomy.get("private_repo_allowlist", []) + if private_repos is None: + private_repos = [] + if not isinstance(private_repos, list): + errors.append("autonomy.private_repo_allowlist must be a list") + else: + for repo in private_repos: + if not isinstance(repo, str) or not REPO_SLUG_RE.fullmatch(repo): + errors.append( + "autonomy.private_repo_allowlist entries must use owner/repo" + ) + continuation_grants = autonomy.get("continuation_grants", {}) if continuation_grants is None: continuation_grants = {} @@ -759,7 +784,7 @@ def _summarize_paths(paths: List[str], *, limit: int = 3) -> str: def check_memory_sync( oacp_dir: Path, *, - runner: Runner = run_command, + runner: DoctorRunner = run_command, now_fn: Optional[Callable[[], dt.datetime]] = None, ) -> DoctorCategory: """Check OACP_HOME memory sync configuration and git state.""" @@ -767,10 +792,24 @@ def check_memory_sync( marker = oacp_dir / MARKER_FILE if runner is run_command: - def git_runner(command: Sequence[str]) -> Tuple[int, str]: - return run_git_command(command, cwd=oacp_dir) + def default_git_runner( + command: Sequence[str], + *, + timeout: Optional[int] = None, + ) -> Tuple[int, str]: + return run_git_command(command, cwd=oacp_dir, timeout=timeout) + + git_runner: MemorySyncRunner = default_git_runner else: - git_runner = runner + def custom_git_runner( + command: Sequence[str], + *, + timeout: Optional[int] = None, + ) -> Tuple[int, str]: + del timeout + return runner(command) + + git_runner = custom_git_runner if not is_configured(oacp_dir): cat.results.append( @@ -1151,7 +1190,7 @@ def run_doctor( project: Optional[str] = None, oacp_dir: Path, include_memory: bool = False, - runner: Runner = run_command, + runner: DoctorRunner = run_command, yaml_loader: Optional[Any] = None, which_fn: WhichFn = shutil.which, now_fn: Optional[Callable[[], dt.datetime]] = None, diff --git a/scripts/record_autonomy_outcome.py b/scripts/record_autonomy_outcome.py new file mode 100644 index 0000000..cd4a198 --- /dev/null +++ b/scripts/record_autonomy_outcome.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Record a structured human outcome in an autonomy audit file.""" + +from __future__ import annotations + +import argparse +import copy +import datetime as dt +import fcntl +import json +import os +import sys +import tempfile +from pathlib import Path +from typing import Any, Dict, Optional, Sequence, Tuple + +import yaml + +from _oacp_constants import utc_now_iso +from autonomy_gate import ( + AUTONOMY_AUDIT_SCHEMA_VERSION, + normalize_continuation_scope, +) + + +HUMAN_DECISIONS = {"approved", "modified", "declined"} +GRANT_DECISIONS = {"not_requested", "approved", "modified", "denied"} + + +def _load_mapping(path: Path) -> Dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a YAML mapping") + return data + + +def _parse_utc(value: str, field: str) -> dt.datetime: + try: + return dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=dt.timezone.utc + ) + except ValueError as exc: + raise ValueError(f"{field} must use YYYY-MM-DDTHH:MM:SSZ") from exc + + +def _requested_scope( + audit: Dict[str, Any], +) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]: + profile = audit.get("task_profile") + if not isinstance(profile, dict): + profile = audit.get("scope_envelope") + if not isinstance(profile, dict): + return False, None, None + grants = profile.get("continuation_grants") + if not isinstance(grants, dict): + return False, None, None + grant = grants.get("approved_thread_continuation") + if not isinstance(grant, dict): + return False, None, None + scope, error = normalize_continuation_scope(grant.get("scope")) + return True, scope, error + + +def build_human_outcome( + audit: Dict[str, Any], + *, + decision: str, + grant_decision: str = "not_requested", + decided_at_utc: Optional[str] = None, + granted_scope: Optional[Dict[str, Any]] = None, + actor: str = "human", +) -> Dict[str, Any]: + if decision not in HUMAN_DECISIONS: + raise ValueError(f"decision must be one of: {', '.join(sorted(HUMAN_DECISIONS))}") + if grant_decision not in GRANT_DECISIONS: + choices = ", ".join(sorted(GRANT_DECISIONS)) + raise ValueError(f"grant_decision must be one of: {choices}") + if not actor.strip(): + raise ValueError("actor must be non-empty") + + pause_recorded_at = str(audit.get("created_at_utc") or "") + if not pause_recorded_at: + raise ValueError("audit.created_at_utc is required") + pause_time = _parse_utc(pause_recorded_at, "audit.created_at_utc") + decision_time_text = decided_at_utc or utc_now_iso() + decision_time = _parse_utc(decision_time_text, "decided_at_utc") + if decision_time < pause_time: + raise ValueError("decided_at_utc cannot precede audit.created_at_utc") + + request_present, requested_scope, request_error = _requested_scope(audit) + if request_present and request_error is None and grant_decision == "not_requested": + raise ValueError( + "audit contains a grant request; record an explicit --grant-decision" + ) + normalized_granted_scope: Optional[Dict[str, Any]] = None + if grant_decision == "approved": + candidate_scope = granted_scope if granted_scope is not None else requested_scope + normalized_granted_scope, error = normalize_continuation_scope( + candidate_scope + ) + if error: + raise ValueError(f"approved grant requires a valid scope: {error}") + elif grant_decision == "modified": + if granted_scope is None: + raise ValueError("modified grant requires --grant-scope-file") + normalized_granted_scope, error = normalize_continuation_scope( + granted_scope + ) + if error: + raise ValueError(f"modified grant requires a valid scope: {error}") + elif granted_scope is not None: + raise ValueError( + "granted scope is valid only for approved or modified grants" + ) + + if decision == "declined" and grant_decision in {"approved", "modified"}: + raise ValueError("a declined task cannot approve or modify a grant") + + pause_reasons = audit.get("reason_codes") or [] + if not isinstance(pause_reasons, list) or not all( + isinstance(item, str) for item in pause_reasons + ): + raise ValueError("audit.reason_codes must be a list of strings") + + return { + "recorded": True, + "actor": actor.strip(), + "decision": decision, + "decided_at_utc": decision_time_text, + "decision_latency_seconds": int((decision_time - pause_time).total_seconds()), + "pause_reason_codes": list(pause_reasons), + "grant": { + "decision": grant_decision, + "request_present": request_present, + "request_error": request_error, + "requested_scope": requested_scope, + "granted_scope": normalized_granted_scope, + }, + } + + +def record_human_outcome( + audit: Dict[str, Any], + *, + replace: bool = False, + **kwargs: Any, +) -> Dict[str, Any]: + updated = copy.deepcopy(audit) + schema_version = updated.get("schema_version") + if schema_version not in {1, AUTONOMY_AUDIT_SCHEMA_VERSION}: + raise ValueError("audit.schema_version must be 1 or 2") + if updated.get("decision") != "paused": + raise ValueError("human outcomes may be recorded only for paused audits") + result = updated.get("result") + if not isinstance(result, dict): + raise ValueError("audit.result must be a mapping") + existing = result.get("human_outcome") + if ( + not replace + and isinstance(existing, dict) + and existing.get("recorded") is True + ): + raise ValueError("audit already has a recorded human outcome; use --replace") + result["human_outcome"] = build_human_outcome(updated, **kwargs) + updated["schema_version"] = AUTONOMY_AUDIT_SCHEMA_VERSION + updated.setdefault("conversation_id", None) + updated.setdefault("parent_message_id", None) + return updated + + +def _atomic_write_yaml(path: Path, data: Dict[str, Any]) -> None: + content = yaml.safe_dump(data, sort_keys=False, allow_unicode=True) + mode = path.stat().st_mode + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{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, mode) + os.replace(temp_path, path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + +def _grant_scope_from_file(path: Optional[Path]) -> Optional[Dict[str, Any]]: + if path is None: + return None + data = _load_mapping(path) + scope = data.get("scope", data) + if not isinstance(scope, dict): + raise ValueError(f"{path} must contain a grant scope mapping") + return scope + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("audit_file", type=Path) + parser.add_argument("--decision", required=True, choices=sorted(HUMAN_DECISIONS)) + parser.add_argument( + "--grant-decision", + default="not_requested", + choices=sorted(GRANT_DECISIONS), + ) + parser.add_argument("--grant-scope-file", type=Path) + parser.add_argument("--decided-at") + parser.add_argument("--actor", default="human") + parser.add_argument( + "--replace", + action="store_true", + help="replace an existing recorded human outcome", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + try: + with args.audit_file.open("r", encoding="utf-8") as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + try: + audit = _load_mapping(args.audit_file) + updated = record_human_outcome( + audit, + replace=args.replace, + decision=args.decision, + grant_decision=args.grant_decision, + decided_at_utc=args.decided_at, + granted_scope=_grant_scope_from_file(args.grant_scope_file), + actor=args.actor, + ) + if not args.dry_run: + _atomic_write_yaml(args.audit_file, updated) + finally: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) + + outcome = updated["result"]["human_outcome"] + if args.json: + print(json.dumps({ + "audit_file": str(args.audit_file), + "dry_run": args.dry_run, + "schema_version": updated["schema_version"], + "human_outcome": outcome, + }, indent=2)) + elif args.dry_run: + print(yaml.safe_dump(updated, sort_keys=False, allow_unicode=True).rstrip()) + else: + print( + f"OK: {args.audit_file} — {outcome['decision']} " + f"({outcome['decision_latency_seconds']}s)" + ) + return 0 + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/setup_runtime.py b/scripts/setup_runtime.py index cc0c89f..63a871b 100644 --- a/scripts/setup_runtime.py +++ b/scripts/setup_runtime.py @@ -103,7 +103,7 @@ """ CLAUDE_SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json" -CLAUDE_MEMORY_HOOK_COMMANDS = { +CLAUDE_HOOK_COMMANDS = { "SessionStart": { "matcher": "startup", "hooks": [ @@ -123,6 +123,19 @@ } ], }, + # Static envelope shim: per-task constraints live in the + # compiled active_envelope.json, so this settings entry never changes per + # dispatch and is a no-op while no envelope is active. + "PreToolUse": { + "matcher": "Bash|Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "oacp-envelope-hook", + "timeout": 15, + } + ], + }, } @@ -186,7 +199,7 @@ def _write_claude_memory_settings(repo_dir: Path) -> Optional[bool]: return None changed = False - for event_name, entry in CLAUDE_MEMORY_HOOK_COMMANDS.items(): + for event_name, entry in CLAUDE_HOOK_COMMANDS.items(): entries = hooks.setdefault(event_name, []) if not isinstance(entries, list): _warn_claude_settings( diff --git a/scripts/update_workspace.sh b/scripts/update_workspace.sh index c79d194..2a49ed7 100755 --- a/scripts/update_workspace.sh +++ b/scripts/update_workspace.sh @@ -219,16 +219,17 @@ if [[ -d "$PROJECT_ROOT/agents" ]]; then autonomy: default_mode: always_pause auto_review_thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 destructive_ops: pause - external_side_effects: pause + external_side_effects: allow_pr_artifacts auth_config_or_secrets: pause dependency_changes: pause public_visibility: pause git_push_or_deploy: pause allow_without_task_profile: - brainstorm_request + private_repo_allowlist: [] EOM fi log_action "+" "file agents/$agent_name/config.yaml" diff --git a/scripts/validate_message.py b/scripts/validate_message.py index 58be202..07b01ac 100755 --- a/scripts/validate_message.py +++ b/scripts/validate_message.py @@ -7,6 +7,7 @@ import argparse import datetime as dt +import math import re import sys from pathlib import Path @@ -38,6 +39,12 @@ "expires_at", "channel", "autonomy_hint", + "model", + "turns", + "input_tokens", + "output_tokens", + "wall_time_s", + "est_cost_usd", ) ALLOWED_FIELDS = set(REQUIRED_FIELDS + OPTIONAL_FIELDS) ALLOWED_TYPES = { @@ -56,6 +63,19 @@ } ALLOWED_PRIORITIES = {"P0", "P1", "P2", "P3"} ALLOWED_AUTONOMY_HINTS = frozenset({"auto_proceed"}) +REVIEW_TELEMETRY_FIELDS = frozenset({ + "model", + "turns", + "input_tokens", + "output_tokens", + "wall_time_s", + "est_cost_usd", +}) +REVIEW_TELEMETRY_TYPES = frozenset({ + "review_feedback", + "review_lgtm", + "review_addressed", +}) UTC_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") FIELD_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") CONVERSATION_ID_RE = re.compile(r"^conv-\d{8}-[A-Za-z0-9._-]{1,64}-\d{1,6}$") @@ -212,6 +232,24 @@ def _agent_pattern_error(field: str, value: Optional[str] = None) -> str: return f"field '{field}' list entry '{value}' must match {AGENT_RE.pattern}" +def _is_nonnegative_int(value: Any) -> bool: + if isinstance(value, bool): + return False + if isinstance(value, int): + return value >= 0 + return bool(re.fullmatch(r"\d+", str(value).strip())) + + +def _is_nonnegative_number(value: Any) -> bool: + if isinstance(value, bool): + return False + try: + number = float(str(value).strip()) + except (TypeError, ValueError): + return False + return math.isfinite(number) and number >= 0 + + def validate_message_dict(data: Dict[str, Any]) -> List[str]: errors: List[str] = [] @@ -274,6 +312,23 @@ def validate_message_dict(data: Dict[str, Any]) -> List[str]: if priority and priority not in ALLOWED_PRIORITIES: errors.append(f"field 'priority' must be one of: {', '.join(sorted(ALLOWED_PRIORITIES))}") + present_telemetry = REVIEW_TELEMETRY_FIELDS.intersection(data) + if present_telemetry and msg_type not in REVIEW_TELEMETRY_TYPES: + errors.append( + "review telemetry fields are allowed only for: " + f"{', '.join(sorted(REVIEW_TELEMETRY_TYPES))}" + ) + if "model" in data: + model = data.get("model") + if not isinstance(model, str) or not model.strip(): + errors.append("field 'model' must be a non-empty string") + for field in ("turns", "input_tokens", "output_tokens"): + if field in data and not _is_nonnegative_int(data.get(field)): + errors.append(f"field '{field}' must be a non-negative integer") + for field in ("wall_time_s", "est_cost_usd"): + if field in data and not _is_nonnegative_number(data.get(field)): + errors.append(f"field '{field}' must be a non-negative number") + autonomy_hint_value = data.get("autonomy_hint", "") if not isinstance(autonomy_hint_value, (dict, list)): autonomy_hint = str(autonomy_hint_value or "").strip() diff --git a/templates/inbox_message.template.yaml b/templates/inbox_message.template.yaml index bb95132..8d489ac 100644 --- a/templates/inbox_message.template.yaml +++ b/templates/inbox_message.template.yaml @@ -25,3 +25,9 @@ body: | touches_auth_config_or_secrets: false touches_dependencies: false public_visibility: false + target_repo: "" # owner/repo; required for allow_pr_artifacts + creates_or_updates_pr: false + comments_on_github: false + commits_changes: false + sends_oacp_reply_only: true + continuation_grants: {} diff --git a/templates/receiver_config.template.yaml b/templates/receiver_config.template.yaml index 8576205..6fcb814 100644 --- a/templates/receiver_config.template.yaml +++ b/templates/receiver_config.template.yaml @@ -3,16 +3,21 @@ autonomy: default_mode: always_pause # always_pause | auto_review auto_review_thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 destructive_ops: pause - external_side_effects: pause + # allow_pr_artifacts permits PR/review/issue-comment artifacts on private + # repos; direct main pushes, deploys, publishes, and merges still pause. + external_side_effects: allow_pr_artifacts auth_config_or_secrets: pause dependency_changes: pause public_visibility: pause git_push_or_deploy: pause allow_without_task_profile: - brainstorm_request + # Populate explicitly with repositories independently known to be private. + # allow_pr_artifacts never trusts the sender's visibility declaration alone. + private_repo_allowlist: [] continuation_grants: # Default-off. When enabled, receivers may honor explicit same-thread # continuation grants that stay inside the granted scope envelope. diff --git a/tests/conformance/autonomy/README.md b/tests/conformance/autonomy/README.md index 3fd9f65..db6442f 100644 --- a/tests/conformance/autonomy/README.md +++ b/tests/conformance/autonomy/README.md @@ -26,6 +26,13 @@ Consumers may add implementation-specific trace fields, but `decision`, `mode`, These fixtures may also include: - `actuals:` pointing at checkpoint input under `actuals/` +- `audits:` pointing at prior same-thread audit records under `audits/` - `expected.logged_notes` for demoted side-effect verb matches - `expected.continuation_grant` for default-off and enabled grant behavior - `expected.result.threshold_checkpoint` for envelope drift decisions +- `expected.breached` for the pinned top-level breach list +- `expected.task_profile` for full declared-profile capture + +The executable runner is `tests/test_autonomy_gate.py`; every expected fixture +is evaluated against `scripts/autonomy_gate.py`. Evaluator reason codes are a +pinned enum, and any unregistered code fails the runner. diff --git a/tests/conformance/autonomy/actuals/checkpoint_breach.yaml b/tests/conformance/autonomy/actuals/checkpoint_breach.yaml index 2dc8662..bb13201 100644 --- a/tests/conformance/autonomy/actuals/checkpoint_breach.yaml +++ b/tests/conformance/autonomy/actuals/checkpoint_breach.yaml @@ -1,5 +1,7 @@ actual_minutes: 20 actual_files_touched: 3 +predicted_risk_materialized: true +completed_at_utc: "2026-05-12T12:30:00Z" side_effects_actual: creates_or_updates_pr: false comments_on_github: false diff --git a/tests/conformance/autonomy/actuals/declaration_error.yaml b/tests/conformance/autonomy/actuals/declaration_error.yaml new file mode 100644 index 0000000..585a4e6 --- /dev/null +++ b/tests/conformance/autonomy/actuals/declaration_error.yaml @@ -0,0 +1,8 @@ +actual_minutes: 8 +actual_files_touched: 1 +predicted_risk_materialized: true +completed_at_utc: "2026-07-10T23:58:00Z" +side_effects_actual: + creates_or_updates_pr: true + comments_on_github: false + commits_changes: false diff --git a/tests/conformance/autonomy/actuals/top_level_side_effect_keys.yaml b/tests/conformance/autonomy/actuals/top_level_side_effect_keys.yaml index 55048f0..9ec6594 100644 --- a/tests/conformance/autonomy/actuals/top_level_side_effect_keys.yaml +++ b/tests/conformance/autonomy/actuals/top_level_side_effect_keys.yaml @@ -1,5 +1,7 @@ actual_minutes: 5 actual_files_touched: 1 +predicted_risk_materialized: false +completed_at_utc: "2026-05-12T12:25:00Z" creates_or_updates_pr: true comments_on_github: true commits_changes: true diff --git a/tests/conformance/autonomy/audits/prior_thread_grant_approved.yaml b/tests/conformance/autonomy/audits/prior_thread_grant_approved.yaml new file mode 100644 index 0000000..8faf68a --- /dev/null +++ b/tests/conformance/autonomy/audits/prior_thread_grant_approved.yaml @@ -0,0 +1,48 @@ +schema_version: 2 +spec_version: "0.3.5" +created_at_utc: "2026-05-26T04:10:05Z" +receiver: codex +sender: iris +message_id: msg-20260526041000-iris-parent +message_type: task_request +conversation_id: conv-20260526-iris-001 +decision: paused +mode: auto_review +reason_codes: + - external_side_effects_pause +task_profile: + continuation_grants: + approved_thread_continuation: + scope: + max_actual_minutes: 30 + max_actual_files_touched: 3 + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true +result: + final_state: done + completion_kind: human_approved + human_outcome: + recorded: true + actor: human + decision: approved + decided_at_utc: "2026-05-26T04:12:00Z" + decision_latency_seconds: 115 + pause_reason_codes: + - external_side_effects_pause + grant: + decision: approved + request_present: true + request_error: null + requested_scope: + max_actual_minutes: 30 + max_actual_files_touched: 3 + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + granted_scope: + max_actual_minutes: 30 + max_actual_files_touched: 3 + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true diff --git a/tests/conformance/autonomy/configs/always_pause.yaml b/tests/conformance/autonomy/configs/always_pause.yaml index 65ddf0b..d2184ed 100644 --- a/tests/conformance/autonomy/configs/always_pause.yaml +++ b/tests/conformance/autonomy/configs/always_pause.yaml @@ -1,10 +1,10 @@ autonomy: default_mode: always_pause auto_review_thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 destructive_ops: pause - external_side_effects: pause + external_side_effects: allow_pr_artifacts auth_config_or_secrets: pause dependency_changes: pause public_visibility: pause diff --git a/tests/conformance/autonomy/configs/auto_review_continuation_enabled.yaml b/tests/conformance/autonomy/configs/auto_review_continuation_enabled.yaml index 34c0444..f1abc38 100644 --- a/tests/conformance/autonomy/configs/auto_review_continuation_enabled.yaml +++ b/tests/conformance/autonomy/configs/auto_review_continuation_enabled.yaml @@ -1,7 +1,7 @@ autonomy: default_mode: auto_review auto_review_thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 destructive_ops: pause external_side_effects: pause diff --git a/tests/conformance/autonomy/configs/auto_review_external_pause.yaml b/tests/conformance/autonomy/configs/auto_review_external_pause.yaml new file mode 100644 index 0000000..edc3381 --- /dev/null +++ b/tests/conformance/autonomy/configs/auto_review_external_pause.yaml @@ -0,0 +1,15 @@ +autonomy: + default_mode: auto_review + auto_review_thresholds: + max_estimated_minutes: 45 + max_expected_files_touched: 5 + destructive_ops: pause + external_side_effects: pause + auth_config_or_secrets: pause + dependency_changes: pause + public_visibility: pause + git_push_or_deploy: pause + allow_without_task_profile: + - brainstorm_request + continuation_grants: + enabled: false diff --git a/tests/conformance/autonomy/configs/auto_review_standard.yaml b/tests/conformance/autonomy/configs/auto_review_standard.yaml index 7bb419e..43b051d 100644 --- a/tests/conformance/autonomy/configs/auto_review_standard.yaml +++ b/tests/conformance/autonomy/configs/auto_review_standard.yaml @@ -1,15 +1,17 @@ autonomy: default_mode: auto_review auto_review_thresholds: - max_estimated_minutes: 30 + max_estimated_minutes: 45 max_expected_files_touched: 5 destructive_ops: pause - external_side_effects: pause + external_side_effects: allow_pr_artifacts auth_config_or_secrets: pause dependency_changes: pause public_visibility: pause git_push_or_deploy: pause allow_without_task_profile: - brainstorm_request + private_repo_allowlist: + - example-org/private-repo continuation_grants: enabled: false diff --git a/tests/conformance/autonomy/expected/brainstorm_side_effect_verbs_auto_accepts.yaml b/tests/conformance/autonomy/expected/brainstorm_side_effect_verbs_auto_accepts.yaml index d462ba2..e90d57e 100644 --- a/tests/conformance/autonomy/expected/brainstorm_side_effect_verbs_auto_accepts.yaml +++ b/tests/conformance/autonomy/expected/brainstorm_side_effect_verbs_auto_accepts.yaml @@ -11,6 +11,7 @@ expected: - task_profile_not_required - task_type_allowed - hard_stops_clear + - lexical_advisory - workspace_check_required logged_notes: - matched_pattern: deploy diff --git a/tests/conformance/autonomy/expected/checkpoint_breach_pauses.yaml b/tests/conformance/autonomy/expected/checkpoint_breach_pauses.yaml index 578f391..606162c 100644 --- a/tests/conformance/autonomy/expected/checkpoint_breach_pauses.yaml +++ b/tests/conformance/autonomy/expected/checkpoint_breach_pauses.yaml @@ -10,6 +10,7 @@ expected: result: final_state: paused predicted_risk_materialized: true + completed_at_utc: "2026-05-12T12:30:00Z" threshold_checkpoint: evaluated: true breached: true diff --git a/tests/conformance/autonomy/expected/clean_auto_review_task.yaml b/tests/conformance/autonomy/expected/clean_auto_review_task.yaml index a4a970d..dd4dc77 100644 --- a/tests/conformance/autonomy/expected/clean_auto_review_task.yaml +++ b/tests/conformance/autonomy/expected/clean_auto_review_task.yaml @@ -13,3 +13,13 @@ expected: - risk_threshold_passed - hard_stops_clear - workspace_check_required + breached: [] + task_profile: + estimated_minutes: 20 + risk_tier: P3 + expected_files_touched: 1 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/autonomy/expected/continuation_grant_disabled_pauses.yaml b/tests/conformance/autonomy/expected/continuation_grant_disabled_pauses.yaml index 98054fc..f528e37 100644 --- a/tests/conformance/autonomy/expected/continuation_grant_disabled_pauses.yaml +++ b/tests/conformance/autonomy/expected/continuation_grant_disabled_pauses.yaml @@ -1,5 +1,5 @@ case: continuation_grant_disabled_pauses -config: configs/auto_review_standard.yaml +config: configs/auto_review_external_pause.yaml message: messages/continuation_grant.yaml expected: decision: paused diff --git a/tests/conformance/autonomy/expected/continuation_grant_drift_pauses.yaml b/tests/conformance/autonomy/expected/continuation_grant_drift_pauses.yaml index 8e71b0a..dd0676f 100644 --- a/tests/conformance/autonomy/expected/continuation_grant_drift_pauses.yaml +++ b/tests/conformance/autonomy/expected/continuation_grant_drift_pauses.yaml @@ -2,6 +2,8 @@ case: continuation_grant_drift_pauses config: configs/auto_review_continuation_enabled.yaml message: messages/continuation_grant.yaml actuals: actuals/continuation_drift.yaml +audits: + - audits/prior_thread_grant_approved.yaml expected: decision: paused mode: auto_review @@ -9,6 +11,7 @@ expected: - threshold_checkpoint_breached continuation_grant: decision: accepted + standing_grant_found: true result: final_state: paused predicted_risk_materialized: true diff --git a/tests/conformance/autonomy/expected/continuation_grant_enabled_auto_accepts.yaml b/tests/conformance/autonomy/expected/continuation_grant_enabled_auto_accepts.yaml index 9cbdff0..c67e943 100644 --- a/tests/conformance/autonomy/expected/continuation_grant_enabled_auto_accepts.yaml +++ b/tests/conformance/autonomy/expected/continuation_grant_enabled_auto_accepts.yaml @@ -2,6 +2,8 @@ case: continuation_grant_enabled_auto_accepts config: configs/auto_review_continuation_enabled.yaml message: messages/continuation_grant.yaml actuals: actuals/continuation_within.yaml +audits: + - audits/prior_thread_grant_approved.yaml expected: decision: auto_accepted mode: auto_review @@ -17,6 +19,8 @@ expected: - continuation_grant_accepted continuation_grant: decision: accepted + standing_grant_found: true + source_message_id: msg-20260526041000-iris-parent result: final_state: done predicted_risk_materialized: false diff --git a/tests/conformance/autonomy/expected/continuation_grant_external_uncovered_pauses.yaml b/tests/conformance/autonomy/expected/continuation_grant_external_uncovered_pauses.yaml index 5534238..39bef2d 100644 --- a/tests/conformance/autonomy/expected/continuation_grant_external_uncovered_pauses.yaml +++ b/tests/conformance/autonomy/expected/continuation_grant_external_uncovered_pauses.yaml @@ -1,10 +1,15 @@ case: continuation_grant_external_uncovered_pauses config: configs/auto_review_continuation_enabled.yaml message: messages/continuation_grant_external_uncovered.yaml +audits: + - audits/prior_thread_grant_approved.yaml expected: decision: paused mode: auto_review reason_codes: - - external_side_effects_pause + - continuation_grant_scope_exceeded continuation_grant: decision: accepted + standing_grant_found: true + breached: + - task_profile.external_side_effects diff --git a/tests/conformance/autonomy/expected/declaration_error_pauses.yaml b/tests/conformance/autonomy/expected/declaration_error_pauses.yaml new file mode 100644 index 0000000..0d8d1ae --- /dev/null +++ b/tests/conformance/autonomy/expected/declaration_error_pauses.yaml @@ -0,0 +1,24 @@ +case: declaration_error_pauses +config: configs/auto_review_standard.yaml +message: messages/clean_task.yaml +actuals: actuals/declaration_error.yaml +expected: + decision: paused + mode: auto_review + reason_codes: + - declaration_error + breached: + - side_effects_actual.creates_or_updates_pr + result: + final_state: paused + completion_kind: declaration_error + actual_minutes: 8 + actual_files_touched: 1 + predicted_risk_materialized: true + completed_at_utc: "2026-07-10T23:58:00Z" + threshold_checkpoint: + evaluated: true + breached: true + declaration_errors: + - side_effects_actual.creates_or_updates_pr + action: paused_for_reauthorization diff --git a/tests/conformance/autonomy/expected/guardrails_fence_auto_accepts.yaml b/tests/conformance/autonomy/expected/guardrails_fence_auto_accepts.yaml new file mode 100644 index 0000000..de3d22f --- /dev/null +++ b/tests/conformance/autonomy/expected/guardrails_fence_auto_accepts.yaml @@ -0,0 +1,23 @@ +case: guardrails_fence_auto_accepts +config: configs/auto_review_standard.yaml +message: messages/guardrails_fence.yaml +expected: + decision: auto_accepted + mode: auto_review + reason_codes: + - message_valid + - message_not_expired + - message_hash_recorded + - task_profile_present + - task_type_allowed + - risk_threshold_passed + - hard_stops_clear + - lexical_advisory + - workspace_check_required + logged_notes: + - matched_pattern: oacp-guardrails + - matched_pattern: deploy + - matched_pattern: publish + - matched_pattern: merge + - matched_pattern: auth + - matched_pattern: secrets diff --git a/tests/conformance/autonomy/expected/hard_stop_sensitive_commercial_pauses.yaml b/tests/conformance/autonomy/expected/hard_stop_sensitive_commercial_pauses.yaml index 001f210..16183d5 100644 --- a/tests/conformance/autonomy/expected/hard_stop_sensitive_commercial_pauses.yaml +++ b/tests/conformance/autonomy/expected/hard_stop_sensitive_commercial_pauses.yaml @@ -5,5 +5,5 @@ expected: decision: paused mode: auto_review reason_codes: - - hard_stop_sensitive_scope + - hard_stop_content_sensitivity matched_pattern: "commercial" diff --git a/tests/conformance/autonomy/expected/hard_stop_sensitive_pricing_pauses.yaml b/tests/conformance/autonomy/expected/hard_stop_sensitive_pricing_pauses.yaml index 52607c7..a7b94e7 100644 --- a/tests/conformance/autonomy/expected/hard_stop_sensitive_pricing_pauses.yaml +++ b/tests/conformance/autonomy/expected/hard_stop_sensitive_pricing_pauses.yaml @@ -5,5 +5,5 @@ expected: decision: paused mode: auto_review reason_codes: - - hard_stop_sensitive_scope + - hard_stop_content_sensitivity matched_pattern: "pricing" diff --git a/tests/conformance/autonomy/expected/lexical_declared_false_auto_accepts.yaml b/tests/conformance/autonomy/expected/lexical_declared_false_auto_accepts.yaml new file mode 100644 index 0000000..32e70ef --- /dev/null +++ b/tests/conformance/autonomy/expected/lexical_declared_false_auto_accepts.yaml @@ -0,0 +1,19 @@ +case: lexical_declared_false_auto_accepts +config: configs/auto_review_standard.yaml +message: messages/lexical_declared_false.yaml +expected: + decision: auto_accepted + mode: auto_review + reason_codes: + - message_valid + - message_not_expired + - message_hash_recorded + - task_profile_present + - task_type_allowed + - risk_threshold_passed + - hard_stops_clear + - lexical_advisory + - workspace_check_required + logged_notes: + - matched_pattern: deploy + - matched_pattern: auth diff --git a/tests/conformance/autonomy/expected/minutes_45_auto_accepts.yaml b/tests/conformance/autonomy/expected/minutes_45_auto_accepts.yaml new file mode 100644 index 0000000..d6eb0b0 --- /dev/null +++ b/tests/conformance/autonomy/expected/minutes_45_auto_accepts.yaml @@ -0,0 +1,15 @@ +case: minutes_45_auto_accepts +config: configs/auto_review_standard.yaml +message: messages/minutes_45.yaml +expected: + decision: auto_accepted + mode: auto_review + reason_codes: + - message_valid + - message_not_expired + - message_hash_recorded + - task_profile_present + - task_type_allowed + - risk_threshold_passed + - hard_stops_clear + - workspace_check_required diff --git a/tests/conformance/autonomy/expected/minutes_46_pauses.yaml b/tests/conformance/autonomy/expected/minutes_46_pauses.yaml new file mode 100644 index 0000000..e05206c --- /dev/null +++ b/tests/conformance/autonomy/expected/minutes_46_pauses.yaml @@ -0,0 +1,10 @@ +case: minutes_46_pauses +config: configs/auto_review_standard.yaml +message: messages/minutes_46.yaml +expected: + decision: paused + mode: auto_review + reason_codes: + - estimated_minutes_exceeds_threshold + breached: + - estimated_minutes_exceeds_threshold diff --git a/tests/conformance/autonomy/expected/negated_guardrails_auto_accepts.yaml b/tests/conformance/autonomy/expected/negated_guardrails_auto_accepts.yaml new file mode 100644 index 0000000..62addd2 --- /dev/null +++ b/tests/conformance/autonomy/expected/negated_guardrails_auto_accepts.yaml @@ -0,0 +1,20 @@ +case: negated_guardrails_auto_accepts +config: configs/auto_review_standard.yaml +message: messages/negated_guardrails.yaml +expected: + decision: auto_accepted + mode: auto_review + reason_codes: + - message_valid + - message_not_expired + - message_hash_recorded + - task_profile_present + - task_type_allowed + - risk_threshold_passed + - hard_stops_clear + - lexical_advisory + - workspace_check_required + logged_notes: + - matched_pattern: publish + - matched_pattern: merge + - matched_pattern: secrets diff --git a/tests/conformance/autonomy/expected/private_pr_artifacts_auto_accepts.yaml b/tests/conformance/autonomy/expected/private_pr_artifacts_auto_accepts.yaml new file mode 100644 index 0000000..bfcd5d7 --- /dev/null +++ b/tests/conformance/autonomy/expected/private_pr_artifacts_auto_accepts.yaml @@ -0,0 +1,15 @@ +case: private_pr_artifacts_auto_accepts +config: configs/auto_review_standard.yaml +message: messages/private_pr_artifacts.yaml +expected: + decision: auto_accepted + mode: auto_review + reason_codes: + - message_valid + - message_not_expired + - message_hash_recorded + - task_profile_present + - task_type_allowed + - risk_threshold_passed + - hard_stops_clear + - workspace_check_required diff --git a/tests/conformance/autonomy/expected/public_pr_artifacts_pause.yaml b/tests/conformance/autonomy/expected/public_pr_artifacts_pause.yaml new file mode 100644 index 0000000..bcd8b60 --- /dev/null +++ b/tests/conformance/autonomy/expected/public_pr_artifacts_pause.yaml @@ -0,0 +1,10 @@ +case: public_pr_artifacts_pause +config: configs/auto_review_standard.yaml +message: messages/public_pr_artifacts.yaml +expected: + decision: paused + mode: auto_review + reason_codes: + - external_side_effects_not_pr_artifact + breached: + - external_side_effects_not_pr_artifact diff --git a/tests/conformance/autonomy/expected/side_effect_booleans_pause.yaml b/tests/conformance/autonomy/expected/side_effect_booleans_pause.yaml index cdce915..96086eb 100644 --- a/tests/conformance/autonomy/expected/side_effect_booleans_pause.yaml +++ b/tests/conformance/autonomy/expected/side_effect_booleans_pause.yaml @@ -1,5 +1,5 @@ case: side_effect_booleans_pause -config: configs/auto_review_standard.yaml +config: configs/auto_review_external_pause.yaml message: messages/side_effect_booleans.yaml expected: decision: paused diff --git a/tests/conformance/autonomy/expected/top_level_side_effect_actuals_ignored.yaml b/tests/conformance/autonomy/expected/top_level_side_effect_actuals_ignored.yaml index ed6177a..27c802e 100644 --- a/tests/conformance/autonomy/expected/top_level_side_effect_actuals_ignored.yaml +++ b/tests/conformance/autonomy/expected/top_level_side_effect_actuals_ignored.yaml @@ -17,6 +17,7 @@ expected: result: final_state: done predicted_risk_materialized: false + completed_at_utc: "2026-05-12T12:25:00Z" threshold_checkpoint: evaluated: true breached: false diff --git a/tests/conformance/autonomy/messages/guardrails_fence.yaml b/tests/conformance/autonomy/messages/guardrails_fence.yaml new file mode 100644 index 0000000..4fa7c4a --- /dev/null +++ b/tests/conformance/autonomy/messages/guardrails_fence.yaml @@ -0,0 +1,24 @@ +id: msg-20260710235000-iris-guardrails-fence +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:50:00Z" +subject: "Guardrail-fenced docs cleanup" +body: | + ## Task + Clarify one documentation paragraph. + + ```oacp-guardrails + Do not merge, deploy, publish, or touch auth config and secrets. + ``` + + task_profile: + estimated_minutes: 10 + risk_tier: P3 + expected_files_touched: 1 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/autonomy/messages/hard_stop_external_side_effects.yaml b/tests/conformance/autonomy/messages/hard_stop_external_side_effects.yaml index 1eae25e..220abed 100644 --- a/tests/conformance/autonomy/messages/hard_stop_external_side_effects.yaml +++ b/tests/conformance/autonomy/messages/hard_stop_external_side_effects.yaml @@ -15,7 +15,7 @@ body: | risk_tier: P3 expected_files_touched: 1 destructive_ops: false - external_side_effects: false + external_side_effects: true touches_auth_config_or_secrets: false touches_dependencies: false public_visibility: false diff --git a/tests/conformance/autonomy/messages/hard_stop_sensitive_scope.yaml b/tests/conformance/autonomy/messages/hard_stop_sensitive_scope.yaml index 3e82cac..fc59c72 100644 --- a/tests/conformance/autonomy/messages/hard_stop_sensitive_scope.yaml +++ b/tests/conformance/autonomy/messages/hard_stop_sensitive_scope.yaml @@ -15,6 +15,6 @@ body: | expected_files_touched: 1 destructive_ops: false external_side_effects: false - touches_auth_config_or_secrets: false + touches_auth_config_or_secrets: true touches_dependencies: false public_visibility: false diff --git a/tests/conformance/autonomy/messages/lexical_declared_false.yaml b/tests/conformance/autonomy/messages/lexical_declared_false.yaml new file mode 100644 index 0000000..8bf2d9e --- /dev/null +++ b/tests/conformance/autonomy/messages/lexical_declared_false.yaml @@ -0,0 +1,21 @@ +id: msg-20260710235600-iris-lexical-advisory +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:56:00Z" +subject: "Summarize deployment terminology" +body: | + ## Task + Summarize how teams deploy packages and describe auth boundaries. Do not + perform those actions or change sensitive files. + + task_profile: + estimated_minutes: 10 + risk_tier: P3 + expected_files_touched: 1 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/autonomy/messages/minutes_45.yaml b/tests/conformance/autonomy/messages/minutes_45.yaml new file mode 100644 index 0000000..7f6004e --- /dev/null +++ b/tests/conformance/autonomy/messages/minutes_45.yaml @@ -0,0 +1,20 @@ +id: msg-20260710235200-iris-minutes-45 +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:52:00Z" +subject: "Forty-five minute task" +body: | + ## Task + Update one bounded protocol example. + + task_profile: + estimated_minutes: 45 + risk_tier: P2 + expected_files_touched: 2 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/autonomy/messages/minutes_46.yaml b/tests/conformance/autonomy/messages/minutes_46.yaml new file mode 100644 index 0000000..f6dbdac --- /dev/null +++ b/tests/conformance/autonomy/messages/minutes_46.yaml @@ -0,0 +1,20 @@ +id: msg-20260710235300-iris-minutes-46 +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:53:00Z" +subject: "Forty-six minute task" +body: | + ## Task + Update one bounded protocol example. + + task_profile: + estimated_minutes: 46 + risk_tier: P2 + expected_files_touched: 2 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/autonomy/messages/negated_guardrails.yaml b/tests/conformance/autonomy/messages/negated_guardrails.yaml new file mode 100644 index 0000000..8dddd20 --- /dev/null +++ b/tests/conformance/autonomy/messages/negated_guardrails.yaml @@ -0,0 +1,21 @@ +id: msg-20260710235100-iris-negated-guardrails +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:51:00Z" +subject: "Negated guardrails fallback" +body: | + ## Task + Clarify one documentation paragraph. Do not merge or publish it. + Never touch secrets. + + task_profile: + estimated_minutes: 10 + risk_tier: P3 + expected_files_touched: 1 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/autonomy/messages/private_pr_artifacts.yaml b/tests/conformance/autonomy/messages/private_pr_artifacts.yaml new file mode 100644 index 0000000..74b9366 --- /dev/null +++ b/tests/conformance/autonomy/messages/private_pr_artifacts.yaml @@ -0,0 +1,25 @@ +id: msg-20260710235400-iris-private-pr +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:54:00Z" +subject: "Private repository PR artifacts" +body: | + ## Task + Update the existing branch, open a pull request, and post the review comment. + + task_profile: + estimated_minutes: 30 + risk_tier: P2 + expected_files_touched: 3 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + sends_oacp_reply_only: false diff --git a/tests/conformance/autonomy/messages/public_pr_artifacts.yaml b/tests/conformance/autonomy/messages/public_pr_artifacts.yaml new file mode 100644 index 0000000..6b4aa73 --- /dev/null +++ b/tests/conformance/autonomy/messages/public_pr_artifacts.yaml @@ -0,0 +1,25 @@ +id: msg-20260710235500-iris-public-pr +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-07-10T23:55:00Z" +subject: "Externally visible PR artifacts" +body: | + ## Task + Update the existing branch, open the release PR, and post the review comment. + + task_profile: + estimated_minutes: 30 + risk_tier: P2 + expected_files_touched: 3 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: kiloloop/oacp + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + sends_oacp_reply_only: false diff --git a/tests/conformance/autonomy/messages/sensitive_commercial.yaml b/tests/conformance/autonomy/messages/sensitive_commercial.yaml index 74155a2..a5da460 100644 --- a/tests/conformance/autonomy/messages/sensitive_commercial.yaml +++ b/tests/conformance/autonomy/messages/sensitive_commercial.yaml @@ -7,7 +7,11 @@ created_at_utc: "2026-05-12T12:14:00Z" subject: "Commercial terms update" body: | ## Task - Update the commercial terms summary for the product page. + ```oacp-guardrails + Do not discuss commercial terms in the generated summary. + ``` + + Update one unrelated summary paragraph. task_profile: estimated_minutes: 10 diff --git a/tests/conformance/autonomy/messages/sensitive_config.yaml b/tests/conformance/autonomy/messages/sensitive_config.yaml index a7928ec..30d7e2a 100644 --- a/tests/conformance/autonomy/messages/sensitive_config.yaml +++ b/tests/conformance/autonomy/messages/sensitive_config.yaml @@ -15,6 +15,6 @@ body: | expected_files_touched: 1 destructive_ops: false external_side_effects: false - touches_auth_config_or_secrets: false + touches_auth_config_or_secrets: true touches_dependencies: false public_visibility: false diff --git a/tests/conformance/autonomy/messages/sensitive_credentials.yaml b/tests/conformance/autonomy/messages/sensitive_credentials.yaml index ef4f017..0c57db6 100644 --- a/tests/conformance/autonomy/messages/sensitive_credentials.yaml +++ b/tests/conformance/autonomy/messages/sensitive_credentials.yaml @@ -15,6 +15,6 @@ body: | expected_files_touched: 1 destructive_ops: false external_side_effects: false - touches_auth_config_or_secrets: false + touches_auth_config_or_secrets: true touches_dependencies: false public_visibility: false diff --git a/tests/conformance/autonomy/messages/sensitive_pricing.yaml b/tests/conformance/autonomy/messages/sensitive_pricing.yaml index e2d25b4..298309b 100644 --- a/tests/conformance/autonomy/messages/sensitive_pricing.yaml +++ b/tests/conformance/autonomy/messages/sensitive_pricing.yaml @@ -7,7 +7,7 @@ created_at_utc: "2026-05-12T12:18:00Z" subject: "Pricing page update" body: | ## Task - Update the pricing page copy. + Do not discuss pricing changes in the generated summary. task_profile: estimated_minutes: 10 diff --git a/tests/conformance/autonomy/messages/sensitive_secret.yaml b/tests/conformance/autonomy/messages/sensitive_secret.yaml index 20f2f5a..45ce5d3 100644 --- a/tests/conformance/autonomy/messages/sensitive_secret.yaml +++ b/tests/conformance/autonomy/messages/sensitive_secret.yaml @@ -15,6 +15,6 @@ body: | expected_files_touched: 1 destructive_ops: false external_side_effects: false - touches_auth_config_or_secrets: false + touches_auth_config_or_secrets: true touches_dependencies: false public_visibility: false diff --git a/tests/conformance/autonomy/messages/side_effect_before_sensitive.yaml b/tests/conformance/autonomy/messages/side_effect_before_sensitive.yaml index 2252fd6..730650e 100644 --- a/tests/conformance/autonomy/messages/side_effect_before_sensitive.yaml +++ b/tests/conformance/autonomy/messages/side_effect_before_sensitive.yaml @@ -14,7 +14,7 @@ body: | risk_tier: P3 expected_files_touched: 1 destructive_ops: false - external_side_effects: false - touches_auth_config_or_secrets: false + external_side_effects: true + touches_auth_config_or_secrets: true touches_dependencies: false public_visibility: false diff --git a/tests/conformance/envelope/README.md b/tests/conformance/envelope/README.md new file mode 100644 index 0000000..aa603ff --- /dev/null +++ b/tests/conformance/envelope/README.md @@ -0,0 +1,47 @@ +# OACP Envelope Compilation Conformance Fixtures + +These fixtures define the canonical contract for envelope compilation: compiling an +admitted message's `task_profile` plus the receiver's autonomy config into a +runtime envelope (`active_envelope.json`). Runtime adapters and alternative +compiler implementations should compile each `message` + `config` pair and +compare their result to the matching file under `expected/`. + +Expected decision files use this shape: + +```yaml +case: clean_pr_task_compiles +config: configs/auto_review_standard.yaml +message: messages/clean_pr_task.yaml +expected: + compiles: true + envelope: + constraints: + creates_or_updates_pr: true +``` + +Compile-failure cases use: + +```yaml +expected: + compiles: false + error: envelope_compile_error +``` + +Rules pinned by these fixtures: + +- Compilation is fail-closed: a missing, unparsable, or invalid + `task_profile`, or a malformed receiver config, must fail with + `envelope_compile_error` (the receiver pauses the task instead of executing + unenforced). +- The envelope embeds the receiver-side `private_repo_allowlist` so runtime + enforcement does not depend on re-reading config. +- Granular side-effect fields absent from a legacy profile compile to + `false`, never to "allowed". +- `counters.files_touched` always starts empty; counters are runtime state, + not declaration state. + +Volatile fields (`compiled_at_utc`, `message_sha256`, `compiler`, +`spec_version`) are intentionally not pinned here; consumers compare the +listed fields as a subset. The executable runner is +`tests/test_envelope_conformance_fixtures.py`; every expected fixture is +evaluated against `scripts/envelope_compiler.py`. diff --git a/tests/conformance/envelope/configs/auto_review_standard.yaml b/tests/conformance/envelope/configs/auto_review_standard.yaml new file mode 100644 index 0000000..43b051d --- /dev/null +++ b/tests/conformance/envelope/configs/auto_review_standard.yaml @@ -0,0 +1,17 @@ +autonomy: + default_mode: auto_review + auto_review_thresholds: + max_estimated_minutes: 45 + max_expected_files_touched: 5 + destructive_ops: pause + external_side_effects: allow_pr_artifacts + auth_config_or_secrets: pause + dependency_changes: pause + public_visibility: pause + git_push_or_deploy: pause + allow_without_task_profile: + - brainstorm_request + private_repo_allowlist: + - example-org/private-repo + continuation_grants: + enabled: false diff --git a/tests/conformance/envelope/configs/malformed.yaml b/tests/conformance/envelope/configs/malformed.yaml new file mode 100644 index 0000000..d1549b0 --- /dev/null +++ b/tests/conformance/envelope/configs/malformed.yaml @@ -0,0 +1,2 @@ +autonomy: + default_mode: bogus_mode diff --git a/tests/conformance/envelope/expected/clean_pr_task_compiles.yaml b/tests/conformance/envelope/expected/clean_pr_task_compiles.yaml new file mode 100644 index 0000000..df46181 --- /dev/null +++ b/tests/conformance/envelope/expected/clean_pr_task_compiles.yaml @@ -0,0 +1,29 @@ +case: clean_pr_task_compiles +config: configs/auto_review_standard.yaml +message: messages/clean_pr_task.yaml +expected: + compiles: true + envelope: + envelope_version: 1 + project: test-proj + receiver: claude + message_id: msg-20260712020000-iris-env1 + enforcement: hooks + constraints: + estimated_minutes: 30 + expected_files_touched: 4 + risk_tier: P2 + target_repo: example-org/private-repo + destructive_ops: false + external_side_effects: true + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + sends_oacp_reply_only: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + private_repo_allowlist: + - example-org/private-repo + counters: + files_touched: [] diff --git a/tests/conformance/envelope/expected/docs_only_task_compiles.yaml b/tests/conformance/envelope/expected/docs_only_task_compiles.yaml new file mode 100644 index 0000000..6ee3a2f --- /dev/null +++ b/tests/conformance/envelope/expected/docs_only_task_compiles.yaml @@ -0,0 +1,29 @@ +case: docs_only_task_compiles +config: configs/auto_review_standard.yaml +message: messages/docs_only_task.yaml +expected: + compiles: true + envelope: + envelope_version: 1 + project: test-proj + receiver: claude + message_id: msg-20260712020001-iris-env2 + enforcement: hooks + constraints: + estimated_minutes: 20 + expected_files_touched: 1 + risk_tier: P3 + target_repo: "" + destructive_ops: false + external_side_effects: false + creates_or_updates_pr: false + comments_on_github: false + commits_changes: false + sends_oacp_reply_only: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + private_repo_allowlist: + - example-org/private-repo + counters: + files_touched: [] diff --git a/tests/conformance/envelope/expected/invalid_numeric_fails.yaml b/tests/conformance/envelope/expected/invalid_numeric_fails.yaml new file mode 100644 index 0000000..9068d0b --- /dev/null +++ b/tests/conformance/envelope/expected/invalid_numeric_fails.yaml @@ -0,0 +1,6 @@ +case: invalid_numeric_fails +config: configs/auto_review_standard.yaml +message: messages/invalid_numeric.yaml +expected: + compiles: false + error: envelope_compile_error diff --git a/tests/conformance/envelope/expected/malformed_config_fails.yaml b/tests/conformance/envelope/expected/malformed_config_fails.yaml new file mode 100644 index 0000000..c147ee0 --- /dev/null +++ b/tests/conformance/envelope/expected/malformed_config_fails.yaml @@ -0,0 +1,6 @@ +case: malformed_config_fails +config: configs/malformed.yaml +message: messages/clean_pr_task.yaml +expected: + compiles: false + error: envelope_compile_error diff --git a/tests/conformance/envelope/expected/missing_profile_fails.yaml b/tests/conformance/envelope/expected/missing_profile_fails.yaml new file mode 100644 index 0000000..71a8502 --- /dev/null +++ b/tests/conformance/envelope/expected/missing_profile_fails.yaml @@ -0,0 +1,6 @@ +case: missing_profile_fails +config: configs/auto_review_standard.yaml +message: messages/missing_profile.yaml +expected: + compiles: false + error: envelope_compile_error diff --git a/tests/conformance/envelope/expected/unparsable_profile_fails.yaml b/tests/conformance/envelope/expected/unparsable_profile_fails.yaml new file mode 100644 index 0000000..f8b8149 --- /dev/null +++ b/tests/conformance/envelope/expected/unparsable_profile_fails.yaml @@ -0,0 +1,6 @@ +case: unparsable_profile_fails +config: configs/auto_review_standard.yaml +message: messages/unparsable_profile.yaml +expected: + compiles: false + error: envelope_compile_error diff --git a/tests/conformance/envelope/messages/clean_pr_task.yaml b/tests/conformance/envelope/messages/clean_pr_task.yaml new file mode 100644 index 0000000..aeb1269 --- /dev/null +++ b/tests/conformance/envelope/messages/clean_pr_task.yaml @@ -0,0 +1,25 @@ +id: msg-20260712020000-iris-env1 +from: iris +to: claude +type: task_request +priority: P2 +created_at_utc: "2026-07-12T02:00:00Z" +subject: "Implement one module with tests, open a PR" +body: | + ## Task + Implement one module with unit tests and open a PR for review. + + task_profile: + estimated_minutes: 30 + expected_files_touched: 4 + risk_tier: P2 + target_repo: example-org/private-repo + destructive_ops: false + external_side_effects: true + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + sends_oacp_reply_only: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/envelope/messages/docs_only_task.yaml b/tests/conformance/envelope/messages/docs_only_task.yaml new file mode 100644 index 0000000..073f4fb --- /dev/null +++ b/tests/conformance/envelope/messages/docs_only_task.yaml @@ -0,0 +1,20 @@ +id: msg-20260712020001-iris-env2 +from: iris +to: claude +type: task_request +priority: P3 +created_at_utc: "2026-07-12T02:00:01Z" +subject: "Small docs cleanup" +body: | + ## Task + Update one documentation paragraph for clarity. + + task_profile: + estimated_minutes: 20 + risk_tier: P3 + expected_files_touched: 1 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/envelope/messages/invalid_numeric.yaml b/tests/conformance/envelope/messages/invalid_numeric.yaml new file mode 100644 index 0000000..d70eaee --- /dev/null +++ b/tests/conformance/envelope/messages/invalid_numeric.yaml @@ -0,0 +1,20 @@ +id: msg-20260712020004-iris-env5 +from: iris +to: claude +type: task_request +priority: P2 +created_at_utc: "2026-07-12T02:00:04Z" +subject: "Task with a non-numeric estimate" +body: | + ## Task + The estimate is not an integer. + + task_profile: + estimated_minutes: soon + expected_files_touched: 2 + risk_tier: P2 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false diff --git a/tests/conformance/envelope/messages/missing_profile.yaml b/tests/conformance/envelope/messages/missing_profile.yaml new file mode 100644 index 0000000..05da8b8 --- /dev/null +++ b/tests/conformance/envelope/messages/missing_profile.yaml @@ -0,0 +1,10 @@ +id: msg-20260712020002-iris-env3 +from: iris +to: claude +type: task_request +priority: P2 +created_at_utc: "2026-07-12T02:00:02Z" +subject: "Task without a declared profile" +body: | + ## Task + Do a thing, no profile declared. diff --git a/tests/conformance/envelope/messages/unparsable_profile.yaml b/tests/conformance/envelope/messages/unparsable_profile.yaml new file mode 100644 index 0000000..a67b583 --- /dev/null +++ b/tests/conformance/envelope/messages/unparsable_profile.yaml @@ -0,0 +1,14 @@ +id: msg-20260712020003-iris-env4 +from: iris +to: claude +type: task_request +priority: P2 +created_at_utc: "2026-07-12T02:00:03Z" +subject: "Task with a broken profile block" +body: | + ## Task + The profile below does not parse. + + task_profile: + estimated_minutes: [broken + risk_tier: P2 diff --git a/tests/test_autonomy_conformance_fixtures.py b/tests/test_autonomy_conformance_fixtures.py index 3959824..045fe86 100644 --- a/tests/test_autonomy_conformance_fixtures.py +++ b/tests/test_autonomy_conformance_fixtures.py @@ -30,6 +30,9 @@ def test_expected_cases_reference_existing_config_and_message() -> None: if data.get("actuals"): actuals = FIXTURE_ROOT / data["actuals"] assert actuals.is_file(), f"{expected_path.name} references missing {actuals}" + for audit_ref in data.get("audits") or []: + audit = FIXTURE_ROOT / audit_ref + assert audit.is_file(), f"{expected_path.name} references missing {audit}" assert data["expected"]["decision"] in {"auto_accepted", "paused", "rejected"} assert data["expected"]["mode"] in {"always_pause", "auto_review"} assert isinstance(data["expected"]["reason_codes"], list) @@ -39,6 +42,14 @@ def test_expected_cases_reference_existing_config_and_message() -> None: assert result.get("final_state") in {"done", "paused", "blocked", "superseded", "error"} +def test_prior_grant_audits_use_schema_version_two() -> None: + for audit_path in sorted((FIXTURE_ROOT / "audits").glob("*.yaml")): + audit = _load_yaml(audit_path) + assert audit["schema_version"] == 2 + outcome = audit.get("result", {}).get("human_outcome", {}) + assert outcome.get("recorded") is True + + def test_no_sender_trust_fixture_surface() -> None: forbidden = "trusted_senders" for yaml_path in sorted(FIXTURE_ROOT.rglob("*.yaml")): diff --git a/tests/test_autonomy_gate.py b/tests/test_autonomy_gate.py index 90b9230..c4d82d9 100644 --- a/tests/test_autonomy_gate.py +++ b/tests/test_autonomy_gate.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib +import shutil import sys from pathlib import Path from typing import Any, Dict @@ -14,7 +15,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -from autonomy_gate import evaluate_autonomy # noqa: E402 +from autonomy_gate import ( # noqa: E402 + PINNED_REASON_CODES, + canonical_policy_sha256, + evaluate_autonomy, +) FIXTURE_ROOT = Path(__file__).parent / "conformance" / "autonomy" @@ -36,7 +41,7 @@ def _assert_subset(expected: Any, actual: Any) -> None: assert actual == expected -def test_autonomy_gate_matches_conformance_fixtures() -> None: +def test_autonomy_gate_matches_conformance_fixtures(tmp_path: Path) -> None: expected_files = sorted((FIXTURE_ROOT / "expected").glob("*.yaml")) assert expected_files @@ -47,8 +52,21 @@ def test_autonomy_gate_matches_conformance_fixtures() -> None: actuals = None if fixture.get("actuals"): actuals = _load_yaml(FIXTURE_ROOT / fixture["actuals"]) - - decision = evaluate_autonomy(message, config, actuals=actuals) + audit_dir = None + if fixture.get("audits"): + audit_dir = tmp_path / expected_path.stem + audit_dir.mkdir() + for audit_ref in fixture["audits"]: + source = FIXTURE_ROOT / audit_ref + shutil.copy2(source, audit_dir / source.name) + + decision = evaluate_autonomy( + message, + config, + actuals=actuals, + audit_dir=audit_dir, + receiver="codex", + ) expected = fixture["expected"] assert decision["decision"] == expected["decision"], expected_path.name @@ -71,6 +89,15 @@ def test_autonomy_gate_matches_conformance_fixtures() -> None: if "result" in expected: _assert_subset(expected["result"], decision["result"]) + if "breached" in expected: + assert decision["breached"] == expected["breached"] + + if "task_profile" in expected: + _assert_subset(expected["task_profile"], decision["task_profile"]) + + assert set(decision["reason_codes"]) <= PINNED_REASON_CODES + assert "completed_at_utc" in decision["result"] + def test_autonomy_gate_output_uses_canonical_final_states() -> None: allowed = {"done", "paused", "blocked", "superseded", "error"} @@ -82,6 +109,8 @@ def test_autonomy_gate_output_uses_canonical_final_states() -> None: decision = evaluate_autonomy(message, config, actuals=actuals) assert decision["result"]["final_state"] in allowed + assert decision["schema_version"] == 2 + assert "human_outcome" in decision["result"] def test_autonomy_gate_records_raw_message_hash_when_path_provided() -> None: @@ -122,6 +151,44 @@ def test_autonomy_gate_records_hash_for_malformed_config() -> None: assert decision["message_sha256"] == expected_hash +def test_policy_hash_ignores_comments_and_formatting() -> None: + first = yaml.safe_load( + """ +autonomy: + default_mode: auto_review # comment-only difference + auto_review_thresholds: {max_estimated_minutes: 45} +""" + ) + second = yaml.safe_load( + """ +autonomy: + auto_review_thresholds: + max_estimated_minutes: 45 + default_mode: auto_review +""" + ) + + assert canonical_policy_sha256(first) == canonical_policy_sha256(second) + + +def test_guardrails_fence_keeps_operative_terms_visible_as_advisories() -> None: + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + message = _load_yaml(FIXTURE_ROOT / "messages" / "clean_task.yaml") + message["body"] = message["body"].replace( + "## Task\nUpdate one documentation paragraph for clarity.", + "## Task\nUpdate one documentation paragraph for clarity.\n\n" + "```oacp-guardrails\nDeploy to production and change auth config.\n```", + ) + + decision = evaluate_autonomy(message, config) + + assert decision["decision"] == "auto_accepted" + patterns = [note["matched_pattern"] for note in decision["logged_notes"]] + assert "deploy" in patterns + assert "auth" in patterns + assert "lexical_advisory" in decision["reason_codes"] + + def test_autonomy_gate_pauses_same_receiver_replay(tmp_path: Path) -> None: config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") message = _load_yaml(FIXTURE_ROOT / "messages" / "clean_task.yaml") @@ -175,3 +242,193 @@ def test_autonomy_gate_allows_different_receiver_audit(tmp_path: Path) -> None: decision = evaluate_autonomy(message, config, audit_dir=tmp_path, receiver="codex") assert decision["decision"] == "auto_accepted" + + +def test_sender_declared_continuation_requires_prior_human_approval() -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + + decision = evaluate_autonomy(message, config) + + assert decision["decision"] == "paused" + assert decision["reason_codes"][0] == "continuation_grant_missing_approval" + assert decision["continuation_grant"]["decision"] == "missing_approval" + + +def test_latest_same_thread_grant_denial_repauses(tmp_path: Path) -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + message["conversation_id"] = "conv-20260526-iris-001" + approved = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + denied = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + denied["message_id"] = "msg-20260526042000-iris-denial" + outcome = denied["result"]["human_outcome"] + outcome["decided_at_utc"] = "2026-05-26T04:20:00Z" + outcome["grant"]["decision"] = "denied" + outcome["grant"]["granted_scope"] = None + (tmp_path / "approved.yaml").write_text( + yaml.safe_dump(approved, sort_keys=False), + encoding="utf-8", + ) + (tmp_path / "denied.yaml").write_text( + yaml.safe_dump(denied, sort_keys=False), + encoding="utf-8", + ) + + decision = evaluate_autonomy( + message, + config, + audit_dir=tmp_path, + receiver="codex", + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"][0] == "continuation_grant_denied" + assert decision["continuation_grant"]["decision"] == "denied" + + +def test_standing_grant_matches_conversation_beyond_immediate_parent( + tmp_path: Path, +) -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + message["conversation_id"] = "conv-20260526-iris-001" + message["parent_message_id"] = "msg-20260526042000-iris-intermediate" + prior = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + (tmp_path / "prior.yaml").write_text( + yaml.safe_dump(prior, sort_keys=False), + encoding="utf-8", + ) + + decision = evaluate_autonomy( + message, + config, + audit_dir=tmp_path, + receiver="codex", + ) + + assert decision["decision"] == "auto_accepted" + assert decision["continuation_grant"]["standing_grant_found"] is True + assert decision["continuation_grant"]["source_message_id"] == prior["message_id"] + + +def test_standing_grant_does_not_cross_senders(tmp_path: Path) -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + message["from"] = "claude" + message["id"] = "msg-20260526042500-claude-continuation" + prior = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + (tmp_path / "prior.yaml").write_text( + yaml.safe_dump(prior, sort_keys=False), + encoding="utf-8", + ) + + decision = evaluate_autonomy( + message, + config, + audit_dir=tmp_path, + receiver="codex", + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"][0] == "continuation_grant_missing_approval" + + +def test_followup_created_before_human_approval_cannot_use_grant( + tmp_path: Path, +) -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + message["created_at_utc"] = "2026-05-26T04:11:00Z" + prior = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + (tmp_path / "prior.yaml").write_text( + yaml.safe_dump(prior, sort_keys=False), + encoding="utf-8", + ) + + decision = evaluate_autonomy( + message, + config, + audit_dir=tmp_path, + receiver="codex", + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"][0] == "continuation_grant_missing_approval" + + +def test_prior_standing_grant_does_not_require_sender_to_repeat_request( + tmp_path: Path, +) -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + message["body"] = message["body"].split("\n continuation_grants:", 1)[0] + prior = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + (tmp_path / "prior.yaml").write_text( + yaml.safe_dump(prior, sort_keys=False), + encoding="utf-8", + ) + + decision = evaluate_autonomy( + message, + config, + audit_dir=tmp_path, + receiver="codex", + ) + + assert decision["decision"] == "auto_accepted" + assert decision["continuation_grant"]["request_present"] is False + assert decision["continuation_grant"]["standing_grant_found"] is True + + +def test_followup_declared_files_outside_grant_repauses(tmp_path: Path) -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "continuation_grant.yaml") + message["body"] = message["body"].replace( + "expected_files_touched: 1", + "expected_files_touched: 4", + ) + prior = _load_yaml( + FIXTURE_ROOT / "audits" / "prior_thread_grant_approved.yaml" + ) + (tmp_path / "prior.yaml").write_text( + yaml.safe_dump(prior, sort_keys=False), + encoding="utf-8", + ) + + decision = evaluate_autonomy( + message, + config, + audit_dir=tmp_path, + receiver="codex", + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["continuation_grant_scope_exceeded"] + assert decision["breached"] == ["task_profile.expected_files_touched"] diff --git a/tests/test_claude_envelope_hook.py b/tests/test_claude_envelope_hook.py new file mode 100644 index 0000000..c4582bf --- /dev/null +++ b/tests/test_claude_envelope_hook.py @@ -0,0 +1,661 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for scripts/claude_envelope_hook.py with recorded tool_input shapes.""" + +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +import pytest + +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 + + +CONSTRAINTS: Dict[str, Any] = { + "estimated_minutes": 30, + "expected_files_touched": 2, + "risk_tier": "P2", + "target_repo": "example-org/private-repo", + "destructive_ops": False, + "external_side_effects": True, + "creates_or_updates_pr": True, + "comments_on_github": False, + "commits_changes": True, + "sends_oacp_reply_only": False, + "touches_auth_config_or_secrets": False, + "touches_dependencies": False, + "public_visibility": False, + "private_repo_allowlist": ["example-org/private-repo"], +} + + +def make_envelope(**overrides: Any) -> Dict[str, Any]: + constraints = dict(CONSTRAINTS) + constraints.update(overrides) + return { + "envelope_version": 1, + "spec_version": "0.3.5", + "compiler": "envelope_compiler.py", + "compiled_at_utc": "2026-07-12T02:00:00Z", + "project": "test-proj", + "receiver": "claude", + "message_id": "msg-1", + "message_sha256": "0" * 64, + "constraints": constraints, + "counters": {"files_touched": []}, + "enforcement": "hooks", + } + + +def bash(command: str, envelope: Optional[Dict[str, Any]] = None) -> hook.Decision: + envelope = envelope or make_envelope() + return hook.classify("Bash", {"command": command}, "/repo", envelope) + + +@pytest.fixture(autouse=True) +def _pin_repo_resolution(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + hook, "resolve_cwd_repo", lambda *a, **k: "example-org/private-repo" + ) + monkeypatch.setattr( + hook, "resolve_current_branch", lambda *a, **k: "feat/widget" + ) + + +# ── Bash: destructive tokens ───────────────────────────────────────────────── + + +def test_destructive_rm_rf_denied() -> None: + decision = bash("rm -rf build/") + assert decision.action == "deny" + assert "rm -rf" in decision.reason + + +def test_destructive_force_flag_denied() -> None: + assert bash("git push --force origin feat/x").action == "deny" + + +def test_no_verify_denied() -> None: + assert bash("git commit --no-verify -m x").action == "deny" + + +def test_plain_commands_allowed() -> None: + assert bash("ls -la").action == "allow" + assert bash("pytest tests/ -x").action == "allow" + assert bash("make preflight").action == "allow" + + +# ── Bash: git ──────────────────────────────────────────────────────────────── + + +def test_git_commit_allowed_when_declared() -> None: + assert bash("git commit -m 'add widget'").action == "allow" + + +def test_git_commit_denied_when_not_declared() -> None: + decision = bash("git commit -m x", make_envelope(commits_changes=False)) + assert decision.action == "deny" + assert "commits_changes" in decision.reason + + +def test_git_push_feature_branch_allowed() -> None: + assert bash("git push -u origin feat/widget").action == "allow" + + +def test_git_push_to_main_denied() -> None: + decision = bash("git push origin HEAD:main") + assert decision.action == "deny" + assert "protected branch" in decision.reason + + +def test_git_push_head_refspec_checks_current_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(hook, "resolve_current_branch", lambda *a, **k: "main") + assert bash("git push origin HEAD").action == "deny" + + +def test_git_push_denied_without_pr_declaration() -> None: + decision = bash( + "git push origin feat/x", make_envelope(creates_or_updates_pr=False) + ) + assert decision.action == "deny" + assert "creates_or_updates_pr" in decision.reason + + +def test_git_push_denied_without_external_side_effects() -> None: + decision = bash( + "git push origin feat/x", + make_envelope(external_side_effects=False, creates_or_updates_pr=False), + ) + assert decision.action == "deny" + + +def test_git_push_repo_mismatch_denied(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(hook, "resolve_cwd_repo", lambda *a, **k: "other/repo") + decision = bash("git push origin feat/x") + assert decision.action == "deny" + assert "other/repo" in decision.reason + + +def test_git_push_unresolvable_repo_asks(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(hook, "resolve_cwd_repo", lambda *a, **k: None) + assert bash("git push origin feat/x").action == "ask" + + +def test_git_readonly_allowed() -> None: + assert bash("git status && git diff --stat").action == "allow" + assert bash("git log --oneline -5").action == "allow" + + +# ── Bash: gh ───────────────────────────────────────────────────────────────── + + +def test_gh_pr_create_allowed_on_allowlisted_repo() -> None: + decision = bash( + "gh pr create -R example-org/private-repo --title x --body y" + ) + assert decision.action == "allow" + + +def test_gh_pr_create_denied_on_unlisted_repo() -> None: + decision = bash("gh pr create -R other/repo --title x --body y") + assert decision.action == "deny" + assert "other/repo" in decision.reason + + +def test_gh_pr_create_denied_when_not_declared() -> None: + decision = bash( + "gh pr create -R example-org/private-repo --title x", + make_envelope(creates_or_updates_pr=False), + ) + assert decision.action == "deny" + + +def test_gh_pr_comment_gated_by_comments_flag() -> None: + denied = bash("gh pr comment 12 --body hi") + assert denied.action == "deny" + assert "comments_on_github" in denied.reason + allowed = bash( + "gh pr comment 12 --body hi", make_envelope(comments_on_github=True) + ) + assert allowed.action == "allow" + + +def test_gh_pr_merge_always_denied() -> None: + decision = bash("gh pr merge 12 --squash") + assert decision.action == "deny" + assert "allow class" in decision.reason + + +def test_gh_issue_create_denied() -> None: + assert bash("gh issue create --title x").action == "deny" + + +def test_gh_release_denied() -> None: + assert bash("gh release create v1.0.0").action == "deny" + + +def test_gh_readonly_allowed() -> None: + assert bash("gh pr view 12 --json state").action == "allow" + assert bash("gh pr checks 12").action == "allow" + assert bash("gh issue list --state open").action == "allow" + assert bash("gh auth status").action == "allow" + + +def test_gh_api_get_allowed_post_asks() -> None: + assert bash("gh api repos/example-org/private-repo/pulls").action == "allow" + assert bash("gh api -X POST repos/example-org/private-repo/pulls").action == "ask" + + +def test_gh_auth_login_denied() -> None: + decision = bash("gh auth login") + assert decision.action == "deny" + assert "auth" in decision.reason + + +def test_public_visibility_true_denies_mutations() -> None: + decision = bash( + "gh pr create -R example-org/private-repo --title x", + make_envelope(public_visibility=True), + ) + assert decision.action == "deny" + + +# ── Bash: dependencies, secrets, escalation ────────────────────────────────── + + +def test_pip_install_denied() -> None: + decision = bash("pip install requests") + assert decision.action == "deny" + assert "dependencies" in decision.reason + + +def test_uv_and_npm_variants_denied() -> None: + assert bash("uv add httpx").action == "deny" + assert bash("uv pip install httpx").action == "deny" + assert bash("npm install left-pad").action == "deny" + assert bash("python3 -m pip install requests").action == "deny" + + +def test_dependency_install_allowed_when_declared() -> None: + decision = bash("pip install requests", make_envelope(touches_dependencies=True)) + assert decision.action == "allow" + + +def test_pip_list_allowed() -> None: + assert bash("pip list").action == "allow" + + +def test_redirect_to_secret_path_denied() -> None: + decision = bash("echo TOKEN=x > .env") + assert decision.action == "deny" + assert ".env" in decision.reason + + +def test_redirect_to_plain_path_allowed_and_counted() -> None: + decision = bash("echo hello > notes.txt") + assert decision.action == "allow" + assert decision.new_files == ["/repo/notes.txt"] + + +def test_redirect_to_dev_null_not_counted() -> None: + decision = bash("make test > /dev/null") + assert decision.action == "allow" + assert decision.new_files == [] + + +def test_cp_to_ssh_dir_denied() -> None: + assert bash("cp key ~/.ssh/id_rsa").action == "deny" + + +def test_oacp_send_always_allowed() -> None: + decision = bash( + "oacp send test-proj --from claude --to iris --type notification " + "--subject done --body done", + make_envelope(external_side_effects=False), + ) + assert decision.action == "allow" + + +def test_compound_command_deny_wins() -> None: + assert bash("ls && gh pr merge 12").action == "deny" + + +def test_unbalanced_quotes_ask() -> None: + assert bash("echo 'unclosed").action == "ask" + + +def test_sudo_asks() -> None: + assert bash("sudo systemctl restart nginx").action == "ask" + + +# ── Round-2 regressions (codex findings F-001..F-004) ──────────────────────── + + +def test_oacp_envelope_clear_denied() -> None: + decision = bash("oacp envelope clear --project test-proj") + assert decision.action == "deny" + assert "self" in decision.reason or "envelope" in decision.reason + + +def test_oacp_envelope_compile_denied() -> None: + assert bash("oacp envelope compile msg.yaml --extend").action == "deny" + + +def test_oacp_envelope_show_allowed() -> None: + assert bash("oacp envelope show --project test-proj").action == "allow" + + +def test_oacp_memory_push_asks() -> None: + assert bash("oacp memory push").action == "ask" + + +def test_oacp_readonly_subcommands_allowed() -> None: + assert bash("oacp inbox test-proj --agent claude").action == "allow" + assert bash("oacp validate msg.yaml").action == "allow" + assert bash("oacp doctor").action == "allow" + + +def test_newline_separated_mutation_denied() -> None: + assert bash("ls\ngh pr merge 162 --squash").action == "deny" + + +def test_background_separated_mutation_denied() -> None: + assert bash("true & gh pr merge 162 --squash").action == "deny" + + +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" + + +def test_wrapper_with_flags_asks() -> None: + assert bash("env -i gh pr merge 162 --squash").action == "ask" + + +def test_command_substitution_content_classified() -> None: + assert bash("echo $(gh pr merge 12)").action == "deny" + assert bash("echo `oacp envelope clear`").action == "deny" + + +def test_gh_api_implicit_post_asks() -> None: + decision = bash("gh api repos/example-org/private-repo/pulls/162 -f state=closed") + assert decision.action == "ask" + + +def test_gh_global_repo_flag_before_group_denied() -> None: + decision = bash("gh --repo example-org/private-repo pr merge 162 --squash") + assert decision.action == "deny" + + +def test_gh_unknown_mutation_asks() -> None: + assert bash("gh run delete 123").action == "ask" + + +def test_git_push_mirror_denied() -> None: + decision = bash("git push --mirror origin") + assert decision.action == "deny" + assert "--mirror" in decision.reason + + +def test_git_push_force_prefixed_main_refspec_denied() -> None: + assert bash("git push origin +main").action == "deny" + + +def test_bash_redirect_to_dependency_manifest_denied() -> None: + decision = bash("echo hi > package.json") + assert decision.action == "deny" + assert "package.json" in decision.reason + + +def test_sed_in_place_on_secret_denied() -> None: + decision = bash("sed -i s/x/y/ .env") + assert decision.action == "deny" + assert ".env" in decision.reason + + +def test_touch_counts_against_file_counter() -> None: + decision = bash("touch a b c") # expected_files_touched: 2 + assert decision.action == "deny" + assert decision.reason.startswith(hook.BLOCKED_OPENER) + assert "expected 2, now 3" in decision.reason + + +def test_bash_writes_accumulate_counter() -> None: + decision = bash("touch a b") + assert decision.action == "allow" + assert decision.new_files == ["/repo/a", "/repo/b"] + + +# ── Round-3 regressions (codex findings F-005..F-007) ──────────────────────── + + +def test_redirect_on_recognized_gh_command_gated() -> None: + decision = bash("gh pr view 162 > .env") + assert decision.action == "deny" + assert ".env" in decision.reason + + +def test_redirect_on_recognized_git_command_gated() -> None: + decision = bash("git status > package.json") + assert decision.action == "deny" + assert "package.json" in decision.reason + + +def test_redirects_on_oacp_commands_count_toward_ceiling() -> None: + decision = bash("oacp doctor > a && oacp doctor > b && oacp doctor > c") + assert decision.action == "deny" + assert decision.reason.startswith(hook.BLOCKED_OPENER) + assert "expected 2, now 3" in decision.reason + + +def test_gh_attached_short_repo_flag_feeds_repo_gate() -> None: + decision = bash("gh pr create -Rother/repo --title x") + assert decision.action == "deny" + assert "other/repo" in decision.reason + + +def test_gh_auth_switch_denied() -> None: + decision = bash("gh auth switch --hostname github.com") + assert decision.action == "deny" + assert "auth" in decision.reason + + +def test_git_push_wildcard_refspec_denied() -> None: + decision = bash("git push origin 'refs/heads/*:refs/heads/*'") + assert decision.action == "deny" + assert "wildcard" in decision.reason + + +def test_uv_run_recurses_into_nested_command() -> None: + decision = bash("uv run oacp envelope clear --project test-proj") + assert decision.action == "deny" + assert "envelope" in decision.reason + + +def test_uv_run_plain_nested_command_allowed() -> None: + assert bash("uv run pytest tests/ -x").action == "allow" + + +def test_source_and_dot_ask() -> None: + assert bash("source ./setup.sh").action == "ask" + assert bash(". ./disable-envelope.sh").action == "ask" + + +def test_interpreter_inline_code_asks() -> None: + py = 'python3 -c "print(1)"' + assert bash(py).action == "ask" + assert bash("node -e 'console.log(1)'").action == "ask" + + +def test_python_module_form_still_classified_not_asked() -> None: + # `-m pip install` is dependency-classified, not inline-code escalated. + assert bash("python3 -m pip install requests").action == "deny" + assert bash("python3 -m pytest tests/").action == "allow" + + +# ── File tools ─────────────────────────────────────────────────────────────── + + +def edit(path: str, envelope: Optional[Dict[str, Any]] = None) -> hook.Decision: + envelope = envelope or make_envelope() + return hook.classify("Edit", {"file_path": path}, "/repo", envelope) + + +def test_secret_paths_denied() -> None: + for path in ( + "/repo/.env", + "/repo/.env.production", + "/home/u/.ssh/id_rsa", + "/repo/server.pem", + "/repo/signing.key", + "/oacp/projects/p/agents/claude/config.yaml", + "/repo/aws_credentials.json", + ): + assert edit(path).action == "deny", path + + +def test_dependency_manifests_denied() -> None: + for path in ( + "/repo/pyproject.toml", + "/repo/package.json", + "/repo/requirements-dev.txt", + "/repo/uv.lock", + ): + assert edit(path).action == "deny", path + + +def test_dependency_manifest_allowed_when_declared() -> None: + decision = edit( + "/repo/pyproject.toml", make_envelope(touches_dependencies=True) + ) + assert decision.action == "allow" + + +def test_file_counter_drift_denied_with_canonical_opener() -> None: + envelope = make_envelope() # expected_files_touched: 2 + envelope["counters"]["files_touched"] = ["/repo/a.py", "/repo/b.py"] + decision = edit("/repo/c.py", envelope) + assert decision.action == "deny" + assert decision.reason.startswith(hook.BLOCKED_OPENER) + assert "expected 2, now 3" in decision.reason + + +def test_file_counter_records_new_files() -> None: + decision = edit("/repo/a.py") + assert decision.action == "allow" + assert decision.new_files == ["/repo/a.py"] + + +def test_recounted_file_not_recorded_twice() -> None: + envelope = make_envelope() + envelope["counters"]["files_touched"] = ["/repo/a.py"] + decision = edit("/repo/a.py", envelope) + assert decision.action == "allow" + assert decision.new_files == [] + + +def test_relative_paths_normalized_against_cwd() -> None: + envelope = make_envelope() + decision = hook.classify("Edit", {"file_path": "a.py"}, "/repo", envelope) + assert decision.new_files == ["/repo/a.py"] + + +def test_write_and_notebook_share_classification() -> None: + assert ( + hook.classify("Write", {"file_path": "/repo/.env"}, "/repo", make_envelope()) + .action + == "deny" + ) + assert ( + hook.classify( + "NotebookEdit", {"notebook_path": "/repo/nb.ipynb"}, "/repo", make_envelope() + ).action + == "allow" + ) + + +def test_unknown_tool_allowed() -> None: + assert hook.classify("Grep", {}, "/repo", make_envelope()).action == "allow" + + +# ── End-to-end: process() + main() ─────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _hermetic_oacp_home(monkeypatch: pytest.MonkeyPatch): + """Keep a developer's real $OACP_HOME out of marker discovery.""" + monkeypatch.delenv("OACP_HOME", raising=False) + + +def _make_workspace(tmp_path: Path) -> Path: + """Create an OACP home + repo dir with a .oacp marker symlink.""" + project_dir = tmp_path / "home" / "projects" / "test-proj" + project_dir.mkdir(parents=True) + marker = project_dir / "workspace.json" + marker.write_text( + json.dumps({"project_name": "test-proj"}), encoding="utf-8" + ) + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".oacp").symlink_to(marker) + return repo + + +def _install_envelope(tmp_path: Path, envelope: Dict[str, Any]) -> Path: + target = envelope_path(tmp_path / "home", "test-proj", "claude") + write_envelope(target, envelope) + return target + + +def test_process_no_marker_allows(tmp_path: Path) -> None: + plain = tmp_path / "plain" + plain.mkdir() + decision = hook.process( + {"tool_name": "Bash", "tool_input": {"command": "gh pr merge 1"}, "cwd": str(plain)} + ) + assert decision.action == "allow" + + +def test_process_no_envelope_allows(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + decision = hook.process( + {"tool_name": "Bash", "tool_input": {"command": "gh pr merge 1"}, "cwd": str(repo)} + ) + assert decision.action == "allow" + + +def test_process_enforces_and_persists_counters(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + target = _install_envelope(tmp_path, make_envelope()) + + denied = hook.process( + {"tool_name": "Bash", "tool_input": {"command": "gh pr merge 1"}, "cwd": str(repo)} + ) + assert denied.action == "deny" + + allowed = hook.process( + {"tool_name": "Write", "tool_input": {"file_path": "a.py"}, "cwd": str(repo)} + ) + assert allowed.action == "allow" + stored = load_envelope(target) + assert stored["counters"]["files_touched"] == [str(Path(repo) / "a.py")] + + +def test_main_emits_deny_json(tmp_path: Path, monkeypatch, capsys) -> None: + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + payload = { + "tool_name": "Bash", + "tool_input": {"command": "gh pr merge 1"}, + "cwd": str(repo), + } + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + assert hook.main([]) == 0 + output = json.loads(capsys.readouterr().out) + assert output["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_main_allow_is_silent(tmp_path: Path, monkeypatch, capsys) -> None: + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + payload = { + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "cwd": str(repo), + } + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + assert hook.main([]) == 0 + assert capsys.readouterr().out == "" + + +def test_main_corrupt_envelope_asks(tmp_path: Path, monkeypatch, capsys) -> None: + repo = _make_workspace(tmp_path) + target = envelope_path(tmp_path / "home", "test-proj", "claude") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("{not json", encoding="utf-8") + payload = { + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "cwd": str(repo), + } + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + assert hook.main([]) == 0 + output = json.loads(capsys.readouterr().out) + assert output["hookSpecificOutput"]["permissionDecision"] == "ask" + + +def test_main_malformed_stdin_asks(monkeypatch, capsys) -> None: + monkeypatch.setattr(sys, "stdin", io.StringIO("not json")) + assert hook.main([]) == 0 + output = json.loads(capsys.readouterr().out) + assert output["hookSpecificOutput"]["permissionDecision"] == "ask" diff --git a/tests/test_envelope_compiler.py b/tests/test_envelope_compiler.py new file mode 100644 index 0000000..2390d54 --- /dev/null +++ b/tests/test_envelope_compiler.py @@ -0,0 +1,305 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for scripts/envelope_compiler.py.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, Dict + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from envelope_compiler import ( # noqa: E402 + ENVELOPE_COMPILE_ERROR, + EnvelopeCompileError, + build_envelope, + envelope_path, + load_envelope, + main, +) + + +RECEIVER_CONFIG: Dict[str, Any] = { + "autonomy": { + "default_mode": "auto_review", + "auto_review_thresholds": { + "max_estimated_minutes": 45, + "max_expected_files_touched": 5, + "destructive_ops": "pause", + "external_side_effects": "allow_pr_artifacts", + "auth_config_or_secrets": "pause", + "dependency_changes": "pause", + "public_visibility": "pause", + "git_push_or_deploy": "pause", + }, + "allow_without_task_profile": ["brainstorm_request"], + "private_repo_allowlist": ["example-org/private-repo"], + "continuation_grants": {"enabled": False}, + } +} + +PROFILE_BODY = """\ +Implement the widget. + +task_profile: + estimated_minutes: 30 + expected_files_touched: 4 + risk_tier: P2 + target_repo: example-org/private-repo + destructive_ops: false + external_side_effects: true + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + sends_oacp_reply_only: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false +""" + + +def make_message(body: str = PROFILE_BODY, message_id: str = "msg-1") -> Dict[str, Any]: + return { + "id": message_id, + "from": "iris", + "to": "claude", + "type": "task_request", + "priority": "P2", + "created_at_utc": "2026-07-12T01:00:00Z", + "subject": "Implement the widget", + "body": body, + } + + +def test_build_envelope_happy_path() -> None: + envelope = build_envelope( + make_message(), + RECEIVER_CONFIG, + receiver="claude", + project="test-proj", + now_iso="2026-07-12T02:00:00Z", + ) + assert envelope["envelope_version"] == 1 + assert envelope["message_id"] == "msg-1" + assert envelope["project"] == "test-proj" + assert envelope["receiver"] == "claude" + assert envelope["enforcement"] == "hooks" + assert envelope["compiled_at_utc"] == "2026-07-12T02:00:00Z" + assert envelope["counters"] == {"files_touched": []} + + constraints = envelope["constraints"] + assert constraints["estimated_minutes"] == 30 + assert constraints["expected_files_touched"] == 4 + assert constraints["risk_tier"] == "P2" + assert constraints["target_repo"] == "example-org/private-repo" + assert constraints["creates_or_updates_pr"] is True + assert constraints["comments_on_github"] is False + assert constraints["commits_changes"] is True + assert constraints["touches_auth_config_or_secrets"] is False + assert constraints["private_repo_allowlist"] == ["example-org/private-repo"] + assert "continuation_grants" not in constraints + + +def test_build_envelope_missing_profile_fails_closed() -> None: + with pytest.raises(EnvelopeCompileError): + build_envelope( + make_message(body="No profile here."), + RECEIVER_CONFIG, + receiver="claude", + project="test-proj", + ) + + +def test_build_envelope_unparsable_profile_fails_closed() -> None: + body = "task_profile:\n estimated_minutes: [broken\n" + with pytest.raises(EnvelopeCompileError): + build_envelope( + make_message(body=body), + RECEIVER_CONFIG, + receiver="claude", + project="test-proj", + ) + + +def test_build_envelope_invalid_numeric_fails_closed() -> None: + body = PROFILE_BODY.replace("estimated_minutes: 30", "estimated_minutes: soon") + with pytest.raises(EnvelopeCompileError): + build_envelope( + make_message(body=body), + RECEIVER_CONFIG, + receiver="claude", + project="test-proj", + ) + + +def test_build_envelope_malformed_config_fails_closed() -> None: + with pytest.raises(EnvelopeCompileError): + build_envelope( + make_message(), + {"autonomy": {"default_mode": "bogus"}}, + receiver="claude", + project="test-proj", + ) + + +def test_build_envelope_missing_message_id_fails_closed() -> None: + message = make_message() + message["id"] = "" + with pytest.raises(EnvelopeCompileError): + build_envelope( + message, + RECEIVER_CONFIG, + receiver="claude", + project="test-proj", + ) + + +# ── CLI ────────────────────────────────────────────────────────────────────── + + +def _workspace(tmp_path: Path, project: str = "test-proj") -> Path: + agent_dir = tmp_path / "projects" / project / "agents" / "claude" + agent_dir.mkdir(parents=True) + (agent_dir / "config.yaml").write_text( + yaml.safe_dump(RECEIVER_CONFIG), encoding="utf-8" + ) + inbox = agent_dir / "inbox" + inbox.mkdir() + return inbox + + +def _write_message(inbox: Path, message_id: str = "msg-1") -> Path: + path = inbox / f"{message_id}.yaml" + path.write_text(yaml.safe_dump(make_message(message_id=message_id)), encoding="utf-8") + return path + + +def test_cli_compile_show_clear_roundtrip(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), "--json", + ]) == 0 + envelope = json.loads(capsys.readouterr().out) + assert envelope["message_id"] == "msg-1" + assert envelope["project"] == "test-proj" # inferred from the message path + + target = envelope_path(tmp_path, "test-proj", "claude") + assert target.is_file() + + assert main([ + "show", "--project", "test-proj", "--oacp-dir", str(tmp_path), + ]) == 0 + assert json.loads(capsys.readouterr().out)["message_id"] == "msg-1" + + assert main([ + "clear", "--project", "test-proj", "--oacp-dir", str(tmp_path), + ]) == 0 + capsys.readouterr() + assert not target.is_file() + + assert main([ + "show", "--project", "test-proj", "--oacp-dir", str(tmp_path), + ]) == 1 + + +def test_cli_compile_refuses_second_message_without_extend( + tmp_path: Path, capsys +) -> None: + inbox = _workspace(tmp_path) + first = _write_message(inbox, "msg-1") + second = _write_message(inbox, "msg-2") + + assert main(["compile", str(first), "--oacp-dir", str(tmp_path)]) == 0 + capsys.readouterr() + assert main(["compile", str(second), "--oacp-dir", str(tmp_path)]) == 3 + err = capsys.readouterr().err + assert ENVELOPE_COMPILE_ERROR in err + assert "msg-1" in err + + +def test_cli_recompile_same_message_preserves_counters(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 + + target = envelope_path(tmp_path, "test-proj", "claude") + envelope = load_envelope(target) + envelope["counters"]["files_touched"] = ["/tmp/a.py", "/tmp/b.py"] + target.write_text(json.dumps(envelope), encoding="utf-8") + + assert main(["compile", str(message_path), "--oacp-dir", str(tmp_path)]) == 0 + assert load_envelope(target)["counters"]["files_touched"] == [ + "/tmp/a.py", + "/tmp/b.py", + ] + + +def test_cli_extend_preserves_counters_across_messages(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + first = _write_message(inbox, "msg-1") + second = _write_message(inbox, "msg-2") + assert main(["compile", str(first), "--oacp-dir", str(tmp_path)]) == 0 + + target = envelope_path(tmp_path, "test-proj", "claude") + envelope = load_envelope(target) + envelope["counters"]["files_touched"] = ["/tmp/a.py"] + target.write_text(json.dumps(envelope), encoding="utf-8") + + assert main([ + "compile", str(second), "--oacp-dir", str(tmp_path), "--extend", + ]) == 0 + updated = load_envelope(target) + assert updated["message_id"] == "msg-2" + assert updated["counters"]["files_touched"] == ["/tmp/a.py"] + + +def test_cli_force_resets_counters_for_new_message(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + first = _write_message(inbox, "msg-1") + second = _write_message(inbox, "msg-2") + assert main(["compile", str(first), "--oacp-dir", str(tmp_path)]) == 0 + + target = envelope_path(tmp_path, "test-proj", "claude") + envelope = load_envelope(target) + envelope["counters"]["files_touched"] = ["/tmp/a.py"] + target.write_text(json.dumps(envelope), encoding="utf-8") + + assert main([ + "compile", str(second), "--oacp-dir", str(tmp_path), "--force", + ]) == 0 + updated = load_envelope(target) + assert updated["message_id"] == "msg-2" + assert updated["counters"]["files_touched"] == [] + + +def test_cli_compile_missing_profile_exits_3(tmp_path: Path, capsys) -> None: + inbox = _workspace(tmp_path) + path = inbox / "msg-np.yaml" + path.write_text( + yaml.safe_dump(make_message(body="No profile.", message_id="msg-np")), + encoding="utf-8", + ) + assert main(["compile", str(path), "--oacp-dir", str(tmp_path)]) == 3 + assert ENVELOPE_COMPILE_ERROR in capsys.readouterr().err + + +def test_cli_compile_requires_project_when_not_inferable( + tmp_path: Path, capsys +) -> None: + _workspace(tmp_path) + outside = tmp_path / "elsewhere.yaml" + outside.write_text(yaml.safe_dump(make_message()), encoding="utf-8") + assert main(["compile", str(outside), "--oacp-dir", str(tmp_path)]) == 3 + assert "cannot infer project" in capsys.readouterr().err + + assert main([ + "compile", str(outside), "--project", "test-proj", "--oacp-dir", str(tmp_path), + ]) == 0 diff --git a/tests/test_envelope_conformance_fixtures.py b/tests/test_envelope_conformance_fixtures.py new file mode 100644 index 0000000..47b17fb --- /dev/null +++ b/tests/test_envelope_conformance_fixtures.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 + +"""Executable runner for the envelope compilation conformance fixtures.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Any, Dict + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from envelope_compiler import ( # noqa: E402 + ENVELOPE_COMPILE_ERROR, + EnvelopeCompileError, + build_envelope, +) + + +FIXTURE_ROOT = Path(__file__).parent / "conformance" / "envelope" +COMPILED_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") + + +def _load_yaml(path: Path) -> Dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + return data + + +def _assert_subset(expected: Any, actual: Any, context: str) -> None: + if isinstance(expected, dict): + assert isinstance(actual, dict), context + for key, value in expected.items(): + assert key in actual, f"{context}: missing {key}" + _assert_subset(value, actual[key], f"{context}.{key}") + return + assert actual == expected, f"{context}: {actual!r} != {expected!r}" + + +def _expected_fixtures(): + files = sorted((FIXTURE_ROOT / "expected").glob("*.yaml")) + assert files, "envelope conformance fixtures must not be empty" + return files + + +@pytest.mark.parametrize( + "expected_path", _expected_fixtures(), ids=lambda path: path.stem +) +def test_envelope_compiler_matches_conformance_fixtures(expected_path: Path) -> None: + fixture = _load_yaml(expected_path) + config = _load_yaml(FIXTURE_ROOT / fixture["config"]) + message = _load_yaml(FIXTURE_ROOT / fixture["message"]) + expected = fixture["expected"] + + if not expected["compiles"]: + assert expected["error"] == ENVELOPE_COMPILE_ERROR + with pytest.raises(EnvelopeCompileError): + build_envelope( + message, config, receiver="claude", project="test-proj" + ) + return + + envelope = build_envelope( + message, config, receiver="claude", project="test-proj" + ) + _assert_subset(expected["envelope"], envelope, expected_path.stem) + + # Volatile fields are unpinned but must be present and well-formed. + assert COMPILED_AT_RE.match(envelope["compiled_at_utc"]) + assert re.fullmatch(r"[0-9a-f]{64}", envelope["message_sha256"]) + assert envelope["spec_version"] + assert envelope["compiler"] + + +def test_expected_cases_reference_existing_config_and_message() -> None: + for expected_path in _expected_fixtures(): + fixture = _load_yaml(expected_path) + assert (FIXTURE_ROOT / fixture["config"]).is_file(), expected_path.name + assert (FIXTURE_ROOT / fixture["message"]).is_file(), expected_path.name + expected = fixture["expected"] + assert isinstance(expected.get("compiles"), bool), expected_path.name + if expected["compiles"]: + assert "envelope" in expected, expected_path.name + else: + assert expected.get("error") == ENVELOPE_COMPILE_ERROR, expected_path.name + + +def test_fixture_coverage_spans_success_and_failure() -> None: + outcomes = { + _load_yaml(path)["expected"]["compiles"] for path in _expected_fixtures() + } + assert outcomes == {True, False} diff --git a/tests/test_memory_sync.py b/tests/test_memory_sync.py new file mode 100644 index 0000000..30088f4 --- /dev/null +++ b/tests/test_memory_sync.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the durable-memory git sync engine.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import oacp_doctor # noqa: E402 +from memory_sync import ( # noqa: E402 + CANONICAL_MEMORY_GITIGNORE, + GIT_NETWORK_TIMEOUT_SECONDS, + MemorySyncError, + clone_memory_repo, + pull_memory, + push_memory, + push_remote, +) + + +@pytest.fixture(autouse=True) +def _isolate_git_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + + +def _git(*args: str, cwd: Optional[Path] = None) -> str: + completed = subprocess.run( + ["git", *args], + cwd=str(cwd) if cwd is not None else None, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + raise AssertionError( + f"git {' '.join(args)} failed ({completed.returncode}): " + f"{completed.stdout}\n{completed.stderr}" + ) + return completed.stdout.strip() + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _configure_identity(repo: Path) -> None: + _git("config", "user.name", "OACP Test", cwd=repo) + _git("config", "user.email", "oacp-test@example.invalid", cwd=repo) + + +def _create_remote(tmp_path: Path) -> Tuple[Path, Path]: + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + _git("init", "--bare", str(remote)) + _git("init", str(seed)) + _git("checkout", "-b", "main", cwd=seed) + _configure_identity(seed) + _write(seed / ".gitignore", CANONICAL_MEMORY_GITIGNORE) + _write(seed / ".oacp-memory-repo", "memory sync enabled\n") + _write(seed / "org-memory" / "recent.md", "seed\n") + _git("add", ".gitignore", ".oacp-memory-repo", "org-memory/recent.md", cwd=seed) + _git("commit", "-m", "seed memory", cwd=seed) + _git("remote", "add", "origin", str(remote), cwd=seed) + _git("push", "-u", "origin", "main", cwd=seed) + _git("--git-dir", str(remote), "symbolic-ref", "HEAD", "refs/heads/main") + return remote, seed + + +def _clone(remote: Path, destination: Path) -> None: + clone_memory_repo(destination, str(remote)) + _configure_identity(destination) + + +def _commit_and_push(repo: Path, relative_path: str, content: str) -> None: + _write(repo / relative_path, content) + _git("add", relative_path, cwd=repo) + _git("commit", "-m", f"update {relative_path}", cwd=repo) + _git("push", cwd=repo) + + +class RecordingRunner: + def __init__(self) -> None: + self.calls: List[Tuple[Tuple[str, ...], Optional[int]]] = [] + + def __call__( + self, + command: Sequence[str], + *, + timeout: Optional[int] = None, + ) -> Tuple[int, str]: + call = tuple(command) + self.calls.append((call, timeout)) + args = list(command[1:]) + if args == ["rev-parse", "--is-inside-work-tree"]: + return 0, "true" + if args == ["status", "--porcelain"]: + return 0, "" + if args == ["remote"]: + return 0, "origin" + if args == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]: + return 0, "origin/main" + if args == ["rev-list", "--left-right", "--count", "HEAD...origin/main"]: + return 0, "0 1" + return 0, "" + + +def _assert_timed_call( + runner: RecordingRunner, + expected_args: Sequence[str], +) -> None: + assert any( + list(command[1:]) == list(expected_args) + and timeout == GIT_NETWORK_TIMEOUT_SECONDS + for command, timeout in runner.calls + ) + + +def test_fetch_pull_push_and_clone_receive_network_timeouts(tmp_path: Path) -> None: + root = tmp_path / "oacp" + root.mkdir() + _write(root / ".oacp-memory-repo", "memory sync enabled\n") + runner = RecordingRunner() + + lines = pull_memory(root, runner=runner) + push_remote(root, runner=runner) + clone_target = tmp_path / "clone" + clone_memory_repo(clone_target, "example.invalid/repo.git", runner=runner) + + assert lines == ["OACP memory pull: synced 1 commit(s)."] + _assert_timed_call(runner, ["fetch", "--quiet"]) + _assert_timed_call(runner, ["pull", "--ff-only"]) + _assert_timed_call(runner, ["push"]) + _assert_timed_call( + runner, + ["clone", "example.invalid/repo.git", str(clone_target)], + ) + + +def test_doctor_memory_fetch_forwards_timeout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "oacp" + root.mkdir() + _write(root / ".oacp-memory-repo", "memory sync enabled\n") + _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) + calls: List[Tuple[Tuple[str, ...], Optional[int]]] = [] + + def fake_run_git_command( + command: Sequence[str], + *, + cwd: Path, + timeout: Optional[int] = None, + ) -> Tuple[int, str]: + assert cwd == root + call = tuple(command) + calls.append((call, timeout)) + args = list(command[1:]) + if args == ["rev-parse", "--is-inside-work-tree"]: + return 0, "true" + if args == ["ls-files"]: + return 0, ".gitignore\n.oacp-memory-repo" + if args == ["ls-files", "--others", "--exclude-standard"]: + return 0, "" + if args == ["status", "--porcelain"]: + return 0, "" + if args == ["remote"]: + return 0, "origin" + if args == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]: + return 1, "" + if args == ["rev-parse", "--verify", "HEAD"]: + return 1, "" + return 0, "" + + monkeypatch.setattr(oacp_doctor, "run_git_command", fake_run_git_command) + + oacp_doctor.check_memory_sync(root) + + assert any( + list(command[1:]) == ["fetch", "--quiet"] + and timeout == GIT_NETWORK_TIMEOUT_SECONDS + for command, timeout in calls + ) + + +def test_force_clone_preserves_existing_home_as_backup(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + root = tmp_path / "oacp" + _write(root / "local-only.txt", "keep me\n") + + lines = clone_memory_repo(root, str(remote), force=True) + + backups = list(tmp_path.glob("oacp.backup-*")) + assert len(backups) == 1 + assert (backups[0] / "local-only.txt").read_text(encoding="utf-8") == "keep me\n" + assert (root / "org-memory" / "recent.md").read_text(encoding="utf-8") == "seed\n" + assert any("Moved existing OACP_HOME aside" in line for line in lines) + + +def test_force_clone_failure_restores_existing_home(tmp_path: Path) -> None: + root = tmp_path / "oacp" + _write(root / "local-only.txt", "keep me\n") + + with pytest.raises(MemorySyncError, match="git clone failed"): + clone_memory_repo(root, str(tmp_path / "missing.git"), force=True) + + assert (root / "local-only.txt").read_text(encoding="utf-8") == "keep me\n" + assert not list(tmp_path.glob("oacp.backup-*")) + + +def test_pull_memory_fast_forwards_real_repo(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + root = tmp_path / "oacp" + _clone(remote, root) + _commit_and_push(seed, "org-memory/new.md", "remote update\n") + + lines = pull_memory(root) + + assert lines == ["OACP memory pull: synced 1 commit(s)."] + assert (root / "org-memory" / "new.md").read_text(encoding="utf-8") == "remote update\n" + + +def test_push_memory_commits_dirty_memory_and_pushes(tmp_path: Path) -> None: + remote, _ = _create_remote(tmp_path) + root = tmp_path / "oacp" + _clone(remote, root) + _write(root / "org-memory" / "local.md", "local update\n") + + lines, code = push_memory(root) + + assert code == 0 + assert any("committed 1 file(s)" in line for line in lines) + assert _git("status", "--porcelain", cwd=root) == "" + assert ( + _git("--git-dir", str(remote), "show", "main:org-memory/local.md") + == "local update" + ) + + +def test_push_memory_refuses_behind_repo(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + root = tmp_path / "oacp" + _clone(remote, root) + _commit_and_push(seed, "org-memory/remote.md", "remote update\n") + + lines, code = push_memory(root) + + assert code == 1 + assert any("behind upstream" in line for line in lines) + + +def test_push_memory_refuses_diverged_repo(tmp_path: Path) -> None: + remote, seed = _create_remote(tmp_path) + root = tmp_path / "oacp" + _clone(remote, root) + _write(root / "org-memory" / "local.md", "local update\n") + _git("add", "org-memory/local.md", cwd=root) + _git("commit", "-m", "local update", cwd=root) + _commit_and_push(seed, "org-memory/remote.md", "remote update\n") + + lines, code = push_memory(root) + + assert code == 1 + assert any("diverged" in line for line in lines) diff --git a/tests/test_oacp_cli.py b/tests/test_oacp_cli.py index f841438..fcaab5e 100644 --- a/tests/test_oacp_cli.py +++ b/tests/test_oacp_cli.py @@ -88,6 +88,39 @@ def test_dispatches_setup(self, run_script) -> None: "setup_runtime.py", ["claude", "--project", "demo"] ) + @mock.patch("oacp.cli._run_script", return_value=0) + def test_dispatches_autonomy_outcome(self, run_script) -> None: + code, stdout, stderr = self._run([ + "autonomy-outcome", + "/tmp/audit.yaml", + "--decision", + "approved", + ]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + run_script.assert_called_once_with( + "record_autonomy_outcome.py", + ["/tmp/audit.yaml", "--decision", "approved"], + ) + + @mock.patch("oacp.cli._run_script", return_value=0) + def test_dispatches_envelope(self, run_script) -> None: + code, stdout, stderr = self._run([ + "envelope", + "compile", + "/tmp/msg.yaml", + "--receiver", + "claude", + ]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + run_script.assert_called_once_with( + "envelope_compiler.py", + ["compile", "/tmp/msg.yaml", "--receiver", "claude"], + ) + @mock.patch("oacp.cli._run_script", return_value=0) def test_help_for_subcommand(self, run_script) -> None: code, stdout, stderr = self._run(["help", "send"]) @@ -110,6 +143,8 @@ def test_help_text_includes_new_commands(self) -> None: self.assertIn("watch", stdout) self.assertIn("memory", stdout) self.assertIn("setup", stdout) + self.assertIn("autonomy-outcome", stdout) + self.assertIn("envelope", stdout) def test_run_script_restores_sys_path_after_nested_mutation(self) -> None: script_path = "/tmp/send_inbox_message.py" diff --git a/tests/test_oacp_coordinator.py b/tests/test_oacp_coordinator.py index cd71658..06ec364 100644 --- a/tests/test_oacp_coordinator.py +++ b/tests/test_oacp_coordinator.py @@ -10,6 +10,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp_servers")) @@ -223,6 +224,33 @@ def test_mcp_tools_call_claim_packet_bad_lease_uses_default(self) -> None: self.assertIn("result", resp) self.assertNotIn("error", resp) + def test_mcp_tools_call_unexpected_error_returns_internal_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + hub = Path(tmp) + _setup_project(hub, "demo") + c = OACPCoordinator(oacp_dir=hub) + req = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "claim_packet", "arguments": {}}, + } + + with mock.patch( + "oacp_coordinator.call_tool", + side_effect=OSError("simulated disk failure"), + ): + resp = handle_mcp_request(c, req) + + self.assertEqual( + resp, + { + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32603, "message": "internal error"}, + }, + ) + def test_mcp_tools_list(self) -> None: with tempfile.TemporaryDirectory() as tmp: hub = Path(tmp) diff --git a/tests/test_oacp_doctor.py b/tests/test_oacp_doctor.py index 359e6a6..97ad7c3 100644 --- a/tests/test_oacp_doctor.py +++ b/tests/test_oacp_doctor.py @@ -245,6 +245,7 @@ def test_valid_autonomy_config(self) -> None: "git_push_or_deploy": "pause", }, "allow_without_task_profile": ["brainstorm_request"], + "private_repo_allowlist": ["example-org/private-repo"], "continuation_grants": {"enabled": False}, } } @@ -271,6 +272,57 @@ def test_rejects_malformed_continuation_grants(self) -> None: errors = validate_autonomy_config_data(data) self.assertTrue(any("continuation_grants.enabled" in error for error in errors)) + def test_accepts_private_pr_artifact_policy(self) -> None: + data = { + "autonomy": { + "default_mode": "auto_review", + "auto_review_thresholds": { + "max_estimated_minutes": 45, + "max_expected_files_touched": 5, + "destructive_ops": "pause", + "external_side_effects": "allow_pr_artifacts", + "auth_config_or_secrets": "pause", + "dependency_changes": "pause", + "public_visibility": "pause", + "git_push_or_deploy": "pause", + }, + "private_repo_allowlist": ["example-org/private-repo"], + } + } + self.assertEqual(validate_autonomy_config_data(data), []) + + def test_rejects_pr_artifact_policy_on_other_thresholds(self) -> None: + data = { + "autonomy": { + "default_mode": "auto_review", + "auto_review_thresholds": { + "max_estimated_minutes": 45, + "max_expected_files_touched": 5, + "destructive_ops": "allow_pr_artifacts", + "external_side_effects": "allow_pr_artifacts", + "auth_config_or_secrets": "pause", + "dependency_changes": "pause", + "public_visibility": "pause", + "git_push_or_deploy": "pause", + }, + } + } + errors = validate_autonomy_config_data(data) + self.assertTrue(any("destructive_ops" in error for error in errors)) + + def test_rejects_malformed_private_repo_allowlist(self) -> None: + data = { + "autonomy": { + "default_mode": "always_pause", + "private_repo_allowlist": ["missing-owner", 42], + } + } + errors = validate_autonomy_config_data(data) + self.assertEqual( + sum("private_repo_allowlist" in error for error in errors), + 2, + ) + def test_rejects_trusted_senders(self) -> None: data = { "autonomy": { @@ -636,6 +688,38 @@ def runner(command: Sequence[str]) -> Tuple[int, str]: self.assertEqual(results["tracked-allowlist"].severity, Severity.warn) self.assertNotIn("agents-tracked", results) + def test_memory_sync_adapts_legacy_runner_for_network_timeout(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + _write(root / ".oacp-memory-repo", "marker\n") + _write(root / ".gitignore", CANONICAL_MEMORY_GITIGNORE) + calls = [] + + def runner(command: Sequence[str]) -> Tuple[int, str]: + calls.append(list(command)) + args = list(command[1:]) + if args == ["rev-parse", "--is-inside-work-tree"]: + return 0, "true" + if args == ["status", "--porcelain"]: + return 0, "" + if args == ["remote"]: + return 0, "origin" + if args == ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]: + return 0, "origin/main" + if args == ["fetch", "--quiet"]: + return 0, "" + if args == ["rev-list", "--left-right", "--count", "HEAD...origin/main"]: + return 0, "0 0" + if args == ["rev-parse", "--verify", "HEAD"]: + return 1, "" + if args[:1] == ["ls-files"]: + return 0, "" + return 0, "" + + check_memory_sync(root, runner=runner) + + self.assertIn(["git", "fetch", "--quiet"], calls) + class TestApplyFixes(unittest.TestCase): """Tests for the --fix path.""" diff --git a/tests/test_record_autonomy_outcome.py b/tests/test_record_autonomy_outcome.py new file mode 100644 index 0000000..678618e --- /dev/null +++ b/tests/test_record_autonomy_outcome.py @@ -0,0 +1,288 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Tests for structured autonomy approval and decline outcomes.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import record_autonomy_outcome as outcome_recorder # noqa: E402 +from record_autonomy_outcome import ( # noqa: E402 + build_human_outcome, + main, + record_human_outcome, +) + + +SCOPE = { + "max_actual_minutes": 30, + "max_actual_files_touched": 3, + "creates_or_updates_pr": True, + "comments_on_github": True, + "commits_changes": False, +} + + +def _audit(*, requested_grant: bool = False) -> Dict[str, Any]: + profile: Dict[str, Any] = {} + if requested_grant: + profile["continuation_grants"] = { + "approved_thread_continuation": {"scope": dict(SCOPE)} + } + return { + "schema_version": 1, + "created_at_utc": "2026-07-11T01:00:00Z", + "receiver": "codex", + "message_id": "msg-20260711010000-iris-test", + "decision": "paused", + "reason_codes": ["expected_files_touched_exceeds_threshold"], + "task_profile": profile, + "result": {"final_state": "paused"}, + } + + +def test_records_task_approval_latency_without_grant() -> None: + outcome = build_human_outcome( + _audit(), + decision="approved", + decided_at_utc="2026-07-11T01:02:05Z", + ) + + assert outcome["decision"] == "approved" + assert outcome["decision_latency_seconds"] == 125 + assert outcome["pause_reason_codes"] == [ + "expected_files_touched_exceeds_threshold" + ] + assert outcome["grant"]["decision"] == "not_requested" + assert outcome["grant"]["request_present"] is False + assert outcome["grant"]["request_error"] is None + assert outcome["grant"]["granted_scope"] is None + + +def test_approved_grant_uses_requested_scope() -> None: + updated = record_human_outcome( + _audit(requested_grant=True), + decision="approved", + grant_decision="approved", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + assert updated["schema_version"] == 2 + assert updated["conversation_id"] is None + assert updated["parent_message_id"] is None + grant = updated["result"]["human_outcome"]["grant"] + assert grant["requested_scope"] == SCOPE + assert grant["granted_scope"] == SCOPE + + +def test_malformed_grant_request_does_not_block_task_decline( + tmp_path: Path, +) -> None: + audit = _audit(requested_grant=True) + request = audit["task_profile"]["continuation_grants"][ + "approved_thread_continuation" + ] + del request["scope"]["max_actual_minutes"] + audit_path = tmp_path / "audit.yaml" + audit_path.write_text( + yaml.safe_dump(audit, sort_keys=False), + encoding="utf-8", + ) + + code = main([ + str(audit_path), + "--decision", + "declined", + "--decided-at", + "2026-07-11T01:01:00Z", + ]) + + assert code == 0 + updated = yaml.safe_load(audit_path.read_text(encoding="utf-8")) + outcome = updated["result"]["human_outcome"] + grant = outcome["grant"] + assert grant["decision"] == "not_requested" + assert grant["request_present"] is True + assert grant["request_error"] == "max_actual_minutes_invalid" + assert grant["requested_scope"] is None + + +def test_malformed_grant_request_requires_scope_for_approval() -> None: + audit = _audit(requested_grant=True) + request = audit["task_profile"]["continuation_grants"][ + "approved_thread_continuation" + ] + del request["scope"]["max_actual_minutes"] + + with pytest.raises( + ValueError, + match="approved grant requires a valid scope", + ): + build_human_outcome( + audit, + decision="approved", + grant_decision="approved", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + +def test_modified_grant_requires_explicit_scope() -> None: + with pytest.raises(ValueError, match="requires --grant-scope-file"): + build_human_outcome( + _audit(requested_grant=True), + decision="modified", + grant_decision="modified", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + +def test_grant_request_requires_explicit_grant_decision() -> None: + with pytest.raises(ValueError, match="explicit --grant-decision"): + build_human_outcome( + _audit(requested_grant=True), + decision="approved", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + +def test_declined_task_cannot_approve_grant() -> None: + with pytest.raises(ValueError, match="declined task"): + build_human_outcome( + _audit(requested_grant=True), + decision="declined", + grant_decision="approved", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + +def test_rejects_unknown_audit_schema() -> None: + audit = _audit() + audit["schema_version"] = 99 + + with pytest.raises(ValueError, match="schema_version"): + record_human_outcome( + audit, + decision="approved", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + +def test_refuses_to_overwrite_recorded_outcome_without_replace() -> None: + updated = record_human_outcome( + _audit(), + decision="approved", + decided_at_utc="2026-07-11T01:01:00Z", + ) + + with pytest.raises(ValueError, match="already has a recorded human outcome"): + record_human_outcome( + updated, + decision="declined", + decided_at_utc="2026-07-11T01:02:00Z", + ) + + replaced = record_human_outcome( + updated, + replace=True, + decision="declined", + decided_at_utc="2026-07-11T01:02:00Z", + ) + assert replaced["result"]["human_outcome"]["decision"] == "declined" + + +def test_cli_atomically_updates_audit(tmp_path: Path) -> None: + audit_path = tmp_path / "audit.yaml" + audit_path.write_text( + yaml.safe_dump(_audit(), sort_keys=False), + encoding="utf-8", + ) + + code = main([ + str(audit_path), + "--decision", + "declined", + "--grant-decision", + "denied", + "--decided-at", + "2026-07-11T01:03:00Z", + "--json", + ]) + + assert code == 0 + updated = yaml.safe_load(audit_path.read_text(encoding="utf-8")) + assert updated["schema_version"] == 2 + outcome = updated["result"]["human_outcome"] + assert outcome["decision"] == "declined" + assert outcome["grant"]["decision"] == "denied" + assert not list(tmp_path.glob("*.tmp")) + + +def test_cli_dry_run_does_not_modify_audit(tmp_path: Path) -> None: + audit_path = tmp_path / "audit.yaml" + original = yaml.safe_dump(_audit(), sort_keys=False) + audit_path.write_text(original, encoding="utf-8") + + code = main([ + str(audit_path), + "--decision", + "approved", + "--decided-at", + "2026-07-11T01:01:00Z", + "--dry-run", + ]) + + assert code == 0 + assert audit_path.read_text(encoding="utf-8") == original + + +def test_cli_locks_the_read_modify_write_sequence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + audit_path = tmp_path / "audit.yaml" + audit_path.write_text( + yaml.safe_dump(_audit(), sort_keys=False), + encoding="utf-8", + ) + lock_state = {"held": False} + real_load = outcome_recorder._load_mapping + real_write = outcome_recorder._atomic_write_yaml + + def fake_flock(_fd: int, operation: int) -> None: + if operation == outcome_recorder.fcntl.LOCK_EX: + assert lock_state["held"] is False + lock_state["held"] = True + else: + assert operation == outcome_recorder.fcntl.LOCK_UN + assert lock_state["held"] is True + lock_state["held"] = False + + def checked_load(path: Path) -> Dict[str, Any]: + assert lock_state["held"] is True + return real_load(path) + + def checked_write(path: Path, data: Dict[str, Any]) -> None: + assert lock_state["held"] is True + real_write(path, data) + + monkeypatch.setattr(outcome_recorder.fcntl, "flock", fake_flock) + monkeypatch.setattr(outcome_recorder, "_load_mapping", checked_load) + monkeypatch.setattr(outcome_recorder, "_atomic_write_yaml", checked_write) + + code = main([ + str(audit_path), + "--decision", + "approved", + "--decided-at", + "2026-07-11T01:01:00Z", + ]) + + assert code == 0 + assert lock_state["held"] is False diff --git a/tests/test_setup_runtime.py b/tests/test_setup_runtime.py index 8230b19..c3c30a0 100644 --- a/tests/test_setup_runtime.py +++ b/tests/test_setup_runtime.py @@ -44,12 +44,34 @@ def test_claude_creates_agent_file_and_skills_dir(self) -> None: settings = json.loads(settings_file.read_text(encoding="utf-8")) self.assertIn("SessionStart", settings["hooks"]) self.assertIn("SessionEnd", settings["hooks"]) + self.assertIn("PreToolUse", settings["hooks"]) + envelope_entry = settings["hooks"]["PreToolUse"][0] + self.assertEqual(envelope_entry["matcher"], "Bash|Edit|Write|NotebookEdit") + self.assertEqual( + envelope_entry["hooks"][0]["command"], "oacp-envelope-hook" + ) self.assertIn(".claude/agents/myproj.md", result["created_files"]) self.assertIn(".claude/skills/", result["created_files"]) self.assertIn(".claude/hooks/oacp-memory-pull.sh", result["created_files"]) self.assertIn(".claude/hooks/oacp-memory-push.sh", result["created_files"]) self.assertIn(".claude/settings.json", result["created_files"]) + def test_claude_envelope_hook_registration_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) + setup_runtime("claude", repo_dir=repo_dir, project_name="myproj") + setup_runtime("claude", repo_dir=repo_dir, project_name="myproj") + + settings_file = repo_dir / ".claude" / "settings.json" + settings = json.loads(settings_file.read_text(encoding="utf-8")) + envelope_entries = [ + entry + for entry in settings["hooks"]["PreToolUse"] + for hook in entry.get("hooks", []) + if hook.get("command") == "oacp-envelope-hook" + ] + self.assertEqual(len(envelope_entries), 1) + def test_codex_creates_agents_md(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) diff --git a/tests/test_validate_message.py b/tests/test_validate_message.py index aa979c4..b8a8a70 100644 --- a/tests/test_validate_message.py +++ b/tests/test_validate_message.py @@ -136,6 +136,57 @@ def test_autonomy_hint_must_be_scalar(self): self.assertTrue(any("autonomy_hint" in e for e in errors)) +class TestReviewTelemetryFields(unittest.TestCase): + def test_all_six_fields_are_accepted_for_review_messages(self): + msg = _base_msg( + type="review_lgtm", + model="gpt-5", + turns=3, + input_tokens=1200, + output_tokens=450, + wall_time_s=12.5, + est_cost_usd=0.42, + ) + self.assertEqual(validate_message_dict(msg), []) + + def test_numeric_strings_from_yaml_base_loader_are_accepted(self): + msg = _base_msg( + type="review_feedback", + model="claude-opus", + turns="2", + input_tokens="1000", + output_tokens="250", + wall_time_s="8.75", + est_cost_usd="0.10", + ) + self.assertEqual(validate_message_dict(msg), []) + + def test_telemetry_is_rejected_outside_review_loop(self): + errors = validate_message_dict(_base_msg(model="gpt-5")) + self.assertTrue(any("review telemetry fields" in error for error in errors)) + + def test_invalid_telemetry_types_are_rejected(self): + msg = _base_msg( + type="review_addressed", + model="", + turns=-1, + input_tokens="many", + output_tokens=True, + wall_time_s="NaN", + est_cost_usd=-0.01, + ) + errors = validate_message_dict(msg) + for field in ( + "model", + "turns", + "input_tokens", + "output_tokens", + "wall_time_s", + "est_cost_usd", + ): + self.assertTrue(any(field in error for error in errors), field) + + class TestBackwardCompatStringTo(unittest.TestCase): def test_backward_compat_string_to(self): """v1 messages with string 'to' field must still validate."""