Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.1] - 2026-08-04

### Fixed

- Receiver configs that omit the `autonomy` block, such as signing-only
configs, now preserve the same conservative `always_pause` behavior as an
absent config file. Doctor reports the config as valid, and the autonomy
evaluator records the normal `admission_paused` / `mode_always_pause`
fingerprint instead of treating the config as malformed.
- The Claude envelope hook now splits compound Bash commands only at shell
operators outside quoted arguments and command substitutions, so read-only
patterns containing literal pipes and backslash-newline continuations no
longer produce false escalation while later mutating segments remain gated.
Its conservative parser fallback recognizes only a small, redirect-free
read-only command set. Every emitted ask or deny reason now starts with the
stable `[oacp-envelope]` source tag and includes the active task message id
when available, making hook prompts distinguishable from harness prompts.
- Envelope enforcement is now session-scoped. The compiled envelope
records the harness session that compiled it (via a hook-observed
session claim consumed by the compiler), and the Claude adapter
no-ops for tool calls from any other session — a concurrent
interactive session in the same repository no longer inherits a
dispatched task's constraints or consumes its `files_touched`
budget. Envelopes compiled without a resolvable session (un-hooked
runtimes, or callers the harness gave no session id) stay unbound
and keep the historical whole-workspace enforcement scope, and
same-task recompiles — including a post-re-authorization
`--extend` from outside the bound session — inherit the existing
binding.
- The autonomy gate evaluator now persists complete, lock-serialized
audit records to the `--audit-dir` destination; previously an
evaluation could finish without writing the durable record.
- `oacp doctor` now reports trust-pin completeness per receiver and
gates enforce-mode signing readiness on it: every catalog identity
must carry an active pin before a receiver flips to enforce, with
gaps broken down per receiver in doctor output.

## [0.4.0] - 2026-07-17

### Added
Expand Down
65 changes: 35 additions & 30 deletions docs/guides/runtime_capability_matrix.md

Large diffs are not rendered by default.

68 changes: 55 additions & 13 deletions docs/protocol/autonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ autonomy:
enabled: false
```

When config is absent, receivers behave as `always_pause`. Malformed config
causes a pause and should be surfaced by `oacp doctor`.
When config is absent, receivers behave as `always_pause`. A present config
that omits the `autonomy` block entirely, such as a signing-only config,
resolves the same way: `always_pause`, not `config_malformed`. An explicit but
malformed `autonomy` value still causes a pause and should be surfaced by
`oacp doctor`.

`external_side_effects` accepts three policy actions:

Expand Down Expand Up @@ -222,7 +225,7 @@ Every autonomy decision writes one YAML file:

```yaml
schema_version: 2
spec_version: "0.4.0"
spec_version: "0.4.1"
created_at_utc: "2026-05-12T13:23:25Z"
receiver: codex
sender: iris
Expand Down Expand Up @@ -313,9 +316,12 @@ missing or malformed config. `sender` is normally traceability metadata and
also binds an enabled standing grant to the sender that received approval.
`policy_sha256` is the SHA-256 of a canonical, key-sorted serialization of the
parsed policy, so comments and YAML formatting do not produce false drift.
`spec_version: "0.4.0"` pins Gate 1 integrity enforcement plus the recalibrated
Gate 2/3 policy, full task-profile capture, explicit `breached` list, and the
outcome block shown above. Audit `schema_version: 2` adds thread identity and
`spec_version: "0.4.1"` pins everything 0.4.0 pinned — Gate 1 integrity
enforcement, the recalibrated Gate 2/3 policy, full task-profile capture, the
explicit `breached` list, and the outcome block shown above — plus
session-scoped envelope enforcement, the enforce-mode trust-pin completeness
gate, and preserved `always_pause` defaults for configs without an `autonomy`
block. Audit `schema_version: 2` adds thread identity and
the structured `result.human_outcome` block. Recorders may upgrade a v1 audit
to v2 when the first human outcome is written; standing grants trust only v2
records.
Expand Down Expand Up @@ -623,7 +629,7 @@ The envelope is written to
```json
{
"envelope_version": 1,
"spec_version": "0.4.0",
"spec_version": "0.4.1",
"compiler": "envelope_compiler.py",
"compiled_at_utc": "2026-07-12T02:00:00Z",
"project": "my-project",
Expand All @@ -649,7 +655,8 @@ The envelope is written to
"private_repo_allowlist": ["example-org/private-repo"]
},
"counters": {"files_touched": []},
"enforcement": "hooks"
"enforcement": "hooks",
"session_id": "sess-…"
}
```

Expand All @@ -664,6 +671,18 @@ Compilation rules:
runtime enforcement never trusts sender declarations alone.
- Granular side-effect fields absent from a legacy profile compile to
`false`. `counters` are runtime state and always start empty.
- **Session binding.** The envelope records which harness session it
belongs to. The runtime hook — the only party that sees the harness
session id (it is not present in the command environment) — records a
short-lived session claim when it observes the compile command; the
compiler consumes the claim (matching it against the message file it
actually compiles, within a freshness window) and stamps `session_id`.
A recompile for the same task — including a post-re-authorization
`--extend` run from outside the bound session — inherits the existing
binding. When no valid claim exists (an un-hooked runtime compiled, or
the harness supplied no session id) the envelope compiles unbound
(`session_id: null`) and enforcement keeps the historical
(project, agent) scope for every session.

### Delivery: static shim, dynamic envelope

Expand All @@ -678,6 +697,16 @@ completion clear against the task's audit record (see below), so the
enforcement window can be exited from inside the session exactly once the
task lifecycle is over.

Enforcement is **session-scoped** when the envelope carries a session
binding: a tool call from a different harness session gets pre-envelope
behavior — no classification, no `files_touched` accounting — so a
concurrent interactive session in the same repository neither inherits the
dispatched task's constraints nor consumes its file budget. The scope never
silently narrows: an unbound envelope enforces every session in the
(project, agent) workspace exactly as before, and a caller the harness gave
no session id is enforced even under a bound envelope (it cannot be proven
foreign).

Runtime decisions:

- **deny** — the call breaches a declared-false capability (destructive
Expand Down Expand Up @@ -715,6 +744,18 @@ Runtime decisions:
review; unenforceable never silently degrades to allowed.
- **allow** — emitted as *no output*: the envelope can only narrow the
harness's own permission surface, never widen or bypass it.
- Every emitted **ask** or **deny** reason starts with the stable
`[oacp-envelope]` source tag. When an active envelope supplies a message id,
the reason also includes `[task <message-id>]`; operators can therefore
distinguish an envelope decision from the harness's native permission
prompt and identify the task that caused it.
- Compound Bash classification is quote-aware: separators inside quoted
arguments remain argument data, backslash-newline continuations stay in the
same segment, and top-level or command-substitution segments are still
inspected independently so a read-only head cannot conceal a later
mutation. If argv parsing degrades, only a small known read-only command set
with no shell control, redirection, expansion, or substitution syntax can
pass; everything else escalates to **ask**.
- `oacp send` is never denied; it is the checkpoint notification pipe. The
exemption is exactly that wide: read-only oacp subcommands pass, all other
oacp mutations are classified.
Expand Down Expand Up @@ -784,11 +825,12 @@ unconditionally — completion sanctions the exit, never recompilation.
### Envelope drift

A tool call that would exceed `expected_files_touched` is denied with the
canonical checkpoint opener (`Blocked: autonomy threshold exceeded —
files_touched expected N, now M`), which forces the Threshold-Exceeded
Checkpoint protocol above: the deny fires once, the session stops, notifies
the sender, and awaits re-authorization. A revised profile is recompiled
with `oacp envelope compile --extend`, which preserves accumulated counters.
canonical tagged checkpoint opener (`[oacp-envelope] Blocked: autonomy
threshold exceeded — files_touched expected N, now M`), followed by the
active task id when available. This forces the Threshold-Exceeded Checkpoint
protocol above: the deny fires once, the session stops, notifies the sender,
and awaits re-authorization. A revised profile is recompiled with `oacp
envelope compile --extend`, which preserves accumulated counters.

### Enforcement recording

Expand Down
31 changes: 28 additions & 3 deletions docs/protocol/message_signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,38 @@ outcome — `unsigned`, `signed-verified`, `signed-unknown-kid`,
`message_auth` audit block; none of them rejects, quarantines-as-rejection,
or changes how a message is processed. A verified signature is a recorded
fact about who signed, not a permission. Enforce mode (rejection, receipt
ledger, quarantine activation) is **v0.4.1**, activated only after a warn
soak on live fleet traffic; the seams exist in v0.4.0 and none activate.
ledger, quarantine activation) lands in **a later release**, activated only
after a warn soak on live fleet traffic and an explicit enforcement ruling;
the seams exist in v0.4.0 and none activate.

Receivers opt in per-agent via `signing.verify_mode: off | warn` in
`agents/<receiver>/config.yaml`. An early `enforce` value degrades to
`warn`; anything else degrades to `off`.

## Enforce-mode preparation

Before any receiver flips to `signing.verify_mode: enforce`, run a fleet-wide
re-pin sweep and then `oacp doctor --project <name>`. Every receiver must pin
every peer identity in the project catalog (a receiver's own catalog identity
is exempt), and every active receiver pin must refer to an identity recorded
in the catalog. Revoked pins remain as audit history and are excluded from the
pin-to-catalog completeness direction. Re-import the applicable public stub
for each missing relationship:

```bash
oacp trust import /path/to/<kid>.pub.json --project <name> --agent <receiver>
oacp doctor --project <name>
```

Doctor reports one aggregate `pin completeness` result with counts in both
directions across all receiver profiles. Gaps are warnings while the project
remains in warn mode. If any receiver explicitly configures enforce mode, the
same aggregate result becomes a blocking error so a missing pin cannot turn
into silent message rejection after the flip. Unlike the advisory
`catalog-not-pinned` drift signal, pre-enforce completeness intentionally has
no liveness exemption: every cataloged peer relationship must be ready before
any profile enforces.

## Trust root

Two files, deliberately asymmetric in authority:
Expand Down Expand Up @@ -182,7 +207,7 @@ telemetry only.
attests to a key, and key custody on-machine is only as strong as the
file permissions. This is an accepted, documented limitation of the
warn-mode rollout.
- **Keystore hardening is planned follow-up work** for v0.4.1+: OS
- **Keystore hardening is planned follow-up work** for a later release: OS
keychain / vault-backed signer backends behind the same `kid` seam.

## Rotation and revocation
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "oacp-cli"
version = "0.4.0"
version = "0.4.1"
description = "Open Agent Coordination Protocol CLI for file-based multi-agent workflows"
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion scripts/_oacp_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# The protocol spec version the tooling implements. Stamped into audit
# records, compiled envelopes, and workspace.json at init so every artifact
# names the contract it was produced under.
SPEC_VERSION = "0.4.0"
SPEC_VERSION = "0.4.1"
CREATABLE_RUNTIMES = ("claude", "codex", "cursor", "gemini")
ALL_RUNTIMES = ("claude", "codex", "cursor", "gemini", "human", "unknown")
CANONICAL_CAPABILITIES = {
Expand Down
111 changes: 99 additions & 12 deletions scripts/autonomy_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@
import datetime as dt
import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict, FrozenSet, List, Optional, Sequence, Tuple

import yaml

from _oacp_constants import REPO_SLUG_RE, SPEC_VERSION, utc_now_iso
from _oacp_constants import REPO_SLUG_RE, SPEC_VERSION, locked_audit, utc_now_iso
from validate_message import validate_message_dict


Expand Down Expand Up @@ -249,9 +251,81 @@ def load_yaml_file(path: Path) -> Dict[str, Any]:
return data


def write_audit_record(
audit_dir: Path,
decision: Dict[str, Any],
*,
config: Dict[str, Any],
message: Dict[str, Any],
message_path: Path,
policy_path: Path,
receiver: str,
now_utc: Optional[dt.datetime] = None,
) -> Path:
"""Persist a documented audit event without mutating evaluator stdout.

The evaluator's result block is admission-time state. Receivers still own
terminal result updates, human outcomes, and message-auth attachment.
"""
audit_dir.mkdir(parents=True, exist_ok=True)
created_at = utc_now_iso(now_utc)
autonomy = config.get("autonomy")
raw_thresholds = (
autonomy.get("auto_review_thresholds")
if isinstance(autonomy, dict)
else None
)
thresholds = {
key: raw_thresholds.get(key) if isinstance(raw_thresholds, dict) else None
for key in NUMERIC_THRESHOLD_KEYS
}
audit_record = dict(decision)
audit_record.setdefault("created_at_utc", created_at)
audit_record.setdefault("message_subject", message.get("subject"))
audit_record.setdefault("message_path", str(message_path))
audit_record.setdefault("policy_path", str(policy_path))
audit_record.setdefault("thresholds", thresholds)
audit_record.setdefault("runtime", {"agent": receiver, "model": None})

message_id = str(decision.get("message_id") or "missing-message-id")
safe_message_id = re.sub(r"[^A-Za-z0-9._-]", "_", message_id).strip("._")
safe_message_id = safe_message_id[:200] or "missing-message-id"
stamp = created_at.replace(":", "").replace("-", "")
audit_path = audit_dir / f"{stamp}_{safe_message_id}.yaml"
content = yaml.safe_dump(audit_record, sort_keys=False, allow_unicode=True)
temp_path: Optional[Path] = None

with locked_audit(audit_path):
if audit_path.exists():
raise FileExistsError(f"audit record already exists: {audit_path}")
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=audit_dir,
prefix=f".{audit_path.name}.",
suffix=".tmp",
delete=False,
) as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
temp_path = Path(handle.name)
os.chmod(temp_path, 0o600)
os.replace(temp_path, audit_path)
finally:
if temp_path is not None and temp_path.exists():
temp_path.unlink()

return audit_path


def validate_receiver_config(config: Dict[str, Any]) -> List[str]:
errors: List[str] = []
autonomy = config.get("autonomy")
if "autonomy" not in config:
return []

autonomy = config["autonomy"]
if not isinstance(autonomy, dict):
return ["field 'autonomy' must be a mapping"]

Expand Down Expand Up @@ -310,7 +384,8 @@ def receiver_policy(config: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
if errors:
raise AutonomyConfigError("; ".join(errors))

autonomy = config["autonomy"]
autonomy = config.get("autonomy", {"default_mode": "always_pause"})
assert isinstance(autonomy, dict) # Guaranteed by validation above.
thresholds = autonomy.get("auto_review_thresholds") or {}
continuation = autonomy.get("continuation_grants") or {}
return str(autonomy.get("default_mode")), {
Expand Down Expand Up @@ -1580,17 +1655,29 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
config = load_yaml_file(args.config)
message = load_yaml_file(args.message)
actuals = load_yaml_file(args.actuals) if args.actuals else None
print(json.dumps(
evaluate_autonomy(
message,
config,
actuals,
decision = evaluate_autonomy(
message,
config,
actuals,
message_path=args.message,
audit_dir=args.audit_dir,
receiver=args.receiver,
)
if args.audit_dir is not None and decision.get("reason_codes") != [
"message_replayed"
]:
write_audit_record(
args.audit_dir,
decision,
config=config,
message=message,
message_path=args.message,
audit_dir=args.audit_dir,
policy_path=args.config,
receiver=args.receiver,
),
indent=2,
))
)
elif args.audit_dir is not None:
print("NOTE: replay detected; audit record not written", file=sys.stderr)
print(json.dumps(decision, indent=2))
return 0
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
Expand Down
Loading