diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3974e51d..bd4f3867 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -80,3 +80,18 @@ updates: # with agt_ref as one deliberate change, so a Dependabot bump of the SDK # alone would always go red in CI. - dependency-name: "agent-control-specification*" + + # The adapters are their own uv project, with their own lockfile, so the docs + # build never resolves NAT's dependency tree. + - package-ecosystem: "uv" + directory: "/adapters" + schedule: + interval: "weekly" + day: "monday" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + labels: + - "dependencies" diff --git a/.github/workflows/adapter_tests.yml b/.github/workflows/adapter_tests.yml new file mode 100644 index 00000000..abff7f20 --- /dev/null +++ b/.github/workflows/adapter_tests.yml @@ -0,0 +1,124 @@ +name: adapter-tests + +# Runs the adapter and conformance suites, and checks the built package, +# on every push or PR that touches adapter or spec source. + +on: + push: + paths: + - 'adapters/**' + - 'specification/**' + - 'docs/spec/**' + - '.github/workflows/adapter_tests.yml' + pull_request: + paths: + - 'adapters/**' + - 'specification/**' + - 'docs/spec/**' + - '.github/workflows/adapter_tests.yml' + +permissions: + contents: read + +defaults: + run: + # Explicit bash turns on pipefail, so a failing command in a pipeline fails the step. + shell: bash + +jobs: + dco: + # Every commit needs a Signed-off-by trailer (the DCO in CONTRIBUTING.md). + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Check Signed-off-by on every PR commit + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + missing=0 + while read -r sha; do + if ! git log -1 --format=%B "$sha" | grep -qi '^Signed-off-by: .*<.*@.*>'; then + echo "::error::commit $sha has no Signed-off-by trailer (CONTRIBUTING.md DCO)" + missing=1 + fi + done < <(git rev-list "$BASE_SHA..$HEAD_SHA" --no-merges) + exit $missing + + tests: + # Every suite, with the NAT extra installed so NAT tests run. run_conformance.py + # reports passed, skipped and failed separately and fails on any skip outside + # its allowlist (only the manual Cursor procedure). + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: adapters + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.9.9" + - name: Install the adapters, the NAT extra and the test group from the lockfile + run: uv sync --locked --python 3.12 --extra nat --group test + - name: Run every suite + run: uv run python run_conformance.py claude cursor nat + + tests-without-nat: + # The documented setup for Claude Code and Cursor users, who never install NAT. + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: adapters + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.9.9" + - name: Install the adapters and the test group, without the NAT extra + run: uv sync --locked --python 3.12 --group test + - name: Run the shared and Claude Code and Cursor suites + run: uv run python run_conformance.py claude cursor + - name: Check that imports flow integrations, client, core + run: uv run lint-imports + + package: + # Builds the sdist and a wheel from it, then installs the wheel outside the + # repository and checks the console scripts, entry points, bundled schemas + # and NAT discovery that users depend on. + # Runs on the oldest supported Python as well as the one the tests use. + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + env: + PYTHON: ${{ matrix.python }} + defaults: + run: + working-directory: adapters + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.9.9" + - name: Build the sdist and a wheel from it + run: uv build --python "$PYTHON" -o dist + - name: Install the wheel outside the repository and check it + run: uv run --no-project --python "$PYTHON" python scripts/check_wheel.py dist/acs_adapters-*-py3-none-any.whl diff --git a/.gitignore b/.gitignore index 45725bbf..ebe83900 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ references/ # Python build artifacts from `uv pip install -e .` *.egg-info/ __pycache__/ +**/__pycache__/ +*.pyc # Subagent-driven development scratch. Never committed. .superpowers/ @@ -15,3 +17,14 @@ _site/ # Browser session logs. Never committed. .playwright-mcp/ + +# Adapter manual-test scratch dirs (project-level hook configs with +# machine-specific paths from local probing — must never land in commits) +adapters/*/.cursor/ +adapters/*/.claude/ +adapters/*/.acs-handshake-cache/ + +# Python venvs (NAT manual test environment lives here; the path is +# tied to the machine that built it and must not ship) +adapters/*/.venv/ +adapters/*/.nat-venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8288239c..aa5a3693 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,6 +110,16 @@ The guards in `tests/` check things the build itself will not catch, including s Put new guards in `tests/`. Collection is scoped there by `testpaths` in `pyproject.toml`, so a test written anywhere else never runs in CI and will pass review looking like coverage it does not provide. A suite that needs dependencies outside `uv.lock` belongs in its own workflow with its own environment, because the deploy gate installs only what the lockfile carries. +For changes under `adapters/` (the reference adapters, example Guardian, and their test suites), run the adapter conformance gate before opening a PR — it lives in its own workflow (`.github/workflows/adapter_tests.yml`) with its own environment, per the rule above: + +```bash +cd adapters # its own uv project and lockfile +uv sync --locked --extra nat --group test +uv run python run_conformance.py claude cursor nat # shared checks + those adapter suites +``` + +Without the NAT runtime, sync without `--extra nat` and leave `nat` off the command. The command reports passes, skips, and failures separately, and fails on any failure or unexpected skip. The same workflow also builds the package and checks the installed wheel outside the repository (`adapters/scripts/check_wheel.py`). See `adapters/README.md` for what the suite proves and its scope. + For prose contributions, follow the [editorial style guide](./STYLE.md). For schema contributions, validate `specification/v0.1.0/acs_schema.json` against the JSON Schema spec before submitting. All submissions go through GitHub pull request review. See [GitHub's PR guide](https://docs.github.com/en/pull-requests) if you're new to the workflow. diff --git a/adapters/.gitignore b/adapters/.gitignore new file mode 100644 index 00000000..3bbe7b6f --- /dev/null +++ b/adapters/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/adapters/README.md b/adapters/README.md new file mode 100644 index 00000000..cfbba337 --- /dev/null +++ b/adapters/README.md @@ -0,0 +1,256 @@ +# ACS Adapters + +Reference implementations that wire popular agent frameworks to an ACS Guardian. The goal: a framework adopts ACS through **configuration only**, with no agent code changes. + +## What is a Guardian? + +The **Guardian** is the policy enforcement point: a long-running HTTP service that receives every ACS envelope from the adapter, evaluates it against the deployment's policy, and returns one of five dispositions (allow / deny / modify / ask / defer). It's the "decider"; the adapter is the "messenger." + +Two roles in any deployment: + +- **Production Guardian** — your real policy engine. Typically OPA/Rego, Cedar, or a vendor SDK plugged in behind an HTTP server that speaks the ACS wire protocol. The adapter doesn't care what's inside; only the wire contract matters. +- **`acs.guardian.example_guardian`** (the `acs-example-guardian` command) in this repo — a teaching artifact and test substrate. Implements the full wire protocol (handshake, envelope schema validation, HMAC signing, rolling chain, replay protection, skew rejection, dispositions, `system/ping`) but with a deliberately tiny deterministic policy: denies a short list of destructive shell commands, writes to system paths, subagent spawns (unless `ACS_ALLOW_SUBAGENT=1`) and unknown methods, and allows the rest. Useful for local testing; **not for production**. + +Production deployments swap the policy code in the example Guardian's `evaluate_step(method, params, request_id, chain_hash)` function with their real engine and keep the wire-protocol scaffolding. + +Running the Guardian — terminal window, `launchd`, `systemd`, container — is the operator's responsibility. The adapter expects it to be reachable at `$ACS_GUARDIAN_URL`; if it isn't, the §6.4 fail posture applies and an `ACS_AUDIT` event is emitted. + +## ACS v0.1.0 emission-conformance check + +**What this proves — and what it does not.** This suite proves **emission conformance**: the reference adapters, driven through their real production entry points, emit schema-valid, signed ACS v0.1.0 Core traffic and honor the decisions it tests. It is **not** a requirement-by-requirement audit of every ACS-Core MUST — `docs/spec/conformance.md` and the sections it cites contain far more normative requirements (handshake/negotiation edge cases, full decision semantics, two-sided live payload validation, hook-completeness, Wrapped MCP) than are checked here. A full ACS-Core deployment-conformance claim also spans the Guardian, the framework wiring, and production configuration — not the adapter alone. That larger program is tracked in issue #33 (requirement ledger + behavioral tests); do not read a green run here as "ACS-Core conformant." + +Known limitations kept in view: Cursor's real-framework wiring is a manual GUI procedure, and Wrapped MCP is validated for namespace shape only, not full wrapping. ACS-Core requires `protocols/MCP/*` for any session that involves MCP and lets only MCP `tools/call` collapse into the generic tool hooks. These adapters perform that collapse and wrap no other MCP surface, so a session that uses MCP falls short of ACS-Core there. Issue #33 tracks the reference-stack side. The adapters also emit no Trace events and do not claim ACS-Trace: `ACS_AUDIT` lines are local audit records, not OpenTelemetry or OCSF output. + +One authoritative command runs the shared Guardian checks, shared-library tests, and the adapter suites you name. At least one platform is required; there is no implicit run-all default. Name one, two, or all three platforms in any order: + +```bash +cd adapters +uv sync --locked --group test # add --extra nat to run the NAT suite + +# One platform +uv run python run_conformance.py cursor + +# Two platforms +uv run python run_conformance.py claude cursor + +# All platforms. NAT needs the nat extra, or its skips count as +# unexpected and fail. +uv run python run_conformance.py claude cursor nat +``` + +`claude-code` is also accepted as an alias for `claude`. Repeating a platform does not run it twice. The Guardian, shared-library (`_common`), `core`, `client` and `package` checks run once for every command, regardless of the platform selection. + +It prints every skip by name + reason and fails on any failure **or any _unexpected_ skip** — a skip not on the exact allowlist. The allowlist tolerates only the manual Cursor GUI procedure and the live Claude Code tests, which call the real `claude` CLI and run only with `ACS_LIVE_CLAUDE=1`; a dependency-gated test (NAT when `nvidia-nat-core` is installed) that silently skips is a failure, never green. `--strict` additionally fails on the allowlisted skips too, for a fully-provisioned gate. + +Under the hood it drives two complementary layers: + +**Scope of the single-file Core suite:** `test_acs_core_conformance.py` drives the **claude-code and cursor** adapters end-to-end (its live-disposition and prompt-gate classes spawn those two subprocesses). **NAT is covered separately**: its conformance evidence lives in `adapters/tests/integrations/nat/` (emission, live dispositions, lifecycle, security, run against the real `nvidia-nat-core`) and runs in CI's `tests` job, which installs the NAT extra. Adding NAT to the single-file Core suite is a tracked follow-up: it needs in-process middleware harnessing where the suite today drives shell subprocesses. + +**1. Guardian checks** — `uv run python -m unittest tests.conformance.test_acs_core_conformance` exercises the Core requirements enumerated in `docs/spec/conformance.md`'s summary (handshake incl. forward-compat + malformed-ClientHello refusal, envelope shape, the minimum hook set, dispositions, rolling chain, replay + skew rejection, HMAC-SHA256 baseline incl. signed errors, decision honoring + fail-open audit + audit-cause differentiation) **plus reference-stack coverage of SHOULD/conditional items** (MODIFY, system/ping, the `protocols/MCP/*` namespace shape). These are the summary-level Core checks, **not** an enumeration of all normative MUST occurrences in the linked sections — the requirement-by-requirement ledger is tracked in issue #33. A deployment that legitimately omits a SHOULD/conditional item is not non-conformant for failing those specific tests. Each test docstring quotes the spec line it falsifies. A `CitationGuard` class checks that the ten ACS-Core bullet names the tests cite are still in `conformance.md`, in order, so a renamed, added or removed requirement turns the suite red; it does not compare wording, so when a requirement's text changes, a person re-reads the tests that cite it. + +**2. Emission conformance** — each adapter's `tests/integrations//test_emission.py` runs the **production adapter** (the real subprocess for Claude Code / Cursor; the real `pre_invoke` / `post_invoke` / lifecycle-observer middleware for NAT) against a validating **CaptureGuardian** and asserts the *exact bytes it sends* pass the canonical schemas. Two layers of checks: + +- *Per-event:* each Core method is emitted exactly once, envelope + payload validate against the canonical schemas, and per-envelope invariants hold — UUID `request_id`, RFC 3339 `timestamp`, `agent_id`/`session_id`/`platform` metadata, the `{value: …}` argument wrapper, and an **independently-recomputed** HMAC-SHA256 signature (a from-scratch HKDF+HMAC+JCS verifier in `acs/testing/capture_guardian.py`, *not* the adapters' own `acs.core` signing code, so a shared signing+verifying bug can't pass both sides; pinned by a frozen known-answer vector). +- *Per-session* (`*SequentialSession`, one Guardian + one handshake cache across an ordered sequence): the handshake fires exactly once, all `request_id`s are unique, `toolCallResult.request_id_ref` links back to its `toolCallRequest`, and — the real honesty check — the handshake's advertised `methods_implemented` **equals** the set of methods **actually emitted in the run** (equality, not just subset: catches over- *and* under-advertising), not merely methods that happen to have a schema. + +Breaking any adapter's method mapping, envelope/payload field, or signing turns the suite red against the schema files themselves. The oracle is proven non-vacuous by `tests/common/test_capture_guardian.py` (feed it broken envelopes, assert it flags each defect class + the signature KAT). Schemas load from the copy bundled in the package, or from `specification/v0.1.0/` in a source checkout (`ACS_SPEC_DIR` overrides); missing schemas are a hard FAIL. Format checking (`uuid`, `date-time`) is enforced via `rfc3339-validator`. + +**Where each adapter's emission is actually exercised:** Claude Code and Cursor run headless here and in CI (real subprocess). NAT's emission tests drive the real middleware and require `nvidia-nat-core`; they run in the CI `tests` job (which installs the NAT extra) and `skipUnless` it locally — a skip is never counted as a pass (`run_conformance.py nat` and the CI job both fail on an unexpected NAT skip). Cursor's *end-to-end framework* wiring (real Cursor GUI in the loop) remains a manual procedure in `docs/cursor/live_verification.md`; the emission suite proves the adapter, the manual test proves the wiring. + +**Wrapped MCP caveat.** The suite verifies the wire-format *shape* of `protocols/MCP/*` only (a harness-built envelope validates, the Guardian returns a structured response, no crash). Nothing here demonstrates real MCP **wrapping**: the adapters flatten MCP onto `steps/toolCallRequest`/`Result` (Cursor's `beforeMCPExecution`, Claude's `mcp__*` tools) rather than emitting the wrapped `protocols/MCP/*` form, and the reference Guardian does not implement intact wrapping, version negotiation, or request/result correlation for it. **Do not read any of this as "we wrap MCP."** ACS-Core states the requirement directly. Deployments whose sessions involve MCP at any point MUST implement `protocols/MCP/*`. MCP `tools/call` MAY instead flow through `steps/toolCallRequest` and `steps/toolCallResult` when tool-level policy suffices, but capability negotiation, prompt fetches, resource reads, and notifications MUST be wrapped. These adapters cover the `tools/call` case only: they flatten MCP tool calls onto `steps/toolCallRequest` and `steps/toolCallResult` and send nothing for the other MCP surfaces. A Claude Code or Cursor session that uses MCP therefore falls short of ACS-Core on those surfaces, a known limitation. Issue #33 tracks the reference-stack side. See `test_acs_core_conformance.py::Core10_WrappedMcp`. + +## How adapters work + +The adapters are **translators**. Each one speaks its framework's hook protocol on one side and ACS JSON-RPC on the other. The framework's agent code is untouched. The Guardian's policy code is untouched. The adapter is the bilingual layer between them. + +### The general pattern (same for all three adapters) + +For each event the framework fires: + +``` + framework adapter Guardian + │ │ │ + │ hook event (framework │ │ + │ native JSON / call) │ │ + │ ───────────────────────► │ │ + │ │ ACS JSON-RPC request │ + │ │ ──────────────────────► │ + │ │ │ evaluate + │ │ │ policy + │ │ ACS decision │ + │ │ ◄────────────────────── │ + │ decision (framework │ │ + │ native response shape) │ │ + │ ◄─────────────────────── │ │ + │ │ │ + ▼ ▼ ▼ + applies the appends + decision audit chain +``` + +Six steps: + +1. Framework fires its hook with a payload in its own format. +2. Adapter receives that payload, translates to an ACS JSON-RPC request. +3. Adapter POSTs to the Guardian endpoint. +4. Guardian evaluates against policy, returns an ACS decision (`allow` / `deny` / `modify` / `ask` / `defer`). +5. Adapter translates that decision back to whatever the framework expects to receive. +6. Framework applies the decision (run / block / modify the action). + +### Concrete walkthrough: Claude Code, ALLOW path + +You ask Claude Code to `echo hello`. + +For brevity, this walkthrough shows the envelope SHAPES and omits the +HMAC-SHA256 `signature` block on each envelope and the once-per-session +`handshake/hello` round-trip that precedes the first content-bearing +event. Both are present in real envelopes — run `uv run python tests/integrations/claude_code/e2e_check.py` +from `adapters/` to see verbatim envelopes including signatures. + +**Step 1.** Claude Code is about to call its Bash tool. Before it runs, Claude Code's hook system fires `PreToolUse`. Your `settings.json` configures `PreToolUse` to run `acs-claude-code-hook`. Claude Code spawns that process and pipes the event to stdin: + +```json +{ + "session_id": "abc-123", + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "echo hello"}, + "tool_use_id": "...", + "cwd": "/tmp/...", + "permission_mode": "default" +} +``` + +**Step 2.** The adapter reads that JSON, builds an ACS JSON-RPC request conforming to v0.1.0 `request-envelope.json` and `hooks/tool-call-request.json`: + +```json +{ + "jsonrpc": "2.0", + "id": "", + "method": "steps/toolCallRequest", + "params": { + "acs_version": "0.1.0", + "request_id": "", + "timestamp": "2026-06-17T12:34:56.789Z", + "metadata": { + "agent_id": "claude-code:a1b2c3d4", + "session_id": "abc-123", + "cwd": "/tmp/...", + "platform": "claude-code" + }, + "payload": { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "echo hello"}} + } + } +} +``` + +Notice the shape: `acs_version` / `request_id` / `timestamp` / `metadata` live inside `params`, not at the envelope root (the envelope schema's `additionalProperties: false` rejects unknown top-level keys). Each tool argument is wrapped as `{value: ...}` so ACS-Provenance can attach provenance per-argument without changing the schema. + +**Step 3.** The adapter POSTs to the Guardian endpoint (`http://127.0.0.1:8787/acs`). + +**Step 4.** The Guardian evaluates. Our example Guardian's deterministic policy: `echo hello` doesn't match the destructive-Bash regex. Returns a response conforming to `response-envelope.json`: + +```json +{ + "jsonrpc": "2.0", + "id": "", + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "", + "decision": "allow", + "chain_hash": "..." + } +} +``` + +**Step 5.** The adapter prints nothing. An allow leaves Claude Code's own permission rules in charge; a `permissionDecision` of `"allow"` would skip the user's permission prompt. + +**Step 6.** Claude Code applies its own permission rules. `echo` is one of its read-only commands, which run without a prompt, so the Bash tool runs and you see `hello` printed. + +**What this costs, measured honestly.** For the shell-spawned adapters (Claude Code, Cursor), the dominant cost is NOT the Guardian round-trip — it's the per-event Python process spawn: interpreter start plus imports. Measured on an M-series MacBook (warm cache, local Guardian): ~60–90 ms per hook event end-to-end, of which the Guardian's policy evaluation is single-digit milliseconds. Your numbers will differ with hardware and Python version; measure with `/usr/bin/time` before quoting any. The first event of a session additionally pays the `handshake/hello` round-trip (cached afterward). NAT's adapter runs in-process and pays no spawn cost — its overhead is the HTTP round-trip alone. + +**Degraded-Guardian behavior.** The first event of a session handshakes, and the answer decides the whole session (§4.1). A Guardian that refuses (`SESSION_REFUSED`, `UNSUPPORTED_VERSION`, `PROVENANCE_REQUIRED`) refuses every step of that session. When no answer arrives within `ACS_HANDSHAKE_TIMEOUT_SECONDS` (default 5s), the session has no negotiated Guardian for its lifetime, and each hook's `ACS_STARTUP_POSTURE` decides: `proceed` lets the step run, audited, with nothing sent to the Guardian, and `refuse`, the default, denies it. With a negotiated Guardian, a step that gets no usable decision follows the ServerHello's `on_decision_failure` (the example Guardian declares `deny`), or is denied whatever the Guardian declares when the hook sets `ACS_DEFAULT_DENY=1`. A dead Guardian costs one timeout per session, and the next session handshakes again. **Incident procedure:** set `ACS_DISABLED=1` in the adapter's environment to bypass all hooks immediately (one stderr line per event, no Guardian traffic); unset to re-enable. This is faster and more reversible than editing hook config files per machine mid-incident. + +### DENY path differs only in steps 4–6 + +Same as above, but with `command: "rm -rf /home/u"`: + +- **Step 4:** Guardian returns `{"decision": "deny", "reasoning": "destructive Bash pattern in: rm -rf /home/u"}` +- **Step 5:** Adapter emits `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "destructive Bash pattern in: rm -rf /home/u", "additionalContext": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u"}, "systemMessage": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u"}` +- **Step 6:** Claude Code reads `permissionDecision: "deny"`, does not execute the Bash tool, and surfaces the reason: *"The command was blocked: a policy denied the Bash tool call, so it never ran."* + +### What changes across the three adapters + +The general pattern is identical. The framework-specific translation differs: + +| | Claude Code | Cursor | NAT | +|---|---|---|---| +| **Where the adapter lives** | Separate shell process spawned per hook | Separate shell process spawned per hook | In-process Python class, same memory space as the agent | +| **How the framework sends the event** | JSON on stdin; event type is a field inside the JSON (`hook_event_name`) | JSON on stdin; event type passed as a CLI argument (one command per event in `hooks.json`) | Python method call: `pre_invoke(context)` with `context.function_context.name` | +| **Native event field names** | `tool_name`, `tool_input`, `tool_response` | `tool_name`, `tool_input`, `tool_output`, `command` (for shell) | `context.function_context.name`, the input in `context.modified_args[0]` or `context.modified_kwargs` | +| **Native allow/deny output** | Nothing on allow; `{"hookSpecificOutput": {"permissionDecision": "deny"}}` on stdout to block | `{}` on allow for a tool call, `{"permission": "deny"}` on stdout to block, `{"continue": false}` for a prompt, exit code 0 | Raise `ACSGuardianDenied`, or set `context.action = InvocationAction.SKIP` where NAT has it | +| **Native modify mechanism** | `hookSpecificOutput.updatedInput` with `permissionDecision: "ask"` | `updated_input` | Rewrite the input in `context.modified_args[0]` when it is passed positionally, else in `context.modified_kwargs`; replace `context.output` for output | +| **Process model** | OS spawns a Python process for every hook event | OS spawns a Python process for every hook event | Zero IPC; everything in the same Python interpreter | + +The Guardian-side wire format is **the same** for all three. The adapter is bilingual: it knows the framework's protocol on one side and ACS on the other. + +### Decision honoring is a framework property + +Every adapter relies on its framework providing the §6.4 guarantee: the framework MUST wait for the verdict and apply it before the action executes. If a framework fired the hook fire-and-forget and continued the action without waiting, the adapter would still send to the Guardian and the audit chain would still record the decision — but the framework wouldn't apply it. That would be non-conformant. None of the three frameworks here does that; how each one delivers the guarantee is in the per-adapter README. + +### The key insight + +ACS standardizes the wire format and the decision contract. Adapters live where the boundary is: between the framework and the Guardian. Each adapter: + +1. Knows the framework's hook protocol (the framework's JSON shape, response field names, exit codes). +2. Knows ACS (always the same). +3. Translates between them. + +The framework's agent code is untouched. The Guardian's policy code is untouched. The adapter is the bilingual translator that makes them speak. **One Guardian, one ACS contract, three adapters that translate three different protocols into that contract.** Add a new framework, write a new adapter, the Guardian doesn't change. + +--- + +## Directory layout + +The adapters are one Python package, `acs`, distributed as `acs-adapters`: + +``` +adapters/ +├── pyproject.toml # distribution, extras, console scripts, entry points +├── uv.lock # exact dependency versions, with hashes +├── src/acs/ +│ ├── core/ # no I/O: envelopes, signing, decisions, error codes, bundled schemas +│ ├── client/ # I/O: settings and fail posture, the secret, the handshake, the round trip, session state and turns, audit +│ ├── integrations// +│ │ ├── hook.py # the installed hook command (Claude Code, Cursor) +│ │ ├── adapter.py # the hook, or the NAT middleware +│ │ └── wire.py # writes the framework's own config +│ ├── guardian/example_guardian.py # the example Guardian: wire handling, replay, skew and the session chain +│ ├── examples/destructive_bash.py # the example Guardian's toy policy +│ └── testing/ # CaptureGuardian and the shared test harness +├── tests/ # common/, core/, client/, package/, conformance/, integrations// +├── docs// # README, mapping.md, example config, example payloads +└── scripts/check_wheel.py # installs a built wheel outside the repo and checks it +``` + +Imports flow `integrations → client → core`, and none of the three imports `guardian`, `examples` or `testing`; `uv run lint-imports` checks both rules in CI. + +Installing the package (`uv tool install ./adapters`, or `pip install ./adapters`) puts these commands on your PATH: `acs-claude-code-hook`, `acs-cursor-hook `, `acs-claude-code-wire`, `acs-cursor-wire`, `acs-nat-wire` and `acs-example-guardian`. For NAT, install the `nat` extra (`pip install './adapters[nat]'`); NAT then finds `_type: acs_guardian` through the package's `nat.components` entry point. + +--- + +## Contributing a new adapter + +1. Add `docs//` with a README and a `mapping.md` documenting how the framework's hook events map to ACS `steps/*` methods, and how the framework's response shape relates to ACS dispositions. +2. (Optional but encouraged) Write the adapter in `src/acs/integrations//`, with tests in `tests/integrations//`. The Claude Code adapter is the template. +3. For a hook command, add a standard-library-only `hook.py` that calls `acs.integrations._bootstrap.run_hook` (see `claude_code/hook.py`), and install that as the command, so an adapter whose dependencies fail to import still writes an audit record and denies the step unless the hook sets `ACS_STARTUP_POSTURE=proceed`. +4. Register it in `pyproject.toml`: an entry in the `acs.integrations` entry-point group, and its commands under `[project.scripts]`. An integration in another distribution registers in the same group. +5. Add its suite to `PLATFORM_SUITES` in `run_conformance.py`, so `run_conformance.py ` runs it. +6. Open a PR against `GenAI-Security-Project/agent-control-standard`. + +Keep module-level state constant or set once at startup. Read `ACS_GUARDIAN_URL`, `ACS_DEFAULT_DENY`, `ACS_STARTUP_POSTURE`, `ACS_REQUIRE_PROFILES`, `ACS_HANDSHAKE` and `ACS_DISABLED` once with `acs.client.Config.from_env` at the entry point, pass the `Config` and `FailPosture` down, gate each step on `Config.gate(open_session(...))`, build envelopes with `acs.core.request_envelope` and send them unsigned to `acs.client.round_trip`, which signs as the last step and returns either the decision or the `FailPosture.resolve` resolution to write in the host's shape, and keep the open turn with `acs.client.open_turn` / `current_turn` / `close_turn`. + +The bar for "reference implementation" status is: round-trip tests pass against the example Guardian, documented configuration for users, and an explicit conformance posture statement matching the format in the Claude Code adapter's README. diff --git a/adapters/SECURITY.md b/adapters/SECURITY.md new file mode 100644 index 00000000..c1b67dbe --- /dev/null +++ b/adapters/SECURITY.md @@ -0,0 +1,249 @@ +# Adapter security posture + +This document is the threat model for the three reference adapters +(`src/acs/integrations/`), the example Guardian (`src/acs/guardian/`), +and the shared helpers in `src/acs/core/` and `src/acs/client/`. It says what attacks the code defends +against, how, and what is explicitly out of scope. + +ACS is a security project — the whole point of the standard is to +police agent behavior. Adapter code that itself has security holes +undermines the standard. Each threat below names the tests that fail +when its mitigation stops holding, or says why it has none. + +## In-scope components + +| Component | Trust posture | +|---|---| +| Adapter process (`integrations/claude_code/`, `integrations/cursor/`) | Shell-spawned by the framework per hook event. Runs as the framework's user. Reads framework JSON from stdin. | +| Adapter middleware (`integrations/nat/`) | In-process inside the agent's Python runtime. Same address space as the agent. | +| Guardian process (`src/acs/guardian/`) | HTTP server. Holds the HMAC secret. Computes the rolling audit chain. | +| Wire transport | JSON-RPC over HTTP (deployments wrap with HTTPS / mTLS for production). | +| HMAC shared secret | Loaded from `ACS_HMAC_SECRET_FILE` (preferred) or `ACS_HMAC_SECRET` (env-var fallback), and named by the label in `ACS_HMAC_KEY_ID` (§10). A response signed under any other label fails verification; no other secret is tried. | +| Cache files | `~/.cache/acs-adapter-handshake/` (ServerHello cache), `~/.cache/acs-adapter-session/` (per-session step-id tracking). | +| Audit log | `ACS_AUDIT ` lines on adapter stderr. | + +## Threat model + +### Defended attacks (with the mitigation that defeats each) + +**T1 — Envelope tampering on the wire.** +An attacker intercepts the adapter ↔ Guardian connection and modifies +the payload, method, or session_id. +*Mitigation:* HMAC-SHA256 over JCS-canonicalized envelope with the +signature field removed (Specification §10). Both sides verify with +`hmac.compare_digest` (constant-time). Signed input includes +`method`, `metadata.session_id`, `request_id`, and `timestamp` so the +signature is bound to the whole envelope. +*Test:* `test_acs_core_conformance.py::Core07_BaselineIntegrity::test_tampered_request_signature_invalid` + +**T2 — Cross-session signature lift.** +An attacker captures a signed envelope from session A and replays it +under a different session_id. +*Mitigation:* HMAC key is HKDF-SHA256-derived per-session from +`(input_keying_material, session_id)`. A signature valid under +session A's derived key fails verification under session B's key. +*Test:* `Core07_BaselineIntegrity::{test_per_session_key_derivation, +test_signature_covers_session_id}` + +**T3 — Replay within a session.** +Same envelope re-sent within the same session_id. +*Mitigation:* Guardian tracks `request_id` per session and rejects +duplicates with `REPLAY_DETECTED` (-32005, §10.3). Per session, persisted +under `ACS_GUARDIAN_STATE_DIR` so a Guardian restart keeps it; setting it +to "" keeps it in memory only, for development. +*Test:* `Core06_ReplayProtection::test_duplicate_request_id_rejected_with_32005`, +`FailureMode2_GuardianRestartReplayWindow::test_replay_protection_survives_guardian_restart` + +**T4 — Stale or future-dated envelope.** +Captured envelope replayed outside the freshness window. +*Mitigation:* Guardian rejects timestamps outside the negotiated skew +window (default ±5 min) with `TIMESTAMP_OUT_OF_WINDOW` (-32006, §10.3). +*Test:* `Core06_ReplayProtection::test_timestamp_outside_window_rejected_with_32006` +(past and future) + +**T5 — SSRF via `ACS_GUARDIAN_URL`.** +Attacker controls the env var; sets it to `file:///etc/passwd` to +read arbitrary files, or to `data://` to feed a crafted response. +*Mitigation:* `validate_guardian_url()` rejects any scheme that is +not `http` or `https`. Called from `acs.client.exchange` (every +step's round trip), `open_session` and `ping`. The step and handshake +exchanges do not follow redirects, so a response cannot send the +request to a host the check never saw; a redirect status is a +decision failure under the posture. +*Test:* `GuardianUrlSchemeAllowlist`: `file`, `ftp`, `data` and +`javascript` rejected, `http` and `https` accepted. + +**T6 — Guardian HTTP DoS via oversized body.** +Attacker POSTs a request with Content-Length > available RAM, +expecting the Guardian to allocate. +*Mitigation:* Guardian refuses Content-Length > `MAX_REQUEST_BODY_BYTES` +(1 MiB, the `max_payload_size_bytes` every adapter's ClientHello declares) before +reading the body. Returns 413 + JSON-RPC error -32600. +*Test:* `GuardianBodySizeCap::test_oversized_request_rejected` + +**T7 — Leaked HMAC secret via world-readable file.** +Operator (or misconfiguration) leaves `ACS_HMAC_SECRET_FILE` mode +0644 / 0640; any local process can read the key. +*Mitigation:* `load_hmac_secret()` rejects the file unless its mode is +`& 0o077 == 0` (no group / other access) AND it is owned by the +running user AND it is not a symlink. `SecretFilePermissionsError` +prevents the adapter from silently using a leaked secret. +*Test:* `HmacSecretFilePermissions::{test_world_readable_rejected, +test_group_readable_rejected, test_symlink_rejected}` + +**T8 — Cache / session-state poisoning.** +Local attacker writes the handshake cache or session-state file +between adapter invocations. For session-state, this would let them +change the open turn or the decision-failure counts the next hook reads. +*Mitigation:* Both cache directories created mode 0700; cache files +created mode 0600 via `os.open(...O_CREAT, 0o600)`. A local attacker +without same-uid privileges cannot read or write the state. These +modes do not separate the agent from the developer: the agent runs as +the developer, so it can read the HMAC key file, the handshake cache +and the audit file, and write the cache. The record's HMAC stops a +writer that cannot read the key; the agent can, so it can also write a +record that passes, and only a separate user or a sandbox keeps the key +from the agent. Each +session-state update is one read-modify-write under an exclusive lock on +a sidecar file, published through a temp file of its own with +`os.replace`, so parallel hook processes neither lose updates nor read a +partial file. The +handshake record carries an HMAC under the session key over the +decision, the session and the Guardian URL, so a record modified, +damaged or copied from another endpoint by a writer without the key, or +left by an earlier adapter, refuses the session, and so does a decision +that cannot be stored; a deleted record makes the next event handshake again. +*Test:* `CacheDirPermissions::{test_session_state_dir_is_0700, +test_session_state_file_is_0600}`, +`Item22_StoredSessionDecisionIsAuthenticated` + +**T9 — Regex DoS via oversized command.** +Attacker submits a multi-MB command string crafted to trigger +catastrophic backtracking in the destructive-bash regex set. +*Mitigation:* `scan_destructive_bash_safely()` refuses to scan +commands longer than `DESTRUCTIVE_SCAN_MAX_LEN` (8 KiB). The skip is +audited and the caller MUST treat the return value `"input_too_large"` +as suspicious — NOT as "safe to allow." Real shell commands are tiny; +multi-KB strings are either tunneled data or a DoS attempt. +*Test:* `RegexInputSizeCap::test_oversized_command_is_short_circuited` + +**T10 — Audit-log injection.** +Field values in audit events (`session_id`, `method`, `error=str(e)`) +could contain newlines or control characters that, if naively written, +would let an attacker forge fake `ACS_AUDIT` lines. +*Mitigation:* `audit_event()` emits a single line of +`json.dumps(payload, sort_keys=True)`. JSON encoding escapes `\n`, +`\r`, and other control characters in string values. The +`ACS_AUDIT ` prefix is the only unescaped text. +*No dedicated test* — the property is enforced by the JSON encoder; +adding a test would be checking stdlib behavior. + +**T11 — Handshake downgrade.** +Attacker MITMs the handshake response to claim the Guardian doesn't +support signing, hoping the adapter then sends unsigned envelopes. +*Mitigation:* The adapter signs every request with its own secret, +whatever `signature_algorithms_supported` the handshake advertised, +and a hook with no secret refuses its session and sends nothing. The +handshake's advertised algorithm list does not control signing. +*No test* — this is an absence of behavior. Verified by reading the +code: `sign_envelope()` consults `load_signing_material()` only. + +**T12 — Guardian-side signature stripping.** +Attacker MITMs the Guardian's response and removes the `signature` +field, hoping the adapter's response verification silently accepts. +*Mitigation:* `verify_signature()` returns False for a response with no +signature, and every request is signed. The adapter rejects the response +and denies the step whatever the posture, with an audit event. +*Test:* `ExchangeCauses::test_every_cause` ("unsigned result" and +"unsigned error" are refused) and `Resolve::test_resolution_table` (a +refusal denies under either posture). + +### Out of scope (deployment / operational concerns) + +**O1 — Plaintext HTTP exposes payload content.** +Even when HMAC-signed, the body is readable on the wire. An attacker +on the network path can read tool names, arguments, prompts, results. +*Posture:* Use HTTPS for any non-loopback deployment. Set +`ACS_GUARDIAN_URL=https://...` and put the Guardian behind TLS (real +cert, or mTLS for stronger client identity). The adapter's URL +allowlist accepts `https://`. We do not bundle a TLS implementation. + +**O2 — Regex bypass via shell quoting / expansion.** +Shell evaluates `r''m -rf /` as `rm -rf /` after quote removal. The +adapter only sees the literal `r''m -rf /` from the framework, which +does not match our destructive-bash regex. Same for `$(echo rm) -rf /`, +backslash escapes, locale-dependent commands, etc. +*Posture:* The destructive-bash regex is a teaching artifact and a +defense-in-depth heuristic, NOT a security boundary. Production +deployments wire a real policy engine (OPA/Rego, Cedar, or a vendor +policy bundle) into the Guardian. Documented in +`src/acs/guardian/example_guardian.py` and `adapters/README.md`. + +**O3 — Secret in env-var visible to child processes / `ps eauxw`.** +`ACS_HMAC_SECRET` shows up in `/proc/PID/environ` and is inherited by +any child process the adapter or Guardian spawns. +*Posture:* That's exactly why `ACS_HMAC_SECRET_FILE` exists. The env +var is a development convenience; production deployments use the file +path with mode 0600. Documented in the Guardian README. + +**O4 — Compromised Guardian.** +A Guardian that holds the symmetric HMAC key can re-sign a rewritten +chain head and present a clean history. The HMAC baseline detects +network tampering and cross-Guardian disagreement but cannot prove +non-repudiation against the Guardian itself. +*Posture:* That's what the ACS-Crypto profile (asymmetric / PQC +signatures) and the ACS-Audit profile (`request_hash` per +ContextEntry) are for. v0.1 baseline acknowledges this tradeoff +(`conformance.md`, "What 'ACS-Core conformant' guarantees"); we don't implement those profiles. + +**O5 — Compromised secret-file storage.** +A reader of the storage volume (cloud snapshot, backup, etc.) gets +the HMAC key. File permissions don't help there. +*Posture:* Encrypted storage, sealed secrets (Vault, K8s sealed +secrets, KMS-backed envelopes). Out of scope for the adapter; the +adapter just receives the key bytes from a file path. + +**O6 — Memory disclosure / core dumps.** +HMAC key in process memory. No explicit zeroize. +*Posture:* Disable core dumps in production (`ulimit -c 0` / +`fs.suid_dumpable=0`). Python doesn't expose secure-erase primitives; +acceptable v0.1 tradeoff. + +**O7 — Compromised adapter binary.** +A malicious adapter binary (shipped via supply-chain attack on the +ACS repo, or a tampered local install) does whatever it wants. +*Posture:* Out of scope. Verify the adapter against the published +hash. The ACS-Inspect profile addresses the AgBOM side; binary +integrity of the adapter itself is a deployment-tools concern. + +**O8 — Compromised framework (Claude Code / Cursor / NAT).** +If Claude Code itself is compromised, all bets are off — it can +choose not to call hooks, ignore deny responses, etc. ACS-Core §6.4 +("decision honoring") is a property the framework must implement; an +adapter cannot enforce it on a hostile framework. +*Posture:* Framework integrity is a separate trust boundary outside +ACS's scope. + +## Mitigation matrix at a glance + +| Threat | Mitigation | Test | +|---|---|---| +| T1 envelope tampering | HMAC-SHA256 over JCS canonical input | `Core07_BaselineIntegrity::test_tampered_request_signature_invalid` | +| T2 cross-session lift | per-session HKDF key derivation | `Core07_BaselineIntegrity::test_signature_covers_session_id` | +| T3 in-session replay | per-session `request_id` set + -32005 | `Core06_ReplayProtection::test_duplicate_request_id_rejected_with_32005` | +| T4 timestamp skew | skew-window check + -32006 | `Core06_ReplayProtection::test_timestamp_outside_window_rejected_with_32006` | +| T5 SSRF | URL scheme allowlist (http/https) | `GuardianUrlSchemeAllowlist::*` (6 tests) | +| T6 body-size DoS | Content-Length cap + 413 | `GuardianBodySizeCap::test_oversized_request_rejected` | +| T7 leaky secret file | mode/owner/symlink check on `ACS_HMAC_SECRET_FILE` | `HmacSecretFilePermissions::*` (4 tests) | +| T8 cache poisoning | dir 0700 + file 0600 | `CacheDirPermissions::*` (2 tests) | +| T9 regex DoS | 8 KiB input cap + audit | `RegexInputSizeCap::*` (3 tests) | +| T10 log injection | `json.dumps` escaping | enforced by stdlib | +| T11 handshake downgrade | adapter signs based on own config, not handshake | code-level | +| T12 response sig stripping | `verify_signature` False when the signature is absent | `ExchangeCauses::test_every_cause` | + +## How to report a finding + +**Do not file public issues for security vulnerabilities.** Use +GitHub's [private vulnerability reporting](https://github.com/GenAI-Security-Project/agent-control-standard/security/advisories/new) +to disclose privately — the process and response commitment are in +[`CONTRIBUTING.md` § Reporting Security Issues](../CONTRIBUTING.md#reporting-security-issues). diff --git a/adapters/docs/claude_code/README.md b/adapters/docs/claude_code/README.md new file mode 100644 index 00000000..0ea9aa14 --- /dev/null +++ b/adapters/docs/claude_code/README.md @@ -0,0 +1,318 @@ +# ACS adapter: Claude Code + +A drop-in adapter that wires [Claude Code](https://docs.claude.com/claude-code) hooks to an ACS Guardian. No agent code changes; configuration only. + +## How it works + +Claude Code fires a hook (e.g. `PreToolUse`) by running a shell command and passing the hook event as JSON on stdin. The command's stdout becomes the hook's decision. + +This adapter is that command. On every hook event Claude Code spawns the adapter as a subprocess; the adapter: + +1. Reads the Claude Code hook event from stdin. +2. Translates it to an ACS JSON-RPC envelope ([mapping.md](./mapping.md)). +3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET_FILE`, under the label `ACS_HMAC_KEY_ID`). +4. POSTs to the Guardian. +5. Verifies the response signature. +6. Translates the verdict back to Claude Code's expected output shape and writes to stdout. +7. Exits. + +The handshake/hello fires once per session, cached on disk so subsequent events skip the round-trip. + +### Decision honoring (§6.4) + +ACS-Core §6.4 requires the framework to wait for the verdict and apply it before the action executes. Claude Code provides this guarantee through its hook protocol: the adapter is invoked as a blocking subprocess and the framework reads its stdout for the decision, so the tool can't run until the adapter exits with a verdict. The adapter relies on this — without it, a Guardian deny would arrive after the side effect. + +## Install — five steps + +You need three pieces co-located on the same machine: a running Guardian, a shared HMAC secret, and a `~/.claude/settings.json` that wires the adapter into Claude Code's hooks. `acs-claude-code-wire` does step 3 for you. + +Commands below assume `$ACS_REPO` points at your local clone of `GenAI-Security-Project/agent-control-standard` (or your fork). Export it once: + +```bash +export ACS_REPO=/path/to/your/clone # e.g., $HOME/code/ACS +uv tool install "$ACS_REPO/adapters" # or: pipx install "$ACS_REPO/adapters" +``` + +Installing the package puts `acs-claude-code-hook`, `acs-claude-code-wire` and `acs-example-guardian` on your PATH. + +### 1. Generate the shared HMAC secret + +Both the adapter and the Guardian read this file. Mode 0600 is enforced by the adapter — anything looser and it refuses to start. + +```bash +mkdir -p ~/.acs +openssl rand -hex 32 > ~/.acs/hmac.key +chmod 600 ~/.acs/hmac.key +``` + +### 2. Run the Guardian + +Use the example Guardian for testing; a production Guardian is the same wire protocol with a real policy engine attached. + +```bash +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ + acs-example-guardian --port 8787 +``` + +Keep this terminal open. You should see `[guardian] listening on 127.0.0.1:8787`. + +(For long-running setups: a `launchd` plist on macOS or a `systemd` unit on Linux. Out of scope here.) + +### 3. Wire `~/.claude/settings.json` + +`acs-claude-code-wire` does this safely: dry-run by default, atomic write with a timestamped backup when you pass `--write`. The backup is 0600 with any inline secret replaced by ``, and a settings file that holds an inline secret is made 0600. It refuses to wire when another copy of the adapter is already wired in a hook file the host may load, and lists where, since two copies would send every step twice, and both requests for one tool call would carry the same `request_id`. It checks user, project, local and managed settings, and the hooks of each installed plugin that `enabledPlugins` turns on for the project. Cursor also runs the Claude Code hooks in user, project and local settings, so wiring one of those files also checks the `hooks.json` files Cursor loads; with Cursor's **Include Third-Party Plugins, Skills, and Other Configs** setting off, pass `--cursor-third-party-imports-off` to skip that check. It wires the absolute path of the installed `acs-claude-code-hook`, and stops if that command is not installed, or if a `--hook-command` you pass is not an absolute path to an executable file. It writes `--secret-file` and `--audit-file` as absolute paths, resolved from the directory it runs in, so the hook finds them whatever directory the host starts it in. + +```bash +# Preview the change without touching the file +acs-claude-code-wire \ + --guardian-url=http://127.0.0.1:8787/acs \ + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ + --audit-file=~/.acs/audit.log + +# Apply it (creates ~/.claude/settings.json.bak.) +acs-claude-code-wire \ + --guardian-url=http://127.0.0.1:8787/acs \ + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ + --audit-file=~/.acs/audit.log \ + --write +``` + +What it wires by default: + +| Hook | Posture | +|---|---| +| `PreToolUse` | **fail-CLOSED** (gate): the tool call is denied | +| `UserPromptSubmit` | **fail-CLOSED** (gate): the prompt is blocked | +| `SessionStart` | the Guardian's posture; a Guardian `deny` refuses the session | +| `PostToolUse` | the Guardian's posture; under `deny` the output is withheld | +| `PostToolUseFailure` | the Guardian's posture; sends the failed tool's error text | +| `Stop` | the Guardian's posture, recorded only, since Claude Code reads a block on `Stop` as "keep going"; sends the reply (`agentResponse`) and closes the turn | +| `StopFailure` | the Guardian's posture, recorded only; closes the turn with outcome `error` | +| `SessionEnd` | the Guardian's posture, recorded only; `timeout: 15`, since SessionEnd hooks otherwise share a 1.5 s budget | + +The posture is what a step does when no usable decision arrives (§6.4). A gate hook's command carries `ACS_DEFAULT_DENY=1`, so it fails closed whatever the Guardian declares; the other hooks apply the ServerHello's `on_decision_failure`, and the example Guardian declares `deny`. In a session whose handshake gets no answer, every hook refuses its step unless it sets `ACS_STARTUP_POSTURE=proceed` (§4.1). Override with `--default-deny` (`ACS_DEFAULT_DENY=1` on every hook) or `--all-fail-open` (`ACS_DEFAULT_DENY=0` and `ACS_STARTUP_POSTURE=proceed` on every hook, gates included, so a step with no usable decision proceeds, audited, unless the Guardian declares `deny`). A fail-closed `PreToolUse` or `UserPromptSubmit` hook also carries `"onFailure": "block"`, so Claude Code v2.1.295 or later blocks the tool call or prompt when the hook itself crashes, times out or cannot start. On the other hooks a blocking failure stops nothing, since the action has already happened, so the wire tool leaves the field out there. + +To remove the wiring later: `acs-claude-code-wire --unwire --write`. + +### 4. Restart any open Claude Code session + +Claude Code reads `~/.claude/settings.json` at session start, not live. Existing sessions keep their pre-wiring config. + +### 5. Verify the install + +Two complementary checks. The end-to-end check is the one to run if you only have time for one: + +```bash +cd "$ACS_REPO/adapters" +uv sync --locked --group test +uv run python tests/integrations/claude_code/e2e_check.py +``` + +Real Claude is driven through five scenarios — allow, deny, Read tool, multi-tool handshake-once, withheld output — every envelope is printed verbatim, you read PASS/FAIL per scenario. Wall-clock ~60-90s because real Claude is in the loop. The final line is either `ACS-CORE SMOKE PASS (claude-code)` (exit 0) — a smoke verdict, not a conformance certification — or a per-scenario failure list (exit 1). + +You can also do an in-session manual smoke test (see [Smoke tests](#smoke-tests) below). + +## Prerequisites + +- **`claude` CLI** installed and authenticated — install guide: +- **Python 3.11+** and the `acs-adapters` package (`uv tool install "$ACS_REPO/adapters"`). Its runtime dependencies are `rfc8785` and `jsonschema`. To run the test suites: `cd "$ACS_REPO/adapters" && uv sync --locked --group test`. +- **Canonical ACS schemas**: bundled in the package, and read from `specification/v0.1.0/` when running from a clone of this repo. Set `ACS_SPEC_DIR` only to validate against a different spec checkout. + +## Smoke tests + +Five tests, ordered from broadest to most specific. Run any/all. + +### Smoke #1 — automated test suite (unit + integration, ~30s) + +Run from `adapters/`: + +```bash +cd "$ACS_REPO/adapters" +uv run python run_conformance.py claude +# Runs the shared Guardian, _common, core, client and package checks, then Claude Code's suite. +``` + +The command reports passes, skips, and failures separately. It fails on any test failure or unexpected skip. The live tests, which drive the real `claude` CLI on your account, run only with `ACS_LIVE_CLAUDE=1`: + +```bash +ACS_LIVE_CLAUDE=1 uv run python run_conformance.py claude +``` + +### Smoke #2 — real Claude end-to-end (~60-90s) + +```bash +cd "$ACS_REPO/adapters" +uv sync --locked --group test +uv run python tests/integrations/claude_code/e2e_check.py +``` + +Drives real Claude through 5 scenarios with a recording Guardian. Prints every envelope on the wire plus per-scenario PASS/FAIL. + +### Smoke #3 — in-session manual test + +Open a real Claude Code session. Try: + +``` +echo hello via Bash +``` + +The Guardian allows it and the hook prints nothing, so Claude Code's own permission rules decide; `echo` is one of its read-only commands, which run without a prompt. + +In your Guardian terminal you should see roughly this sequence (one new entry per hook Claude fires): + +``` +[guardian] handshake/hello session=... +[guardian] steps/sessionStart session=... +[guardian] steps/turnStart session=... +[guardian] steps/userMessage session=... +[guardian] steps/toolCallRequest session=... +[guardian] steps/toolCallResult session=... +[guardian] steps/agentResponse session=... +[guardian] steps/turnEnd session=... +[guardian] steps/sessionEnd session=... +``` + +Then try a denied command in the same session: + +``` +Run: rm -rf /home/some-fake-path +``` + +Claude should refuse and surface the Guardian's `reasoning` field. The example Guardian's regex catches `rm -rf /...`; the Bash never runs. + +### Smoke #4 — audit-cause differentiation + +Verifies that the adapter's audit log distinguishes a Guardian that rejected the request (a configuration error) from a Guardian it could not reach (an ops issue). Both commands run against the Guardian started above, on `127.0.0.1:8787` under the label `2026-11`. + +A secret the Guardian does not hold, under the Guardian's label: + +```bash +ACS_GUARDIAN_URL="http://127.0.0.1:8787/acs" \ +ACS_HMAC_SECRET="$(openssl rand -hex 32)" ACS_HMAC_KEY_ID=2026-11 \ +acs-claude-code-hook 2>&1 <<'EOF' +{"session_id":"11111111-1111-4111-8111-111111111111","transcript_path":"/tmp/t","cwd":"/tmp","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo test"}} +EOF +``` + +Expected: the Guardian answers the handshake with `-32004 SIGNATURE_INVALID`, signed with its own secret. The hook cannot verify that answer, so it refuses the session whatever the posture (spec issue #32), and stdout carries a deny: + +``` +ACS_AUDIT {"acs_audit_event": "session_refused", "cause": "error_signature_invalid", "claimed_cause": "signature_invalid_response", "claimed_error_code": -32004, ...} +ACS_AUDIT {"acs_audit_event": "session_refused_fail_closed", "cause": "error_signature_invalid", ..., "hook": "PreToolUse", ...} +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ACS adapter: session refused (error_signature_invalid)", "additionalContext": "ACS blocked this tool call: ACS adapter: session refused (error_signature_invalid)"}, "systemMessage": "ACS blocked this tool call: ACS adapter: session refused (error_signature_invalid)"} +``` + +Guardian unreachable, with the Guardian's secret and label: + +```bash +ACS_GUARDIAN_URL="http://127.0.0.1:1/dead" \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ +acs-claude-code-hook 2>&1 <<'EOF' +{"session_id":"11111111-1111-4111-8111-111111111111","transcript_path":"/tmp/t","cwd":"/tmp","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo test"}} +EOF +``` + +Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (`refuse` unless the hook sets `ACS_STARTUP_POSTURE=proceed`); stdout carries the deny: + +``` +ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_closed", ...} +ACS_AUDIT {"acs_audit_event": "session_not_started_fail_closed", "cause": "transport_failure", ..., "hook": "PreToolUse", ...} +``` +{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "ACS adapter: session not started (transport_failure)", "additionalContext": "ACS blocked this tool call: ACS adapter: session not started (transport_failure)"}, "systemMessage": "ACS blocked this tool call: ACS adapter: session not started (transport_failure)"} + +Distinct `acs_audit_event` and `cause`: a rejection refuses the session (`session_refused_fail_closed`) whatever the posture, and an unreachable Guardian follows the startup posture (`session_not_started_fail_closed`, or `unguarded_session_bypass` under `ACS_STARTUP_POSTURE=proceed`). Operators grep on either to triage. A hook with no secret sends nothing and refuses the session with `secret_missing`. + +### Smoke #5 — pre-flight inventory (paranoid) + +If you're debugging, this is the fastest "where did I go wrong" sweep: + +```bash +echo "=== Guardian listening? ===" +lsof -i :8787 | head -3 || echo "NOT RUNNING" + +echo "=== Secret file 0600? ===" +ls -la ~/.acs/hmac.key + +echo "=== Hooks wired? ===" +python3 -c "import json, os; d=json.load(open(os.path.expanduser('~/.claude/settings.json'))); print(list(d.get('hooks',{}).keys()))" + +echo "=== Guardian responds to system/ping? ===" +cd "$ACS_REPO/adapters" && uv run python -c " +from acs.client import ping; import json +r = ping('http://127.0.0.1:8787/acs'); print(json.dumps(r, indent=2)[:200] if r else 'no response') +" +``` + +## Files + +- `src/acs/integrations/claude_code/hook.py`: the entry point installed as `acs-claude-code-hook`. Stdlib only, so it can still audit and apply the configured posture when the adapter's dependencies are missing. +- `src/acs/integrations/claude_code/adapter.py`: the adapter itself, which `hook.py` loads. Stdlib + `rfc8785` for JCS canonicalization. +- `src/acs/integrations/claude_code/wire.py`: the settings.json wiring CLI, installed as `acs-claude-code-wire` (dry-run by default; `--write` to apply). +- `tests/integrations/claude_code/e2e_check.py`: real-Claude end-to-end verifier (5 scenarios). +- `docs/claude_code/settings.json.example`: reference wiring (`acs-claude-code-wire` produces a more comprehensive one). +- `docs/claude_code/mapping.md`: Claude Code hook → ACS step method table, plus disposition translation. +- `tests/integrations/claude_code/`: round-trip, schema and live integration tests. +- `docs/claude_code/example_payloads.md`: masked real-world payload examples showing exactly what Claude Code emits. + +The adapter shares `acs.core` (signing, decision rules, error codes) and `acs.client` (handshake cache, session state, audit events, URL allowlist) with the Cursor and NAT adapters. + +## Configuration + +The adapter is configured by environment variables, typically set per-hook by `acs-claude-code-wire`: + +| Variable | Default | Purpose | +|---|---|---| +| `ACS_GUARDIAN_URL` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only; SSRF allowlist refuses other schemes. | +| `ACS_HMAC_SECRET_FILE` | (unset) | Path to a 0600 file holding the shared HMAC secret. | +| `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | +| `ACS_HMAC_KEY_ID` | (unset) | The label that names the secret (§10); the Guardian holds the same one. Required with a secret: a secret without a label refuses the session, and a response signed under another label fails verification. | +| `ACS_DEFAULT_DENY` | (unset) | `1`: fail-closed whatever the Guardian declares. Unset or `0`: the ServerHello's `on_decision_failure` applies, and the example Guardian declares `deny` (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. | +| `ACS_STARTUP_POSTURE` | `refuse` | `proceed` or `refuse`: what this hook does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the step run, audited, with nothing sent to the Guardian; `refuse` denies it. The session keeps no negotiated Guardian for its lifetime, and the next session handshakes again. Any other value refuses. | +| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused and it sends no `sessionStart`; the stored record keeps what the Guardian answered, so each hook applies its own setting, and a later hook without the requirement starts the session. | +| `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. | +| `ACS_AGENT_ID` | derived from `CLAUDE_PROJECT_DIR`, or the session's first `cwd` | Stable agent identifier sent in `metadata.agent_id`. | +| `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. | +| `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Deadline for the whole handshake/hello exchange, including a response that arrives slowly. A value that is not a positive number is replaced by `5` and recorded as `invalid_setting`. | +| `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr — the durable record of every decision failure (§6.4). | +| `ACS_DISABLED` | (unset) | `1` = incident kill switch: bypass all hooks immediately, no Guardian traffic. | +| `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | + +## On-disk state + +- `~/.cache/acs-adapter-handshake/.json` — the session's handshake decision (negotiated ServerHello, refusal, or no answer) with an HMAC under the session key over the decision, the session and the Guardian URL. Mode 0600; kept for the session's lifetime. A record that is modified, damaged, copied from another endpoint or written by an earlier adapter refuses the session, and so does a decision that cannot be stored. +- `~/.cache/acs-guardian-state/.json` — Guardian-side per-session chain head + replay set; survives Guardian restart. + +## Conformance status + +Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md`. Some Core items are conditional or SHOULD-level: `subagentStart` binds subagent-capable clients, MODIFY and `system/ping` are SHOULD, and Wrapped MCP binds sessions that involve MCP. The row notes say how each one applies here. + +| ACS-Core item | Status | +|---|---| +| Handshake (`handshake/hello`) | ✓ adapter sends ClientHello on the first event of a session; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1). | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ always claims `acs-core`: a hook with no usable secret and label refuses its session before the handshake. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | +| JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema for every mapped hook (`tests/integrations/claude_code/test_emission.py`); format checking enforces `uuid` and `date-time`. | +| Hook taxonomy (Core minimum set) | ✓ `sessionStart`, `userMessage`, `toolCallRequest`, `toolCallResult`, `agentResponse` (the reply `Stop` carries in `last_assistant_message`), `sessionEnd`; plus `subagentStart`, which ACS-Core requires of a subagent-capable client. Claude Code spawns subagents through the Agent tool (legacy `Task`), and the adapter remaps that PreToolUse to `steps/subagentStart` (see mapping.md). `subagentStop`, which ACS-Core lists as SHOULD-emit, stays deliberately unmapped (see mapping.md). The adapter also emits the turn boundary: `turnStart` (emitted on UserPromptSubmit) / `turnEnd` (Stop). `preCompact` deliberately unmapped — the platform exposes no entry list (mapping.md). | +| Dispositions (ALLOW/DENY/ASK/DEFER) | ✓ on **pre-execution** hooks (`PreToolUse`, `UserPromptSubmit`, `turnStart`). On `PreToolUse` an ALLOW prints nothing, so Claude Code's own permission rules and prompt still decide, and MODIFY is partial: an `ask` carrying `updatedInput` (`parameter_overrides` merged onto the original input), so the user approves the rewritten call. On `PostToolUse` a DENY, ASK, DEFER or unusable MODIFY replaces the output Claude sees for `Bash`, MCP tools, `Read`, `Grep`, `Glob`, `WebFetch`, `WebSearch`, `Write`, `Edit` and `Agent`, in each tool's output schema, and is recorded as `unenforceable_decision` for any other tool. **Lifecycle hooks (`Stop → agentResponse, turnEnd`, `SessionEnd`) are observation-only**: Claude Code fires them after the reply / turn / session has completed, and a Guardian `deny` on them is recorded. See `mapping.md`. | +| Unknown-disposition fail posture | ✓ a verdict outside the five dispositions is denied under either posture and counted as `malformed_response`. | +| SessionContext + published `chain_hash` | ✓ session_id propagated; Guardian computes rolling SHA-256 chain per §8.2 (`tests/conformance/test_acs_core_conformance.py::Core05_SessionContext`). | +| Replay protection (`request_id` + `timestamp`) | ✓ adapter sends both; Guardian rejects duplicate `request_id` (REPLAY_DETECTED -32005) and timestamps outside skew window (TIMESTAMP_OUT_OF_WINDOW -32006) per §10.3. | +| Baseline integrity (HMAC-SHA256 signature) | ✓ HKDF-derived per-session key signs every request and response under `ACS_HMAC_KEY_ID`; a hook with no secret of at least 32 bytes, or no label, refuses its session and sends nothing. The Guardian rejects an unsigned or tampered request with SIGNATURE_INVALID -32004. | +| Decision honoring (§6.4) | ✓ adapter blocks on subprocess return; every decision failure emits a structured `ACS_AUDIT` event under either posture; audit `cause` field distinguishes failure modes. | +| Liveness `system/ping` | ⚠ Guardian-side only. ACS-Core says `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it (§13). The example Guardian answers ping as an always-allow method outside the chain, replay, and signature checks. The adapter does not declare or send `system/ping`, so a deployment that uses it must name an alternative liveness mechanism in its configuration, such as continuous observed hook traffic. Omitting both is non-conformant. | +| `nonce` (optional replay field) | ✗ adapter does not emit `nonce`; the envelope field is OPTIONAL in v0.1. | +| Wrapped MCP `protocols/MCP/*` | ⚠ partial. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter covers only that case: Claude Code surfaces MCP tool calls as `mcp__*` tools, and the adapter sends them as `steps/toolCallRequest` and `steps/toolCallResult`. It wraps no other MCP surface (`initialize`, `prompts/get`, `resources/read`, notifications), so a session that uses MCP falls short of ACS-Core there. The first MCP tool a signed session uses records `profile_claim_degraded` once. Known limitation. | + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| Adapter exits 0 with empty stdout; Guardian terminal silent | Adapter pointed at the wrong URL (check `ACS_GUARDIAN_URL` in your settings.json); check stderr for `ACS_AUDIT cause=transport_failure`. | +| Every hook gets denied | Likely the Guardian is down: a session whose handshake gets no answer refuses its steps unless `ACS_STARTUP_POSTURE=proceed`, and the gate hooks carry `ACS_DEFAULT_DENY=1`. Check the Guardian process is running. | +| Adapter says `SecretFilePermissionsError` | The HMAC secret file is mode > 0600. `chmod 600 ~/.acs/hmac.key`. | +| Guardian returns `-32004 SIGNATURE_INVALID` | Adapter and Guardian aren't reading the same secret. `cat ~/.acs/hmac.key` on both sides should match. | +| Guardian returns `-32006 TIMESTAMP_OUT_OF_WINDOW` | Clock skew between adapter and Guardian > 5 minutes. Sync time (`sudo sntp -sS time.apple.com` on macOS). | +| Guardian returns `-32600 Invalid Request` for `metadata.session_id` | Session ID isn't a UUID. Real Claude Code always sends UUIDs; if you're hand-crafting envelopes for testing, fix the fixture. | + +Everything the adapter does that's not policy decision-making is audited on stderr as a JSON line prefixed `ACS_AUDIT`. The `cause` field tells you which failure mode fired. diff --git a/adapters/docs/claude_code/example_payloads.md b/adapters/docs/claude_code/example_payloads.md new file mode 100644 index 00000000..b3117e37 --- /dev/null +++ b/adapters/docs/claude_code/example_payloads.md @@ -0,0 +1,173 @@ +# Real-world payload examples — Claude Code + +These are the actual JSON shapes Claude Code emits on stdin for each hook, captured from a real `claude` session and **masked**. Identifying fields are replaced with placeholders. Use these to understand the schema the adapter parses. + +All fields documented in [Claude Code's hook docs](https://code.claude.com/docs/en/hooks) plus several that appear in real payloads but aren't in the public docs (flagged below). + +--- + +## SessionStart + +```json +{ + "session_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/Users//.claude/projects//.jsonl", + "cwd": "/path/to/project", + "hook_event_name": "SessionStart", + "source": "startup" +} +``` + +`source` enum: `startup`, `resume`, `clear`, `compact`. + +--- + +## UserPromptSubmit + +```json +{ + "session_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/Users//.claude/projects//.jsonl", + "cwd": "/path/to/project", + "permission_mode": "default", + "hook_event_name": "UserPromptSubmit", + "prompt": "" +} +``` + +`permission_mode` enum: `default`, `plan`, `acceptEdits`, `auto`, `dontAsk`, `bypassPermissions`. + +--- + +## PreToolUse (Bash tool) + +```json +{ + "session_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/Users//.claude/projects//.jsonl", + "cwd": "/path/to/project", + "permission_mode": "acceptEdits", + "effort": {"level": "high"}, + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "echo hello", + "description": "Echo a test string" + }, + "tool_use_id": "toolu_00000000000000000000000000" +} +``` + +Fields not in the public docs but present in real payloads: `effort.level`, `tool_use_id`. The adapter forwards `tool_use_id` to the Guardian as part of the payload context. + +--- + +## PostToolUse (Bash tool) + +```json +{ + "session_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/Users//.claude/projects//.jsonl", + "cwd": "/path/to/project", + "permission_mode": "acceptEdits", + "effort": {"level": "high"}, + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "echo hello", + "description": "Echo a test string" + }, + "tool_response": { + "stdout": "hello", + "stderr": "", + "interrupted": false, + "isImage": false, + "noOutputExpected": false + }, + "tool_use_id": "toolu_00000000000000000000000000", + "duration_ms": 5616 +} +``` + +**Important schema difference from docs:** the public docs describe a `tool_output` string field, but real Claude Code emits a `tool_response` **object** with `stdout`, `stderr`, `interrupted`, `isImage`, `noOutputExpected`. The adapter reads `tool_response` first, falls back to `tool_output` for forward-compat. + +--- + +## Adapter response shapes + +### Allow (any PreToolUse) + +Nothing on stdout. Claude Code's own permission rules and prompt then decide; a `permissionDecision` of `"allow"` would skip that prompt. + +### Deny (PreToolUse) + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "destructive Bash pattern in: rm -rf /home/u", + "additionalContext": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u" + }, + "systemMessage": "ACS blocked this tool call: destructive Bash pattern in: rm -rf /home/u" +} +``` + +### Modify (PreToolUse) — shows the user the rewritten tool_input to approve + +```json +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": "sanitized by Guardian", + "updatedInput": {"command": "echo hello # sanitized by Guardian"} + } +} +``` + +### Block (UserPromptSubmit) + +UserPromptSubmit uses top-level `decision: "block"`. `Stop` never gets one: Claude Code reads it there as "keep going". + +```json +{ + "decision": "block", + "reason": "ACS adapter: Guardian unreachable" +} +``` + +### Withheld output (PostToolUse, Bash tool) + +A block alone leaves Claude the original output, so a deny also replaces it in the tool's output shape: + +```json +{ + "decision": "block", + "reason": "leaks a key", + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "updatedToolOutput": { + "stdout": "[output withheld by the ACS Guardian: leaks a key]", + "stderr": "", + "interrupted": false, + "isImage": false + } + } +} +``` + +--- + +## Masking convention used here + +| Field | Real value contains | Masked as | +|---|---|---| +| `session_id` | Real UUID from the session | `00000000-0000-0000-0000-000000000001` | +| `transcript_path` | Real absolute path on the user's machine | `/Users//.claude/projects//.jsonl` | +| `cwd` | Real working directory at runtime | `/path/to/project` | +| `prompt` | Actual user input | `` | +| `tool_use_id` | Real Claude Code internal id | `toolu_00000000000000000000000000` | +| `command` | Real command (sometimes preserved when benign) | Either preserved or `` | + +No real session data is committed to this repo. diff --git a/adapters/docs/claude_code/mapping.md b/adapters/docs/claude_code/mapping.md new file mode 100644 index 00000000..205f243c --- /dev/null +++ b/adapters/docs/claude_code/mapping.md @@ -0,0 +1,59 @@ +# Claude Code → ACS hook mapping + +Each Claude Code hook event maps to an ACS `steps/*` method. The adapter (`src/acs/integrations/claude_code/adapter.py`) does the translation in both directions: Claude Code hook event → ACS JSON-RPC request, ACS decision → Claude Code response. + +## Hook event mapping + +| Claude Code hook | ACS step method | Notes | +|---|---|---| +| `SessionStart` | `steps/sessionStart` | Session bounds, sent once per session by the hook process that opens the session and built from its event (see Concurrency). A `SessionStart` with `source: resume` or `compact` in a session that already started sends nothing. After a `SessionEnd`, the next hook of the same Claude Code session (`--resume` and `--continue` reuse its `session_id`) opens a new ACS session, with its own `session_id`, handshake and `sessionStart`, whose `platform_context.host_session_id` names the Claude Code session; nothing is sent into the ended one. A Guardian `deny`, or a refusal of the request, refuses the session: every later step of it is denied in its hook's own shape. A `sessionStart` that gets no decision is recorded, and each later hook settles its step with its own fail posture; `sessionEnd`, which is audit-only, goes out under either posture and reports that failure (§6.4). Session-level — carries no `turn_id`. | +| `SessionEnd` | `steps/sessionEnd` | Session close (chain seal). Carries `summary.decision_failures`, the session's decision failures per failure class (§6.4). The adapter marks the ACS session ended before it sends `sessionEnd`, so a hook Claude Code cancels at its timeout still leaves the next hook a new session, and removes the session's records afterwards. When the mark cannot be written, the adapter sends no `sessionEnd` and keeps the records, recorded as `session_end_not_sent`, so the next hook continues the open session rather than a sealed one. `acs-claude-code-wire` gives this hook a 15 s `timeout`, since SessionEnd hooks share a 1.5 s budget that a per-hook `timeout` raises, up to 60 s (hooks reference, SessionEnd). Session-level — no `turn_id`. | +| `UserPromptSubmit` | `steps/turnStart` **then** `steps/userMessage` | A prompt OPENS a turn: the adapter first emits a decision-eligible `steps/turnStart` (`triggered_by: user_message`; a Guardian MAY `deny` to block the whole turn), then the `steps/userMessage` carrying the `prompt`. Both — and every in-turn step below — carry `metadata.turn_id`. The `turn_id` is held in per-session state until the matching `Stop`. | +| `Stop` | `steps/agentResponse` **then** `steps/turnEnd` | End of a **turn** (every reply), not the session. When `last_assistant_message` carries the reply, the adapter first sends it as `steps/agentResponse` under the open turn. The reply has already reached the user, so a Guardian decision on it is recorded (`unenforceable_decision`) and the turn still ends. `steps/sessionEnd` "seals the chain" once per session, so mapping `Stop` there sealed it after every reply; `steps/turnEnd` (audit-only, `outcome: completed`) is the correct per-turn boundary and echoes the open turn's `turn_id`. A `Stop` with no open turn is skipped + audited (`turn_end_no_open_turn`) rather than emitting a fabricated `turn_id`. Claude Code runs no `Stop` after a user interrupt, so a turn still open when the next `UserPromptSubmit` arrives is closed first with a `steps/turnEnd` of outcome `interrupted`. | +| `StopFailure` | `steps/turnEnd` | The turn ended on an API error: `outcome: error`. Its `last_assistant_message` is the error text, so no `steps/agentResponse` is sent. Claude Code ignores this hook's output. | +| `PreToolUse` | `steps/toolCallRequest` | Fires before the tool runs. Decision gates execution. Carries `metadata.turn_id`. | +| `PreToolUse` with `tool_name="Agent"` (legacy: `"Task"`) | `steps/subagentStart` | The `Agent` tool IS Claude Code's subagent spawn (per the hooks reference; older builds named it `Task`, still matched), so it routes to the confused-deputy gate (ACS-Core requires `subagentStart` of a subagent-capable client) instead of the generic tool hook. All four required fields are real: `parent_step_id` is this step's own deterministic request_id (the delegation step and the spawn event are the same wire step on this platform), `subagent_session_id` is a uuid5 of the `tool_use_id` under a distinct namespace, `intent_derivation` is `derived_from_parent` (the Agent prompt is parent-authored — never `"fresh"`). The Guardian's decision translates back through the normal PreToolUse `permissionDecision` shape. `PostToolUse` for the Agent call stays `steps/toolCallResult` (it is honestly the tool's result); the separate `SubagentStop` event is not forwarded (see its row below). | +| `PostToolUse` | `steps/toolCallResult` | Fires after the tool runs. `tool_response` carries the output. A decision that keeps the output from Claude replaces it with `updatedToolOutput`, which "works for all tools", but Claude Code ignores a replacement that does not match a built-in tool's output schema and uses the original output. The adapter therefore replaces output only for tools whose shape it knows: `Bash`, in the documented `stdout` / `stderr` / `interrupted` / `isImage` shape; MCP tools, as text, which Claude Code passes through unvalidated; and, following the output schemas in the Claude Code 2.1.317 program, `Read` (its `text` output), `Grep`, `Glob`, `WebFetch`, `WebSearch`, `Write`, `Edit`, and `Agent` (its `completed` and `async_launched` outputs). Each keeps the fields and literals its schema requires and carries the withheld notice where the content was. Any other tool or output variant, or a result with no `tool_response`, is blocked, which shows Claude the reason next to the original output, and recorded as `unenforceable_decision`. | +| `PostToolUseFailure` | `steps/toolCallResult` | Fires when a tool that started fails, including a Bash command that exits nonzero and an MCP tool's error result. `exit_status: failure`, the `error` text as the output, and `request_id_ref` from `tool_use_id`. The hook accepts only `additionalContext`, and Claude has already received the error, so a Guardian decision other than `allow` is given to Claude as context and recorded as `unenforceable_decision`; withholding output covers successful calls only. | +| `SubagentStop` | — not forwarded | `KNOWN_UNMAPPED`. `steps/subagentStop` is SHOULD-emit, and its `final_chain_hash` is optional for a framework with no session chain (since PR #21), but it requires the `subagent_session_id` its `subagentStart` sent, which the adapter derives from the Agent call's `tool_use_id`. `SubagentStop` carries the subagent's `agent_id` instead, and it also fires for Claude Code's internal agents, so the adapter cannot name the subagent session a stop ends. Mapping it needs the Agent call's `agent_id` recorded against its `tool_use_id`. | +| `Notification` | — not forwarded | `KNOWN_UNMAPPED`: it fires on permission and idle prompts, not on the model's reply, so it carries no `agentResponse` content. The reply goes out from `Stop`. `acs-claude-code-wire` no longer wires it, and wiring or unwiring removes the ACS hook an earlier version wired there. | + +**Deliberately not mapped — `PreCompact`.** `steps/preCompact` requires `entries_to_compact` (`minItems: 1`) — the step_ids being folded into the summary, which is what the post-compaction provenance lineage references. Claude Code's `PreCompact` event carries no entry list, so an honest payload cannot be built; an empty or fabricated list would defeat the hook's provenance-laundering defense. This is a documented Guardian visibility gap (the adapter's `KNOWN_UNMAPPED` set), not an oversight. + +**Deliberately not mapped — the skill lifecycle set (`steps/skillRegister`, `steps/skillLoad`, `steps/skillUnload`).** Claude Code fires no hook event when a skill registers, activates, or unloads, and the payloads' integrity fields (`definition.digest`, the registration-bound load path) appear in no hook event, so an honest payload cannot be built. A skill *invocation* is governed: it arrives as `PreToolUse` for the `Skill` tool and routes through `steps/toolCallRequest` like any other tool call. Lifecycle wiring becomes possible when the platform exposes skill lifecycle events. + +Any other Claude Code hook event reaching the adapter (upstream renames, events wired in `settings.json` that the adapter doesn't know) emits an `unmapped_hook_event` audit line and lets Claude Code proceed — an ungoverned event is recorded as ungoverned, never silently dropped. Unmapped today: `PermissionRequest` and other lifecycle events with no semantic ACS equivalent in v0.1.0. + +## Disposition mapping + +Block-shape hooks are `UserPromptSubmit`, where a block stops the prompt, and `PostToolUse`, where a block only adds the reason next to the tool's output, so the adapter also replaces that output where Claude Code applies a replacement (see the mapping table). A fail-closed decision failure on `PostToolUse` withholds the output the same way. `Stop` is **audit-only** — Claude Code reads `{"decision":"block"}` on it as "do NOT stop, keep going," so the adapter never emits a block there; a Guardian `deny` is recorded (`unenforceable_decision`) and nothing is emitted. (`SubagentStop` is not forwarded at all — see the mapping table.) + +| ACS disposition | Claude Code response on `PreToolUse` | Response on `PostToolUse` / `UserPromptSubmit` | +|---|---|---| +| `allow` | empty: Claude Code's own permission rules and prompt still decide (a `permissionDecision` of `"allow"` would skip the prompt) | empty (Claude Code proceeds) | +| `deny` | `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "…", "additionalContext": "ACS blocked this tool call: …"}}` | `UserPromptSubmit`: `{"decision": "block", "reason": "..."}`. `PostToolUse`: the block plus `updatedToolOutput` holding a withheld notice (see the mapping table) | +| `modify` | `…"permissionDecision": "ask", "updatedInput": {…}`: the user sees and approves the rewritten call. The `parameter_overrides` are MERGED onto the original input (Claude Code replaces the whole input, so sending overrides alone would drop other arguments); substituted to `deny` if there is no usable object | `PostToolUse`: `updatedToolOutput` holding `modified_content` for `Bash` and MCP tools; withheld like a `deny` when there is none or the tool is another built-in, where the adapter cannot tell which field the content replaces | +| `ask` | `…"permissionDecision": "ask"` — **native**; Claude Code surfaces its own approval prompt. Auto mode runs `SubagentHandback` with no prompt, so there an ASK is a `deny`, audited as `unenforceable_decision`, and a MODIFY is `"allow"` with `updatedInput`, so the rewritten report is what is delivered | `PostToolUse`: withheld like a `deny`, since the tool already ran; `UserPromptSubmit`: substituted to `block` | +| `defer` | substituted to `deny` + `defer_substituted_deny` audit event: Claude Code's own `"defer"` ends the turn for a later resume and works only in non-interactive `-p` runs | `PostToolUse`: withheld like a `deny`; `UserPromptSubmit`: substituted to `block` | + +An arrived decision the host CANNOT apply is substituted to a block/deny (never silently proceeded) and audited. A MODIFY the host cannot apply is a DENY recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5): redactions or replacement content on a tool call, structured edits on a tool result, replacement content for a built-in tool other than `Bash`, whose output shape the hooks reference does not document, and any MODIFY on a prompt. Note the ASK and DEFER substitutions here are **deployment behavior, not spec-mandated**: §9.2 places disposition substitution on the Guardian, and §6's `timeout_decision: deny` default governs an *expired* defer, not an instant client-side substitution. A deployment that needs approver-driven resumption should run ACS-Core's ASK/DEFER flow at the Guardian layer and have the Guardian return an effective decision before responding to the adapter. A contradictory `modifications` object (both wholesale + structured, or overlapping targets) fails closed to `deny` per §6.3. + +## Concurrency + +Claude Code can run the hooks of parallel tool calls at the same time. The first hook process of a session holds the session's startup lock while it sends the session's one `handshake/hello` and its `steps/sessionStart`; it records how `sessionStart` went in a signed startup record next to the session record. Every other hook process of the session waits for that lock and follows the recorded outcome, so the Guardian sees the handshake, then `sessionStart`, then the session's other steps. A hook process that cannot take the lock, or finds a startup record that fails its checks, refuses the session and audits why. A start claimed by a hook process that ended before recording its outcome, as when the host cancels a hook at its timeout, is recorded once as a `sessionStart` without a decision (`session_start_incomplete`, failure class `observed_agent_error`), so each later hook settles its step with its own fail posture and `sessionEnd` reports it. `UserPromptSubmit` and `Stop` open and close the turn, and a session's first MCP tool call records `profile_claim_degraded`; other tool hooks only read session state. Every write of session state is one locked read-modify-write through a temp file of its own, and apart from the startup lock, no lock is held during a Guardian round trip. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. A second wired copy of the adapter would send every event twice, and both requests for one tool call would carry the same `request_id`, so `acs-claude-code-wire` refuses to wire when it finds one in user, project, local or managed settings, in the hooks of an installed plugin that `enabledPlugins` turns on, or, for a settings file Cursor imports, in a `hooks.json` Cursor loads. + +## Conformance posture + +The Claude Code adapter covers ACS-Core's mandatory floor as listed below. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). Every row below describes what the code in `adapter.py` actually does — this table drifted from the code once (PR #22 review caught six false claims in it), so treat the code as ground truth and this table as its description: + +- Handshake: `handshake/hello` is performed on the first hook event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`refuse`, the default, denies the step; `proceed` lets it run with nothing sent to the Guardian). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. The ServerHello's response envelope is signature-verified and bound to the ClientHello before it is stored; its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json) and is recorded as `method_not_evaluated`; the hook returns no output, so Claude Code's own permission flow still applies, and a result never cites a request that was not sent. With no negotiated list, every mapped hook is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. +- Turns: a `UserPromptSubmit` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `Stop` closes it with `steps/turnEnd`, `StopFailure` with outcome `error`, and the next `UserPromptSubmit` with outcome `interrupted` when neither ran. A turnStart the Guardian does not allow blocks the prompt and is closed at once with outcome `denied_at_start`. The `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step; session-level steps (`sessionStart`/`sessionEnd`) carry none. +- Subagent reports: on Claude Code 2.1.271 or later, a subagent in auto mode or a `-p` run hands its report back through the `SubagentHandback` tool. Its `Agent` result then carries only a short note, and the report reaches the Guardian twice: as the `message` of the `SubagentHandback` call (`steps/toolCallRequest`, which a deny stops) and, once delivered, as a `steps/userMessage` of the main session. A Guardian that keeps a report from the main agent denies the `SubagentHandback` call; a deny on the `Agent` result withholds only the note (live check, Claude Code 2.1.317, 2026-11-03). +- Cursor: Cursor runs the Claude Code hooks it imports from `.claude/settings.json` with its own events, which carry `cursor_version`; the hook hands those to the Cursor adapter, so Cursor gets Cursor's answer shapes (live check, Cursor 3.17.8, 2026-11-03). +- What the user sees: `permissionDecisionReason` and a block's `reason` reach only Claude, so every answer that blocks a step or withholds a result also carries `systemMessage`, which Claude Code shows the user (hooks reference, JSON output). A `PreToolUse` deny also carries the reason in `additionalContext`: when several `PreToolUse` hooks deny, Claude reads only the `permissionDecisionReason` of the one that finishes last, and `additionalContext` reaches Claude either way (checked live with Claude Code 2.1.318 and a second project hook that denies). +- Dispositions: on `PreToolUse`, ALLOW prints nothing, so Claude Code's own permission rules and prompt still decide; DENY and ASK are honored natively (`permissionDecision`); MODIFY is an `ask` carrying `updatedInput` (overrides merged onto the original input) when a usable `parameter_overrides` object is present, so the user approves the rewritten call; DEFER substitutes to deny with an audit event. On `PostToolUse` the tool has already run: a DENY, ASK, DEFER or unusable MODIFY replaces the output Claude sees (see the mapping table) and is recorded as `unenforceable_decision` when the hook carries no output to shape a replacement on. **Lifecycle hooks (`Stop → agentResponse, turnEnd`, `StopFailure`, `SessionEnd`) are observation-only**: Claude Code fires them after the reply / turn / session has completed, so a Guardian `deny` is recorded as `unenforceable_decision`, and a MODIFY as `modify_unsupported`, and neither can undo it. Deployments needing pre-delivery gating place the gate at `UserPromptSubmit` (prompt / turn start) for prompts and `PreToolUse` for tools. +- Session context: `session_id` comes from Claude Code's own hook event field on every request; the Guardian-side audit chain accumulates against it. `agent_id` is `ACS_AGENT_ID` when set, else derived as `claude-code:` from `CLAUDE_PROJECT_DIR`, or from the `cwd` of the session's first hook, since Claude Code's `cwd` follows Claude's `cd`. Turn state and decision-failure counts are keyed on the session alone. +- Replay protection: `request_id` (UUID) and `timestamp` are populated on every request. `PreToolUse` request_ids are deterministic (UUID5 of Claude Code's `tool_use_id`) so the matching `PostToolUse` can cite `request_id_ref`. An `Agent` result cites none, since hooks.md defines `request_id_ref` as the `request_id` of a `toolCallRequest` and the `Agent` call goes out as `steps/subagentStart`. +- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, with an HKDF-derived per-session key from `ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`. The signature names the configured label `ACS_HMAC_KEY_ID` as `key_id`, and a response under any other label fails verification with no other secret tried; an unsigned `SIGNATURE_INVALID`, a Guardian's answer to a label it does not hold, is recorded as `key_id_rejected` and fails closed. `rfc8785` is a hard dependency (no fallback canonicalization — §10 permits none). Responses are signature-verified AND bound to their request (`id` + `request_id`) under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. A hook with no secret, a secret under 32 bytes, or no label refuses its session and sends nothing (`secret_missing`, `secret_too_short`, `key_id_missing`), whatever the posture: ACS-Core signs every request. +- Decision honoring: the fail posture is the ServerHello's `on_decision_failure`, which the example Guardian declares `deny`, and `ACS_DEFAULT_DENY=1` makes it `deny` whatever the Guardian declares (most-restrictive-wins). Guardian **refusals** (`SIGNATURE_INVALID`, `REPLAY_DETECTED`, `TIMESTAMP_OUT_OF_WINDOW`, malformed/oversized envelope) always fail closed regardless of posture — a refusal is an alive Guardian rejecting the envelope, and each refusal is attacker-reachable (spec issue #32). Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on `PreToolUse`, `UserPromptSubmit` and `PostToolUse`, whose output is withheld; on other hooks it follows the posture. A decision result that fails response-envelope.json's `AcsResult` schema, validated with format checking (for example a missing `type: "final"`, a decision outside the five, a `deny` without `reasoning`, or a `chain_hash` that is not 64 lowercase hex characters), is a `malformed_response` decision failure: an `allow` follows the posture, and any other verdict is denied whatever the posture, since carried out as an allow an unreadable or restrictive verdict could run what the Guardian denied (spec issue #32). Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and `sessionEnd` reports the session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). +- Audit: every decision failure, refusal, substitution, unevaluated method, and unmapped event emits an `ACS_AUDIT` line to stderr and, when `ACS_AUDIT_FILE` is set, appends to that file (0600). diff --git a/adapters/docs/claude_code/settings.json.example b/adapters/docs/claude_code/settings.json.example new file mode 100644 index 00000000..8db00c82 --- /dev/null +++ b/adapters/docs/claude_code/settings.json.example @@ -0,0 +1,123 @@ +{ + "_comment": [ + "Drop into ~/.claude/settings.json (or merge with your existing config).", + "Prefer generating this with acs-claude-code-wire, which validates hook coverage and", + "warns when a subset of the ACS-Core minimum is wired. This file is the wire tool's", + "output for the placeholder arguments below, as", + "tests/integrations/claude_code/test_example_config.py asserts. Every setting rides", + "inline in the command string, the only channel Claude Code passes to a command hook.", + "", + "Replace ACS_GUARDIAN_URL with your deployment's endpoint and the", + "/path/to/ placeholders with real absolute paths.", + "", + "ACS_HMAC_SECRET_FILE: REQUIRED for ACS-Core baseline integrity (\u00a710), at least 32 bytes.", + "ACS_HMAC_KEY_ID: REQUIRED with the secret. The label that names it; the Guardian holds the same one.", + "Without a secret and its label every hook refuses its session and", + "denies every step. Generate one:", + " mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key", + "and give the SAME file to the Guardian.", + "", + "ACS_AUDIT_FILE: where \u00a76.4 audit events land durably. Hook-process", + "stderr is collected by nothing; this file is the durable record of every", + "decision failure.", + "", + "ACS_DEFAULT_DENY=1 and onFailure block are set on the GATE hooks (PreToolUse,", + "UserPromptSubmit), the ones whose verdict blocks an action. The other hooks", + "apply the Guardian's on_decision_failure, and a session whose handshake gets", + "no answer refuses its steps. Incident kill switch: set ACS_DISABLED=1 to", + "bypass all hooks immediately." + ], + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_DEFAULT_DENY=1 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired", + "onFailure": "block" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_DEFAULT_DENY=1 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired", + "onFailure": "block" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired", + "timeout": 15 + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + } + ] + } + ], + "StopFailure": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-claude-code-hook # acs-adapter-wired" + } + ] + } + ] + } +} diff --git a/adapters/docs/cursor/README.md b/adapters/docs/cursor/README.md new file mode 100644 index 00000000..ae6e6574 --- /dev/null +++ b/adapters/docs/cursor/README.md @@ -0,0 +1,362 @@ +# ACS adapter: Cursor + +A drop-in adapter that wires [Cursor](https://cursor.com) hooks to an ACS Guardian. No agent code changes; configuration only. + +## How it works + +Cursor fires a hook (e.g. `preToolUse`) by running a shell command from `hooks.json`. The command receives the hook event as JSON on stdin and emits Cursor-shaped JSON on stdout. + +This adapter is that command. On every hook event Cursor spawns it as a subprocess; the adapter: + +1. Reads the Cursor hook event from stdin. +2. Translates it to an ACS JSON-RPC envelope ([mapping.md](./mapping.md)). +3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET_FILE`, under the label `ACS_HMAC_KEY_ID`). +4. POSTs to the Guardian. +5. Verifies the response signature. +6. Translates the verdict back to Cursor's expected output shape and writes to stdout (or exits with code 2 for `beforeSubmitPrompt`). +7. Exits. + +The handshake/hello fires once per session, cached on disk so subsequent events skip the round-trip. + +### Decision honoring (§6.4) + +ACS-Core §6.4 requires the framework to wait for the verdict and apply it before the action executes. Cursor provides this guarantee through its hook protocol: like Claude Code, the adapter is invoked as a blocking subprocess and Cursor reads its stdout (or exit code, for `beforeSubmitPrompt`) for the decision before the action runs. Cursor also provides a native `failClosed: true` flag per hook entry that we set on gate hooks for defense in depth. + +## Install — five steps + +You need three pieces co-located on the same machine: a running Guardian, a shared HMAC secret, and a `hooks.json` that wires the adapter into Cursor's hooks. `acs-cursor-wire` does step 3 for you. + +Commands below assume `$ACS_REPO` points at your local clone of `GenAI-Security-Project/agent-control-standard` (or your fork). Export it once: + +```bash +export ACS_REPO=/path/to/your/clone # e.g., $HOME/code/ACS +uv tool install "$ACS_REPO/adapters" # or: pipx install "$ACS_REPO/adapters" +``` + +Installing the package puts `acs-cursor-hook`, `acs-cursor-wire` and `acs-example-guardian` on your PATH. + +### 1. Generate the shared HMAC secret + +Both the adapter and the Guardian read this file. Mode 0600 is enforced by the adapter — anything looser and it refuses to start. + +```bash +mkdir -p ~/.acs +openssl rand -hex 32 > ~/.acs/hmac.key +chmod 600 ~/.acs/hmac.key +``` + +### 2. Run the Guardian + +Use the example Guardian for testing; a production Guardian is the same wire protocol with a real policy engine attached. + +```bash +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ + acs-example-guardian --port 8787 +``` + +Keep this terminal open. You should see `[guardian] listening on 127.0.0.1:8787`. + +(For long-running setups: a `launchd` plist on macOS or a `systemd` unit on Linux. Out of scope here.) + +### 3. Wire `hooks.json` + +`acs-cursor-wire` does this safely: dry-run by default, atomic write with a timestamped backup when you pass `--write`. The backup is 0600 with any inline secret replaced by ``, and a settings file that holds an inline secret is made 0600. It refuses to wire when another copy of the adapter is already wired in a hook file the host may load, and lists where, since two copies would send every step twice, and both requests for one tool call would carry the same `request_id`. It checks the enterprise, project and user `hooks.json` listed below and, unless you pass `--cursor-third-party-imports-off` because Cursor's **Include Third-Party Plugins, Skills, and Other Configs** setting is off, the Claude Code settings Cursor imports. It wires the absolute path of the installed `acs-cursor-hook`, and stops if that command is not installed, or if a `--hook-command` you pass is not an absolute path to an executable file. It writes `--secret-file` and `--audit-file` as absolute paths, resolved from the directory it runs in, so the hook finds them whatever directory the host starts it in. + +Cursor runs the hooks from every source below and merges their answers, with any deny winning: +- `~/.cursor/hooks.json`: **user-level** (every workspace) +- `/.cursor/hooks.json`: **project-level** (this workspace only) +- the enterprise `hooks.json` (`/Library/Application Support/Cursor/` on macOS, `/etc/cursor/` on Linux, `C:\ProgramData\Cursor\` on Windows), team hooks and plugin hooks +- with **Include Third-Party Plugins, Skills, and Other Configs** on, the default: the Claude Code hooks in `~/.claude/settings.json` and the project's `.claude/settings.json` and `.claude/settings.local.json` + +Cursor runs an imported Claude Code hook with its own events, so `acs-claude-code-hook` hands an event that carries `cursor_version` to the Cursor adapter, and a workspace wired only for Claude Code is governed in Cursor with Cursor's answers. The wire tools refuse to wire a second ACS hook that Cursor would also run. + +```bash +# Preview wiring into ~/.cursor/hooks.json (default — user-level) +acs-cursor-wire \ + --guardian-url=http://127.0.0.1:8787/acs \ + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ + --audit-file=~/.acs/audit.log + +# Apply user-level wiring +acs-cursor-wire \ + --guardian-url=http://127.0.0.1:8787/acs \ + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ + --audit-file=~/.acs/audit.log \ + --write + +# OR — project-level wiring for a specific workspace +acs-cursor-wire \ + --guardian-url=http://127.0.0.1:8787/acs \ + --secret-file=~/.acs/hmac.key \ + --key-id=2026-11 \ + --audit-file=~/.acs/audit.log \ + --settings=./.cursor/hooks.json \ + --write +``` + +What it wires by default: the six unconditional hooks of the ACS-Core minimum set, plus `subagentStart`, which ACS-Core requires of a subagent-capable client such as Cursor, `postToolUseFailure`, which carries a failed tool's error text, and `stop`, which closes the turn. + +| Cursor event | ACS step method | Posture | +|---|---|---| +| `sessionStart` | sessionStart | the Guardian's posture; a Guardian `deny` refuses the session | +| `beforeSubmitPrompt` | userMessage | **fail-CLOSED** (gate) | +| `preToolUse` | toolCallRequest | **fail-CLOSED** (gate) | +| `postToolUse` | toolCallResult | the Guardian's posture; under `deny` an MCP tool's output is withheld | +| `postToolUseFailure` | toolCallResult | the Guardian's posture | +| `afterAgentResponse` | agentResponse | the Guardian's posture | +| `stop` | turnEnd | the Guardian's posture | +| `sessionEnd` | sessionEnd | the Guardian's posture; `timeout: 15` | +| `subagentStart` | subagentStart | **fail-CLOSED** (gate. The confused-deputy spawn gate, required by ACS-Core for subagent-capable clients.) | + +Gate hooks get **both** `ACS_DEFAULT_DENY=1` (our env var) AND `failClosed: true` (Cursor's native flag), and each covers a different failure. `failClosed: true` blocks when the hook process itself fails: it crashes, times out, or returns invalid JSON. `ACS_DEFAULT_DENY` and the negotiated posture decide the failures the adapter handles, such as an unreachable Guardian. Once the adapter decides a step proceeds, it answers `{}` on `preToolUse`, `beforeShellExecution` and `beforeMCPExecution`, which leaves the call to Cursor's own sandbox and approval, and its allow shape on `subagentStart` and `beforeSubmitPrompt`. Each is output, so `failClosed` never turns a decided proceed into a block. An explicit `{"permission": "allow"}` on a tool gate can take the call out of Cursor's sandbox: on 2026-11-03, Cursor 3.17.8 in Allowlist (with Sandbox) mode went straight to an unsandboxed run with it, and kept its sandbox and approval with `{}`. + +The posture is what a step does when no usable decision arrives (§6.4). A gate hook's command carries `ACS_DEFAULT_DENY=1`, so it fails closed whatever the Guardian declares; the other hooks apply the ServerHello's `on_decision_failure`, and the example Guardian declares `deny`. In a session whose handshake gets no answer, every hook refuses its step unless it sets `ACS_STARTUP_POSTURE=proceed` (§4.1). Override with `--default-deny` (`ACS_DEFAULT_DENY=1` and `failClosed: true` on every hook) or `--all-fail-open` (`ACS_DEFAULT_DENY=0` and `ACS_STARTUP_POSTURE=proceed` on every hook, gates included, so a step with no usable decision proceeds, audited, unless the Guardian declares `deny`). + +To remove the wiring later: `acs-cursor-wire --unwire --write`. + +### 4. Restart Cursor + +Cursor reads `hooks.json` at startup, not live. Existing windows keep their pre-wiring config until restart. + +### 5. Verify the install + +```bash +cd "$ACS_REPO/adapters" +uv sync --locked --group test +uv run python tests/integrations/cursor/e2e_check.py +``` + +Cursor is a GUI app — it has no headless CLI like `claude --print`. The e2e check is therefore **semi-automated**: the script does everything programmatic (Guardian setup, hooks wiring into a temp workspace, validation of envelopes) and prints precise instructions for actions you perform in Cursor. Wall-clock ~5-10 minutes total. + +The final line is `ACS-CORE SMOKE PASS (cursor)` (exit 0) — a smoke verdict, not a conformance certification — or a per-scenario failure list (exit 1). + +You can also do an in-session manual smoke test (see [Smoke tests](#smoke-tests) below). + +## Prerequisites + +- **Cursor** installed — +- **Python 3.11+** and the `acs-adapters` package (`uv tool install "$ACS_REPO/adapters"`). Its runtime dependencies are `rfc8785` and `jsonschema`. To run the test suites: `cd "$ACS_REPO/adapters" && uv sync --locked --group test`. +- **Canonical ACS schemas**: bundled in the package, and read from `specification/v0.1.0/` when running from a clone of this repo. Set `ACS_SPEC_DIR` only to validate against a different spec checkout. + +## Smoke tests + +Five tests, ordered from broadest to most specific. Run any/all. + +### Smoke #1 — automated test suite (unit + integration, ~30s) + +Run from `adapters/`: + +```bash +cd "$ACS_REPO/adapters" +uv run python run_conformance.py cursor +# Runs the shared Guardian, _common, core, client and package checks, then Cursor's suite. +``` + +The command reports passes, skips, and failures separately. It fails on any test failure or unexpected skip. The manual Cursor procedure is an expected skip in headless runs. + +### Smoke #2 — semi-automated Cursor end-to-end (~5-10 minutes) + +```bash +cd "$ACS_REPO/adapters" +uv sync --locked --group test +uv run python tests/integrations/cursor/e2e_check.py +``` + +Sets up a temp workspace with project-level `.cursor/hooks.json`, starts a recording Guardian wired to the real `example_guardian` policy, and walks you through 6 steps (SETUP, ALLOW, READ-TOOL, DESTRUCTIVE, USER-MESSAGE, HANDSHAKE-ONCE). The same shipping policy is active across every step — running `rm -rf` during any prompt gets the real destructive-Bash regex applied. At each step it tells you exactly what to do in Cursor, then validates the envelopes that arrived. + +### Smoke #3 — in-session manual test + +Open a Cursor workspace where hooks are wired. In the Agent panel try: + +``` +Use the shell to run: echo hello +``` + +In your Guardian terminal you should see roughly this sequence: + +``` +[guardian] handshake/hello session=... +[guardian] steps/sessionStart session=... +[guardian] steps/userMessage session=... +[guardian] steps/toolCallRequest session=... +[guardian] steps/toolCallResult session=... +[guardian] steps/agentResponse session=... +``` + +Then ask Cursor for a denied command: + +``` +Use the shell to run: rm -rf /tmp/some-fake-path +``` + +The example Guardian's regex matches `rm -rf /...`; Cursor surfaces the Guardian's `reasoning` to the user and the command never runs. + +### Smoke #4 — audit-cause differentiation + +Verifies that the adapter's audit log distinguishes a Guardian that rejected the request (a configuration error) from a Guardian it could not reach (an ops issue). Both commands run against the Guardian started above, on `127.0.0.1:8787` under the label `2026-11`. + +A secret the Guardian does not hold, under the Guardian's label: + +```bash +ACS_GUARDIAN_URL="http://127.0.0.1:8787/acs" \ +ACS_HMAC_SECRET="$(openssl rand -hex 32)" ACS_HMAC_KEY_ID=2026-11 \ +acs-cursor-hook preToolUse 2>&1 <<'EOF' +{"session_id":"11111111-1111-4111-8111-111111111111","tool_name":"Bash","tool_input":{"command":"echo test"}} +EOF +``` + +Expected: the Guardian answers the handshake with `-32004 SIGNATURE_INVALID`, signed with its own secret. The hook cannot verify that answer, so it refuses the session whatever the posture (spec issue #32), and stdout carries a deny: + +``` +ACS_AUDIT {"acs_audit_event": "session_refused", "cause": "error_signature_invalid", "claimed_cause": "signature_invalid_response", "claimed_error_code": -32004, ...} +ACS_AUDIT {"acs_audit_event": "session_refused_fail_closed", "cause": "error_signature_invalid", ..., "event": "preToolUse", ...} +{"permission": "deny", "user_message": "ACS adapter: session refused (error_signature_invalid)", "agent_message": "ACS adapter: session refused (error_signature_invalid)"} +``` + +Guardian unreachable, with the Guardian's secret and label: + +```bash +ACS_GUARDIAN_URL="http://127.0.0.1:1/dead" \ +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ +acs-cursor-hook preToolUse 2>&1 <<'EOF' +{"session_id":"11111111-1111-4111-8111-111111111111","tool_name":"Bash","tool_input":{"command":"echo test"}} +EOF +``` + +Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (`refuse` unless the hook sets `ACS_STARTUP_POSTURE=proceed`); stdout carries the deny: + +``` +ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_closed", ...} +ACS_AUDIT {"acs_audit_event": "session_not_started_fail_closed", "cause": "transport_failure", ..., "event": "preToolUse", ...} +``` +{"permission": "deny", "user_message": "ACS adapter: session not started (transport_failure)", "agent_message": "ACS adapter: session not started (transport_failure)"} + +Distinct `acs_audit_event` and `cause`: a rejection refuses the session (`session_refused_fail_closed`) whatever the posture, and an unreachable Guardian follows the startup posture (`session_not_started_fail_closed`, or `unguarded_session_bypass` under `ACS_STARTUP_POSTURE=proceed`). Operators grep on either to triage. A hook with no secret sends nothing and refuses the session with `secret_missing`. + +### Smoke #5 — pre-flight inventory (paranoid) + +If you're debugging, this is the fastest "where did I go wrong" sweep: + +```bash +echo "=== Guardian listening? ===" +lsof -i :8787 | head -3 || echo "NOT RUNNING" + +echo "=== Secret file 0600? ===" +ls -la ~/.acs/hmac.key + +echo "=== Hooks wired? ===" +python3 -c "import json, os; d=json.load(open(os.path.expanduser('~/.cursor/hooks.json'))); print(list(d.get('hooks',{}).keys()))" + +echo "=== Guardian responds to system/ping? ===" +cd "$ACS_REPO/adapters" && uv run python -c " +from acs.client import ping; import json +r = ping('http://127.0.0.1:8787/acs'); print(json.dumps(r, indent=2)[:200] if r else 'no response') +" +``` + +## Files + +- `src/acs/integrations/cursor/hook.py`: the entry point installed as `acs-cursor-hook`. Stdlib only, so it can still audit and apply the configured posture when the adapter's dependencies are missing. +- `src/acs/integrations/cursor/adapter.py`: the adapter itself, which `hook.py` loads. Stdlib + `rfc8785` for JCS canonicalization. +- `src/acs/integrations/cursor/wire.py`: the `hooks.json` wiring CLI, installed as `acs-cursor-wire` (dry-run by default; `--write` to apply). +- `tests/integrations/cursor/e2e_check.py`: semi-automated Cursor end-to-end verifier (6 steps, real `example_guardian` policy). +- `docs/cursor/hooks.json.example`: reference wiring (`acs-cursor-wire` produces a more comprehensive one). +- `docs/cursor/mapping.md`: Cursor hook → ACS step method table, plus disposition translation. +- `tests/integrations/cursor/`: round-trip and schema tests, plus the manual-procedure placeholder. +- `docs/cursor/example_payloads.md`: masked real-world payload examples showing exactly what Cursor emits. +- `docs/cursor/live_verification.md`: manual reproduction procedure (Cursor has no headless mode). + +The adapter shares `acs.core` (signing, decision rules, error codes) and `acs.client` (handshake cache, session state, audit events, URL allowlist) with the Claude Code and NAT adapters. + +## How it differs from the Claude Code adapter + +Same architectural pattern (shell-stdin/stdout, JSON in, JSON out), different protocol: + +| Aspect | Claude Code | Cursor | +|---|---|---| +| Event dispatch | Single command, event type in `hook_event_name` field | One command per event, event name passed as `argv[1]` | +| Allow/deny field | `hookSpecificOutput.permissionDecision` | `permission` (top-level) | +| Modify input field | `hookSpecificOutput.updatedInput` | `updated_input` | +| Block via exit code | Optional (`exit 2`) | Supported (`exit 2`); the adapter answers `beforeSubmitPrompt` with `{"continue": false, "user_message"}` and exit 0 instead, so Cursor shows the message | +| Fail-closed flag | env var `ACS_DEFAULT_DENY` | hook-level `failClosed: true` in `hooks.json`, PLUS env var | +| Per-event output keys | Mostly uniform via `hookSpecificOutput` | Event-specific (`permission`, `additional_context`, `updated_mcp_tool_output`, `followup_message`, ...) | +| Headless CLI for tests | `claude --print` | None — Cursor is a desktop GUI; e2e is semi-automated | + +The adapter handles all these protocol differences internally; the Guardian sees the same ACS JSON-RPC shape from both. + +## Configuration + +The adapter is configured by environment variables, typically set per-hook by `acs-cursor-wire`: + +| Variable | Default | Purpose | +|---|---|---| +| `ACS_GUARDIAN_URL` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only; SSRF allowlist refuses other schemes. | +| `ACS_HMAC_SECRET_FILE` | (unset) | Path to a 0600 file holding the shared HMAC secret. | +| `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | +| `ACS_HMAC_KEY_ID` | (unset) | The label that names the secret (§10); the Guardian holds the same one. Required with a secret: a secret without a label refuses the session, and a response signed under another label fails verification. | +| `ACS_DEFAULT_DENY` | (unset) | `1`: fail-closed whatever the Guardian declares. Unset or `0`: the ServerHello's `on_decision_failure` applies, and the example Guardian declares `deny` (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless of this setting — see spec issue #32. | +| `ACS_STARTUP_POSTURE` | `refuse` | `proceed` or `refuse`: what this hook does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the step run, audited, with nothing sent to the Guardian; `refuse` denies it. The session keeps no negotiated Guardian for its lifetime, and the next session handshakes again. Any other value refuses. | +| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused and it sends no `sessionStart`; the stored record keeps what the Guardian answered, so each hook applies its own setting, and a later hook without the requirement starts the session. | +| `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every step. | +| `ACS_AGENT_ID` | derived from cwd | Stable agent identifier sent in `metadata.agent_id`. | +| `ACS_HANDSHAKE_CACHE` | `~/.cache/acs-adapter-handshake/` | Each session's handshake decision, kept for the session's lifetime. | +| `ACS_HANDSHAKE_TIMEOUT_SECONDS` | `5` | Deadline for the whole handshake/hello exchange, including a response that arrives slowly. A value that is not a positive number is replaced by `5` and recorded as `invalid_setting`. | +| `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr. | +| `ACS_DISABLED` | (unset) | `1` = incident kill switch: bypass all hooks immediately, no Guardian traffic. Each gate answers with its proceed shape (`{}` on the tool gates), since `failClosed` blocks a hook that prints nothing. | +| `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | + +The adapter is invoked as `acs-cursor-hook `, where `` is one of: `sessionStart`, `sessionEnd`, `stop`, `preToolUse`, `postToolUse`, `postToolUseFailure`, `subagentStart`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `afterFileEdit`, `afterTabFileEdit`, `beforeSubmitPrompt`, `preCompact`, `afterAgentResponse`, `afterAgentThought`. + +## On-disk state + +- `~/.cache/acs-adapter-handshake/.json` — the session's handshake decision (negotiated ServerHello, refusal, or no answer) with an HMAC under the session key over the decision, the session and the Guardian URL. Mode 0600; kept for the session's lifetime. A record that is modified, damaged, copied from another endpoint or written by an earlier adapter refuses the session, and so does a decision that cannot be stored. +- `~/.cache/acs-adapter-session/.json` — session-state cache (last step_id, seen step_ids). Used by `subagentStart` and `preCompact` to populate real `parent_step_id` / `entries_to_compact` from session history. +- `~/.cache/acs-guardian-state/.json` — Guardian-side per-session chain head + replay set; survives Guardian restart. + +## Conformance status + +Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md`. Some Core items are conditional or SHOULD-level: `subagentStart` binds subagent-capable clients, MODIFY and `system/ping` are SHOULD, and Wrapped MCP binds sessions that involve MCP. The row notes say how each one applies here. + +| ACS-Core item | Status | +|---|---| +| Handshake (`handshake/hello`) | ✓ on the first event of a session; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ always claims `acs-core`: a hook with no usable secret and label refuses its session before the handshake. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | +| JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema for every mapped hook (emission suite), with format checking | +| Hook taxonomy (Core minimum set) | ✓ the six unconditional hooks are covered, and so is `subagentStart`, which ACS-Core requires of a subagent-capable client. Cursor exposes the spawn boundary, and the default wiring gates it fail-closed. Seventeen Cursor events are mapped in total; `subagentStop` is intentionally omitted — see the honesty table below. | +| Dispositions | ALLOW / DENY supported on **permission (pre-execution) events** (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeSubmitPrompt`, `subagentStart`). ASK holds on `beforeShellExecution` and `beforeMCPExecution`; on `preToolUse` Cursor accepts `ask` without enforcing it, so ASK and DEFER deny there, audited as `unenforceable_decision`. DEFER is substituted to ASK where ASK holds (Cursor has no defer). MODIFY supported on `preToolUse` via `updated_input`. A `deny` on `sessionStart` refuses the session, so every later step of it is denied. **Lifecycle / post-execution hooks (`afterAgentResponse → steps/agentResponse`, `sessionEnd`, `afterShellExecution`, etc.) are observation-only** — Cursor fires them after the message / side effect has occurred; a Guardian `deny` cannot undo it. See `mapping.md`. | +| Unknown-disposition fail posture | ✓ | +| SessionContext + published `chain_hash` | ✓ session_id coerced to UUID; Guardian computes rolling SHA-256 chain | +| Replay protection | ✓ Guardian enforcement (REPLAY_DETECTED -32005, TIMESTAMP_OUT_OF_WINDOW -32006) | +| Baseline integrity (HMAC-SHA256) | ✓ every request and response signed under `ACS_HMAC_KEY_ID`; a hook with no secret of at least 32 bytes, or no label, refuses its session and sends nothing; SIGNATURE_INVALID -32004 on tamper | +| Decision honoring (§6.4) | ✓ Cursor blocks on permission deny; adapter answers every decided step in Cursor's documented JSON; every decision failure emits an `ACS_AUDIT` event under either posture; audit `cause` field distinguishes failure modes (transport vs signature vs malformed envelope vs replay vs skew) | +| Liveness `system/ping` | ⚠ Guardian-side only. ACS-Core says `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it (§13). The example Guardian answers ping as an always-allow method outside the chain, replay, and signature checks. The adapter does not declare or send `system/ping`, so a deployment that uses it must name an alternative liveness mechanism in its configuration, such as continuous observed hook traffic. Omitting both is non-conformant. | +| `nonce` (optional replay field) | ✗ adapter does not emit `nonce`; the envelope field is OPTIONAL in v0.1 | +| Wrapped MCP `protocols/MCP/*` | ⚠ partial. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter covers only that case: it maps `beforeMCPExecution` to `steps/toolCallRequest` and `afterMCPExecution` to `steps/toolCallResult`. It wraps no other MCP surface (`initialize`, `prompts/get`, `resources/read`, notifications), so a session that uses MCP falls short of ACS-Core there. The first MCP tool a session uses records `profile_claim_degraded` once, whether it arrives through the MCP hooks or through `preToolUse` and `postToolUse` as `MCP:`. Known limitation. | + +### Per-hook honesty table + +Cursor does not expose every field the ACS v0.1.0 hook schemas require. Where the schema is strict and Cursor is silent, the adapter populates fields from real session state where possible — and omits the hook entirely when the missing field can only be fabricated. + +| Cursor event → ACS hook | What we fill from real data | What's hardcoded | What's omitted | +|---|---|---|---| +| `subagentStart` → `steps/subagentStart` | `subagent_session_id` (deterministic uuid5 of `parent_session + subagent_id`); `parent_session_id` (the envelope's actual `session_id`); `parent_step_id` (last step_id the adapter has seen in this session, tracked in `~/.cache/acs-adapter-session/`); `subagent_descriptor.{agent_id,agent_name}` (from Cursor's `subagent_id` / `subagent_type`) | `intent_derivation = "derived_from_parent"` (defensible default for IDE-spawned subagents) | — | +| `preCompact` → `steps/preCompact` | `entries_to_compact` (list of step_ids the adapter has observed in this session, snapshotted from session state); `triggered_by` (Cursor's `trigger` field) | `triggered_by = "framework_initiated"` only when Cursor omits `trigger` | — | +| `subagentStop` → `steps/subagentStop` | — | — | **Not forwarded** (`KNOWN_UNMAPPED` in the adapter). Cursor's `subagentStop` carries no identifier of the subagent it ends, so it cannot name the `subagent_session_id` `steps/subagentStop` requires (`mapping.md`). | + +These hooks are emitted only when Cursor's `hooks.json` wires them to the adapter. Per-session state for `parent_step_id` / `entries_to_compact` requires the adapter to be wired to at least one earlier hook in the same session (typically `preToolUse`); the adapter records each step's `request_id` to the session-state file on every invocation. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| Cursor's agent runs commands as if no Guardian exists | Cursor reads `hooks.json` at startup. **Restart Cursor** after `acs-cursor-wire --write`. | +| Adapter exits 0 with empty stdout; Guardian terminal silent | Adapter pointed at the wrong URL (check `ACS_GUARDIAN_URL` in your hooks.json); check stderr for `ACS_AUDIT cause=transport_failure`. | +| Every tool call gets denied | Likely the Guardian is down: a session whose handshake gets no answer refuses its steps unless `ACS_STARTUP_POSTURE=proceed`, and the gate hooks carry `ACS_DEFAULT_DENY=1`. Check the Guardian process is running. | +| Adapter says `SecretFilePermissionsError` | The HMAC secret file is mode > 0600. `chmod 600 ~/.acs/hmac.key`. | +| Guardian returns `-32004 SIGNATURE_INVALID` | Adapter and Guardian aren't reading the same secret. `cat ~/.acs/hmac.key` on both sides should match. | +| Guardian returns `-32006 TIMESTAMP_OUT_OF_WINDOW` | Clock skew between adapter and Guardian > 5 minutes. Sync time. | +| Guardian returns `-32600 Invalid Request` for `metadata.session_id` | Session ID wasn't coerced to a UUID. Cursor sends conversation_ids that aren't UUIDs; the adapter coerces them via uuid5. If you see this error, file a bug — the coercion is unconditional in `_session_id`. | + +Everything the adapter does that's not policy decision-making is audited on stderr as a JSON line prefixed `ACS_AUDIT`. The `cause` field tells you which failure mode fired. diff --git a/adapters/docs/cursor/example_payloads.md b/adapters/docs/cursor/example_payloads.md new file mode 100644 index 00000000..faf84466 --- /dev/null +++ b/adapters/docs/cursor/example_payloads.md @@ -0,0 +1,189 @@ +# Real-world payload examples — Cursor + +These are the actual JSON shapes Cursor emits on stdin for each hook, captured from a real Cursor agent session and **masked**. Identifying fields are replaced with placeholders. Use these to understand the schema the adapter parses. + +The fields documented in Cursor's bundled `create-hook` skill (`~/.cursor/skills-cursor/create-hook/SKILL.md`) plus several that appear in real payloads but aren't in the public-facing docs (flagged below). + +--- + +## sessionStart + +```json +{ + "session_id": "00000000-0000-0000-0000-000000000001", + "transcript_path": "/Users//.cursor/projects//agent-transcripts//.jsonl", + "cwd": "/path/to/workspace", + "hook_event_name": "sessionStart", + "cursor_version": "3.7.x", + "workspace_roots": ["/path/to/workspace"], + "user_email": "" +} +``` + +Fields not in the public skill docs but present in real payloads: `cursor_version`, `workspace_roots`, `user_email`. The adapter does not forward `user_email` to the Guardian by default. + +--- + +## beforeSubmitPrompt + +```json +{ + "conversation_id": "00000000-0000-0000-0000-000000000001", + "generation_id": "00000000-0000-0000-0000-000000000002", + "model": "default", + "composer_mode": "agent", + "prompt": "", + "attachments": [], + "session_id": "00000000-0000-0000-0000-000000000001", + "hook_event_name": "beforeSubmitPrompt", + "cursor_version": "3.7.x", + "workspace_roots": ["/path/to/workspace"], + "user_email": "", + "transcript_path": "/Users//.cursor/projects//agent-transcripts//.jsonl" +} +``` + +`session_id` and `conversation_id` typically hold the same UUID in agent mode. The adapter prefers `session_id`. + +--- + +## preToolUse (Grep) + +```json +{ + "conversation_id": "00000000-0000-0000-0000-000000000001", + "generation_id": "00000000-0000-0000-0000-000000000002", + "model": "default", + "tool_name": "Grep", + "tool_input": { + "pattern": "TODO", + "file_path": "/path/to/workspace" + }, + "tool_use_id": "tool_00000000-0000-0000-0000-000000000003", + "session_id": "00000000-0000-0000-0000-000000000001", + "hook_event_name": "preToolUse", + "cursor_version": "3.7.x", + "workspace_roots": ["/path/to/workspace"], + "user_email": "", + "transcript_path": "/Users//.cursor/projects//agent-transcripts//.jsonl" +} +``` + +--- + +## postToolUse (Grep) + +```json +{ + "conversation_id": "00000000-0000-0000-0000-000000000001", + "generation_id": "00000000-0000-0000-0000-000000000002", + "model": "default", + "tool_name": "Grep", + "tool_input": { + "pattern": "TODO", + "file_path": "/path/to/workspace" + }, + "tool_output": "{\"pattern\":\"TODO\",\"success\":true}", + "duration": 19.175, + "tool_use_id": "tool_00000000-0000-0000-0000-000000000003", + "session_id": "00000000-0000-0000-0000-000000000001", + "hook_event_name": "postToolUse", + "cursor_version": "3.7.x", + "workspace_roots": ["/path/to/workspace"], + "user_email": "", + "transcript_path": "/Users//.cursor/projects//agent-transcripts//.jsonl" +} +``` + +--- + +## beforeShellExecution + +```json +{ + "conversation_id": "00000000-0000-0000-0000-000000000001", + "generation_id": "00000000-0000-0000-0000-000000000002", + "model": "default", + "command": "ls -la", + "cwd": "/path/to/workspace", + "sandbox": true, + "session_id": "00000000-0000-0000-0000-000000000001", + "hook_event_name": "beforeShellExecution", + "cursor_version": "3.7.x", + "workspace_roots": ["/path/to/workspace"], + "user_email": "", + "transcript_path": "/Users//.cursor/projects//agent-transcripts//.jsonl" +} +``` + +`sandbox` indicates whether Cursor will run the command in its sandbox. + +--- + +## Adapter response shapes + +Per-event output keys differ. See the [mapping table](../mapping.md#disposition-mapping) for the full matrix. + +### Allow (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`) + +```json +{} +``` + +Cursor's own sandbox and approval then decide the call. + +### Allow (`subagentStart`) + +```json +{"permission": "allow"} +``` + +### Deny (permission events) + +```json +{ + "permission": "deny", + "user_message": "destructive Bash pattern in: rm -rf /home/u", + "agent_message": "destructive Bash pattern in: rm -rf /home/u" +} +``` + +### Modify (preToolUse with parameter_overrides) + +```json +{ + "permission": "allow", + "updated_input": {"command": "ls -la # sanitized"}, + "user_message": "command sanitized by Guardian" +} +``` + +### Post-tool events (additional_context) + +```json +{"additional_context": "audit: tool ran in 19ms; output 142 bytes"} +``` + +### beforeSubmitPrompt (blocked) + +```json +{"continue": false, "user_message": "ACS adapter: prompt blocked (transport_failure)"} +``` + +The adapter exits with code 0, so Cursor reads the JSON and shows the message. + +--- + +## Masking convention used here + +| Field | Real value contains | Masked as | +|---|---|---| +| `session_id`, `conversation_id` | Real UUIDs | `00000000-0000-0000-0000-000000000001` | +| `generation_id`, `tool_use_id` | Real UUIDs | `00000000-0000-0000-0000-00000000000X` / `tool_` | +| `cursor_version` | Real version (e.g. `3.7.21`) | `3.7.x` | +| `workspace_roots`, `cwd` | Real workspace path | `/path/to/workspace` | +| `transcript_path` | Real absolute path | `/Users//.cursor/...` | +| `user_email` | Real user identity | `` | +| `prompt`, `command` | Real content (sometimes preserved when benign) | `` / preserved | + +No real session data is committed to this repo. diff --git a/adapters/docs/cursor/hooks.json.example b/adapters/docs/cursor/hooks.json.example new file mode 100644 index 00000000..edce0c31 --- /dev/null +++ b/adapters/docs/cursor/hooks.json.example @@ -0,0 +1,77 @@ +{ + "_comment": "Drop into /.cursor/hooks.json (or merge with your existing config). Replace ACS_GUARDIAN_URL with your deployment's endpoint and /path/to/acs-cursor-hook with the installed command (acs-cursor-wire writes it for you). ACS_HMAC_SECRET_FILE is REQUIRED for ACS-Core baseline integrity (§10): a secret of at least 32 bytes, named by ACS_HMAC_KEY_ID, the label the Guardian holds too; without both every hook refuses its session and denies every step. Generate it with mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key and give the SAME file to the Guardian. ACS_AUDIT_FILE is the durable audit sink for §6.4 events.", + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook sessionStart" + } + ], + "beforeSubmitPrompt": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeSubmitPrompt", + "failClosed": true + } + ], + "preToolUse": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook preToolUse", + "failClosed": true + } + ], + "postToolUse": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook postToolUse" + } + ], + "beforeShellExecution": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeShellExecution", + "failClosed": true + } + ], + "afterShellExecution": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterShellExecution" + } + ], + "beforeMCPExecution": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook beforeMCPExecution", + "failClosed": true + } + ], + "afterMCPExecution": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterMCPExecution" + } + ], + "subagentStart": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log ACS_DEFAULT_DENY=1 /path/to/acs-cursor-hook subagentStart", + "failClosed": true + } + ], + "afterAgentResponse": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook afterAgentResponse" + } + ], + "stop": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook stop" + } + ], + "postToolUseFailure": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook postToolUseFailure" + } + ], + "sessionEnd": [ + { + "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 ACS_AUDIT_FILE=/path/to/.acs/audit.log /path/to/acs-cursor-hook sessionEnd", + "timeout": 15 + } + ] + } +} diff --git a/adapters/docs/cursor/live_verification.md b/adapters/docs/cursor/live_verification.md new file mode 100644 index 00000000..3c7a6cc1 --- /dev/null +++ b/adapters/docs/cursor/live_verification.md @@ -0,0 +1,78 @@ +# Cursor live verification + +Cursor is a desktop application with no documented headless mode, so the live test cannot run in CI. It can be reproduced manually by a reviewer with Cursor installed. + +## Status: ✅ Verified via manual reproduction + +The procedure below has been run end-to-end and produced the expected outcomes (5+ events flowed from a real Cursor session through the adapter to the example Guardian, all hooks routed correctly, zero adapter errors). Real captured payloads are not committed to the repo because Cursor's hook events contain session-identifying fields (workspace path, conversation id, user email). + +## Procedure + +```bash +# 1. Generate a shared signing secret and start the example Guardian +mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 acs-example-guardian --port 8787 + +# 2. In a new shell, set up a test project with the adapter wired in +mkdir -p /tmp/acs-cursor-live/.cursor +cat > /tmp/acs-cursor-live/.cursor/hooks.json <<'EOF' +{ + "version": 1, + "hooks": { + "sessionStart": [ + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook sessionStart" } + ], + "beforeSubmitPrompt": [ + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook beforeSubmitPrompt" } + ], + "preToolUse": [ + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook preToolUse" } + ], + "postToolUse": [ + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook postToolUse" } + ], + "beforeShellExecution": [ + { "command": "ACS_GUARDIAN_URL=http://127.0.0.1:8787/acs ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 /path/to/acs-cursor-hook beforeShellExecution" } + ] + } +} +EOF + +# 3. Open the project in Cursor and prompt the agent to do something +# that triggers tool calls (a benign Grep or a Shell command works). + +# 4. Observe the Guardian's stderr for hook events: +# [guardian] steps/sessionStart session= step= +# [guardian] steps/userMessage session= step= +# [guardian] steps/toolCallRequest session= step= +# [guardian] steps/toolCallResult session= step= +``` + +## Expected outcomes + +A benign prompt that triggers tool use should produce, in order, on the Guardian's stderr: + +1. `handshake/hello`, once for the session +2. `steps/sessionStart`, from whichever of Cursor's first hooks opens the session +3. `steps/turnStart`, then `steps/userMessage` (Cursor's `beforeSubmitPrompt`) +4. `steps/toolCallRequest` (Cursor's `preToolUse` for the first tool) +5. `steps/toolCallResult` (Cursor's `postToolUse`) +6. Additional `toolCallRequest` / `toolCallResult` pairs for each subsequent tool the agent invokes + +The Cursor UI should show the agent's tool calls proceeding normally; no policy block messages because the example Guardian's policy only denies destructive Bash patterns and writes to system paths. + +## Deny-path verification + +To exercise the deny path, prompt the Cursor agent to run a command matching the example Guardian's destructive regex against a clearly nonexistent target (something the agent has no real reason to run). The Guardian should respond with `deny`; the adapter should emit `{"permission": "deny", "user_message": "destructive Bash pattern..."}`; Cursor should surface the block in its UI and not execute the command. + +## Cursor event schema notes + +While running the reproduction, the Cursor events sent to the adapter include several fields beyond what the public `create-hook` skill documentation specifies. These are handled by the adapter's existing fallback logic without modification: + +- Both `session_id` and `conversation_id` are present (the adapter prefers `session_id`) +- `generation_id`, `model`, `composer_mode`, `cursor_version` +- `workspace_roots`, `transcript_path`, `user_email` (deployment-specific, the adapter does not forward these to the Guardian by default) +- For tools: `tool_use_id`, `duration` +- For Shell: `command`, `cwd`, `sandbox` + +If your Guardian wants to incorporate any of these into policy decisions, extend `src/acs/integrations/cursor/adapter.py`'s `build_payload` to include the relevant fields. diff --git a/adapters/docs/cursor/mapping.md b/adapters/docs/cursor/mapping.md new file mode 100644 index 00000000..ccb1460c --- /dev/null +++ b/adapters/docs/cursor/mapping.md @@ -0,0 +1,110 @@ +# Cursor → ACS hook mapping + +Schema sources: the official Cursor hooks documentation at docs.cursor.com (as of 2026-08-22) and Cursor's bundled `create-hook` skill (`~/.cursor/skills-cursor/create-hook/SKILL.md`). Every input field the adapter reads and every output field it emits appears in those documents — an earlier revision read fields Cursor never sends (`workspace_path`, `mcp_server`, `mcp_tool`, `exit_code`, `execution_id`, `response`, `thought`), so MCP/shell/agent envelopes reached the Guardian with empty identity and content (PR #22 host-contract audit). + +Each Cursor hook event maps to an ACS `steps/*` method. The adapter (`src/acs/integrations/cursor/adapter.py`) does the translation in both directions. + +## Hook event mapping + +| Cursor hook | ACS step method | Notes | +|---|---|---| +| `sessionStart` | `steps/sessionStart` | Session bounds, sent once per session by the hook process that opens the session and built from its event (see Concurrency). `platform_context` carries the real fields: `workspace_roots`, `cursor_version`, and from Cursor's `sessionStart` event, `composer_mode` and `is_background_agent`. A Guardian `deny`, or a refusal of the request, refuses the session: every later step of it is denied in its hook's own shape. A `sessionStart` that gets no decision is recorded, and each later hook settles its step with its own fail posture; `sessionEnd`, which is audit-only, goes out under either posture and reports that failure (§6.4). Session-level — no `turn_id`. | +| `sessionEnd` | `steps/sessionEnd` | Session close (chain seal). Cursor reasons map onto the ACS enum: `completed`→`completed`, `aborted`→`cancelled`, `error`→`error`, `window_close`/`user_close`→`abandoned`. Carries `summary.decision_failures`, the session's decision failures per failure class (§6.4). The adapter marks the ACS session ended before it sends `sessionEnd`, so a hook Cursor cancels still rolls the session over, and removes the session's records afterwards; when the mark cannot be written, it sends no `sessionEnd` and keeps the records, recorded as `session_end_not_sent`, so the next hook continues the open session; `acs-cursor-wire` gives this hook a 15 s `timeout`; a later hook of the same conversation opens a new ACS session, with its own `session_id`, handshake and `sessionStart`, whose `platform_context.host_session_id` names the conversation. Session-level — no `turn_id`. | +| `stop` | `steps/turnEnd` | End of a **turn** (each reply), not the session. `steps/sessionEnd` seals the chain once per session, so mapping `stop` there sealed it every reply; `steps/turnEnd` (audit-only) is the per-turn boundary and echoes the open turn's `turn_id`. Cursor's `status` maps to the outcome enum: `completed`→`completed`, `aborted`→`interrupted`, `error`→`error`. A `stop` with no open turn is skipped + audited rather than fabricating a `turn_id`. `acs-cursor-wire` wires `stop` by default; a turn still open when the next `beforeSubmitPrompt` arrives is closed first with outcome `interrupted`. | +| `preToolUse` | `steps/toolCallRequest` | Principal interception point; fires for ALL tool types (Shell, Read, Write, MCP, Task, …). `request_id` pinned to uuid5 of `tool_use_id` so the matching result correlates. Carries `metadata.turn_id`. | +| `postToolUse` | `steps/toolCallResult` | `tool_output` is a JSON-STRINGIFIED result payload ("not raw terminal text"); the adapter parses it into `outputs`, maps a nonzero `exitCode` inside it to `exit_status: failure`, coerces `duration`→`duration_ms` (integer), and cites `request_id_ref` from `tool_use_id`. | +| `postToolUseFailure` | `steps/toolCallResult` | Real failure fields: `error_message` flows into `outputs`; `failure_type` maps onto the ACS enum — `error`→`failure`, `timeout`→`timeout`, `permission_denied`→`blocked`. Wired by default. The hook takes only `additional_context`, so a decision other than `allow` is given to the agent as context and recorded as `unenforceable_decision`. | +| `subagentStart` | `steps/subagentStart` | Confused-deputy spawn gate. Lineage from real fields: `parent_step_id` = uuid5 of the event's `tool_call_id` (the delegating Task tool call — the same id its preToolUse pinned as request_id), falling back to the session state's last recorded step, else (audited) the spawn envelope's own request_id. `subagent_model` → descriptor `model_id`. | +| `subagentStop` | — not forwarded | `KNOWN_UNMAPPED`. `steps/subagentStop` is SHOULD-emit, and its `final_chain_hash` is optional for a framework with no session chain (since PR #21), but it requires the `subagent_session_id` its `subagentStart` sent, which the adapter derives from `subagentStart`'s `subagent_id`. Cursor's `subagentStop` carries no identifier of the subagent it ends, so the adapter cannot name that session. | +| `beforeShellExecution` | `steps/toolCallRequest` (tool name = "Shell") | Shell-specific gate. Input: `command`, `cwd`, `sandbox`. Cursor exposes NO correlation id on this hook, so its request_id is random. | +| `afterShellExecution` | `steps/toolCallResult` | Input: `command`, `output`, `duration`, `sandbox` — NO exit code and NO id exist on this hook, so `exit_status` is `success` (the schema requires a value; the Shell result WITH an `exitCode` also flows through `postToolUse`, which does discriminate) and `request_id_ref` is omitted. Documented gap, not an oversight. | +| `beforeMCPExecution` | `steps/toolCallRequest` | Input: `tool_name`, `tool_input` (JSON-stringified params — parsed into `arguments`), plus `url` (remote) or `command` (stdio) → `tool.provider`. | +| `afterMCPExecution` | `steps/toolCallResult` | Input: `tool_name`, `tool_input`, `result_json` (JSON-stringified response — parsed into `outputs`), `duration`. No id on this hook → `request_id_ref` omitted; MCP calls also flow through pre/postToolUse, which carry `tool_use_id` correlation. | +| `beforeReadFile` | — not forwarded | Deliberately unmapped (`KNOWN_UNMAPPED`). This IS a blockable gate upstream (output `permission: allow\|deny` — "Use for access control to block sensitive files"); a `steps/knowledgeRetrieval` mapping is the tracked candidate. Until wired, reads are a documented Guardian visibility gap. | +| `beforeTabFileRead` | — not forwarded | Same as `beforeReadFile`, for Tab inline completions (no `attachments` field). | +| `afterFileEdit` | `steps/toolCallResult` | Input: `file_path` + `edits` [{old_string, new_string}] — both flow into `outputs` so the Guardian sees WHAT changed. | +| `afterTabFileEdit` | `steps/toolCallResult` | Same shape as `afterFileEdit`, from Tab. | +| `beforeSubmitPrompt` | `steps/turnStart` **then** `steps/userMessage` | A prompt OPENS a turn: the adapter first emits a decision-eligible `steps/turnStart` (`triggered_by: user_message`; a Guardian MAY `deny` to block the whole turn), then the `steps/userMessage` carrying `prompt`. Both — and every in-turn step — carry `metadata.turn_id` until the matching `stop`. | +| `preCompact` | `steps/preCompact` | `trigger` (`auto`\|`manual`) maps to the ACS enum (`framework_initiated`\|`manual`); `entries_to_compact` = real observed step_ids, and the emission is skipped + audited when none exist (never a placeholder). | +| `afterAgentResponse` | `steps/agentResponse` | Input field is `text` (the assistant's final text). | +| `afterAgentThought` | `steps/agentResponse` | Input field is `text` (aggregated thinking); modeled as an agent emission. | +| `workspaceOpen` | — not forwarded | App-lifecycle event outside any agent session (no `conversation_id`) — there is no ACS session to attach it to. | + +**Deliberately not mapped — the skill lifecycle set (`steps/skillRegister`, `steps/skillLoad`, `steps/skillUnload`).** Cursor's hook surface exposes no skill registration, activation, or unload events, so there is nothing to translate; wiring becomes possible when the platform adds skill lifecycle hooks. + +## Disposition mapping + +Cursor's documented response keys are event-specific. The adapter translates ACS dispositions accordingly: + +### Permission events (`preToolUse`, `subagentStart`, `beforeShellExecution`, `beforeMCPExecution`) + +| ACS disposition | Cursor output | Notes | +|---|---|---| +| `allow` | `{}` on `preToolUse`, `beforeShellExecution` and `beforeMCPExecution`; `{"permission": "allow"}` on `subagentStart` | `{}` leaves the call to Cursor's own sandbox and approval, as Claude Code's empty answer leaves it to Claude Code's permission rules. Checked live on 2026-11-03 with Cursor 3.17.8 in Allowlist (with Sandbox) mode: with `{}`, a `touch` outside the workspace still ran sandboxed first and then waited for approval, as with no hook; with `{"permission": "allow"}`, Cursor went straight to an unsandboxed run, so an explicit allow is not guaranteed to keep Cursor's own checks. | +| `deny` | `{"permission": "deny", "user_message": reasoning, "agent_message": reasoning}` | Cursor displays user_message in UI; agent_message is fed back to the agent's context. | +| `ask` | `{"permission": "ask", "user_message": reasoning, "agent_message": reasoning}`; on `preToolUse`, `{"permission": "deny", ...}` | Cursor documents `ask` as "accepted by the schema but not enforced for preToolUse today", so on `preToolUse` the tool would run: the adapter denies there and audits `unenforceable_decision`. `beforeShellExecution` and `beforeMCPExecution` prompt the user, and `subagentStart` treats `ask` as `deny`. | +| `defer` | `{"permission": "ask", ...}`; on `preToolUse`, `{"permission": "deny", ...}` | Deployment substitution (not spec-mandated; §9.2 places substitution on the Guardian): Cursor has no defer, so it asks where Cursor enforces `ask` and denies on `preToolUse`, audited as `unenforceable_decision`. | +| `modify` | `{"permission": "allow", "updated_input": original ⊕ parameter_overrides, "user_message": reasoning}` on `preToolUse` only | `updated_input` REPLACES the whole input, so the adapter MERGES the overrides onto the original arguments. On the other permission events, which have no documented updated-input field, and for an `MCP:` tool, whose `updated_input` Cursor ignores when an HTTP MCP server runs it, a MODIFY is a DENY recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5), as are redactions. A contradictory `modifications` object fails closed to deny (§6.3). | + +### `postToolUse` — the only post-tool event with documented output fields + +| ACS disposition | Cursor output | +|---|---| +| `allow` | `{}` (no output; proceed) | +| `deny`, `ask`, `defer`, or an unusable verdict, on an MCP tool | `{"updated_mcp_tool_output": {"content": [{"type": "text", "text": "[output withheld by the ACS Guardian: …]"}]}}`: "For MCP tools only: replaces the tool output seen by the model" | +| the same on another tool, or on an HTTP MCP server's tool (its `tool_output` is `{"redacted": true}`) | `{"additional_context": "ACS Guardian denied this result: …"}` + `unenforceable_decision` audit (the tool already ran and its output stays with the model) | +| `modify` whose `modified_content` is a JSON object, on an MCP tool | `{"updated_mcp_tool_output": }`; the field is an object | +| `modify` with other content, on another tool, or with structured edits | as `deny`, recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5) | +| `allow` with reasoning | `{"additional_context": reasoning}` | + +### Other post-tool events (`postToolUseFailure`, `afterShellExecution`, `afterMCPExecution`, `afterFileEdit`, `afterTabFileEdit`) + +An arrived restrictive decision is recorded as an `unenforceable_decision` audit event, and a MODIFY also as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5). `postToolUseFailure` also gives the decision to the agent as `additional_context`, its one output field; the others document "No output fields currently supported", so the adapter emits nothing there. + +### `beforeSubmitPrompt` + +Cursor's documented output is `{"continue": bool, "user_message": str}`. The adapter answers in that shape with exit code 0, so Cursor reads it and shows `user_message`. Exit code 2 also blocks, but Cursor then shows the raw output instead of the message: + +| ACS disposition | Adapter behavior | +|---|---| +| `allow` | `{"continue": true}`, exit 0 | +| `deny` | `{"continue": false, "user_message": reasoning}`, exit 0 | +| `ask` / `defer` / `modify` | same as deny (substituted to block + audit; the prompt hook can't pause or edit); a MODIFY is also recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5) | + +### Lifecycle events (`sessionEnd`, `stop`, `preCompact`, `afterAgentResponse`, `afterAgentThought`) + +**Observation-only.** The adapter emits empty output. Cursor fires these after the fact (or fire-and-forget), so a Guardian decision cannot undo the side effect: a `deny`, `ask` or `defer` is recorded as `unenforceable_decision`, and a `modify` as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5). Enforcement on prompts belongs at `beforeSubmitPrompt` (turn gate), on tools at `preToolUse` / `beforeShellExecution` / `beforeMCPExecution`, on spawns at `subagentStart`. ACS-Core §hooks.md describes `agentResponse` as decision-eligible; the framework constraint forces this adapter's mapping to honest observation-only. + +## Matchers + +Cursor's `hooks.json` supports `matcher` regex per hook entry. The adapter does not require any matcher; the Guardian filters server-side. Use matchers in `hooks.json` only if you want to scope which calls reach the Guardian (a deployment optimization, not a correctness concern). + +## Exit codes (Cursor's protocol) + +| Exit code | Cursor behavior | When the adapter uses it | +|---|---|---| +| 0 | Success; parse stdout as JSON | Every event, a blocked `beforeSubmitPrompt` included: Cursor shows its `user_message` only from JSON read with exit code 0 | +| 2 | Block (documented Claude Code-compatible behavior) | Not used: Cursor would show the raw output instead of the message | +| Other nonzero | Fail open unless `failClosed: true` | Not used by the adapter (errors are converted to deny via posture) | + +## failClosed + +Cursor's per-hook `failClosed: true` makes Cursor block when the hook crashes, times out, or returns invalid JSON. The adapter's `ACS_DEFAULT_DENY`, the negotiated posture and `ACS_STARTUP_POSTURE` decide the failures the adapter handles, and every step the adapter decides answers in Cursor's native shape: when it proceeds, `{}` on the three tool gates, `{"permission": "allow"}` on `subagentStart` and `{"continue": true}` on `beforeSubmitPrompt`, each of which is output, so `failClosed` reads none of them as a failed hook; a deny when it does not. Use both in production: `failClosed: true` in `hooks.json` for a failed hook process, `ACS_DEFAULT_DENY=1` for a Guardian that cannot decide. + +## Concurrency + +Cursor runs the hooks of one session at the same time: in Cursor 3.17.8, a new chat's `sessionStart` and `beforeSubmitPrompt` overlap. The first hook process of a session holds the session's startup lock while it sends the session's one `handshake/hello` and its `steps/sessionStart`; it records how `sessionStart` went in a signed startup record next to the session record. Every other hook process of the session waits for that lock and follows the recorded outcome, so the Guardian sees the handshake, then `sessionStart`, then the session's other steps. A hook process that cannot take the lock, or finds a startup record that fails its checks, refuses the session and audits why. A start claimed by a hook process that ended before recording its outcome, as when the host cancels a hook at its timeout, is recorded once as a `sessionStart` without a decision (`session_start_incomplete`, failure class `observed_agent_error`), so each later hook settles its step with its own fail posture and `sessionEnd` reports it. Every session-state update (`record_step` for each step sent, opening or closing a turn, and a session's first MCP call's `profile_claim_degraded` record) is one locked read-modify-write through a temp file of its own; apart from the startup lock, no lock is held during a Guardian round trip. The shell and MCP `before`/`after` events carry no call id (see the table above), so parallel shell or MCP calls cannot be paired with their results. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. `acs-cursor-wire` refuses to wire when another copy of the adapter is wired in an enterprise, project or user `hooks.json`, or in the Claude Code settings Cursor's third-party import runs. + +## Conformance posture + +The Cursor adapter covers ACS-Core's mandatory floor in the same shape as the Claude Code adapter. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). The floor it covers: + +- Handshake: `handshake/hello` performed on the first event of a session, and its answer decides the session (§4.1): a verified ServerHello negotiates; a refusal, or an answer that fails its checks, refuses every step; and no answer leaves the session with no negotiated Guardian, so each hook's `ACS_STARTUP_POSTURE` applies (`refuse`, the default, denies the step; `proceed` lets it run with nothing sent to the Guardian). The decision is stored in `~/.cache/acs-adapter-handshake/` with an HMAC under the session key over the decision, the session and the Guardian URL, and holds for the session's lifetime, so a dead Guardian costs one timeout per session; a record that fails its checks, or a decision that cannot be stored, refuses the session. ServerHello signature-verified, bound to the ClientHello, its `on_decision_failure` combines with `ACS_DEFAULT_DENY` (deny wins over proceed), and its `timeout_config` replaces the local decision timeout, per method where `per_method_ms` names one (§6.4). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Its `methods_evaluated` bounds what is sent: a method the Guardian omits resolves as ALLOW-by-default without being sent (handshake.json), recorded as `method_not_evaluated`; permission events and `beforeSubmitPrompt` return their explicit allow shapes, since `failClosed` reads an empty reply as a failed hook. With no negotiated list, every mapped event is sent. `ACS_HANDSHAKE=0` skips the handshake, and the startup posture settles every step. +- Turns: a `beforeSubmitPrompt` opens a turn with an explicit `steps/turnStart` (decision-eligible) before the `steps/userMessage`; `stop` closes it with `steps/turnEnd`, the next `beforeSubmitPrompt` with outcome `interrupted` when no `stop` ran, and a turnStart the Guardian does not allow with outcome `denied_at_start`. `turn_id` is held in per-session state and stamped on `metadata.turn_id` of every in-turn step. +- What the user sees: a deny carries `user_message` and `agent_message` that name ACS ("ACS blocked this tool call: "), and a blocked prompt's `user_message` does too, so whatever Cursor shows and whatever the model relays says that ACS blocked it (live check, Cursor 3.28.7, 2026-11-03). +- Hook taxonomy minimum: all covered, plus many additional Cursor events. `subagentStop`, `beforeReadFile`, `beforeTabFileRead`, `workspaceOpen` deliberately unmapped with documented reasons; unknown events emit an `unmapped_hook_event` audit line rather than going quiet. +- Dispositions: ALLOW / DENY on permission events; ASK where Cursor enforces it (`beforeShellExecution`, `beforeMCPExecution`) and DENY on `preToolUse`, where Cursor accepts `ask` without enforcing it; MODIFY supported on `preToolUse` for tools other than MCP tools (merged overrides) and on an MCP tool's `postToolUse` output, and elsewhere a DENY recorded as `modify_unsupported` (§6.5); DEFER → ASK substitution where ASK holds (deployment behavior, not spec-mandated). A decision Cursor cannot apply to a delivered result is audited as `unenforceable_decision`. +- SessionContext: session_id sent every request (non-UUID `conversation_id` coerced deterministically). +- Replay protection: ✓ +- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope, HKDF per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard dependency. The signature names the configured label `ACS_HMAC_KEY_ID` as `key_id`, and a response under any other label fails verification with no other secret tried; an unsigned `SIGNATURE_INVALID`, a Guardian's answer to a label it does not hold, is recorded as `key_id_rejected` and fails closed. Responses signature-verified and bound to their request under any HTTP status; refusals, binding mismatches, bad response signatures, and responses that cannot be verified fail closed regardless of posture (deliberately stricter than the current §6.4 text — spec issue #32). A hook with no secret, a secret under 32 bytes, or no label refuses its session and sends nothing (`secret_missing`, `secret_too_short`, `key_id_missing`), whatever the posture: ACS-Core signs every request. +- Decision honoring: ✓ (Cursor enforces the permission verdict; the prompt gate emits the documented `continue:false` JSON with exit code 0). Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on the permission events, `beforeSubmitPrompt` and `postToolUse`; on other events it follows the posture. A decision result that fails response-envelope.json's `AcsResult` schema, validated with format checking (for example a missing `type: "final"`, a decision outside the five, a `deny` without `reasoning`, or a `chain_hash` that is not 64 lowercase hex characters), is a `malformed_response` decision failure: an `allow` follows the posture, and any other verdict is denied whatever the posture, since carried out as an allow an unreadable or restrictive verdict could run what the Guardian denied (spec issue #32). Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and `sessionEnd` reports the session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). diff --git a/adapters/docs/guardian/README.md b/adapters/docs/guardian/README.md new file mode 100644 index 00000000..90d52079 --- /dev/null +++ b/adapters/docs/guardian/README.md @@ -0,0 +1,71 @@ +# Example Guardian (shared) + +A minimal, deterministic Guardian that all three reference adapters use for their integration tests and quick local demos. + +**This is a teaching artifact, not a production Guardian.** Real Guardians plug in OPA / Rego, Cedar, or a vendor's policy engine, and live behind whatever identity / mTLS / audit infrastructure the deployment runs. This one answers ACS JSON-RPC requests with a few hard-coded rules, enough to make every adapter's round trip work end to end. + +## What it does + +HTTP server on `127.0.0.1:8787` (`--host`, `--port`) that answers JSON-RPC 2.0 POST requests on any path; the adapters' default URL is `http://127.0.0.1:8787/acs`. For every request except `system/ping` it verifies the HMAC-SHA256 signature under the per-session key (§10) and signs its answer; a request under another `key_id` gets an unsigned `SIGNATURE_INVALID` (-32004). Every `steps/*` request is also checked against `request-envelope.json`, and a replayed `request_id` gets -32005 and a timestamp outside the skew window (`ACS_SKEW_WINDOW_MS`, default 5 minutes) gets -32006. + +## Policy implemented + +Tool names compare case-insensitively, so `Bash` (Claude Code), `Shell` (Cursor) and `shell` (NAT) take the same branch. + +| Hook method | Rule | +|---|---| +| `steps/toolCallRequest`, tool `bash` or `shell`, command matching a destructive pattern (`rm -rf` on `/`, `~` or `$HOME`, `rm --no-preserve-root`, `mkfs`, `dd of=/dev/`, a fork bomb, `> /dev/sd*`, `find` on `/` or home with `-delete` or `-exec rm`, `chmod 777` on `/etc`, `/usr`, `/bin` or `/sbin`) | `deny`, `destructive_command` | +| `steps/toolCallRequest`, tool `bash` or `shell`, command longer than 8 KiB | `deny`, `input_too_large` | +| `steps/toolCallRequest`, tool `write`, `file_path` under `/etc/`, `/usr/`, `/bin/`, `/sbin/` or `/boot/` | `deny`, `protected_path` | +| `steps/toolCallRequest`, tool `task` | `deny`, `subagent_gated`; `allow` with `ACS_ALLOW_SUBAGENT=1` | +| Any other `steps/toolCallRequest` | `allow` | +| `steps/subagentStart` | `deny`, `subagent_gated` (`subagent_fresh_intent_gated` when `intent_derivation` is `fresh`); `allow` with `ACS_ALLOW_SUBAGENT=1` | +| `steps/sessionStart`, `steps/sessionEnd`, `steps/turnStart`, `steps/turnEnd`, `steps/userMessage`, `steps/agentResponse`, `steps/agentTrigger`, `steps/toolCallResult`, `steps/preCompact`, `steps/postCompact`, `steps/subagentStop`, `steps/knowledgeRetrieval`, `steps/memoryStore`, `steps/memoryContextRetrieval` | `allow` | +| `handshake/hello` | a ServerHello (§4) declaring `on_decision_failure: deny`, or `proceed` with `ACS_ON_DECISION_FAILURE=proceed` | +| `system/ping` | `allow` with `{status, echo, server_timestamp}` (§13); unsigned and outside the chain | +| Any other method | `deny`, `unknown_method` | + +Every step response carries `chain_hash`, the head of the session's rolling SHA-256 audit chain (§8.2, `entry_hash = sha256(JCS(entry) || previous_hash)`). The chain head and the seen `request_id`s persist per session under `ACS_GUARDIAN_STATE_DIR` (default `~/.cache/acs-guardian-state/`), so a restart keeps replay protection; `ACS_GUARDIAN_STATE_DIR=""` keeps them in memory, for development. + +## Why it's shared + +All three reference adapters (Claude Code, Cursor and NAT, under `src/acs/integrations/`) speak the same ACS JSON-RPC wire format. Their integration tests all need a Guardian that: + +- Accepts ACS JSON-RPC requests +- Returns deterministic decisions (so tests can assert specific outcomes) +- Logs to stderr so a human running it locally can see what's happening + +## How to run it + +The Guardian starts only with a secret of at least 32 bytes and the label that names it, the same pair the adapters use: + +```bash +openssl rand -hex 32 > ~/.acs-hmac.key && chmod 600 ~/.acs-hmac.key +export ACS_HMAC_SECRET_FILE=~/.acs-hmac.key ACS_HMAC_KEY_ID=local-dev + +# default: 127.0.0.1:8787 +acs-example-guardian + +# custom port; --port 0 binds a free one +acs-example-guardian --port 8788 +``` + +It prints `ACS_GUARDIAN_LISTENING port=` on stdout once bound, and logs each request and its verdict to stderr: + +``` +[guardian] listening on 127.0.0.1:8787 (case-insensitive-tool-policy) +[guardian] steps/toolCallRequest session=3f2a9c1e req=8d41b7a0 +[guardian] → allow +``` + +## How to extend + +The policy is in the `evaluate_step(method, params, request_id, chain_hash)` function. To add a rule, add a pattern to `DESTRUCTIVE_BASH_PATTERNS` (`src/acs/examples/destructive_bash.py`), add a branch in `evaluate_step`, or add a method to `INFORMATIONAL_METHODS`. + +Production deployments replace this whole file with their actual policy engine. The wire shape (request format, response format) stays the same. + +## What this is NOT + +- **Not a production Guardian.** One shared HMAC secret is its only authentication: no per-agent identity, no TLS, no rate limiting, and the policy is the hard-coded table above. +- **Not the only Guardian.** Anyone can write one: the spec defines the wire contract, the implementation is the deployment's choice. +- **Not an SDK.** Just an HTTP server with hard-coded rules. If you want an SDK to build Guardians, that's a separate workstream. diff --git a/adapters/docs/nat/README.md b/adapters/docs/nat/README.md new file mode 100644 index 00000000..94dd53e5 --- /dev/null +++ b/adapters/docs/nat/README.md @@ -0,0 +1,347 @@ +# ACS adapter: NVIDIA Agent Toolkit (NAT) + +A drop-in middleware that wires [NVIDIA NeMo Agent Toolkit](https://github.com/NVIDIA/NeMo-Agent-Toolkit) to an ACS Guardian. No agent code changes; YAML configuration only. + +## How it works + +NAT exposes a `FunctionMiddleware` abstraction that wraps any function — tools, sub-workflows, LLM calls, retrievers, memory operations — at the call site. This adapter is that middleware. On every call NAT routes through a wired attachment point, the adapter: + +1. Receives the `InvocationContext` (function name, arguments, output) from NAT's middleware pipeline. +2. Translates it to an ACS JSON-RPC envelope ([mapping.md](./mapping.md)). +3. Signs it with HMAC-SHA256 (`ACS_HMAC_SECRET` or `ACS_HMAC_SECRET_FILE`, under the label `ACS_HMAC_KEY_ID`). +4. POSTs to the Guardian. +5. Verifies the response signature and the JSON-RPC error/result shape. +6. Applies the verdict: `allow` passes through; `deny` raises `ACSGuardianDenied` (or sets `InvocationAction.SKIP` on NAT releases that expose it); `modify` rewrites each overridden field where NAT reads it, a field the input in `context.modified_args[0]` declares, a keyword argument the call has in `context.modified_kwargs`, or a scalar input under its schema field name, and is denied when an override names none of them; `ask`/`defer` substitute to deny at the middleware boundary. +7. Records a `steps/toolCallResult` envelope in `post_invoke` and redacts the output on deny, and on ask or defer, since nothing can pause the output for a person. Inputs and outputs go to the Guardian in JSON form: a model dumped in JSON mode, and a value such as a `datetime` as its string. A call whose input has two keys that become the same JSON key, such as `1` and `"1"`, is denied and such an output is withheld, under either posture, so the Guardian never sees less than the function does. + +Each root NAT Runner is its own ACS session, with a fresh `session_id`; its `handshake/hello` fires once, at the run's start, and the decision is kept in process state for the run's calls. A Runner started inside a run, a nested workflow or sub-agent, joins that run's session. A call outside any NAT Runner uses the middleware's own session, which gets its own `sessionStart` before the first such call and its `sessionEnd` when NAT shuts the middleware down (`mapping.md`). + +Independently of the middleware path, the adapter reports each run's `WORKFLOW_START` / `WORKFLOW_END` events as `steps/sessionStart` + `steps/userMessage` (on workflow start with input) and `steps/agentResponse` + `steps/sessionEnd` (on workflow end with output). A run that raises or is cancelled reaches no workflow end, so it sends no `agentResponse`, and its `sessionEnd` carries `reason: error` or `cancelled` (see `mapping.md`). A Guardian that denies a run's `sessionStart` refuses that run's calls. When NAT builds the middleware, it registers an `acs_lifecycle` telemetry exporter, which NAT starts for every Runner before its `WORKFLOW_START` and cleans up when the Runner ends. A middleware built without NAT's builder subscribes on each run's first call instead, so it reports that run from the call on. **The run's `sessionStart` and `userMessage` verdicts are applied to the run's calls; `agentResponse` and `sessionEnd` are observation-only**, and a verdict on the output is recorded as `unenforceable_decision`. Enforcement on the calls themselves is the middleware path's job. + +### Decision honoring (§6.4) + +ACS-Core §6.4 requires the framework to wait for the verdict and apply it before the action executes. NAT provides this through `function_middleware_invoke`: `pre_invoke` must complete before `call_next(...)` runs, so a Guardian deny (whether surfaced as `ACSGuardianDenied` or `InvocationAction.SKIP`) is applied before the wrapped function would execute. Verified in `tests/integrations/nat/test_live.py` (deny tests assert `executed["count"] == 0`). + +## Install — five steps + +You need: a running Guardian, a shared HMAC secret, and a NAT `workflow.yml` with `acs_guardian` wired at every attachment point. `acs-nat-wire` does step 3 for you and `--check` keeps it true forever after. + +Commands below assume `$ACS_REPO` points at your local clone of `GenAI-Security-Project/agent-control-standard`. Export it once: + +```bash +export ACS_REPO=/path/to/your/clone # e.g., $HOME/code/ACS +pip install "$ACS_REPO/adapters[nat]" # into the environment your NAT workflow runs in +``` + +The `nat` extra installs NAT itself, the middleware (NAT finds `_type: acs_guardian` through the package's `nat.components` entry point), `acs-nat-wire` and `acs-example-guardian`. + +### 1. Generate the shared HMAC secret + +Both the adapter and the Guardian read this file. Mode 0600 is enforced — anything looser and the adapter refuses to start. + +```bash +mkdir -p ~/.acs +openssl rand -hex 32 > ~/.acs/hmac.key +chmod 600 ~/.acs/hmac.key +``` + +### 2. Run the Guardian + +The example Guardian is for testing; a production Guardian is the same wire protocol with a real policy engine attached. + +```bash +ACS_HMAC_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \ + acs-example-guardian --port 8787 +``` + +Keep this terminal open. You should see `[guardian] listening on 127.0.0.1:8787`. + +### 3. Wire `workflow.yml` + +```bash +cd /path/to/your/project # where your workflow.yml lives + +# Preview the change (dry-run, no file write) +acs-nat-wire \ + --workflow=workflow.yml \ + --guardian-url=http://127.0.0.1:8787/acs \ + --default-deny + +# Apply (timestamped backup at workflow.yml.bak.) +acs-nat-wire \ + --workflow=workflow.yml \ + --guardian-url=http://127.0.0.1:8787/acs \ + --default-deny \ + --write +``` + +`acs-nat-wire` walks every attachment point in your YAML and inserts `acs_guardian`: + +| YAML location | What `acs-nat-wire` does | +|---|---| +| top-level `middleware:` block | adds the `acs_guardian:` definition with your guardian_url, default_deny, timeout_s | +| `workflow.middleware` | prepends `acs_guardian` (policy gate runs before content filters) | +| every `function_groups.*.middleware` | prepends `acs_guardian` | +| every `functions.*.middleware` | prepends `acs_guardian`, creating the list when the function has none | + +Every line we add carries a `# acs-adapter-wired` marker. Re-running is a no-op. `--unwire --write` removes exactly what we added and nothing else. + +To remove later: `acs-nat-wire --workflow=workflow.yml --unwire --write`. + +### 4. Restart your NAT process + +NAT loads `workflow.yml` at startup. Existing processes keep their pre-wiring config until restart. + +### 5. Verify the install + +```bash +# Lint — exits non-zero if any attachment point is missing acs_guardian +acs-nat-wire --workflow=workflow.yml --check + +# Run the live tests against your installed adapter + Guardian +cd "$ACS_REPO/adapters" +uv sync --locked --extra nat --group test +uv run python -m unittest tests.integrations.nat.test_live -v +``` + +`acs-nat-wire --check` is the load-bearing CI gate — see [Coverage discipline](#coverage-discipline-the-yaml-only-rule) below. + +## Prerequisites + +- **NAT**: `nvidia-nat-core` 1.7.x, installed with the `nat` extra (`pip install "$ACS_REPO/adapters[nat]"`). +- **Python 3.11+**. The `nat` extra also brings `ruamel.yaml` for `acs-nat-wire`. To run the test suites: `cd "$ACS_REPO/adapters" && uv sync --locked --extra nat --group test`. +- **Canonical ACS schemas**: bundled in the package, and read from `specification/v0.1.0/` when running from a clone of this repo. Set `ACS_SPEC_DIR` only to validate against a different spec checkout. + +## Coverage discipline (the YAML-only rule) + +NAT's middleware fires per attachment point: the workflow, each function group and each function, and gates only the calls to that point. A function the workflow or another function calls is gated by its own `middleware:` list, so every function needs `acs_guardian` there. If an attachment point is not wired, every call routed through it bypasses the Guardian. This is a structural property of NAT, not a bug in the adapter — Cursor and Claude Code get framework-wide interception for free; NAT does not. + +**The three rules for ACS-conformant NAT deployments:** + +1. **Define every tool in YAML.** Functions registered at runtime in Python code (not in `workflow.yml`) cannot be wired by `acs-nat-wire` and will not be gated by the middleware path. If you need full gating, every function lives in the YAML. +2. **Run `acs-nat-wire --check` in CI** on every workflow YAML you ship. It exits non-zero if any attachment point is missing `acs_guardian`. Treat that as a build failure. +3. **Re-run `acs-nat-wire --write` after any YAML edit** that adds or restructures `function_groups`, `workflow`, or individual `functions`. The marker comments make it idempotent. + +What this gives you: **the same hard-stop guarantee Cursor's `failClosed: true` gives.** The middleware runs *before* the function executes and waits for the Guardian's decision, off the event loop; a Guardian deny raises `ACSGuardianDenied` (or sets `InvocationAction.SKIP`) and NAT does not call the function. + +**The lifecycle observer reports workflow boundaries only.** It sends `sessionStart` and `userMessage` for each run, `agentResponse` for each run that returns, `sessionEnd` for each run whose session the Guardian did not refuse, and no tool calls, so a function the middleware is not wired to reaches the Guardian neither as a decision nor as an observation. Steps 1 and 2 close that gap: define every function in YAML, and run `acs-nat-wire --check` in CI. + +## Smoke tests + +Five tests, ordered from broadest to most specific. Run any/all. + +### Smoke #1 — automated test suite (~10s) + +```bash +cd "$ACS_REPO/adapters" +uv run python run_conformance.py nat +# Runs the shared Guardian, _common, core, client and package checks, then NAT's suite. +``` + +Sync with the `nat` extra first (`uv sync --locked --extra nat --group test`). A missing NAT dependency produces an unexpected skip and fails the command. + +### Smoke #2 — `acs-nat-wire --check` (CI gate) + +```bash +acs-nat-wire \ + --workflow=/path/to/your/workflow.yml --check +``` + +Exit 0 → every attachment point in the YAML is wired with `acs_guardian`. Exit 1 → at least one gap; the output names the file path, line number, and which kind of attachment point (workflow / function_group / function). Wire this into CI as a build gate. + +### Smoke #3 — end-to-end conformance (e2e_check.py) + +```bash +cd "$ACS_REPO/adapters" +uv run python tests/integrations/nat/e2e_check.py +``` + +Five fully-automated scenarios against the **real `example_guardian.evaluate_step` policy** (same one the production example Guardian uses), wired through a recording Guardian so the script can assert on every wire envelope: + +| # | Scenario | What it verifies | +|---|---|---| +| 1 | ALLOW | benign Bash function executes; return value flows back; handshake + toolCallRequest received; every envelope HMAC-signed; every envelope validates against the canonical ACS JSON Schema | +| 2 | DESTRUCTIVE | `rm -rf` on a victim dir with a canary file → real policy denies via `destructive_command` regex; `ACSGuardianDenied` raised; **canary file still on disk** (counterproof — the unit-test counter alone can false-pass if the function returns early for any reason) | +| 3 | READ-TOOL | different tool, same wire contract; arguments wrapped per `tool-call-request.json:26-37`; envelope.arguments.file_path.value matches probe path | +| 4 | HANDSHAKE-ONCE | 3 sequential invocations on the same middleware → exactly 1 `handshake/hello` envelope arrived | +| 5 | LIFECYCLE | A NAT Runner's `WORKFLOW_START`/`WORKFLOW_END`, reported by the middleware's exporter → emits `steps/sessionStart` + `steps/userMessage` + `steps/agentResponse` + `steps/sessionEnd` envelopes with the workflow input/output in the payload (workflow boundaries only: the run's input and output, never its tool calls) | + +The final line is `ACS-CORE SMOKE PASS (nat)` (exit 0) — a smoke verdict, not a conformance certification — or a per-scenario failure list (exit 1). + +Schema validation is against the canonical `request-envelope.json` from `ACS_SPEC_DIR` — adapter ↔ spec, not adapter ↔ test fixture. A drift between adapter and the spec fails this check, not the other way around. + +### Smoke #4 — disposition matrix (test_dispositions_live.py) + +```bash +cd "$ACS_REPO/adapters" +uv run python -m unittest tests.integrations.nat.test_dispositions_live -v +``` + +End-to-end verification that EVERY ACS disposition (ALLOW, DENY, MODIFY, ASK, DEFER, post_invoke DENY) is honored on the LangChain-shaped input path NAT's runtime actually uses (input captured as `modified_args[0]` Pydantic model, not `modified_kwargs`). Catches the silent-bypass class of bug where MODIFY overrides drop or post_invoke redaction crashes on Pydantic strict-fields. + +### Smoke #5 — alternate live tests (test_live.py) + +```bash +cd "$ACS_REPO/adapters" +uv run python -m unittest tests.integrations.nat.test_live -v +``` + +Six tests spin up the example Guardian (subprocess, not in-process), construct a real NAT middleware invocation, and assert side-effect counters: a benign function executes, a destructive function is denied, write-to-protected-path is denied, `default_deny: true` blocks on Guardian unreachable, an unanswered handshake blocks by default, and `ACS_STARTUP_POSTURE=proceed` lets the call through with an audit event. `e2e_check.py` (Smoke #3) is the broader assertion surface; `test_live.py` is the minimal unit-style guarantee that the enforcement contract holds. + +### Smoke #6 — audit-cause differentiation + +Verifies the adapter's audit log distinguishes "Guardian unreachable" (ops issue), "Guardian rejected the envelope" (clock skew, signature, replay), and "adapter exception" (bug). Same fail-posture in all three; different remediation. + +Trigger an unreachable Guardian (with `default_deny: true` in the middleware config): + +```python +# In a script with NAT installed +from acs.integrations.nat.adapter import ACSMiddleware, ACSMiddlewareConfig +# Point at a dead port to force transport_failure +cfg = ACSMiddlewareConfig(guardian_url="http://127.0.0.1:1/dead", default_deny=True) +mw = ACSMiddleware(cfg) +# Call mw.pre_invoke(context) — observe stderr for: +# ACS_AUDIT {"acs_audit_event": "decision_failure_fail_closed", "cause": "transport_failure", ...} +``` + +Send a malformed envelope (Guardian returns -32600 Invalid Request): +``` +ACS_AUDIT {"acs_audit_event": "decision_failure_fail_closed", "cause": "malformed_envelope_response", ...} +``` + +Send a stale request (Guardian returns -32006 timestamp out of window): +``` +ACS_AUDIT {"acs_audit_event": "decision_failure_fail_closed", "cause": "timestamp_out_of_window_response", ...} +``` + +Same `acs_audit_event`; the `cause` field is what operators grep on. + +### Smoke #7 — coverage-gap detection + +Have your Guardian log the `tool.name` of every `steps/toolCallRequest` it receives. After a typical workflow run, every function the workflow calls should appear there; a function that never appears is not wired (run `acs-nat-wire --check` to locate it). + +## Files + +- `src/acs/integrations/nat/adapter.py`: the middleware class, its config and the NAT registration. Stdlib + nvidia-nat-core only. +- `src/acs/integrations/nat/wire.py`: comment-preserving YAML installer and linter, installed as `acs-nat-wire` (`--check`, `--write`, `--unwire`). Requires `ruamel.yaml`. +- `tests/integrations/nat/e2e_check.py`: automated end-to-end conformance check (5 scenarios, real `example_guardian` policy, canary-based assertions). +- `docs/nat/workflow.yml.example`: drop-in NAT workflow YAML wiring the middleware. +- `pyproject.toml`'s `nat` extra: runtime deps (`nvidia-nat-core`, `ruamel.yaml`). +- `docs/nat/mapping.md`: NAT lifecycle point → ACS step method table. +- `tests/integrations/nat/test_adapter.py` — integration tests against the real NAT API + the `_extract_arguments` regression set covering the LangChain-shape input bug. +- `tests/integrations/nat/test_live.py` — 5 live workflow tests exercising NAT's `function_middleware_invoke` orchestration end-to-end. +- `tests/integrations/nat/test_dispositions_live.py` — every ACS disposition (ALLOW, DENY, MODIFY, ASK, DEFER, post_invoke DENY) verified on the `modified_args[0]` Pydantic-model path the LangChain react_agent uses in production. +- `tests/integrations/nat/test_envelope_schema.py` — JSON-RPC envelope shape validation against canonical schemas. +- `tests/integrations/nat/test_lifecycle.py`: a run's function and LLM steps add no lifecycle hooks. +- `tests/integrations/nat/test_sessions.py`: each root Runner is one session, through NAT's builder, session manager, request path and Runner. +- `tests/integrations/nat/test_runner_lifecycle.py`: each run's hooks, decisions, failure counts and end, through NAT's Runner. +- `tests/integrations/nat/test_failure_modes.py` — transport failures, signature checks, replay, timestamp skew, JSON-RPC errors. +- `docs/nat/example_payloads.md` — masked real-world payload examples showing the in-process `InvocationContext` shape and what the adapter sends. + +The adapter shares `acs.core` (signing, decision rules, JSON-RPC error code → cause mapping) and `acs.client` (handshake cache, audit events, URL allowlist) with the Claude Code and Cursor adapters. + +## How it differs from the Claude Code / Cursor adapters + +| Aspect | Claude Code / Cursor | NAT | +|---|---|---| +| Interception mechanism | Shell-command-with-stdin-JSON (process spawn per hook) | In-process Python middleware class (`FunctionMiddleware`) | +| Configuration | `settings.json` / `hooks.json` | NAT workflow YAML `middleware:` block | +| Coverage default | Framework-wide (every hook event fires) | Opt-in per attachment point (YAML must wire each) | +| Block mechanism | JSON stdout deny shape, or `exit 2` | Raise `ACSGuardianDenied` (NAT 1.7.0) or set `InvocationAction.SKIP` (NAT dev) | +| Modify mechanism | Updated input field in JSON response | Rewrite each overridden field in `context.modified_args[0]` or `context.modified_kwargs`, wherever NAT reads it / `context.output` | +| Lifecycle coverage | Whatever events the framework's hook surface exposes | Every function NAT wraps — tools, LLMs, retrievers, memory, sub-workflows — **for observability**; enforcement still per-attachment-point | +| Headless CLI for tests | `claude --print` (Claude); none (Cursor — semi-automated) | None needed — NAT runs in-process; `test_live.py` is fully automated | + +The shared protocol layer is identical: the Guardian sees the same ACS JSON-RPC shape from every adapter. + +## Configuration + +### YAML config (the middleware block — `acs-nat-wire` populates this) + +| Field | Default | Purpose | +|---|---|---| +| `_type` | `acs_guardian` (required) | Registers the middleware via NAT's `register_middleware` decorator. | +| `guardian_url` | `http://127.0.0.1:8787/acs` | Guardian endpoint. http/https only. | +| `default_deny` | `false` | `true`: fail-closed whatever the Guardian declares; `acs-nat-wire` writes `true`, and `--all-fail-open` writes `false`. `false`: the ServerHello's `on_decision_failure` applies, and the example Guardian declares `deny` (most-restrictive-wins). Guardian refusals (bad signature, replay, malformed/oversized envelope) always fail closed regardless — input gate blocks, output gate redacts. See spec issue #32. | +| `timeout_s` | `5.0` | Per-request Guardian round-trip timeout. A ServerHello `timeout_config` replaces it for the session, per method where `per_method_ms` names one. | +| `target_function_or_group` | (unset) | Optional metadata label; derives `agent_id` if `ACS_AGENT_ID` env is unset. | + +### Environment variables (read by the adapter at runtime) + +| Variable | Default | Purpose | +|---|---|---| +| `ACS_HMAC_SECRET_FILE` | (unset) | Path to a 0600 file holding the shared HMAC secret. | +| `ACS_HMAC_SECRET` | (unset) | Inline secret. Less secure (visible in `ps eauxw`). Prefer the file. | +| `ACS_HMAC_KEY_ID` | (unset) | The label that names the secret (§10); the Guardian holds the same one. Required with a secret: a secret without a label refuses the session, and a response signed under another label fails verification. | +| `ACS_AGENT_ID` | derived from `target_function_or_group` | Stable agent identifier sent in `metadata.agent_id`. | +| `ACS_GUARDIAN_HOST_ALLOWLIST` | (unset) | Optional comma-separated hostname allowlist (defense in depth). | +| `ACS_AUDIT_FILE` | (unset) | Append every `ACS_AUDIT` event to this file (created 0600) in addition to stderr. | +| `ACS_DISABLED` | (unset) | `1` = incident kill switch: every call passes through with no Guardian traffic and an `acs_disabled_bypass` audit event. Read when NAT builds the middleware, so set it before the workflow starts. | +| `ACS_STARTUP_POSTURE` | `refuse` | `proceed` or `refuse`: what a call does in a session with no negotiated Guardian, because its handshake got no answer (§4.1). `proceed` lets the call run, audited, with nothing sent to the Guardian, until the session ends; `refuse` blocks it. The session keeps no negotiated Guardian for its lifetime. Any other value refuses. Read when NAT builds the middleware. | +| `ACS_REQUIRE_PROFILES` | (unset) | Comma-separated conformance profiles the Guardian must accept, such as `acs-core`. When the session's negotiation leaves one out, this hook's steps are refused; the stored record keeps what the Guardian answered, so each hook applies its own setting. | +| `ACS_HANDSHAKE` | `1` | Set to `0` to skip the handshake; the session then counts as unanswered, and `ACS_STARTUP_POSTURE` settles every call. Read when NAT builds the middleware. | + +## On-disk state + +NAT runs the adapter in-process. The middleware keeps each session's handshake decision in memory and stores it in `~/.cache/acs-adapter-handshake/` like the other adapters, and removes a session's records once its `sessionEnd` has been handled. Each run's session has a fresh `session_id`, so a restarted process opens new sessions. The Guardian-side state (`~/.cache/acs-guardian-state/.json`) is the same as for the other adapters — chain head + replay set, survives Guardian restart. + +## Conformance status + +Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md`. Some Core items are conditional or SHOULD-level: `subagentStart` binds subagent-capable clients, MODIFY and `system/ping` are SHOULD, and Wrapped MCP binds sessions that involve MCP. The row notes say how each one applies here. + +| ACS-Core item | Status | +|---|---| +| Handshake (`handshake/hello`) | ✓ once per session: each root Runner's at its start, and the middleware's own at its first call outside a run; the decision (negotiated, refused, or no answer under `ACS_STARTUP_POSTURE`) holds for the session's lifetime (§4.1) | +| Profiles (`profiles_supported` / `profiles_accepted`) | ✓ always claims `acs-core`: the middleware refuses a session with no usable secret and label before the handshake. A ServerHello accepting a profile the adapter did not claim is not a valid answer, a claimed profile the Guardian leaves out is audited as `profile_not_accepted`, and a hook's `ACS_REQUIRE_PROFILES` turns a left-out profile into a refusal of that hook's steps. | +| JSON-RPC envelope shape (`request-envelope.json`) | ✓ validates against canonical schema (`test_envelope_schema.py`) | +| Hook taxonomy (Core minimum set) | ⚠ emitted, but read the enforcement split. **Decision-eligible** coverage: `toolCallRequest` / `toolCallResult`, via the `FunctionMiddleware` path — these can actually block/redact. `sessionStart` (ALLOW / DENY) and `userMessage` are applied too: a deny refuses the run's calls. `agentResponse` and `sessionEnd` are emitted **observation-only** by the lifecycle exporter: a verdict on `agentResponse` is recorded as `unenforceable_decision`, not applied, so they provide audit and trace coverage, **not** enforcement, and should not be counted as decision-eligible Core coverage. ACS-Core requires a subagent-capable client to emit `subagentStart` when it spawns a sub-agent, and a framework with no sub-agent abstraction has nothing to emit. The adapter emits no `subagentStart`: a nested Runner's calls reach the Guardian as tool calls in its parent run's session. The Guardian decides by deployment-defined means whether a NAT deployment counts as subagent-capable. Where sub-workflows act as sub-agents, this is a known gap. See `mapping.md`. Verified in `test_lifecycle.py`. | +| Dispositions | ALLOW / DENY / MODIFY supported on **function-middleware (pre-execution)** hooks (`pre_invoke` for every wrapped function — tools, sub-workflows, LLM, retrievers). ASK/DEFER substituted to DENY at the middleware boundary; deployments wanting pause-and-resume should compose with NAT's HITL middleware (`nat.middleware.hitl`). Of the lifecycle hooks, the run's `steps/sessionStart` and `steps/userMessage` verdicts are applied: a deny refuses the run's calls, and a MODIFY on the input is that deny, recorded as `modify_unsupported`. **`steps/agentResponse` and `steps/sessionEnd` are observation-only**: the exporter reports a NAT event after it fires and cannot veto it, so a verdict on the output is recorded as `unenforceable_decision`, a MODIFY also as `modify_unsupported`. See `mapping.md`. | +| Unknown-disposition fail posture | ✓ | +| Post-tool deny redaction | ✓ `post_invoke` clears `context.output = None` and emits an `ACS_AUDIT` `post_invoke_redacted` event, so the caller never receives a result the Guardian denied (§6.4: the Observed Agent applies the verdict). (NAT's `InvocationContext` is a strict Pydantic model with `validate_assignment=True` — ad-hoc attributes like `acs_post_invoke_redacted` would crash; downstream consumers MUST read the audit event for the redaction signal, not an extra attribute.) | +| SessionContext + published `chain_hash` | ✓ every session's `session_id` is a UUID; Guardian computes rolling chain | +| Replay protection | ✓ Guardian enforcement (REPLAY_DETECTED -32005, TIMESTAMP_OUT_OF_WINDOW -32006); audit cause distinguishes both | +| Baseline integrity (HMAC-SHA256) | ✓ every request and response signed under `ACS_HMAC_KEY_ID`; a middleware with no secret of at least 32 bytes, or no label, refuses its session and denies every call; signed responses verified by adapter (pre_invoke + post_invoke reject SIGNATURE_INVALID) | +| Decision honoring (§6.4) | ✓ NAT's middleware contract guarantees the function will not execute if `pre_invoke` raises or sets SKIP — verified in `test_live.py` (deny tests assert `executed["count"] == 0`); every decision failure emits an `ACS_AUDIT` event under either posture | +| `cause` field on every audit event | ✓ `transport_failure`, `adapter_exception`, `response_signature_invalid`, plus a cause for every §17 JSON-RPC error code (for example `session_refused_response`, `signature_invalid_response`, `replay_detected_response`, `internal_error_response`) with `guardian_error_response` as the catch-all fallback for unknown codes | +| Liveness `system/ping` | ⚠ Guardian-side only. ACS-Core says `system/ping` SHOULD be implemented: the Observed Agent declares it in `methods_implemented` and sends it, and the Guardian answers it (§13). The example Guardian answers ping as an always-allow method outside the chain, replay, and signature checks. The adapter does not declare or send `system/ping`, so a deployment that uses it must name an alternative liveness mechanism in its configuration, such as continuous observed hook traffic. Omitting both is non-conformant. | +| Wrapped MCP `protocols/MCP/*` | ✗ not implemented. ACS-Core requires `protocols/MCP/*` for any session that involves MCP, and MCP `tools/call` MAY instead flow through the generic tool hooks. The adapter emits no `protocols/MCP/*` traffic. The function middleware sees an MCP tool only as an ordinary NAT function, so a wired MCP tool call can reach the Guardian only as `steps/toolCallRequest` and `steps/toolCallResult`. This suite does not exercise that path. No other MCP surface is wrapped, so a NAT session that uses MCP falls short of ACS-Core there. Known limitation. | +| `request_id_ref` correlation | ✓ `post_invoke` sets it to the `request_id` of the call's `toolCallRequest`, a uuid4 kept on the call's invocation context, linking result to request | +| **Coverage of every tool call** | ⚠ **opt-in via YAML wiring** — see [Coverage discipline](#coverage-discipline-the-yaml-only-rule) above. `acs-nat-wire --check` is the CI gate that makes this enforceable. | + +## How NAT's defense middleware composes with this + +NAT ships `defense_middleware` (in `nvidia-nat-security`) for prompt-injection and PII checks. The ACS adapter does not replace those — it composes with them. A NAT YAML can list multiple middlewares per group, and they execute in order. `acs-nat-wire` always prepends `acs_guardian` to the chain so the policy gate runs before content filters — denied calls short-circuit before expensive content analysis. Recommended composition: ACS first, then NAT defense middleware, then the function. + +## Compatibility + +The adapter works across multiple NAT releases by feature-detecting the block mechanism: + +- **NAT 1.7.0 (public release):** blocks by raising `ACSGuardianDenied` (NAT documents "Raises: Any exception to abort execution" for `pre_invoke`). +- **NAT dev branch (with `InvocationAction.SKIP`):** prefers setting `context.action = InvocationAction.SKIP` (cleaner, no exception in logs). The adapter detects the symbol's availability at import time. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `acs-nat-wire --check` reports gaps after wiring | YAML was edited after acs-nat-wire ran. Re-run `acs-nat-wire --write`. | +| `acs-nat-wire --check` keeps reporting gaps for the same function | That function is defined in Python code, not YAML, so acs-nat-wire can't reach it. Move the definition into YAML, or add `acs_guardian` to its `middleware` list in code; until then its calls reach the Guardian neither as a decision nor as an observation. | +| `acs-nat-wire` errors out with "ruamel.yaml" missing | Install the `nat` extra: `pip install "$ACS_REPO/adapters[nat]"`. | +| NAT runs functions as if no Guardian exists | NAT loaded the workflow before `acs-nat-wire --write` ran. **Restart your NAT process.** | +| Every call gets denied | Likely the Guardian is down: a session whose handshake gets no answer refuses its calls unless `ACS_STARTUP_POSTURE=proceed`, and `acs-nat-wire` writes `default_deny: true`. Check the Guardian process is running. | +| Adapter says `SecretFilePermissionsError` | HMAC secret file is mode > 0600. `chmod 600 ~/.acs/hmac.key`. | +| Guardian returns `-32004 SIGNATURE_INVALID` | Adapter and Guardian aren't reading the same secret. `cat ~/.acs/hmac.key` on both sides should match. Audit log shows `cause=signature_invalid_response`. | +| Guardian returns `-32005 REPLAY_DETECTED` | Same `request_id` sent twice. Audit log shows `cause=replay_detected_response`. Usually a retry loop bug. | +| Guardian returns `-32006 TIMESTAMP_OUT_OF_WINDOW` | Clock skew between adapter and Guardian > 5 minutes. Sync time. Audit log shows `cause=timestamp_out_of_window_response`. | +| `lifecycle_subscribe_failed` audit event | No active NAT Context (called middleware directly outside a workflow). Test-only path; harmless in production. | + +Everything the adapter does that's not policy decision-making is audited on stderr as a JSON line prefixed `ACS_AUDIT`. The `cause` field tells you which failure mode fired. + +## Running the tests + +```bash +cd "$ACS_REPO/adapters" +uv sync --locked --extra nat --group test +uv run python -m unittest discover -s tests/integrations/nat -t . -v +``` + +If NAT is not installed, NAT-dependent test classes are skipped cleanly (`@unittest.skipUnless`). diff --git a/adapters/docs/nat/example_payloads.md b/adapters/docs/nat/example_payloads.md new file mode 100644 index 00000000..8ffb49b8 --- /dev/null +++ b/adapters/docs/nat/example_payloads.md @@ -0,0 +1,207 @@ +# Real-world payload examples — NAT (NVIDIA Agent Toolkit) + +NAT's adapter doesn't see a wire-format event the way Claude Code and Cursor adapters do. NAT calls the adapter's `pre_invoke` / `post_invoke` methods in-process with a real Python `InvocationContext` object. The examples below show: + +1. **What the adapter receives** (the `InvocationContext` it gets from NAT) +2. **What the adapter sends to the Guardian** (ACS JSON-RPC, same shape as the other adapters) +3. **What the adapter does to NAT's context** (set action, mutate kwargs, etc.) + +All masked. Identifying fields use placeholders. + +--- + +## 1. What the adapter receives from NAT + +NAT constructs an `InvocationContext` and passes it to `pre_invoke(ctx)`. Example for a Bash tool call: + +```python +InvocationContext( + function_context=FunctionMiddlewareContext( + name="Bash", + config=None, + description=None, + input_schema=None, + single_output_schema=NoneType, + stream_output_schema=NoneType, + ), + original_args=(), + original_kwargs={"command": "echo hello"}, + modified_args=(), + modified_kwargs={"command": "echo hello"}, + output=None, + action=None, +) +``` + +In `post_invoke`, the same context arrives with `output` populated: + +```python +InvocationContext( + function_context=FunctionMiddlewareContext(name="Bash", ...), + original_args=(), + original_kwargs={"command": "echo hello"}, + modified_args=(), + modified_kwargs={"command": "echo hello"}, + output="hello\n", + action=None, +) +``` + +NAT's runtime calls `function_middleware_invoke` which orchestrates: build context → `pre_invoke` → call function → `post_invoke`. See `packages/nvidia_nat_core/src/nat/middleware/function_middleware.py`. + +--- + +## 2. What the adapter sends to the Guardian + +The same ACS JSON-RPC shape as the other adapters. The adapter constructs this from the `InvocationContext`: + +### The run's start → `steps/sessionStart` + +The run's session opens with its own `handshake/hello` and then its `sessionStart`, before any call of the run: + +```json +{ + "jsonrpc": "2.0", + "id": "00000000-0000-0000-0000-000000000001", + "method": "steps/sessionStart", + "params": { + "acs_version": "0.1.0", + "request_id": "00000000-0000-0000-0000-000000000002", + "timestamp": "2026-11-03T09:39:08.574Z", + "metadata": { + "agent_id": "nat:00000000", + "session_id": "00000000-0000-4000-8000-000000000000", + "platform": "nat", + "adapter_version": "0.2.0" + }, + "payload": { + "platform_context": { + "workflow_name": "my_workflow", + "workflow_run_id": "00000000-0000-0000-0000-000000000003" + } + }, + "signature": {"algorithm": "HMAC-SHA256", "value": "", "key_id": "2026-11"} + } +} +``` + +### pre_invoke → `steps/toolCallRequest` + +```json +{ + "jsonrpc": "2.0", + "id": "00000000-0000-0000-0000-000000000004", + "method": "steps/toolCallRequest", + "params": { + "acs_version": "0.1.0", + "request_id": "00000000-0000-0000-0000-000000000005", + "timestamp": "2026-11-03T09:39:08.582Z", + "metadata": { + "agent_id": "nat:00000000", + "session_id": "00000000-0000-4000-8000-000000000000", + "platform": "nat", + "adapter_version": "0.2.0" + }, + "payload": { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "echo hello"}} + }, + "signature": {"algorithm": "HMAC-SHA256", "value": "", "key_id": "2026-11"} + } +} +``` + +### post_invoke → `steps/toolCallResult` + +```json +{ + "jsonrpc": "2.0", + "id": "00000000-0000-0000-0000-000000000006", + "method": "steps/toolCallResult", + "params": { + "acs_version": "0.1.0", + "request_id": "00000000-0000-0000-0000-000000000007", + "timestamp": "2026-11-03T09:39:08.586Z", + "metadata": { + "agent_id": "nat:00000000", + "session_id": "00000000-0000-4000-8000-000000000000", + "platform": "nat", + "adapter_version": "0.2.0" + }, + "payload": { + "tool": {"name": "Bash"}, + "exit_status": "success", + "outputs": [{"value": "hello\n"}], + "request_id_ref": "00000000-0000-0000-0000-000000000005" + }, + "signature": {"algorithm": "HMAC-SHA256", "value": "", "key_id": "2026-11"} + } +} +``` + +`request_id_ref` is the `request_id` of the call's `toolCallRequest`, a uuid4 the adapter keeps on the call's invocation context. Each root NAT Runner's session gets a fresh `session_id`; a call outside any Runner uses the middleware's own. + +--- + +## 3. What the adapter does to NAT's context after the Guardian responds + +### Allow (Guardian returns `{"decision": "allow"}`) + +```python +# pre_invoke returns None → NAT proceeds with the call unchanged +return None +``` + +### Deny on NAT dev branch (has InvocationAction.SKIP) + +```python +context.action = InvocationAction.SKIP +return context +# NAT runtime: skips the function call, returns None +``` + +### Deny on NAT 1.7.0 (public release, no InvocationAction) + +```python +raise ACSGuardianDenied("destructive Bash pattern in: rm -rf /home/u") +# NAT runtime: documented "Raises: Any exception to abort execution" +``` + +The adapter feature-detects which mechanism NAT exposes and prefers the action-based path when available. + +### Modify input (Guardian returns `parameter_overrides`) + +```python +# A field the input in context.modified_args[0] declares is rewritten there, +# an existing keyword argument in context.modified_kwargs, and a scalar input +# (a one-argument function's) in place, under its schema field name. +context.modified_args = (ShellInput(command="echo hello # sanitized"),) +return context +# NAT runtime: invokes the function with call_next(*modified_args, **modified_kwargs) +``` + +An override that names no field of the input and no keyword argument the call has, or one the rebuilt input does not keep, cannot be applied: the call is blocked and the MODIFY recorded as `modify_unsupported` (§6.5). + +### Modify output (Guardian returns `modified_content` in post_invoke) + +```python +context.output = "" +return context +# NAT runtime: propagates the modified output as if the function returned it +``` + +--- + +## Masking convention used here + +| Field | Real value contains | Masked as | +|---|---|---| +| `session_id` | A fresh UUID per session | `00000000-0000-4000-8000-000000000000` | +| `agent_id` | `nat:` and 8 hex digits of the target's hash | `nat:00000000` | +| `id`, `request_id`, `request_id_ref`, `workflow_run_id` | Real UUIDs (per request, per run) | `00000000-0000-0000-0000-00000000000X` | +| `signature.value` | The envelope's HMAC | `` | +| `timestamp` | Real RFC 3339 time | Kept | +| `arguments.command.value` | Real command (sometimes preserved when benign) | Preserved or `` | +| `outputs` | Real tool output | Preserved or `` | + +No real session data is committed to this repo. diff --git a/adapters/docs/nat/mapping.md b/adapters/docs/nat/mapping.md new file mode 100644 index 00000000..141e756a --- /dev/null +++ b/adapters/docs/nat/mapping.md @@ -0,0 +1,105 @@ +# NVIDIA Agent Toolkit (NAT) → ACS mapping + +Schema source: NAT public repo (`packages/nvidia_nat_core/src/nat/middleware/`). + +NAT's architecture differs from Claude Code and Cursor: rather than firing events at named lifecycle points, NAT wraps every Function (tool / sub-workflow / LLM / retriever / memory operation / etc.) with a `Middleware` chain. The middleware's `pre_invoke` is called before the function executes, `post_invoke` after. + +The ACS adapter is implemented as a `FunctionMiddleware` that, for each wrapped call, emits an ACS step request. + +## NAT lifecycle → ACS step method + +**What the adapter emits** (the code is ground truth; this table describes it): + +| NAT concept | ACS step method | Emitted by the adapter today? | +|---|---|---| +| Any wrapped function `pre_invoke` (tools, LLMs, retrievers, memory — all of them) | `steps/toolCallRequest` | **Yes** — every wrapped call, with the function name as the tool name | +| Any wrapped function `post_invoke` | `steps/toolCallResult` | **Yes** | +| A root Runner's workflow start (via the `acs_lifecycle` exporter NAT starts for every Runner) | `steps/sessionStart` + `steps/userMessage` | **Yes** — a `sessionStart` deny refuses the run's calls, and a `userMessage` verdict other than allow blocks them; see below | +| The root Runner's workflow end (same exporter), for a run that returns | `steps/agentResponse` + `steps/sessionEnd` | **Yes** — observation-only, see below | +| The end of a run that raises or is cancelled (NAT cleans up the run's exporter) | `steps/sessionEnd` with `reason: error` or `cancelled` | **Yes** — observation-only, see below | +| Retriever calls as `steps/knowledgeRetrieval` | — | **No.** Retrievers surface as `toolCallRequest` with the retriever's function name; the Guardian dispatches on name. A dedicated `knowledgeRetrieval` emission is possible future work. | +| Memory read / write as `steps/memoryContextRetrieval` / `steps/memoryStore` | — | **No.** Same status: memory functions surface as generic tool calls. Documented gap — a Guardian keying policy on the memory-specific hooks will not see them from this adapter. | +| Sub-workflow invocation as `steps/subagentStart` / `steps/subagentStop` | — | **No.** A nested Runner joins its parent run's session, and its calls reach the Guardian there as tool calls. ACS-Core requires `subagentStart` only of a subagent-capable client, and the Guardian determines that capability by deployment-defined means. The requirement is vacuous for NAT only when sub-workflows are genuinely not distinguishable at this seam. Otherwise this is a mapping gap. | + +The Guardian can dispatch on the tool name to apply retriever-, memory-, or subagent-specific policy to the generic `toolCallRequest` stream; what it cannot get from this adapter is the dedicated ACS step methods for those events. + +### The run's input is gated; its output is recorded + +The builder registers an `acs_lifecycle` telemetry exporter, and NAT starts a copy of it for every Runner before the Runner's `WORKFLOW_START` and cleans the copy up when the Runner ends. A root Runner's copy sends `steps/sessionStart` + `steps/userMessage` at its `WORKFLOW_START`, `steps/agentResponse` at its `WORKFLOW_END`, and `steps/sessionEnd` at its cleanup. A middleware built without NAT's builder subscribes to each run's step stream on the run's first call instead and reports that run from the call on. hooks.md makes `userMessage` and `agentResponse` decision-eligible (ALLOW / DENY / MODIFY). Each call of a run waits until its `userMessage` has been answered, so the verdict on the input is applied: a verdict other than allow blocks every later call of the run (`workflow_input_refused`), as does a `userMessage` with no decision under `default_deny`, or one whose envelope the signer refuses (`envelope_not_signable`, whatever the posture). A MODIFY on the input is that DENY, recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5): NAT has already handed the input to the workflow. `agentResponse` goes out after the run has returned its output, and NAT's exporter is a notification stream that cannot veto an event after it fires, so the verdict on the output is recorded, not applied: a verdict other than allow as `unenforceable_decision`, a MODIFY also as `modify_unsupported`. Enforcement on outputs belongs at `pre_invoke` / `post_invoke` of the function that produced them, or in a review function the workflow calls before returning. + +**Each root NAT Runner is one ACS session.** `sessionStart` fires once per session, before any other `steps/*` hook for its `session_id`, and `sessionEnd` seals it (hooks.md), and the per-session HMAC key derives from the `session_id` (§10). So every session gets a fresh UUID for its `session_id`, never derived from NAT's ids or from configuration, and no `session_id` is used twice, across runs, processes or restarts. The session opens at the root Runner's `WORKFLOW_START` with its own `handshake/hello` and then its `steps/sessionStart`, and its `steps/sessionEnd` waits for the session's Guardian exchanges still in flight, a cancelled call's included. Its stored records are removed then. A call of the run after its end, or any call after NAT shuts the middleware down, is blocked and its result withheld, and nothing is sent, whatever the session's decision: a method the Guardian does not evaluate and `ACS_STARTUP_POSTURE=proceed` included, and however many runs have passed. + +**A workflow started inside a run stays in that run's session.** The middleware recognizes a run by the session it sets in the root Runner's context, not by NAT's `workflow_run_id`: every nested Runner inherits that id, `nat serve` takes it from the request's `workflow-run-id` header, and steps replayed from a remote workflow carry the caller's. A Runner of the same run that starts while the session is open in its context, a nested workflow or sub-agent, joins that session: its calls are gated there, and its own input and output are not sent as the user's message or the agent's response. Steps replayed from a remote workflow neither start nor end the session. Two requests that share a `workflow-run-id` header are two sessions. NAT's `workflow_run_id` and `conversation_id` go in `sessionStart`'s `platform_context` for correlation; under `nat serve` the run id is the client's to choose. + +A call belongs to a run while its context is one the run made. NAT reports a run's end in the Runner's own context, a run that raises or is refused included, where the middleware marks the run as left, so a later call there, or in a task created afterwards, is outside the run. A task the run created before its end keeps the run's context, so work the run left behind stays in the run's ended session and is refused. A cancelled run reports no end; for it, the run's `workflow_run_id`, `workflow_trace_id` and Runner input, which NAT restores when the Runner exits, decide instead. Only a workflow's own `WORKFLOW_START` opens or joins a run's session, so a call made before a run never stands in for the run's `sessionStart`. Without the builder, a call opens a run's session only when NAT's context carries a Runner input, so such a run whose input is `None` is reported under the middleware's own session. A call outside any NAT Runner, before or after a run, uses the middleware's own session, with a fresh `session_id` of its own. Its first call sends its `handshake/hello` and `sessionStart`, and NAT's shutdown of the middleware sends its `sessionEnd`, waiting for a session still opening, and removes its records. A call after that is blocked the same way. + +**The run's `sessionStart` decides the run.** The Guardian's `deny` of a run's `steps/sessionStart`, or a refusal of that request, refuses the run's session (hooks.md sessionStart: a Guardian MAY refuse a session): each later call of the run is blocked and its output withheld, and nothing more of the run is sent, `sessionEnd` included. A `sessionStart` that gets no decision is counted once, and each later step of the run follows the fail posture: under `default_deny` it is blocked, and otherwise it goes to the Guardian. The run's `sessionEnd`, which is audit-only, goes out under either posture and reports that count (§6.4). A call waits until the run's `sessionStart` has been answered. + +**Every run ends once.** NAT cleans up a Runner's exporter copy whether the run completed, raised or was cancelled, so the root Runner's session ends then: `reason: completed` after its `WORKFLOW_END`, `cancelled` when the run's task was cancelled, and `error` otherwise, carrying the session's decision failures. A run that raises or is cancelled reaches no `WORKFLOW_END`, so it sends no `steps/agentResponse`. A nested Runner's end never ends its parent's session. A middleware built without NAT's builder ends a run when NAT completes the run's step stream, which NAT does when the run returns or raises; a run cancelled in that mode ends when NAT shuts the middleware down, with `reason: abandoned`. NAT's shutdown of the middleware ends every session still open the same way, after the hooks it already queued. + +**Run a nested workflow in its own task.** NAT 1.7.0 gives a nested Runner a new step stream and does not restore its parent's when it returns (`Runner.__aenter__` sets `event_stream` without resetting it), so a Runner awaited directly inside another run's function hides the parent's later NAT events from every exporter, the parent's `WORKFLOW_END` included. The parent's calls are still gated in its session, and its session still ends, but with `reason: error` and no `steps/agentResponse`, even when the run completed. A nested workflow run in its own task, `asyncio.create_task` or `asyncio.gather`, leaves the parent's stream in place, and the parent ends with `reason: completed`. A NAT `Workflow` object built during a run copies that run's context and restores it whenever it runs later, so build workflows when the application starts: a workflow built inside a run and run in a later one finds the earlier run's ended session, and its calls are refused. + +**Front ends verified.** The tests drive these through NAT's own objects: `nat run` (a `SessionManager` session and run built from a workflow config, with the middleware applied by NAT's function middleware chain), `nat serve` (a `SessionManager` session for an HTTP request, as NAT's HTTP routes open it; NAT runs each websocket message in a task of its own the same way), nested Runners, replayed remote steps, and a middleware built without the builder. The MCP, A2A and eval front ends are separate NAT packages that are not installed here, so they are unverified. + +**Deliberately not mapped — the skill lifecycle set (`steps/skillRegister`, `steps/skillLoad`, `steps/skillUnload`).** NAT composes workflows from functions declared in the workflow YAML; the middleware surface exposes no runtime skill registration, activation, or unload events. Function *invocations* are governed at `pre_invoke`/`post_invoke` (`steps/toolCallRequest`/`toolCallResult`); lifecycle wiring becomes possible when NAT exposes lifecycle events for loadable components. + +## ACS disposition → NAT behavior + +| ACS disposition | NAT action | +|---|---| +| `allow` | `pre_invoke` returns `None` (proceed unchanged) | +| `deny` | `pre_invoke` raises `ACSGuardianDenied` (NAT 1.7.0) or sets `context.action = InvocationAction.SKIP` (NAT dev). NAT's runtime documents both: *"Raises: Any exception to abort execution"* and the action-based equivalent. | +| `modify` | If `modifications.parameter_overrides` is present and is a dict, the adapter rewrites each overridden field where NAT reads it: a field the input model, dataclass or dict in `context.modified_args[0]` declares is rewritten there, a keyword argument the call already has in `context.modified_kwargs`, and a scalar input, which NAT's function middleware chain passes for a one-argument function, under the input schema's field name at its position, the name the Guardian saw it under. NAT runs `call_next(*modified_args, **modified_kwargs)`, so each field is written in one slot only. If `modified_content` is present on a post-tool result, the adapter sets `context.output`. An override that names neither, a field the rebuilt input does not keep, redactions, a call's MODIFY without a `parameter_overrides` object, and structured edits on a result cannot be applied: the call is blocked or the output withheld, recorded as `modify_unsupported` with `reason_codes: ["modify_unsupported"]` (§6.5). | +| `ask` | Substituted to block at the middleware boundary in v1 (NAT has no native pause primitive at the function-middleware layer). Deployments wanting ASK should compose with NAT's HITL middleware (`nat.middleware.hitl`) and have the Guardian resolve before responding. | +| `defer` | Substituted to block in v1 (same reason). On a tool result, `ask` and `defer` both withhold the output (`context.output = None`) and are audited as `post_invoke_redacted`: the tool already ran, and nothing can pause its output for a person. | + +## Configuration + +Middleware is registered with NAT via the `name=` class kwarg on the config: + +```python +class ACSMiddlewareConfig(FunctionMiddlewareBaseConfig, name="acs_guardian"): + ... +``` + +NAT picks this up via its `register_middleware` registration mechanism (the adapter ships with `@register_middleware(config_type=ACSMiddlewareConfig)` applied to a factory function). + +YAML wiring: + +```yaml +middleware: + acs: + _type: acs_guardian # matches the name= kwarg above + guardian_url: http://127.0.0.1:8787/acs + default_deny: true + target_function_or_group: my_tools # optional; otherwise applied via group/workflow membership + target_location: input # NAT-standard field + +function_groups: + my_tools: + middleware: [acs] + +workflow: + _type: react_agent + middleware: [acs] +``` + +## Composition with NAT's defense middleware + +NAT ships `nvidia-nat-security` with `defense_middleware` for content-level checks (PII, output verification, pre-tool LLM gating). The ACS adapter does NOT replace these — both can be attached to the same group. Ordering is by list position in YAML; place ACS first if you want the policy gate before content filters, last if you want content rewrites to be visible to ACS as the final state. + +## Concurrency + +NAT can invoke several wrapped functions at once in one process. A session opens off the event loop at its first step, and every later step of the session reuses that decision, a refusal or no answer included. Each step's Guardian round trip runs in a worker thread (`asyncio.to_thread`), so a slow Guardian leaves the event loop running while the call waits for its decision. Each session's lifecycle hooks go out from a worker thread of their own in the order NAT reports them, and each call waits only until its own session's earlier hooks have gone out, so a slow hook in one run never holds another run's calls. When NAT shuts the middleware down, every session's queued hooks go out first, in order, and then each open session ends. A hook reported after its session ended is audited as `lifecycle_hook_not_sent`. The middleware correlates each call through its own invocation context and keeps no per-call file state. The Guardian's rolling `chain_hash` orders steps by arrival, so the order of concurrent steps is the order they reach the Guardian. + +## Conformance posture + +The NAT adapter covers ACS-Core's mandatory floor. This is the floor the tests check, not a full ACS-Core conformance claim (see `adapters/README.md`). The floor it covers: + +- Hook taxonomy: every wrapped function call surfaces as `toolCallRequest` + `toolCallResult`. +- Dispositions: ALLOW / DENY / MODIFY supported normatively; ASK / DEFER substituted to DENY with audit (HITL composition is the recommended path). +- SessionContext: `session_id` sent on every request: the root Runner's session's, or the middleware's own for a call outside any run. +- Replay protection: `request_id` UUID + timestamp. +- Handshake: each session performs `handshake/hello` once, a run's at its start and the middleware's own at its first call outside a run, and its answer decides the session (§4.1). The ServerHello must answer the ClientHello: a `negotiated_version` sharing a major version the adapter offered, a `methods_evaluated` list, the one transport the adapter offers (its Guardian URL's scheme), a positive `timeout_config.default_ms`, an `on_decision_failure` of `proceed` or `deny`, and only profiles the adapter claimed; otherwise the session has no negotiated Guardian (`invalid_server_hello`). Per-method timeouts bound their own methods. The answer: a refusal blocks every call, and no answer leaves the session with no negotiated Guardian for its lifetime, so `ACS_STARTUP_POSTURE` (`refuse` unless set to `proceed`) settles each call with no Guardian traffic. +- Decision honoring: NAT's middleware contract guarantees the function does not execute if `pre_invoke` raises or sets SKIP. The fail posture is `workflow.yml`'s `default_deny` OR the ServerHello's `on_decision_failure` (most-restrictive-wins); Guardian refusals (signature invalid, replay, malformed/oversized envelope) fail closed regardless of posture, on both the input gate (block) and the output gate (redact) — spec issue #32. Methods absent from the ServerHello's `methods_evaluated` resolve as ALLOW-by-default without being sent (handshake.json) and are recorded as `method_not_evaluated`. Before signing, every step replaces what RFC 8785 cannot encode: a lone surrogate becomes U+FFFD, and an integer past 2^53 - 1, NaN or an infinity becomes a string, recorded as `envelope_normalized`, so the Guardian still sees the step. Two keys that the replacement would turn into one make the envelope unsignable, since the signed view would drop a value. An envelope that still cannot be signed is denied whatever the posture (`envelope_not_signable`) on `pre_invoke` and the workflow input, and its output is withheld on `post_invoke`. Every decision failure is audited under either posture with its `failure_class` (`timeout`, `unreachable`, `malformed_response`, `guardian_error`, `observed_agent_error`, `signature_invalid`, `handshake_failure`, `other`) and `posture`, and each session's `sessionEnd` reports that session's count per class in `summary.decision_failures` (§6.4, #273). A handshake that gets no answer is audited as `handshake_failure`; that session sends the Guardian nothing, `sessionEnd` included (§4.1). +- Baseline integrity: HMAC-SHA256 per §10 over the RFC 8785 (JCS) canonical envelope with an HKDF-derived per-session key (`ACS_HMAC_SECRET_FILE` / `ACS_HMAC_SECRET`); `rfc8785` is a hard runtime dependency. The signature names the configured label `ACS_HMAC_KEY_ID` as `key_id`, and a response under any other label fails verification with no other secret tried; an unsigned `SIGNATURE_INVALID`, a Guardian's answer to a label it does not hold, is recorded as `key_id_rejected` and fails closed. Responses are signature-verified and bound to their request under any HTTP status; a mis-bound, badly-signed or unverifiable response fails closed regardless of posture. A middleware with no secret, a secret under 32 bytes, or no label refuses its session, so every call is denied, and sends nothing (`secret_missing`, `secret_too_short`, `key_id_missing`), whatever the posture: ACS-Core signs every request. diff --git a/adapters/docs/nat/workflow.yml.example b/adapters/docs/nat/workflow.yml.example new file mode 100644 index 00000000..71054d9b --- /dev/null +++ b/adapters/docs/nat/workflow.yml.example @@ -0,0 +1,38 @@ +# Example NAT workflow YAML wiring the ACS adapter. +# Replace `target_function_or_group` and the surrounding workflow definition +# with your real workflow; the middleware block is the only adapter-specific +# part. +# +# Environment (set on the NAT process — the adapter is in-process): +# ACS_HMAC_SECRET_FILE=/path/to/.acs/hmac.key +# REQUIRED for ACS-Core baseline integrity (§10), at least 32 bytes. +# Generate: +# mkdir -p ~/.acs && openssl rand -hex 32 > ~/.acs/hmac.key && chmod 600 ~/.acs/hmac.key +# and give the SAME file to the Guardian. +# ACS_HMAC_KEY_ID=2026-11 +# REQUIRED with the secret: the label that names it (§10); the Guardian holds the same one. +# Without a secret and its label the middleware refuses its sessions and denies every call. +# ACS_AUDIT_FILE=/path/to/.acs/audit.log +# Durable sink for §6.4 audit events (created 0600) in addition +# to stderr. +# ACS_DISABLED=1 +# Incident kill switch: the middleware passes everything through +# with no Guardian traffic. Read when the workflow starts. + +middleware: + acs_guardian: + _type: acs_guardian # registered by the acs-adapters[nat] package + guardian_url: http://127.0.0.1:8787/acs + default_deny: true + timeout_s: 5.0 + # Every session gets a fresh session_id, so there is none to configure. + # target_function_or_group: my_tools + +function_groups: + my_tools: + middleware: [acs_guardian] + +workflow: + _type: react_agent + middleware: [acs_guardian] + # ... rest of your workflow definition (LLM, tools, etc.) diff --git a/adapters/hatch_build.py b/adapters/hatch_build.py new file mode 100644 index 00000000..7448358f --- /dev/null +++ b/adapters/hatch_build.py @@ -0,0 +1,18 @@ +"""Bundle the repository's v0.1.0 schemas as acs/core/schemas when building from a checkout.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +SCHEMAS = Path(__file__).resolve().parent.parent / "specification" / "v0.1.0" + + +class SchemasBuildHook(BuildHookInterface): + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + # A wheel built from the sdist finds the schemas the sdist already carries under src/. + if version == "editable" or not SCHEMAS.is_dir(): + return + dest = "src/acs/core/schemas" if self.target_name == "sdist" else "acs/core/schemas" + build_data["force_include"][str(SCHEMAS)] = dest diff --git a/adapters/pyproject.toml b/adapters/pyproject.toml new file mode 100644 index 00000000..58b164d9 --- /dev/null +++ b/adapters/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "acs-adapters" +version = "0.2.0" +description = "Reference adapters that connect Claude Code, Cursor and NVIDIA NeMo Agent Toolkit to an ACS Guardian" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +# jsonschema is here, not in an extra, so the example Guardian validates envelopes in every install. +# format-nongpl brings the RFC 3339 date-time checker without the GPL rfc3987 package. +dependencies = ["rfc8785>=0.1.4,<0.2", "jsonschema[format-nongpl]>=4.25,<5"] + +[project.optional-dependencies] +nat = ["nvidia-nat-core>=1.7,<1.8", "ruamel.yaml>=0.19,<0.20"] + +[project.scripts] +acs-claude-code-hook = "acs.integrations.claude_code.hook:main" +acs-claude-code-wire = "acs.integrations.claude_code.wire:main" +acs-cursor-hook = "acs.integrations.cursor.hook:main" +acs-cursor-wire = "acs.integrations.cursor.wire:main" +acs-nat-wire = "acs.integrations.nat.wire:main" +acs-example-guardian = "acs.guardian.example_guardian:main" + +# One entry per integration, naming its package; integrations from other +# distributions register here the same way. +[project.entry-points."acs.integrations"] +claude_code = "acs.integrations.claude_code" +cursor = "acs.integrations.cursor" +nat = "acs.integrations.nat" + +# NAT imports this module at startup, which registers `_type: acs_guardian`. +[project.entry-points."nat.components"] +acs_guardian = "acs.integrations.nat.adapter" + +[dependency-groups] +test = ["import-linter>=2", "pytest>=8"] + +[tool.hatch.build.targets.wheel] +packages = ["src/acs"] + +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# Imports flow integrations -> client -> core; checked in CI with `lint-imports`. +[tool.importlinter] +root_package = "acs" + +[[tool.importlinter.contracts]] +name = "Integrations use the client, the client uses core, and core uses neither" +type = "layers" +layers = ["acs.integrations", "acs.client", "acs.core"] + +[[tool.importlinter.contracts]] +name = "The protocol, the client and the integrations import no Guardian, example or test code" +type = "forbidden" +source_modules = ["acs.core", "acs.client", "acs.integrations"] +forbidden_modules = ["acs.guardian", "acs.examples", "acs.testing"] diff --git a/adapters/run_conformance.py b/adapters/run_conformance.py new file mode 100755 index 00000000..1e1bb07f --- /dev/null +++ b/adapters/run_conformance.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Run the selected adapter suites; any failure, or any skip outside EXPECTED_SKIPS (any skip with --strict), fails the run.""" +from __future__ import annotations + +import argparse +import subprocess +import sys +import unittest +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +# (label, cwd, discover-or-module) +SHARED_SUITES = ( + ("guardian-conformance", HERE, ("module", "tests.conformance.test_acs_core_conformance")), + ("_common", HERE, ("discover", "tests/common")), + ("core", HERE, ("discover", "tests/core")), + ("client", HERE, ("discover", "tests/client")), + ("package", HERE, ("discover", "tests/package")), +) + +PLATFORM_SUITES = { + "claude": ("claude-code", HERE, ("discover", "tests/integrations/claude_code")), + "cursor": ("cursor", HERE, ("discover", "tests/integrations/cursor")), + "nat": ("nat", HERE, ("discover", "tests/integrations/nat")), +} + +PLATFORM_ALIASES = { + "claude": "claude", + "claude-code": "claude", + "cursor": "cursor", + "nat": "nat", +} + + +class _CountingResult(unittest.TextTestRunner.resultclass): + """Counts successes via addSuccess(): `testsRun - failures - skips` + underflows when a setUpClass error never increments testsRun.""" + def __init__(self, *a, **k): + super().__init__(*a, **k) + self.successes = 0 + + def addSuccess(self, test): # noqa: N802 + super().addSuccess(test) + self.successes += 1 + + +# Exact (suite, test id, reason) triples tolerated by default; any other skip fails the run. +EXPECTED_SKIPS = frozenset({ + ("cursor", + "tests.integrations.cursor.test_live.LiveCursorPlaceholder.test_run_manual_procedure", + "Cursor live verification is a manual procedure; see live_verification.md"), + ("claude-code", + "tests.integrations.claude_code.test_live.LiveClaudeCodeRoundTrip.test_benign_bash_runs", + "live Claude Code test is opt-in: set ACS_LIVE_CLAUDE=1 to call the real claude CLI"), + ("claude-code", + "tests.integrations.claude_code.test_live.LiveClaudeCodeRoundTrip.test_destructive_bash_blocked", + "live Claude Code test is opt-in: set ACS_LIVE_CLAUDE=1 to call the real claude CLI"), +}) + + +def _is_expected_skip(suite: str, test_id: str, reason: str) -> bool: + return (suite, test_id, reason) in EXPECTED_SKIPS + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run shared ACS checks and the selected adapter emission suites." + ) + ) + parser.add_argument( + "platforms", + metavar="PLATFORM", + nargs="+", + choices=tuple(PLATFORM_ALIASES), + help="one or more of: claude, cursor, nat", + ) + parser.add_argument( + "--strict", + action="store_true", + help="fail on expected skips as well as unexpected skips", + ) + return parser.parse_args(argv) + + +def _select_suites(platforms: list[str]) -> list[tuple[str, Path, tuple[str, str]]]: + suites = list(SHARED_SUITES) + selected: set[str] = set() + for platform in platforms: + canonical = PLATFORM_ALIASES[platform] + if canonical not in selected: + suites.append(PLATFORM_SUITES[canonical]) + selected.add(canonical) + return suites + + +def _run_suite(cwd: Path, kind: tuple[str, str]) -> tuple[int, list[tuple[str, str]], int]: + """(passed, skipped, failed) for one suite; failed includes setUpClass and module errors, so a broken suite cannot read as green.""" + loader = unittest.TestLoader() + sys.path.insert(0, str(cwd)) + if kind[0] == "module": + suite = loader.loadTestsFromName(kind[1]) + else: + suite = loader.discover(str(cwd / kind[1]), top_level_dir=str(cwd)) + runner = unittest.TextTestRunner(verbosity=1, stream=sys.stderr, + resultclass=_CountingResult) + result = runner.run(suite) + failed = len(result.failures) + len(result.errors) + # t.id() is the module.Class.method form the allowlist keys on; str(t) differs on 3.11+. + skipped = [(t.id(), reason) for t, reason in result.skipped] + return result.successes, skipped, failed + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + suites = _select_suites(args.platforms) + rows: list[tuple[str, int, int, int]] = [] + all_skips: list[tuple[str, str, str]] = [] # (suite, test id, reason) + # Each suite runs in its own subprocess, so module state cannot bleed between adapters. + for label, cwd, kind in suites: + cmd = [sys.executable, "-c", + "import sys, json; sys.argv=['x'];" + "from run_conformance import _run_suite; from pathlib import Path;" + f"p,s,f=_run_suite(Path({str(cwd)!r}), {kind!r});" + "print('__RESULT__' + json.dumps([p, s, f]))"] + proc = subprocess.run(cmd, cwd=str(HERE), capture_output=True, text=True) + sys.stderr.write(proc.stderr) + line = [ln for ln in proc.stdout.splitlines() if ln.startswith("__RESULT__")] + if not line: + sys.stderr.write(proc.stdout) + rows.append((label, 0, 0, 1)) + continue + import json + p, skips, f = json.loads(line[0][len("__RESULT__"):]) + rows.append((label, p, len(skips), f)) + for test_id, reason in skips: + all_skips.append((label, test_id, reason)) + + print("\n" + "=" * 60) + print("ACS emission/reference-stack summary") + print("=" * 60) + print(f"{'suite':22s} {'pass':>5} {'skip':>5} {'fail':>5}") + tp = ts = tf = 0 + for label, p, s, f in rows: + print(f"{label:22s} {p:5d} {s:5d} {f:5d}") + tp += p; ts += s; tf += f + print("-" * 60) + print(f"{'TOTAL':22s} {tp:5d} {ts:5d} {tf:5d}") + print("=" * 60) + + unexpected = [] + if all_skips: + print("Skipped tests:") + for suite, test_id, reason in all_skips: + expected = _is_expected_skip(suite, test_id, reason) + if not expected: + unexpected.append((suite, test_id, reason)) + tag = " (expected)" if expected else " (UNEXPECTED)" + print(f" - [{suite}] {test_id}\n reason: {reason}{tag}") + print("=" * 60) + + if tf: + print(f"FAILED — {tf} test(s) failed/errored.") + return 1 + if unexpected: + print(f"FAILED — {len(unexpected)} UNEXPECTED skip(s). A skipped " + "test is not a passing test; a dependency-gated test that " + "silently skips must not bank as green. Install the missing " + "dependency (e.g. nvidia-nat-core) so it runs, or add it to " + "EXPECTED_SKIPS only if it genuinely cannot be automated.") + return 1 + if args.strict and ts: + print(f"FAILED (--strict) — {ts} skipped; --strict requires zero " + "skips of any kind (including the manual Cursor procedure).") + return 1 + print(f"OK — {tp} passed, {ts} skipped " + f"({len(all_skips) - len(unexpected)} expected).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/adapters/scripts/check_wheel.py b/adapters/scripts/check_wheel.py new file mode 100644 index 00000000..9270c2ec --- /dev/null +++ b/adapters/scripts/check_wheel.py @@ -0,0 +1,147 @@ +"""Install a built acs-adapters wheel outside the repository and check what users depend on. + +Usage: python scripts/check_wheel.py dist/acs_adapters--py3-none-any.whl + +Needs `uv` on PATH. Each check runs in a fresh virtual environment in a +temporary directory, with that directory as the working directory, so +nothing resolves from the source tree. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +REPO_SCHEMAS = Path(__file__).resolve().parents[2] / "specification" / "v0.1.0" +SCRIPTS = ("acs-claude-code-hook", "acs-claude-code-wire", "acs-cursor-hook", + "acs-cursor-wire", "acs-nat-wire", "acs-example-guardian") +failures: list[str] = [] + + +def check(name: str, ok: bool, detail: str = "") -> None: + print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" ({detail})" if detail and not ok else "")) + if not ok: + failures.append(name) + + +def venv(root: Path, wheel: Path, extras: str) -> Path: + env_dir = root / f"venv{extras or '-base'}" + subprocess.run(["uv", "venv", "-q", "--python", sys.executable, str(env_dir)], check=True) + py = env_dir / "bin" / "python" + subprocess.run(["uv", "pip", "install", "-q", "--python", str(py), f"{wheel}{extras}"], check=True) + return env_dir + + +def run(cmd: list[str], cwd: Path, **kw) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if k not in ("ACS_SPEC_DIR", "PYTHONPATH", "VIRTUAL_ENV")} + env.update(kw.pop("env", {})) + return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120, **kw) + + +def base_checks(env_dir: Path, wheel_version: str, cwd: Path) -> None: + py, bindir = str(env_dir / "bin" / "python"), env_dir / "bin" + p = run([py, "-c", "import acs; print(acs.__version__)"], cwd) + check("package version matches the wheel", p.stdout.strip() == wheel_version, p.stdout + p.stderr) + + p = run([py, "-c", "from acs.core import schemas_dir; print(schemas_dir())"], cwd) + bundled = Path(p.stdout.strip()) if p.returncode == 0 else None + check("schemas resolve from the installed package", + bundled is not None and env_dir.resolve() in bundled.resolve().parents, p.stdout + p.stderr) + if bundled is not None: + want = {f.relative_to(REPO_SCHEMAS): f.read_bytes() for f in REPO_SCHEMAS.rglob("*") if f.is_file()} + got = {f.relative_to(bundled): f.read_bytes() for f in bundled.rglob("*") if f.is_file()} + check("bundled schemas are byte-identical to specification/v0.1.0", want == got, + f"{len(got)} bundled, {len(want)} in the repository") + + p = run([py, "-c", "import json; from importlib.metadata import entry_points as e; " + "print(json.dumps(sorted(x.name for x in e(group='acs.integrations'))))"], cwd) + check("acs.integrations lists claude_code, cursor and nat", + p.returncode == 0 and json.loads(p.stdout) == ["claude_code", "cursor", "nat"], p.stdout + p.stderr) + + for script in SCRIPTS: + check(f"{script} is installed", (bindir / script).is_file()) + for script, label in (("acs-claude-code-hook", "claude-code"), ("acs-cursor-hook", "cursor")): + p = run([str(bindir / script), "--version"], cwd) + check(f"{script} --version", p.returncode == 0 and f"({label}) {wheel_version}" in p.stdout, p.stdout + p.stderr) + for script in ("acs-claude-code-wire", "acs-cursor-wire"): + p = run([str(bindir / script), "--help"], cwd) + check(f"{script} --help", p.returncode == 0, p.stderr[-300:]) + + event = json.dumps({"hook_event_name": "PreToolUse", "session_id": "s", "tool_name": "Bash", + "tool_input": {"command": "ls"}}) + p = run([str(bindir / "acs-claude-code-hook")], cwd, input=event, env={"ACS_DISABLED": "1"}) + check("the kill switch runs and leaves an audit line", + p.returncode == 0 and "acs_disabled_bypass" in p.stderr, p.stdout + p.stderr) + + # Hide rfc8785: the hooks must still audit and apply ACS_DEFAULT_DENY=1. + shadow = cwd / "without-rfc8785" + shadow.mkdir(exist_ok=True) + (shadow / "rfc8785.py").write_text("raise ImportError('rfc8785 hidden by check_wheel')\n") + degraded = {"PYTHONPATH": str(shadow), "ACS_DEFAULT_DENY": "1"} + p = run([str(bindir / "acs-claude-code-hook")], cwd, input=event, env=degraded) + check("acs-claude-code-hook without rfc8785 audits and denies", + p.returncode == 0 and '"permissionDecision": "deny"' in p.stdout + and "adapter_unavailable" in p.stderr, p.stdout + p.stderr[-300:]) + p = run([str(bindir / "acs-cursor-hook"), "preToolUse"], cwd, input=event, env=degraded) + check("acs-cursor-hook without rfc8785 audits and denies", + p.returncode == 0 and '"permission": "deny"' in p.stdout + and "adapter_unavailable" in p.stderr, p.stdout + p.stderr[-300:]) + + p = run([str(bindir / "acs-example-guardian"), "--port", "0"], cwd) + check("acs-example-guardian refuses to start without a signing secret", + p.returncode != 0 and "REFUSING TO START" in p.stderr, p.stderr[-300:]) + + guardian_env = {k: v for k, v in os.environ.items() if k != "ACS_SPEC_DIR"} + guardian_env["ACS_HMAC_SECRET"] = "ab" * 32 + guardian_env["ACS_HMAC_KEY_ID"] = "check-wheel" + guardian = subprocess.Popen([str(bindir / "acs-example-guardian"), "--port", "0"], cwd=cwd, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=guardian_env) + announced = False + try: + line = guardian.stdout.readline() + time.sleep(0.2) + announced = re.match(r"ACS_GUARDIAN_LISTENING port=\d+", line) is not None + check("acs-example-guardian starts and announces its port", announced, line) + finally: + guardian.terminate() + _, err = guardian.communicate(timeout=10) + # Only meaningful once the Guardian actually started and loaded its schemas. + check("the example Guardian validates envelopes against the bundled schemas", + announced and "validation DISABLED" not in err, err[-300:]) + + +def nat_checks(env_dir: Path, cwd: Path) -> None: + code = ("from nat.runtime.loader import PluginTypes, discover_and_register_plugins\n" + "from nat.cli.type_registry import GlobalTypeRegistry\n" + "discover_and_register_plugins(PluginTypes.COMPONENT)\n" + "print('acs_guardian' in [r.local_name for r in GlobalTypeRegistry.get().get_registered_middleware()])\n") + p = run([str(env_dir / "bin" / "python"), "-c", code], cwd) + check("NAT discovers `_type: acs_guardian` from the [nat] extra", + p.stdout.strip().splitlines()[-1:] == ["True"], p.stderr[-400:]) + p = run([str(env_dir / "bin" / "acs-nat-wire"), "--help"], cwd) + check("acs-nat-wire --help with the [nat] extra", p.returncode == 0, p.stderr[-300:]) + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + wheel = Path(argv[1]).resolve() + wheel_version = wheel.name.split("-")[1] + with tempfile.TemporaryDirectory(prefix="acs-wheel-check-") as tmp: + root = Path(tmp) + # The base install is exactly what the quickstarts install: no extras. + base_checks(venv(root, wheel, ""), wheel_version, root) + nat_checks(venv(root, wheel, "[nat]"), root) + print(f"\n{'OK' if not failures else 'FAILED'}: {len(failures)} failed check(s)") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/adapters/src/acs/__init__.py b/adapters/src/acs/__init__.py new file mode 100644 index 00000000..fb832fc8 --- /dev/null +++ b/adapters/src/acs/__init__.py @@ -0,0 +1,9 @@ +"""ACS reference adapters: the Python client side of the Agent Control Standard.""" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("acs-adapters") +except PackageNotFoundError: + __version__ = "0+unknown" + +__all__ = ["__version__"] diff --git a/adapters/src/acs/client/__init__.py b/adapters/src/acs/client/__init__.py new file mode 100644 index 00000000..117005fa --- /dev/null +++ b/adapters/src/acs/client/__init__.py @@ -0,0 +1,69 @@ +"""The adapter side's I/O: configuration, secrets, the handshake, the round trip, session state and audit.""" +from importlib import import_module +from typing import Any + +# Exports load on first use, so _config, _outcome and _audit import without the signing dependency. +_EXPORTS = { + "FAILURE_CLASSES": "_config", + "failure_class": "_config", + "decision_failure_counts": "_store", + "current_session_id": "_handshake", + "discard_session": "_handshake", + "discard_session_state": "_store", + "mark_session_ended": "_handshake", + "count_decision_failure": "_store", + "Config": "_config", + "Decided": "_outcome", + "FailPosture": "_config", + "GuardianHTTPError": "_exchange", + "Handshake": "_outcome", + "Negotiated": "_outcome", + "NoAnswer": "_outcome", + "Refused": "_outcome", + "RequestTooLargeError": "_exchange", + "Resolution": "_config", + "SecretFilePermissionsError": "_secrets", + "SecretFileUnreadableError": "_secrets", + "SigningMaterialMissing": "_secrets", + "SessionRefused": "_outcome", + "Undecided": "_outcome", + "audit_event": "_audit", + "audit_modify_unsupported": "_audit", + "classify_response": "_exchange", + "close_turn": "_store", + "current_turn": "_store", + "exchange": "_exchange", + "load_hmac_secret": "_secrets", + "load_key_id": "_secrets", + "load_signing_material": "_secrets", + "load_session_state": "_store", + "mark_once": "_store", + "open_session": "_handshake", + "open_turn": "_store", + "ping": "_transport", + "post": "_exchange", + "record_mcp_use": "_profiles", + "record_step": "_store", + "round_trip": "_exchange", + "save_session_state": "_store", + "session_start_outcome": "_handshake", + "sign_envelope": "_signing", + "update_session_state": "_store", + "validate_guardian_url": "_urls", + "verify_signature": "_signing", +} + +__all__ = sorted(_EXPORTS) + + +def __getattr__(name: str) -> Any: + module = _EXPORTS.get(name) + if module is None: + raise AttributeError(f"module 'acs.client' has no attribute {name!r}") + value = getattr(import_module(f"acs.client.{module}"), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return __all__ diff --git a/adapters/src/acs/client/_audit.py b/adapters/src/acs/client/_audit.py new file mode 100644 index 00000000..8a243ade --- /dev/null +++ b/adapters/src/acs/client/_audit.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from acs.core import iso8601_now + + +def audit_event(event_type: str, **fields: Any) -> None: + """Write one JSON audit line to stderr and, when ACS_AUDIT_FILE is set, append it there (created 0600).""" + payload = { + "acs_audit_event": event_type, + "timestamp": iso8601_now(), + **fields, + } + line = "ACS_AUDIT " + json.dumps(payload, sort_keys=True) + "\n" + sys.stderr.write(line) + sys.stderr.flush() + audit_file = os.environ.get("ACS_AUDIT_FILE", "").strip() + if audit_file: + try: + fd = os.open(audit_file, + os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(fd, "a") as f: + f.write(line) + except OSError as e: + # Never crash the hook path over the audit sink, but say so. + sys.stderr.write( + f"ACS_AUDIT_SINK_ERROR cannot append to {audit_file!r}: {e}\n") + sys.stderr.flush() + + +def audit_modify_unsupported(detail: str, **fields: Any) -> None: + """Record a MODIFY the client cannot apply and treats as DENY, under reason code modify_unsupported (§6.5).""" + audit_event("modify_unsupported", reason_codes=["modify_unsupported"], detail=detail, **fields) diff --git a/adapters/src/acs/client/_config.py b/adapters/src/acs/client/_config.py new file mode 100644 index 00000000..b8f96a81 --- /dev/null +++ b/adapters/src/acs/client/_config.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field, replace +from typing import Any + +from acs.client._outcome import Handshake, Negotiated, Refused, SessionRefused, Undecided + +DEFAULT_GUARDIAN_URL = "http://127.0.0.1:8787/acs" +DEFAULT_TIMEOUT_S = 5.0 + + +# §6.4 failure classes, the acs.failure.class values in trace/otel-mapping.json. +FAILURE_CLASSES = ("timeout", "unreachable", "malformed_response", "guardian_error", "observed_agent_error", + "signature_invalid", "handshake_failure", "other") + +_OBSERVED_AGENT_CAUSES = {"adapter_exception", "adapter_build_failed", "adapter_translate_exception", "envelope_not_signable", + "invalid_stdin_json", "guardian_url_rejected", "request_exceeds_max_payload", + "session_start_incomplete"} +_SIGNATURE_CAUSES = {"response_signature_invalid", "error_signature_invalid", "response_unverifiable", + "server_hello_signature_invalid"} + + +def failure_class(cause: str) -> str: + if cause in ("decision_timeout", "handshake_timeout"): + return "timeout" + if cause == "transport_failure": + return "unreachable" + if cause in ("malformed_response", "response_binding_mismatch"): + return "malformed_response" + if cause in _SIGNATURE_CAUSES: + return "signature_invalid" + if cause in _OBSERVED_AGENT_CAUSES: + return "observed_agent_error" + if cause.endswith("_response") or cause.startswith("http_") or cause == "key_id_rejected": + return "guardian_error" + return "other" + + +_DENY_LABELS = { + "guardian_refusal_fail_closed": "refused", + "session_refused_fail_closed": "session refused", + "session_not_started_fail_closed": "session not started", +} + + +@dataclass(frozen=True) +class Resolution: + """How a step without a usable decision ends: whether it proceeds, and the audit record that says why.""" + proceed: bool + audit_type: str + cause: str + fields: Mapping[str, Any] + message: str = "" + # This step's own decision failure (§6.4), counted once; a session gate restating one is not. + decision_failure: bool = False + + @property + def label(self) -> str: + return _DENY_LABELS.get(self.audit_type, "decision-failure") + + +def _positive(ms: object) -> bool: + return isinstance(ms, (int, float)) and not isinstance(ms, bool) and ms > 0 + + +@dataclass(frozen=True) +class FailPosture: + """Whether a step proceeds when no usable decision arrives (§6.4), and how long it waits for one.""" + deny_on_failure: bool + timeout_s: float + # timeout_config.per_method_ms from the ServerHello, in seconds. + per_method_timeout_s: Mapping[str, float] = field(default_factory=dict) + + def timeout_for(self, method: str) -> float: + return self.per_method_timeout_s.get(method, self.timeout_s) + + def negotiated(self, server_hello: Mapping[str, Any] | None) -> FailPosture: + """Apply a ServerHello: deny wins over proceed, and negotiated timeouts replace the local one.""" + if not isinstance(server_hello, Mapping): + return self + deny = self.deny_on_failure or server_hello.get("on_decision_failure") == "deny" + timeout_s = self.timeout_s + timeout_config = server_hello.get("timeout_config") + timeout_config = timeout_config if isinstance(timeout_config, Mapping) else {} + default_ms = timeout_config.get("default_ms") + if _positive(default_ms): + timeout_s = default_ms / 1000.0 + per_method = timeout_config.get("per_method_ms") + per_method = per_method if isinstance(per_method, Mapping) else {} + overrides = {method: ms / 1000.0 for method, ms in per_method.items() if _positive(ms)} + return replace(self, deny_on_failure=deny, timeout_s=timeout_s, per_method_timeout_s=overrides) + + def closed(self) -> FailPosture: + return replace(self, deny_on_failure=True) + + def resolve(self, outcome: Undecided | Refused) -> Resolution: + """The one place a step without a usable decision is settled: a refusal blocks, anything else follows the posture.""" + proceed = not isinstance(outcome, Refused) and not self.deny_on_failure + fields = {**outcome.fields, "failure_class": failure_class(outcome.cause), + "posture": "fail_open" if proceed else "fail_closed"} + if isinstance(outcome, Refused): + audit_type = "guardian_refusal_fail_closed" + else: + audit_type = "fail_open_bypass" if proceed else "decision_failure_fail_closed" + return Resolution(proceed, audit_type, outcome.cause, fields, outcome.message, decision_failure=True) + + +@dataclass(frozen=True) +class Config: + """ACS_GUARDIAN_URL, ACS_DEFAULT_DENY, ACS_STARTUP_POSTURE, ACS_REQUIRE_PROFILES, ACS_HANDSHAKE and ACS_DISABLED, read once at the entry point.""" + guardian_url: str + posture: FailPosture + handshake_enabled: bool + disabled: bool + startup_posture: str | None = None + require_profiles: frozenset[str] = frozenset() + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> Config: + return cls( + guardian_url=env.get("ACS_GUARDIAN_URL", DEFAULT_GUARDIAN_URL), + posture=FailPosture(deny_on_failure=env.get("ACS_DEFAULT_DENY", "0") == "1", + timeout_s=DEFAULT_TIMEOUT_S), + handshake_enabled=env.get("ACS_HANDSHAKE", "1") == "1", + disabled=env.get("ACS_DISABLED") == "1", + startup_posture=env.get("ACS_STARTUP_POSTURE", "").strip().lower() or None, + require_profiles=frozenset(p.strip().lower() for p in env.get("ACS_REQUIRE_PROFILES", "").split(",") + if p.strip()), + ) + + @property + def startup_proceeds(self) -> bool: + """Whether steps proceed with no negotiated Guardian (§4.1): only "proceed" proceeds.""" + return self.startup_posture == "proceed" + + def gate(self, handshake: Handshake, method: str | None = None) -> Resolution | None: + """None when this step goes to the Guardian; otherwise how it ends without one (§4.1), a session whose sessionStart got no decision included.""" + if isinstance(handshake, Negotiated): + failure = handshake.start_failure + # sessionEnd is audit-only and reports the session's decision failures (§6.4), the start's among them. + if failure is None or method == "steps/sessionEnd" or not self.posture.negotiated(handshake.server_hello).deny_on_failure: + return None + return Resolution(False, "decision_failure_fail_closed", failure["cause"], + {**failure["fields"], "method": "steps/sessionStart", "posture": "fail_closed"}) + if isinstance(handshake, SessionRefused): + return Resolution(False, "session_refused_fail_closed", handshake.cause, handshake.fields) + if self.startup_proceeds: + return Resolution(True, "unguarded_session_bypass", handshake.cause, handshake.fields) + return Resolution(False, "session_not_started_fail_closed", handshake.cause, handshake.fields) diff --git a/adapters/src/acs/client/_exchange.py b/adapters/src/acs/client/_exchange.py new file mode 100644 index 00000000..22d586ba --- /dev/null +++ b/adapters/src/acs/client/_exchange.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import http.client +import json +import math +import re +import socket +import threading +import urllib.error +import urllib.request +from dataclasses import replace +from typing import Any + +from acs.client._audit import audit_event +from acs.client._config import FailPosture, Resolution +from acs.client._outcome import Decided, Outcome, Refused, Undecided +from acs.client._signing import sign_envelope, verify_signature +from acs.client._urls import validate_guardian_url +from acs.core import ( + MAX_REQUEST_BODY_BYTES, + guardian_error_cause, + is_guardian_refusal, + modify_composition_violation, + normalize_decision, + response_matches_request, + schemas_dir, +) + + +class RequestTooLargeError(Exception): + """The envelope exceeds the Guardian's body cap; checked before sending, since a 413 can arrive as a connection reset.""" + + +# Statuses that refuse the request when the body carries no JSON-RPC envelope. +HTTP_REFUSAL_STATUSES = frozenset({400, 413}) + + +class GuardianHTTPError(Exception): + """A non-2xx answer; body is the decoded JSON, or None.""" + + def __init__(self, status: int, body: Any): + self.status = status + self.body = body + super().__init__(f"HTTP {status}") + + +def is_jsonrpc_response(body: Any) -> bool: + """True for a JSON-RPC 2.0 response: "jsonrpc": "2.0", and a result or error, each present one an object.""" + if not isinstance(body, dict) or body.get("jsonrpc") != "2.0": + return False + members = [body[k] for k in ("result", "error") if k in body] + return bool(members) and all(isinstance(m, dict) for m in members) + + +def post(request: dict[str, Any], *, guardian_url: str, timeout_s: float) -> Any: + """POST the request and return the decoded JSON body; raises on oversize, a non-2xx status, transport failure or bad JSON.""" + body = json.dumps(request).encode("utf-8") + if len(body) > MAX_REQUEST_BODY_BYTES: + raise RequestTooLargeError(len(body)) + req = urllib.request.Request( + guardian_url, data=body, + headers={"Content-Type": "application/json"}, method="POST", + ) + # A socket timeout bounds each read, not the exchange, so the §6.4 deadline waits on a worker and then shuts its sockets. + done, aborter, box = threading.Event(), _Aborter(), {} + + def fetch() -> None: + _worker.aborter = aborter + try: + box["value"] = _fetch(req, timeout_s, aborter) + except BaseException as e: # noqa: BLE001 + box["error"] = e + finally: + aborter.release() + done.set() + + worker = threading.Thread(target=fetch, name="acs-exchange", daemon=True) + worker.start() + if not done.wait(timeout_s): + aborter.abort() + raise TimeoutError(f"no complete response within {timeout_s:g}s") + if "error" in box: + raise box["error"] + return box["value"] + + +class _Aborter: + """The exchange's sockets, held as duplicates because TLS wrapping detaches the original, shut down together at the deadline.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sockets: list[socket.socket] = [] + self.aborted = threading.Event() + + def add(self, sock: socket.socket) -> None: + with self._lock: + if not self.aborted.is_set(): + self._sockets.append(sock.dup()) + return + sock.close() + raise TimeoutError("the exchange passed its deadline") + + def abort(self) -> None: + with self._lock: + self.aborted.set() + sockets, self._sockets = self._sockets, [] + for sock in sockets: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + sock.close() + + def release(self) -> None: + with self._lock: + sockets, self._sockets = self._sockets, [] + for sock in sockets: + sock.close() + + +_worker = threading.local() + + +def _create_connection(*args: Any, **kwargs: Any) -> socket.socket: + sock = socket.create_connection(*args, **kwargs) + aborter = getattr(_worker, "aborter", None) + if aborter is not None: + aborter.add(sock) + return sock + + +class _HTTPConnection(http.client.HTTPConnection): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._create_connection = _create_connection + + +class _HTTPSConnection(http.client.HTTPSConnection): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._create_connection = _create_connection + + +class _HTTPHandler(urllib.request.HTTPHandler): + def http_open(self, req: urllib.request.Request) -> Any: + return self.do_open(_HTTPConnection, req) + + +class _HTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req: urllib.request.Request) -> Any: + kwargs: dict[str, Any] = {"context": self._context} + if hasattr(self, "_check_hostname"): + kwargs["check_hostname"] = self._check_hostname + return self.do_open(_HTTPSConnection, req, **kwargs) + + +def _open(req: urllib.request.Request, timeout: float) -> Any: + """Send through the environment's proxies on abortable connections; a redirect is an HTTP error, not a new request.""" + opener = urllib.request.OpenerDirector() + for handler in (urllib.request.ProxyHandler(), _HTTPHandler(), _HTTPSHandler(), + urllib.request.HTTPDefaultErrorHandler(), urllib.request.HTTPErrorProcessor()): + opener.add_handler(handler) + return opener.open(req, timeout=timeout) + + +def _fetch(req: urllib.request.Request, timeout_s: float, aborter: _Aborter) -> Any: + cancelled = aborter.aborted + try: + with _open(req, timeout=timeout_s) as resp: + raw = _read(resp, cancelled) + except urllib.error.HTTPError as e: + try: + parsed = json.loads(_read(e, cancelled).decode("utf-8")) + except Exception: # noqa: BLE001 + parsed = None + raise GuardianHTTPError(e.code, parsed) from e + return json.loads(raw.decode("utf-8")) + + +def _read(resp: Any, cancelled: threading.Event) -> bytes: + """Read the body one socket read at a time until the deadline.""" + read1 = getattr(resp, "read1", None) + if read1 is None: + return resp.read() + chunks = [] + while not cancelled.is_set(): + chunk = read1(65536) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + + +_MAX_SAFE_INTEGER = 2**53 - 1 +_SURROGATE = re.compile("[\ud800-\udfff]") + + +class _KeyCollision(ValueError): + pass + + +def _signable(value: Any, replaced: dict[str, int]) -> Any: + if isinstance(value, str): + value, n = _SURROGATE.subn("\ufffd", value) + if n: + replaced["lone_surrogates"] = replaced.get("lone_surrogates", 0) + n + return value + if isinstance(value, bool) or value is None: + return value + if isinstance(value, int) and abs(value) > _MAX_SAFE_INTEGER: + replaced["unsafe_integers"] = replaced.get("unsafe_integers", 0) + 1 + return str(value) + if isinstance(value, float) and not math.isfinite(value): + replaced["non_finite_numbers"] = replaced.get("non_finite_numbers", 0) + 1 + return str(value) + if isinstance(value, dict): + normalized: dict[Any, Any] = {} + for key, item in value.items(): + signed_key = _signable(key, replaced) + if signed_key in normalized: + # The signed view would drop one of the two values. + raise _KeyCollision(f"two keys normalize to {signed_key!r}") + normalized[signed_key] = _signable(item, replaced) + return normalized + if isinstance(value, list): + return [_signable(v, replaced) for v in value] + return value + + +def _normalize(request: dict[str, Any]) -> str | None: + """Replace what RFC 8785 cannot encode (lone surrogates, integers past 2**53 - 1, NaN and infinities) so the + Guardian still sees the step; return why the request cannot be signed when two keys would become one.""" + replaced: dict[str, int] = {} + try: + params = _signable(request.get("params"), replaced) + except RecursionError: + return None + except _KeyCollision as e: + return str(e) + if replaced: + request["params"] = params + audit_event("envelope_normalized", method=request.get("method"), + request_id=(params or {}).get("request_id"), **replaced) + return None + + +def round_trip(request: dict[str, Any], *, guardian_url: str, posture: FailPosture, + session_id: str | None, gate: bool = False) -> Decided | Resolution: + """Sign, exchange within the method's timeout, and resolve anything but a usable decision with the posture; on a gate an unsignable request fails closed.""" + collision = _normalize(request) + try: + if collision is not None: + raise ValueError(collision) + sign_envelope(request, session_id=session_id) + except Exception as e: # noqa: BLE001 + outcome = Undecided("envelope_not_signable", {"error": str(e)}, f"envelope not signable: {e}") + return (posture.closed() if gate else posture).resolve(outcome) + outcome = exchange(request, guardian_url=guardian_url, + timeout_s=posture.timeout_for(request.get("method", "")), session_id=session_id) + if not isinstance(outcome, Decided): + return posture.resolve(outcome) + result = outcome.response.get("result") + try: + problem = result_problem(result) + except Exception as e: # noqa: BLE001 + problem = f"result could not be checked: {e}" + if problem is None: + return outcome + params = request.get("params") or {} + fields = {"method": request.get("method"), "request_id": params.get("request_id"), "problem": problem} + message = f"Guardian response fails response-envelope.json: {problem}" + if normalize_decision(result).disposition == "allow": + return posture.resolve(Undecided("malformed_response", fields, message)) + # A restrictive or unreadable verdict is never carried out as an allow. + return posture.resolve(Refused("malformed_response", fields, message)) + + +_RESULT_VALIDATOR: Any = None + + +def _result_validator() -> Any: + """A validator for response-envelope.json's AcsResult, resolving its references against the bundled schemas.""" + global _RESULT_VALIDATOR + if _RESULT_VALIDATOR is None: + from jsonschema import Draft202012Validator + from referencing import Registry, Resource + + resources = [] + for path in schemas_dir().glob("*.json"): + schema = json.loads(path.read_text(encoding="utf-8")) + if "$id" in schema: + resources.append((schema["$id"], Resource.from_contents(schema))) + registry = Registry().with_resources(resources) + envelope = json.loads((schemas_dir() / "response-envelope.json").read_text(encoding="utf-8")) + _RESULT_VALIDATOR = Draft202012Validator({"$ref": f"{envelope['$id']}#/$defs/AcsResult"}, registry=registry, + format_checker=Draft202012Validator.FORMAT_CHECKER) + return _RESULT_VALIDATOR + + +def result_problem(result: Any) -> str | None: + """Why a decision result fails response-envelope.json's AcsResult, or None.""" + from jsonschema.exceptions import best_match + + errors = list(_result_validator().iter_errors(result)) + if not errors: + return None + if (isinstance(result, dict) and result.get("decision") == "modify" + and all(e.absolute_path and e.absolute_path[0] == "modifications" for e in errors) + and modify_composition_violation(result.get("modifications"))): + # §6.3 makes this a DENY the adapter applies, which trace/otel-mapping.json does not count as a decision failure. + return None + error = best_match(errors) + where = "/".join(str(p) for p in error.absolute_path) + return f"{where}: {error.message}" if where else error.message + + +def exchange(request: dict[str, Any], *, guardian_url: str, timeout_s: float, + session_id: str | None) -> Outcome: + """POST one request and classify the answer; never raises.""" + params = request.get("params") or {} + method, request_id = request.get("method"), params.get("request_id") + try: + validate_guardian_url(guardian_url) + except ValueError as e: + return Undecided("guardian_url_rejected", {"method": method, "error": str(e)}, + f"Guardian URL rejected: {e}") + http_status = None + try: + response = post(request, guardian_url=guardian_url, timeout_s=timeout_s) + except RequestTooLargeError as e: + return Refused("request_exceeds_max_payload", {"body_bytes": e.args[0], "method": method}, + f"envelope exceeds body cap: {e}") + except GuardianHTTPError as e: + if not is_jsonrpc_response(e.body): + fields = {"http_status": e.status, "method": method} + if e.status in HTTP_REFUSAL_STATUSES: + return Refused(f"http_{e.status}_refusal", fields, f"Guardian refused at HTTP layer ({e.status})") + return Undecided(f"http_{e.status}_error", fields, f"Guardian returned HTTP {e.status}") + # A JSON-RPC envelope gets the same binding and signature checks under any status. + response, http_status = e.body, e.status + except (urllib.error.URLError, TimeoutError, OSError) as e: + timed_out = isinstance(e, TimeoutError) or isinstance(getattr(e, "reason", None), TimeoutError) + fields = {"request_id": request_id, "method": method, "error": str(e)} + if timed_out: + return Undecided("decision_timeout", fields, f"decision timed out after {timeout_s:g}s: {e}") + return Undecided("transport_failure", fields, f"Guardian unreachable: {e}") + except (json.JSONDecodeError, UnicodeDecodeError) as e: + return Undecided("malformed_response", {"request_id": request_id, "method": method, "error": str(e)}, + f"Guardian response is not JSON: {e}") + except Exception as e: # noqa: BLE001 + return Undecided("adapter_exception", {"error": str(e)}, f"adapter error: {e}") + try: + outcome = classify_response(request, response, session_id=session_id) + except Exception as e: # noqa: BLE001 + outcome = Refused("response_unverifiable", {"request_id": request_id, "method": method, "error": str(e)}, + f"Guardian response could not be verified: {e}") + if http_status is None or isinstance(outcome, Decided): + return outcome + return replace(outcome, fields={**outcome.fields, "http_status": http_status}) + + +def key_id_rejected(error: Any) -> bool: + """An unsigned SIGNATURE_INVALID error: how a Guardian answers a key_id it holds no secret for (§10).""" + return isinstance(error, dict) and error.get("code") == -32004 and "signature" not in error + + +def classify_response(request: dict[str, Any], response: Any, *, session_id: str | None) -> Outcome: + """Classify a decoded Guardian answer; mis-bound or unverifiable answers and refusal codes fail closed.""" + params = request.get("params") or {} + method, request_id = request.get("method"), params.get("request_id") + if not is_jsonrpc_response(response): + return Undecided("malformed_response", {"request_id": request_id, "method": method}, + "Guardian response is not a JSON-RPC 2.0 response with an object result or error") + if not response_matches_request(request, response): + claimed = response["error"].get("code") if "error" in response else None + return Refused("response_binding_mismatch", + {"claimed_error_code": claimed} if claimed is not None else {}, + "response not bound to request") + if "error" in response: + err = response["error"] + code = err.get("code") + # Errors are signed too; an unverifiable error under fail-open would be an allow. + if not verify_signature(response, session_id=session_id): + if key_id_rejected(err): + return Refused("key_id_rejected", {"claimed_error_code": code}, + "Guardian answered an unsigned SIGNATURE_INVALID, how it reports a key_id it does not " + "hold (§10); check ACS_HMAC_KEY_ID") + return Refused("error_signature_invalid", + {"claimed_error_code": code, "claimed_cause": guardian_error_cause(code)}, + "error response signature invalid") + cause = guardian_error_cause(code) + fields = {"error_code": code, "error_message": err.get("message"), + "request_id": request_id, "method": method} + message = f"Guardian returned JSON-RPC error {code} ({cause}): {err.get('message', '')}" + if is_guardian_refusal(code): + return Refused(cause, fields, message) + return Undecided(cause, fields, message) + if not verify_signature(response, session_id=session_id): + return Refused("response_signature_invalid", {}, "response signature invalid") + return Decided(response) diff --git a/adapters/src/acs/client/_handshake.py b/adapters/src/acs/client/_handshake.py new file mode 100644 index 00000000..9354e9d9 --- /dev/null +++ b/adapters/src/acs/client/_handshake.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import base64 +import contextlib +import hashlib +import hmac +import json +import math +import os +import tempfile +import urllib.error +import urllib.parse +import uuid +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +from acs.client._audit import audit_event +from acs.client._config import Config, Resolution +from acs.client._exchange import GuardianHTTPError, is_jsonrpc_response, key_id_rejected, post +from acs.client._outcome import Decided, Handshake, Negotiated, NoAnswer, SessionRefused, Undecided +from acs.client._secrets import SigningMaterialMissing, load_signing_material +from acs.client._signing import sign_envelope, verify_signature +from acs.client._store import _locked +from acs.client._urls import validate_guardian_url +from acs.core import ( + ACS_VERSION, + MAX_REQUEST_BODY_BYTES, + derive_session_key, + guardian_error_cause, + is_guardian_refusal, + is_session_refusal, + iso8601_now, + jcs_canonicalize, + normalize_decision, + server_hello_problem, +) + + +_HANDSHAKE_CACHE_DIR = Path( + os.environ.get( + "ACS_HANDSHAKE_CACHE", + os.path.join(os.path.expanduser("~"), ".cache", "acs-adapter-handshake"), + ) +) + +_DEFAULT_HANDSHAKE_TIMEOUT_S = 5.0 + +_RECORD_VERSION = 1 +_STARTUP_VERSION = 1 +# A startup record whose sessionStart was claimed but whose outcome was never recorded. +_PENDING = "pending" + + +def _handshake_cache_path(session_id: str, guardian_url: str, directory: Path | None = None) -> Path: + key = hashlib.sha256((session_id + "|" + guardian_url).encode("utf-8", "surrogatepass")).hexdigest() + return (directory or _HANDSHAKE_CACHE_DIR) / f"{key}.json" + + +def current_session_id(host_session_id: str, guardian_url: str) -> str: + """The ACS session a host session's hooks belong to: the host's id until a sessionEnd ends it, then a new one each time.""" + session_id, generation = host_session_id, 0 + while _ended(session_id, guardian_url).exists(): + generation += 1 + session_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"acs-session:{host_session_id}:{generation}")) + return session_id + + +def mark_session_ended(session_id: str, guardian_url: str) -> bool: + """Mark a session ended, so a later hook of the same host session opens a new one; False when it cannot be marked.""" + marker = _ended(session_id, guardian_url) + try: + marker.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + marker.touch(mode=0o600) + except OSError as e: + audit_event("session_record_unwritable", session_id=session_id, guardian_url=guardian_url, + record=str(marker), error=str(e)) + return False + return True + + +def _ended(session_id: str, guardian_url: str) -> Path: + return Path(f"{_handshake_cache_path(session_id, guardian_url)}.ended") + + +def discard_session(session_id: str, guardian_url: str) -> None: + path = _handshake_cache_path(session_id, guardian_url) + for record in (path, Path(f"{path}.started"), Path(f"{path}.lock")): + with contextlib.suppress(OSError): + record.unlink() + + +def open_session(*, config: Config, session_id: str | None, agent_id: str, platform: str, + methods_implemented: list[str], wrapped_protocols: list[str] | None = None, + timeout: float | None = None, profiles_supported: list[str] | None = None, + transports_supported: list[str] | None = None, + start: Callable[[Negotiated], Decided | Resolution | None] | None = None, + count_start_failure: Callable[[str], None] | None = None) -> Handshake: + """The session's decision, stored once under an HMAC; with start, the opening process also sends sessionStart under the startup lock. Never raises.""" + if not session_id: + return NoAnswer("missing_session_id") + # §10: every request is signed, so a session with no usable secret and label is refused before any stored one is read. + try: + ikm, _ = load_signing_material() + except SigningMaterialMissing as e: + return SessionRefused(e.cause, {"error": str(e)}) + except Exception as e: # noqa: BLE001 + return SessionRefused("secret_unavailable", {"error": str(e)}) + path = _handshake_cache_path(session_id, config.guardian_url) + hello = dict(config=config, session_id=session_id, agent_id=agent_id, platform=platform, + methods_implemented=methods_implemented, wrapped_protocols=wrapped_protocols, timeout=timeout, + profiles_supported=profiles_supported, transports_supported=transports_supported) + if start is None: + return _required_profiles(_decide(path, ikm, **hello), config) + started = Path(f"{path}.started") + stored = _read_record(path, ikm, session_id, config.guardian_url) + if stored is not None and not isinstance(stored, Negotiated): + return stored + if stored is not None: + recorded = _read_startup(started, ikm, session_id, config.guardian_url) + if recorded is not None and recorded != _PENDING: + return _required_profiles(recorded, config) + with contextlib.ExitStack() as startup: + try: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + startup.enter_context(_locked(path)) + except OSError as e: + audit_event("session_record_unwritable", session_id=session_id, guardian_url=config.guardian_url, + record=f"{path}.lock", error=str(e)) + return SessionRefused("session_startup_lock_unavailable", {"error": str(e)}) + decision = _decide(path, ikm, **hello) + own = _required_profiles(decision, config) + if not isinstance(own, Negotiated): + return own + recorded = _read_startup(started, ikm, session_id, config.guardian_url) + if recorded == _PENDING: + failure = config.posture.negotiated(decision.server_hello).resolve(Undecided( + "session_start_incomplete", {"record": str(started)}, + "the hook that sent sessionStart ended before its decision arrived")) + if count_start_failure is not None: + count_start_failure(failure.fields["failure_class"]) + outcome = session_start_outcome(decision, failure, session_id=session_id, guardian_url=config.guardian_url) + unrecorded = _write_startup(started, outcome, ikm, session_id, config.guardian_url) + return _required_profiles(unrecorded or outcome, config) + if recorded is not None: + return _required_profiles(recorded, config) + claim = _write_startup(started, None, ikm, session_id, config.guardian_url) + if claim is not None: + return claim + outcome = _start_session(decision, start, config, session_id) + unrecorded = _write_startup(started, outcome, ikm, session_id, config.guardian_url) + return _required_profiles(unrecorded or outcome, config) + + +def _start_session(decision: Negotiated, start: Callable[[Negotiated], Decided | Resolution | None], + config: Config, session_id: str) -> Handshake: + try: + result = start(decision) + except Exception as e: # noqa: BLE001 + result = config.posture.negotiated(decision.server_hello).resolve( + Undecided("adapter_exception", {"error": str(e)}, f"adapter error: {e}")) + return session_start_outcome(decision, result, session_id=session_id, guardian_url=config.guardian_url) + + +def session_start_outcome(decision: Negotiated, result: Decided | Resolution | None, *, session_id: str, + guardian_url: str) -> Handshake: + """The session's decision after its sessionStart answered with result, audited (hooks.md sessionStart: ALLOW / DENY).""" + if result is None: + # The Guardian does not evaluate sessionStart. + return decision + common = {"method": "steps/sessionStart", "session_id": session_id, "guardian_url": guardian_url} + if isinstance(result, Resolution): + audit_event(result.audit_type, **{**result.fields, "cause": result.cause, **common}) + if result.audit_type == "guardian_refusal_fail_closed": + refusal = SessionRefused("session_start_refused", {**result.fields, "refusal": result.cause}) + audit_event("session_refused", **{**refusal.fields, "cause": refusal.cause, **common}) + return refusal + # No decision arrived: recorded once, and each hook's fail posture settles its own steps. + return Negotiated(decision.server_hello, {"cause": result.cause, "fields": dict(result.fields)}) + verdict = normalize_decision((result.response or {}).get("result")) + if verdict.disposition == "allow": + return Negotiated(decision.server_hello, start_context=verdict.reasoning) if verdict.reasoning else decision + refusal = SessionRefused("session_start_denied", {"decision": verdict.disposition, "reasoning": verdict.reasoning}) + audit_event("session_refused", **{**refusal.fields, "cause": refusal.cause, **common}) + return refusal + + +def _decide(path: Path, ikm: bytes, *, config: Config, session_id: str, agent_id: str, platform: str, + methods_implemented: list[str], wrapped_protocols: list[str] | None, timeout: float | None, + profiles_supported: list[str] | None, transports_supported: list[str] | None) -> Handshake: + stored = _read_record(path, ikm, session_id, config.guardian_url) + if stored is not None: + return stored + claimed = profiles_supported if profiles_supported is not None else ["acs-core"] + if not config.handshake_enabled: + decision: Handshake = NoAnswer("handshake_disabled") + else: + decision = _hello( + guardian_url=config.guardian_url, session_id=session_id, agent_id=agent_id, + platform=platform, methods_implemented=methods_implemented, + wrapped_protocols=wrapped_protocols, profiles_supported=claimed, + transports_supported=transports_supported, + timeout=_handshake_timeout_s() if timeout is None else timeout, + ) + decision, ours = _store_first(path, decision, ikm, session_id, config.guardian_url) + if ours: + _audit_start(decision, config, session_id, claimed) + return decision + + +def _handshake_timeout_s() -> float: + raw = os.environ.get("ACS_HANDSHAKE_TIMEOUT_SECONDS", "").strip() + if not raw: + return _DEFAULT_HANDSHAKE_TIMEOUT_S + try: + value = float(raw) + except ValueError: + value = math.nan + if math.isfinite(value) and value > 0: + return value + audit_event("invalid_setting", setting="ACS_HANDSHAKE_TIMEOUT_SECONDS", value=raw, + used=_DEFAULT_HANDSHAKE_TIMEOUT_S) + return _DEFAULT_HANDSHAKE_TIMEOUT_S + + +def _required_profiles(decision: Handshake, config: Config) -> Handshake: + """This hook's ACS_REQUIRE_PROFILES applied to the session's negotiation; the stored record keeps what the Guardian answered.""" + if not isinstance(decision, Negotiated): + return decision + missing = sorted(config.require_profiles - set(decision.server_hello.get("profiles_accepted") or [])) + if missing: + return SessionRefused("required_profile_not_accepted", {"profiles": missing}) + return decision + + +def store_session_decision(directory: Path, session_id: str, guardian_url: str, + decision: Handshake, ikm: bytes) -> Handshake: + return _store_first(_handshake_cache_path(session_id, guardian_url, directory), + decision, ikm, session_id, guardian_url)[0] + + +def _hello(*, guardian_url: str, session_id: str, agent_id: str, platform: str, + methods_implemented: list[str], wrapped_protocols: list[str] | None, + profiles_supported: list[str], transports_supported: list[str] | None, + timeout: float) -> Handshake: + # The client POSTs to the Guardian URL, so that URL's scheme is the one transport it offers. + scheme = urllib.parse.urlsplit(guardian_url).scheme + client_hello = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "handshake/hello", + "params": { + "acs_version": ACS_VERSION, + "request_id": str(uuid.uuid4()), + "timestamp": iso8601_now(), + "metadata": {"agent_id": agent_id, "session_id": session_id, "platform": platform}, + "payload": { + "acs_versions_supported": [ACS_VERSION], + "methods_implemented": methods_implemented, + "transports_supported": (transports_supported if transports_supported is not None + else [scheme]), + "max_payload_size_bytes": MAX_REQUEST_BODY_BYTES, + "provenance_producer": "none", + "wrapped_protocols": wrapped_protocols or [], + "profiles_supported": profiles_supported, + "signature_algorithms_supported": ["HMAC-SHA256"], + }, + }, + } + try: + validate_guardian_url(guardian_url) + sign_envelope(client_hello, session_id=session_id) + except Exception as e: # noqa: BLE001 + return NoAnswer("guardian_url_rejected" if isinstance(e, ValueError) else "adapter_exception", + {"error": str(e)}) + try: + response = post(client_hello, guardian_url=guardian_url, timeout_s=timeout) + except GuardianHTTPError as e: + if not is_jsonrpc_response(e.body): + return NoAnswer(f"http_{e.status}_error", {"http_status": e.status}) + response = e.body + except (urllib.error.URLError, TimeoutError, OSError) as e: + timed_out = isinstance(e, TimeoutError) or isinstance(getattr(e, "reason", None), TimeoutError) + return NoAnswer("handshake_timeout" if timed_out else "transport_failure", {"error": str(e)}) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + return NoAnswer("malformed_response", {"error": str(e)}) + except Exception as e: # noqa: BLE001 + return NoAnswer("adapter_exception", {"error": str(e)}) + try: + return _classify_hello(client_hello, response, session_id) + except Exception as e: # noqa: BLE001 + return SessionRefused("response_unverifiable", {"error": str(e)}) + + +def _classify_hello(client_hello: Mapping[str, Any], response: Any, session_id: str) -> Handshake: + """An answer that fails its checks refuses the session; a verified error that is not a refusal, or a ServerHello that does not answer the ClientHello, is no answer (§4.1).""" + if not is_jsonrpc_response(response): + return NoAnswer("malformed_response") + error = response.get("error") + code = error.get("code") if error is not None else None + if response.get("id") != client_hello["id"]: + return SessionRefused("response_binding_mismatch", + {"claimed_error_code": code} if code is not None else {}) + verified = verify_signature(response, session_id=session_id) + if error is not None: + if not verified: + if key_id_rejected(error): + return SessionRefused("key_id_rejected", {"claimed_error_code": code}) + return SessionRefused("error_signature_invalid", + {"claimed_error_code": code, "claimed_cause": guardian_error_cause(code)}) + fields = {"error_code": code, "error_message": error.get("message")} + # §4.1 names three session refusals; the step-level refusal codes are refused too. + if is_session_refusal(code) or is_guardian_refusal(code): + return SessionRefused(guardian_error_cause(code), fields) + return NoAnswer(guardian_error_cause(code), fields) + if not verified: + return SessionRefused("server_hello_signature_invalid") + server_hello = response["result"] + if "negotiated_version" not in server_hello: + return NoAnswer("invalid_server_hello") + problem = server_hello_problem(server_hello, client_hello["params"]["payload"]) + if problem is not None: + return NoAnswer("invalid_server_hello", {"problem": problem}) + return Negotiated(server_hello) + + +def _audit_start(decision: Handshake, config: Config, session_id: str, claimed: list[str]) -> None: + common = {"cause": getattr(decision, "cause", None), "session_id": session_id, + "guardian_url": config.guardian_url} + if isinstance(decision, Negotiated): + accepted = decision.server_hello.get("profiles_accepted") + declined = sorted(set(claimed) - set(accepted)) if isinstance(accepted, list) else [] + if declined: + audit_event("profile_not_accepted", profiles=declined, session_id=session_id, + guardian_url=config.guardian_url) + elif isinstance(decision, SessionRefused): + audit_event("session_refused", **{**decision.fields, **common}) + elif isinstance(decision, NoAnswer): + audit_event("session_without_negotiated_guardian", + **{**decision.fields, **common, "failure_class": "handshake_failure", + "posture": "fail_open" if config.startup_proceeds else "fail_closed"}) + + +def _record_key(ikm: bytes, session_id: str) -> bytes: + return hmac.new(derive_session_key(ikm, session_id), b"acs-adapter session record", + hashlib.sha256).digest() + + +def _encode(decision: Handshake, session_id: str, guardian_url: str) -> dict[str, Any]: + body: dict[str, Any] = {"acs_session_record": _RECORD_VERSION, "session_id": session_id, + "guardian_url": guardian_url} + if isinstance(decision, Negotiated): + negotiated = {**body, "decision": "negotiated", "server_hello": dict(decision.server_hello)} + if decision.start_failure is not None: + negotiated["start_failure"] = dict(decision.start_failure) + if decision.start_context: + negotiated["start_context"] = decision.start_context + return negotiated + kind = "refused" if isinstance(decision, SessionRefused) else "no_answer" + return {**body, "decision": kind, "cause": decision.cause, "fields": dict(decision.fields)} + + +def _decode(body: Mapping[str, Any]) -> Handshake | None: + kind = body.get("decision") + if kind == "negotiated" and isinstance(body.get("server_hello"), dict): + failure, context = body.get("start_failure"), body.get("start_context", "") + if not isinstance(context, str): + return None + if failure is None: + return Negotiated(body["server_hello"], start_context=context) + if isinstance(failure, dict) and isinstance(failure.get("cause"), str) and isinstance(failure.get("fields"), dict): + return Negotiated(body["server_hello"], failure, context) + return None + if kind in ("refused", "no_answer") and isinstance(body.get("cause"), str) \ + and isinstance(body.get("fields"), dict): + cls = SessionRefused if kind == "refused" else NoAnswer + return cls(body["cause"], body["fields"]) + return None + + +def _mac(body: Mapping[str, Any], ikm: bytes, session_id: str) -> str: + digest = hmac.new(_record_key(ikm, session_id), jcs_canonicalize(body), hashlib.sha256).digest() + return base64.b64encode(digest).decode("ascii") + + +def _is_legacy(record: Any) -> bool: + """The cache an earlier adapter wrote: a ServerHello, bare or in its JSON-RPC envelope, bound to no Guardian URL.""" + if not isinstance(record, dict) or "acs_session_record" in record: + return False + hello = record.get("result") if "jsonrpc" in record else record + return isinstance(hello, dict) and "negotiated_version" in hello + + +def _read_record(path: Path, ikm: bytes, session_id: str, guardian_url: str) -> Handshake | None: + """The stored decision, None only when no record exists, and a refusal for any record that fails its checks.""" + body = _verified(path, ikm, session_id, guardian_url, "acs_session_record", _RECORD_VERSION) + if body is None or isinstance(body, SessionRefused): + return body + decision = _decode(body) + # Only the startup record says how a sessionStart went. + if decision is None or isinstance(decision, Negotiated) and (decision.start_failure is not None or decision.start_context): + return SessionRefused("session_record_invalid", {"record": str(path)}) + return decision + + +def _read_startup(path: Path, ikm: bytes, session_id: str, guardian_url: str) -> Handshake | str | None: + """The session's decision after its sessionStart, None when none is recorded, _PENDING while it is claimed, and a refusal for a record that fails its checks.""" + body = _verified(path, ikm, session_id, guardian_url, "acs_session_startup", _STARTUP_VERSION) + if body is None or isinstance(body, SessionRefused): + return body + if body.get("state") == _PENDING: + return _PENDING + decision = _decode(body) + return decision if decision is not None else SessionRefused("session_record_invalid", {"record": str(path)}) + + +def _verified(path: Path, ikm: bytes, session_id: str, guardian_url: str, marker: str, + version: int) -> Mapping[str, Any] | SessionRefused | None: + """A record's body once its MAC, session and Guardian URL check out; None when no record exists, a refusal otherwise.""" + try: + raw = path.read_bytes() + except FileNotFoundError: + return None + except OSError as e: + return SessionRefused("session_record_unreadable", {"record": str(path), "error": str(e)}) + try: + record = json.loads(raw) + except Exception: # noqa: BLE001 + # Any parse failure counts, including RecursionError from deep nesting. + record = None + if marker == "acs_session_record" and _is_legacy(record): + # Its signature covers the session but not the Guardian URL, so it cannot vouch for this endpoint. + return SessionRefused("session_record_legacy", {"record": str(path)}) + # The marker keeps one kind of record from standing in for the other under the same session key. + if not isinstance(record, dict) or record.get(marker) != version: + return SessionRefused("session_record_invalid", {"record": str(path)}) + body = {k: v for k, v in record.items() if k != "mac"} + try: + authentic = hmac.compare_digest(str(record.get("mac")), _mac(body, ikm, session_id)) + except Exception: # noqa: BLE001 + authentic = False + if not (authentic and body.get("session_id") == session_id and body.get("guardian_url") == guardian_url): + return SessionRefused("session_record_invalid", {"record": str(path)}) + return body + + +def _write_startup(path: Path, outcome: Handshake | None, ikm: bytes, session_id: str, + guardian_url: str) -> SessionRefused | None: + """Claim the session's sessionStart (outcome None), or record its outcome; a refusal, audited, when the record cannot be written.""" + temporary = None + try: + body: dict[str, Any] = {"acs_session_startup": _STARTUP_VERSION, "session_id": session_id, + "guardian_url": guardian_url} + if outcome is None: + body["state"] = _PENDING + else: + decision = _encode(outcome, session_id, guardian_url) + body.update({k: v for k, v in decision.items() if k not in body and k != "acs_session_record"}, + state="done") + record = {**body, "mac": _mac(body, ikm, session_id)} + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".startup-") + with os.fdopen(fd, "w") as f: + json.dump(record, f) + if outcome is None: + # link() fails if any startup record already exists. + os.link(temporary, path) + else: + os.replace(temporary, path) + temporary = None + return None + except Exception as e: # noqa: BLE001 + audit_event("session_record_unwritable", session_id=session_id, guardian_url=guardian_url, + record=str(path), stage="claim" if outcome is None else "outcome", error=str(e)) + return SessionRefused("session_record_unwritable", {"error": str(e)}) + finally: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass + + +def _store_first(path: Path, decision: Handshake, ikm: bytes, session_id: str, + guardian_url: str) -> tuple[Handshake, bool]: + """Store the decision first-wins; return the one that holds and whether it is ours. A decision that cannot be stored refuses the session.""" + temporary = None + try: + body = _encode(decision, session_id, guardian_url) + record = {**body, "mac": _mac(body, ikm, session_id)} + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chmod(path.parent, 0o700) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".record-") + with os.fdopen(fd, "w") as f: + json.dump(record, f) + for _ in range(2): + try: + # link() publishes the complete file atomically and fails if a decision is already stored. + os.link(temporary, path) + return decision, True + except FileExistsError: + existing = _read_record(path, ikm, session_id, guardian_url) + if existing is not None: + return existing, False + raise OSError("the stored record kept disappearing") + except Exception as e: # noqa: BLE001 + audit_event("session_record_unwritable", session_id=session_id, guardian_url=guardian_url, + record=str(path), decision=type(decision).__name__, error=str(e)) + return SessionRefused("session_record_unwritable", {"error": str(e)}), True + finally: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass diff --git a/adapters/src/acs/client/_outcome.py b/adapters/src/acs/client/_outcome.py new file mode 100644 index 00000000..bce5550a --- /dev/null +++ b/adapters/src/acs/client/_outcome.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class Decided: + """A response bound to its request, correctly signed, carrying a result.""" + response: dict[str, Any] + + +@dataclass(frozen=True) +class Undecided: + """No usable decision arrived, so the §6.4 fail posture decides.""" + cause: str + fields: Mapping[str, Any] = field(default_factory=dict) + message: str = "" + + +@dataclass(frozen=True) +class Refused: + """The Guardian refused the envelope, or the answer cannot be trusted; the step fails closed whatever the posture.""" + cause: str + fields: Mapping[str, Any] = field(default_factory=dict) + message: str = "" + + +Outcome = Decided | Undecided | Refused + + +@dataclass(frozen=True) +class Negotiated: + """The Guardian answered the handshake with a verified ServerHello; the session's steps go to it.""" + server_hello: Mapping[str, Any] + # The session's sessionStart got no decision ({"cause", "fields"}); each hook's fail posture settles its steps. + start_failure: Mapping[str, Any] | None = None + # The reasoning the Guardian gave when it allowed the session's sessionStart. + start_context: str = "" + + +@dataclass(frozen=True) +class SessionRefused: + """The Guardian refused the session, or its answer failed its checks; every step of the session is denied.""" + cause: str + fields: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class NoAnswer: + """No ServerHello arrived: the session sends nothing more, and each hook's startup posture settles its steps.""" + cause: str + fields: Mapping[str, Any] = field(default_factory=dict) + + +Handshake = Negotiated | SessionRefused | NoAnswer diff --git a/adapters/src/acs/client/_profiles.py b/adapters/src/acs/client/_profiles.py new file mode 100644 index 00000000..bbde80c5 --- /dev/null +++ b/adapters/src/acs/client/_profiles.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from acs.client._audit import audit_event +from acs.client._store import mark_once + + +def record_mcp_use(session_id: str | None, *, tool: str, workspace: str | None = None) -> None: + """Record, once per session, that using MCP leaves its acs-core claim partial: protocols/MCP/* is not wrapped.""" + if session_id and mark_once(session_id, "profile_claim_degraded", workspace=workspace): + audit_event("profile_claim_degraded", profile="acs-core", session_id=session_id, tool=tool, + detail="the session uses MCP; the adapter governs MCP tools/call through the " + "tool hooks and does not wrap protocols/MCP/*") diff --git a/adapters/src/acs/client/_secrets.py b/adapters/src/acs/client/_secrets.py new file mode 100644 index 00000000..12971515 --- /dev/null +++ b/adapters/src/acs/client/_secrets.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +import stat + +from acs.client._audit import audit_event + + +class SecretFilePermissionsError(RuntimeError): + """ACS_HMAC_SECRET_FILE exists but its permissions / ownership leak the secret.""" + + +class SecretFileUnreadableError(RuntimeError): + """ACS_HMAC_SECRET_FILE is configured but cannot be read, or is empty.""" + + +class SigningMaterialMissing(RuntimeError): + """No usable secret or label, so nothing may be sent: ACS-Core signs every request (§10).""" + + def __init__(self, cause: str, message: str) -> None: + super().__init__(message) + self.cause = cause + + +# §10: the secret MUST be at least 32 bytes from a cryptographically secure random source. +MIN_SECRET_BYTES = 32 + + +def _check_secret_file_perms(path: str) -> None: + # A symlink is refused: its target can change behind the visible path. + if os.path.islink(path): + raise SecretFilePermissionsError( + f"ACS_HMAC_SECRET_FILE {path!r} is a symlink; refusing to follow") + st = os.stat(path) + mode = stat.S_IMODE(st.st_mode) + if mode & 0o077: + raise SecretFilePermissionsError( + f"ACS_HMAC_SECRET_FILE {path!r} mode {oct(mode)} is too permissive; " + f"must be 0600 or 0400 (no group/other access). " + f"Fix: chmod 600 {path}") + if hasattr(os, "geteuid") and st.st_uid != os.geteuid(): + raise SecretFilePermissionsError( + f"ACS_HMAC_SECRET_FILE {path!r} owned by uid {st.st_uid}, " + f"adapter is running as uid {os.geteuid()}; refusing") + + +def load_hmac_secret() -> bytes: + """The input keying material from ACS_HMAC_SECRET_FILE (owner-only, no symlink) or ACS_HMAC_SECRET; b"" when neither is set.""" + path = os.environ.get("ACS_HMAC_SECRET_FILE", "").strip() + if path: + try: + _check_secret_file_perms(path) + with open(path, "rb") as f: + secret = f.read().rstrip(b"\r\n\t ") + if not secret: + raise OSError(f"{path} is empty") + return secret + except SecretFilePermissionsError: + raise + except OSError as e: + # Configured-but-unreadable is an incident, not a missing secret. + audit_event("hmac_secret_file_unreadable", path=path, error=str(e)) + raise SecretFileUnreadableError( + f"ACS_HMAC_SECRET_FILE {path!r} is configured but cannot " + f"be read ({e})" + ) from e + env_val = os.environ.get("ACS_HMAC_SECRET", "") + return env_val.encode("utf-8") if env_val else b"" + + +def load_key_id() -> str: + """ACS_HMAC_KEY_ID, the deployment's label for the secret (§10); "" when unset.""" + return os.environ.get("ACS_HMAC_KEY_ID", "").strip() + + +def load_signing_material() -> tuple[bytes, str]: + """The secret and the label that names it; raises SigningMaterialMissing when either is missing or the secret is under 32 bytes (§10).""" + secret = load_hmac_secret() + if not secret: + raise SigningMaterialMissing("secret_missing", "no ACS_HMAC_SECRET_FILE or ACS_HMAC_SECRET is configured; " + "ACS-Core signs every request (§10)") + if len(secret) < MIN_SECRET_BYTES: + raise SigningMaterialMissing("secret_too_short", f"the secret is {len(secret)} bytes; " + f"§10 requires at least {MIN_SECRET_BYTES}") + label = load_key_id() + if not label: + raise SigningMaterialMissing("key_id_missing", "ACS_HMAC_KEY_ID is not set; it names the configured secret (§10)") + return secret, label diff --git a/adapters/src/acs/client/_signing.py b/adapters/src/acs/client/_signing.py new file mode 100644 index 00000000..9afca263 --- /dev/null +++ b/adapters/src/acs/client/_signing.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from acs.client._secrets import load_hmac_secret, load_key_id, load_signing_material +from acs.core import container_key, derive_session_key, sign_with_key, verify_with_key + + +def sign_envelope(envelope: dict, *, key: bytes | None = None, + session_id: str | None = None, key_id: str | None = None) -> dict: + """Add a signature under key_id, the configured ACS_HMAC_KEY_ID unless given; raises SigningMaterialMissing without a usable secret and label.""" + if key is None: + ikm, _ = load_signing_material() + params = envelope.get("params") or envelope.get("result") or {} + session_id = session_id or (params.get("metadata") or {}).get("session_id") + if not session_id: + raise ValueError("the envelope has no session_id, which the session key derives from (§10)") + key = derive_session_key(ikm, session_id) + label = key_id if key_id is not None else load_key_id() + if not label: + raise ValueError("ACS_HMAC_KEY_ID is not set; it names the configured secret (§10)") + return sign_with_key(envelope, key, key_id=label) + + +def verify_signature(envelope: dict, *, key: bytes | None = None, + session_id: str | None = None, key_id: str | None = None) -> bool: + """Whether the envelope carries a valid signature under key_id (default ACS_HMAC_KEY_ID); an unsigned envelope is not valid.""" + container = envelope.get(container_key(envelope)) or {} + if not isinstance(container, dict): + return False + sig = container.get("signature") + if sig is None: + return False + + if not isinstance(sig, dict) or sig.get("algorithm") != "HMAC-SHA256": + return False + if not sig.get("value"): + return False + # §10: an unknown key_id is rejected, with no fallback to another secret. + label = key_id if key_id is not None else load_key_id() + if not label or sig.get("key_id") != label: + return False + + if key is None: + ikm = load_hmac_secret() + if not ikm: + return False + session_id = session_id or (container.get("metadata") or {}).get("session_id") + if not session_id: + return False + key = derive_session_key(ikm, session_id) + return verify_with_key(envelope, key) diff --git a/adapters/src/acs/client/_store.py b/adapters/src/acs/client/_store.py new file mode 100644 index 00000000..d88e7a96 --- /dev/null +++ b/adapters/src/acs/client/_store.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import tempfile +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any, TypeVar + +try: + import fcntl +except ImportError: # Windows + fcntl = None # type: ignore[assignment] + import msvcrt + +T = TypeVar("T") + + +_SESSION_STATE_DIR = Path( + os.environ.get( + "ACS_SESSION_STATE_DIR", + os.path.join(os.path.expanduser("~"), ".cache", "acs-adapter-session"), + ) +) + + +def _session_state_path(session_id: str, *, workspace: str | None = None) -> Path: + """workspace is part of the key, so two Cursor windows sharing a conversation_id keep separate state.""" + if not session_id: + digest = hashlib.sha256(b"empty").hexdigest() + return _SESSION_STATE_DIR / f"{digest}.json" + if workspace: + digest = hashlib.sha256( + (workspace + "\x00" + session_id).encode("utf-8", "surrogatepass") + ).hexdigest() + else: + digest = hashlib.sha256(session_id.encode("utf-8", "surrogatepass")).hexdigest() + return _SESSION_STATE_DIR / f"{digest}.json" + + +def load_session_state(session_id: str, *, workspace: str | None = None) -> dict: + if not session_id: + return {} + path = _session_state_path(session_id, workspace=workspace) + try: + with open(path) as f: + state = json.load(f) + except Exception: # noqa: BLE001 + # Any parse failure counts, including RecursionError from deep nesting. + return {} + return state if isinstance(state, dict) else {} + + +def save_session_state(session_id: str, state: dict, *, workspace: str | None = None) -> None: + """Replace the session's state; the file is owner-only because its step history backs parent_step_id (§8).""" + update_session_state(session_id, lambda current: (current.clear(), current.update(state)), + workspace=workspace) + + +def discard_session_state(session_id: str, *, workspace: str | None = None) -> None: + if not session_id: + return + path = _session_state_path(session_id, workspace=workspace) + for record in (path, Path(f"{path}.lock")): + with contextlib.suppress(OSError): + record.unlink() + + +def update_session_state(session_id: str, change: Callable[[dict], T], *, + workspace: str | None = None) -> T | None: + """Apply change as one locked read-modify-write, since a session's hooks run concurrently; returns its result, or None when the state cannot be stored.""" + if not session_id: + return None + path = _session_state_path(session_id, workspace=workspace) + try: + _SESSION_STATE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) + # mkdir(exist_ok=True) does not chmod an existing dir — enforce explicitly + try: + os.chmod(_SESSION_STATE_DIR, 0o700) + except OSError: + pass + with _locked(path): + state = load_session_state(session_id, workspace=workspace) + result = change(state) + _write(path, state) + return result + except OSError: + return None + + +@contextlib.contextmanager +def _locked(path: Path) -> Iterator[None]: + fd = os.open(f"{path}.lock", os.O_RDWR | os.O_CREAT, 0o600) + try: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + else: + msvcrt.locking(fd, msvcrt.LK_LOCK, 1) + try: + yield + finally: + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + finally: + os.close(fd) + + +def _write(path: Path, state: dict[str, Any]) -> None: + """Publish the state atomically through a temp file of its own, so concurrent writers never share one.""" + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".state-", suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(state, f) + os.replace(temporary, path) + except BaseException: + try: + os.unlink(temporary) + except OSError: + pass + raise + + +def record_step(session_id: str, step_id: str, *, workspace: str | None = None) -> None: + if not session_id or not step_id: + return + + def change(state: dict) -> None: + seen = state.get("seen_step_ids") + seen = seen if isinstance(seen, list) else [] + if step_id not in seen: + seen.append(step_id) + del seen[:-1000] + state["seen_step_ids"] = seen + state["last_step_id"] = step_id + + update_session_state(session_id, change, workspace=workspace) + + +def mark_once(session_id: str, flag: str, *, workspace: str | None = None) -> bool: + """True the first time `flag` is marked for the session, False after; True also when the state cannot be stored, so a record is never lost.""" + def change(state: dict) -> bool: + marked = state.get("marked") + marked = marked if isinstance(marked, dict) else {} + state["marked"] = {**marked, flag: True} + return not marked.get(flag) + + first = update_session_state(session_id, change, workspace=workspace) + return first is not False + + +def count_decision_failure(session_id: str | None, failure: str, *, workspace: str | None = None) -> None: + """Count one decision failure for sessionEnd's summary; never raises, so a count cannot stop a deny.""" + if not session_id: + return + + def change(state: dict) -> None: + counts = state.get("decision_failures") + counts = counts if isinstance(counts, dict) else {} + counts[failure] = int(counts.get(failure, 0)) + 1 + state["decision_failures"] = counts + + try: + update_session_state(session_id, change, workspace=workspace) + except Exception: # noqa: BLE001 + # The failure itself is already audited; sessionEnd's count is a lower bound (trace/events.md). + pass + + +def decision_failure_counts(session_id: str | None, *, workspace: str | None = None) -> dict[str, int]: + counts = load_session_state(session_id, workspace=workspace).get("decision_failures") if session_id else None + if not isinstance(counts, dict): + return {} + return {k: v for k, v in counts.items() if isinstance(k, str) and isinstance(v, int) and v >= 0} + + +def current_turn(session_id: str | None, *, workspace: str | None = None) -> str | None: + if not session_id: + return None + turn_id = load_session_state(session_id, workspace=workspace).get("turn_id") + return turn_id if isinstance(turn_id, str) else None + + +def open_turn(session_id: str, turn_id: str, *, workspace: str | None = None) -> None: + update_session_state(session_id, lambda state: state.update(turn_id=turn_id), workspace=workspace) + + +def close_turn(session_id: str, *, workspace: str | None = None) -> None: + update_session_state(session_id, lambda state: state.pop("turn_id", None), workspace=workspace) diff --git a/adapters/src/acs/client/_transport.py b/adapters/src/acs/client/_transport.py new file mode 100644 index 00000000..337779eb --- /dev/null +++ b/adapters/src/acs/client/_transport.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +import uuid + +from acs.client._urls import validate_guardian_url +from acs.core import ACS_VERSION, iso8601_now + + +def ping(guardian_url: str, *, echo: str = "ping", timeout: float = 2.0) -> dict | None: + """Send system/ping (§13: unsigned, outside the chain); the result, or None on failure.""" + request = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "system/ping", + "params": { + "acs_version": ACS_VERSION, + "request_id": str(uuid.uuid4()), + "timestamp": iso8601_now(), + "metadata": {"agent_id": "ping-client", "session_id": str(uuid.uuid4())}, + "payload": {"echo": echo}, + }, + } + try: + validate_guardian_url(guardian_url) + except ValueError: + return None + try: + body = json.dumps(request).encode("utf-8") + req = urllib.request.Request( + guardian_url, data=body, + headers={"Content-Type": "application/json"}, method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")).get("result") + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, ValueError): + return None diff --git a/adapters/src/acs/client/_urls.py b/adapters/src/acs/client/_urls.py new file mode 100644 index 00000000..3e3a2bbc --- /dev/null +++ b/adapters/src/acs/client/_urls.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import os + + +_ALLOWED_GUARDIAN_SCHEMES = frozenset({"http", "https"}) + + +def validate_guardian_url(url: str) -> None: + """Refuse a scheme other than http(s), which urlopen would follow to file:// or data://, and a host outside ACS_GUARDIAN_HOST_ALLOWLIST when set.""" + from urllib.parse import urlparse + parsed = urlparse(url) + if parsed.scheme.lower() not in _ALLOWED_GUARDIAN_SCHEMES: + raise ValueError( + f"Guardian URL scheme {parsed.scheme!r} not allowed; " + f"only {sorted(_ALLOWED_GUARDIAN_SCHEMES)} permitted") + allow = os.environ.get("ACS_GUARDIAN_HOST_ALLOWLIST", "").strip() + if allow: + allowed_hosts = {h.strip().lower() for h in allow.split(",") if h.strip()} + host = (parsed.hostname or "").lower() + if host not in allowed_hosts: + raise ValueError( + f"Guardian host {host!r} not in ACS_GUARDIAN_HOST_ALLOWLIST " + f"({sorted(allowed_hosts)})") diff --git a/adapters/src/acs/core/__init__.py b/adapters/src/acs/core/__init__.py new file mode 100644 index 00000000..e4f1a6c5 --- /dev/null +++ b/adapters/src/acs/core/__init__.py @@ -0,0 +1,49 @@ +"""Protocol logic with no I/O: envelopes, signing, decisions, error codes and the ACS schemas.""" +from importlib import import_module +from typing import Any + +# Exports load on first use, so stdlib-only modules import without rfc8785. +_EXPORTS = { + "ACS_VERSION": "_envelope", + "DEFAULT_SKEW_WINDOW_MS": "_envelope", + "Decision": "_decision", + "GUARDIAN_ERROR_CAUSE": "_errors", + "GUARDIAN_REFUSAL_CODES": "_errors", + "KNOWN_DECISIONS": "_decision", + "MAX_REQUEST_BODY_BYTES": "_envelope", + "SESSION_REFUSAL_CODES": "_errors", + "coerce_uuid": "_envelope", + "container_key": "_signing", + "derive_session_key": "_signing", + "guardian_error_cause": "_errors", + "is_guardian_refusal": "_errors", + "is_session_refusal": "_errors", + "iso8601_now": "_envelope", + "jcs_canonicalize": "_canonical", + "method_evaluated": "_handshake", + "modify_composition_violation": "_decision", + "normalize_decision": "_decision", + "parse_iso8601": "_envelope", + "request_envelope": "_envelope", + "response_matches_request": "_envelope", + "schemas_dir": "_schemas", + "server_hello_problem": "_handshake", + "sign_with_key": "_signing", + "turn_start_envelope": "_envelope", + "verify_with_key": "_signing", +} + +__all__ = sorted(_EXPORTS) + + +def __getattr__(name: str) -> Any: + module = _EXPORTS.get(name) + if module is None: + raise AttributeError(f"module 'acs.core' has no attribute {name!r}") + value = getattr(import_module(f"acs.core.{module}"), name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return __all__ diff --git a/adapters/src/acs/core/_canonical.py b/adapters/src/acs/core/_canonical.py new file mode 100644 index 00000000..b7f4e1d9 --- /dev/null +++ b/adapters/src/acs/core/_canonical.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Any + + +try: + import rfc8785 as _rfc8785 # type: ignore[import-not-found] +except ImportError as _rfc8785_import_error: + # §10 permits no other canonicalization, so a missing rfc8785 fails the import instead of falling back. + raise ImportError( + "ACS adapters require the `rfc8785` package for RFC 8785 (JCS) " + "canonical signing — spec §10 permits no alternative " + "canonicalization. Install it: pip install rfc8785" + ) from _rfc8785_import_error + + +def jcs_canonicalize(obj: Any) -> bytes: + return _rfc8785.dumps(obj) diff --git a/adapters/src/acs/core/_decision.py b/adapters/src/acs/core/_decision.py new file mode 100644 index 00000000..57657e95 --- /dev/null +++ b/adapters/src/acs/core/_decision.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any + + +KNOWN_DECISIONS = frozenset({"allow", "deny", "modify", "ask", "defer"}) + + +@dataclass(frozen=True) +class Decision: + """A Guardian result's decision fields coerced to safe types; disposition is "" when unusable.""" + + disposition: str = "" + reasoning: str = "" + modifications: dict = field(default_factory=dict) + + @property + def known(self) -> bool: + return self.disposition in KNOWN_DECISIONS + + +def normalize_decision(result: Any) -> Decision: + """Coerce a Guardian result's decision fields without raising: an exception exits 1, which the hosts treat as proceed.""" + if not isinstance(result, dict): + return Decision() + raw_decision = result.get("decision") + decision = raw_decision.strip().lower() if isinstance(raw_decision, str) else "" + raw_reason = result.get("reasoning", "") + reasoning = (raw_reason if isinstance(raw_reason, str) + else json.dumps(raw_reason, default=str)) + raw_mods = result.get("modifications", {}) + modifications = raw_mods if isinstance(raw_mods, dict) else {} + return Decision(decision, reasoning, modifications) + + +def modify_composition_violation(modifications: dict) -> str | None: + """Why a MODIFY breaks the §6.3 composition rules (both shapes, or overlapping targets), or None.""" + if not isinstance(modifications, dict): + return None + has_content = "modified_content" in modifications + has_redactions = "redactions" in modifications + has_overrides = "parameter_overrides" in modifications + redactions = modifications.get("redactions") + overrides = modifications.get("parameter_overrides") + if has_content and (has_redactions or has_overrides): + return ("modifications combines wholesale modified_content with " + "structured edits — the two shapes are mutually exclusive " + "(§6.3)") + if has_content and not isinstance(modifications.get("modified_content"), str): + return "modified_content is not a string — Guardian intent is unusable (§6.3)" + if has_redactions and not isinstance(redactions, list): + return "redactions is not an array — Guardian intent is unusable (§6.3)" + if has_overrides and not isinstance(overrides, dict): + return ("parameter_overrides is not an object — Guardian intent is " + "unusable (§6.3)") + + def _pointer_token(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") + + def _paths_overlap(left: str, right: str) -> bool: + return (left == right or left == "" or right == "" + or left.startswith(right + "/") + or right.startswith(left + "/")) + + override_paths = { + f"/{_pointer_token(key)}": key + for key in (overrides or {}) + if isinstance(key, str) + } + if has_redactions: + for r in redactions: + if not isinstance(r, dict): + return ("redactions contains a non-object item — Guardian " + "intent is unusable (§6.3)") + path = r.get("path") + if not isinstance(path, str) or (path and not path.startswith("/")): + return (f"redaction path {path!r} is not an RFC 6901 JSON " + "Pointer — Guardian intent is unusable (§6.3)") + # RFC 6901 allows only the ~0 and ~1 escapes. + if re.search(r"~(?:[^01]|$)", path): + return (f"redaction path '{path}' has an invalid JSON Pointer " + "escape — Guardian intent is unusable (§6.3)") + if has_overrides: + for override_path, key in override_paths.items(): + if _paths_overlap(path, override_path): + return (f"redaction path '{path}' overlaps " + f"parameter_override '{key}' — structured " + "targets MUST be disjoint (§6.3)") + return None diff --git a/adapters/src/acs/core/_envelope.py b/adapters/src/acs/core/_envelope.py new file mode 100644 index 00000000..9618b09e --- /dev/null +++ b/adapters/src/acs/core/_envelope.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import datetime +import uuid +from collections.abc import Mapping +from typing import Any + + +ACS_VERSION = "0.1.0" +DEFAULT_SKEW_WINDOW_MS = 300_000 + +MAX_REQUEST_BODY_BYTES = 1_048_576 + + +def request_envelope(method: str, *, request_id: str, metadata: Mapping[str, Any], + payload: Mapping[str, Any]) -> dict[str, Any]: + """An unsigned step request; round_trip signs it just before sending.""" + return { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": { + "acs_version": ACS_VERSION, + "request_id": request_id, + "timestamp": iso8601_now(), + "metadata": dict(metadata), + "payload": dict(payload), + }, + } + + +def turn_start_envelope(metadata: Mapping[str, Any], turn_id: str) -> dict[str, Any]: + """turn_id doubles as request_id, as turn-start.json recommends.""" + return request_envelope("steps/turnStart", request_id=turn_id, metadata={**metadata, "turn_id": turn_id}, + payload={"turn_id": turn_id, "triggered_by": "user_message"}) + + +def response_matches_request(request: dict, response: dict) -> bool: + """Same JSON-RPC id and, for a result, the same request_id, so a signed response cannot be replayed onto another call.""" + if response.get("id") != request.get("id"): + return False + result = response.get("result") + if result is not None: + want = (request.get("params") or {}).get("request_id") + if result.get("request_id") != want: + return False + return True + + +def iso8601_now() -> str: + return ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +def coerce_uuid(raw: str | None, *, namespace_prefix: str = "acs") -> str: + if not raw: + return str(uuid.uuid4()) + try: + return str(uuid.UUID(raw)) + except (ValueError, AttributeError, TypeError): + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{namespace_prefix}:{raw}")) + + +def parse_iso8601(ts: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")) diff --git a/adapters/src/acs/core/_errors.py b/adapters/src/acs/core/_errors.py new file mode 100644 index 00000000..bd4b62b2 --- /dev/null +++ b/adapters/src/acs/core/_errors.py @@ -0,0 +1,61 @@ +from __future__ import annotations + + +GUARDIAN_ERROR_CAUSE: dict[int, str] = { + -32000: "session_refused_response", + -32001: "unsupported_version_response", + -32002: "provenance_required_response", + -32003: "capability_not_negotiated_response", + -32004: "signature_invalid_response", + -32005: "replay_detected_response", + -32006: "timestamp_out_of_window_response", + -32007: "chain_mismatch_response", + -32600: "malformed_envelope_response", + -32601: "method_not_found_response", + -32602: "invalid_params_response", + -32603: "internal_error_response", + -32700: "parse_error_response", +} + + +def _code(code: object) -> int | None: + """code as a JSON integer, which -32000.0 also is (RFC 8785 writes both as -32000); never a bool.""" + if isinstance(code, bool): + return None + if isinstance(code, int): + return code + if isinstance(code, float) and code.is_integer(): + return int(code) + return None + + +def guardian_error_cause(code: object) -> str: + value = _code(code) + return "guardian_error_response" if value is None else GUARDIAN_ERROR_CAUSE.get(value, "guardian_error_response") + + +# Stricter than §6.4 as written (issue #32): each refusal is attacker-reachable, so it fails closed under either posture. +GUARDIAN_REFUSAL_CODES = frozenset({ + -32000, # SESSION_REFUSED + -32004, # SIGNATURE_INVALID + -32005, # REPLAY_DETECTED + -32006, # TIMESTAMP_OUT_OF_WINDOW + -32600, # Invalid Request + -32700, # Parse error +}) + + +SESSION_REFUSAL_CODES = frozenset({ + -32000, # SESSION_REFUSED + -32001, # UNSUPPORTED_VERSION + -32002, # PROVENANCE_REQUIRED +}) + + +def is_session_refusal(code: object) -> bool: + """True when a handshake error code refuses the session (§4.1).""" + return _code(code) in SESSION_REFUSAL_CODES + + +def is_guardian_refusal(code: object) -> bool: + return _code(code) in GUARDIAN_REFUSAL_CODES diff --git a/adapters/src/acs/core/_handshake.py b/adapters/src/acs/core/_handshake.py new file mode 100644 index 00000000..e87dfe06 --- /dev/null +++ b/adapters/src/acs/core/_handshake.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +_SEMVER = re.compile(r"\d+\.\d+\.\d+") + + +def method_evaluated(server_hello: dict | None, method: str) -> bool: + """handshake.json: a method missing from methods_evaluated is ALLOW-by-default; with no list, every method is evaluated.""" + if not isinstance(server_hello, dict): + return True + evaluated = server_hello.get("methods_evaluated") + if not isinstance(evaluated, list): + return True + return method in evaluated + + +def _positive_int(value: object) -> bool: + """JSON Schema's integer of at least 1: 5000.0 qualifies, booleans do not.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + return (isinstance(value, int) or value.is_integer()) and value >= 1 + + +def _strings(value: object) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +def server_hello_problem(server_hello: Mapping[str, Any], offered: Mapping[str, Any]) -> str | None: + """Why the ServerHello does not answer the ClientHello `offered`, or None.""" + version = server_hello.get("negotiated_version") + if not isinstance(version, str) or not _SEMVER.fullmatch(version): + return "negotiated_version is not a semantic version" + majors = {v.split(".", 1)[0] for v in offered.get("acs_versions_supported", []) if isinstance(v, str)} + if version.split(".", 1)[0] not in majors: + return f"negotiated_version {version} matches no offered major version" + if not _strings(server_hello.get("methods_evaluated")): + return "methods_evaluated is not a list of method names" + transport = server_hello.get("selected_transport") + if transport not in offered.get("transports_supported", []): + return f"selected_transport {transport!r} was not offered" + timeouts = server_hello.get("timeout_config") + if not isinstance(timeouts, Mapping) or not _positive_int(timeouts.get("default_ms")): + return "timeout_config.default_ms is not a positive integer" + per_method = timeouts.get("per_method_ms", {}) + if not isinstance(per_method, Mapping) or not all(_positive_int(ms) for ms in per_method.values()): + return "timeout_config.per_method_ms is not a map of positive integers" + posture = server_hello.get("on_decision_failure", "proceed") + if posture not in ("proceed", "deny"): + return f"on_decision_failure {posture!r} is neither proceed nor deny" + accepted = server_hello.get("profiles_accepted") + if accepted is not None: + if not _strings(accepted): + return "profiles_accepted is not a list of profile names" + unclaimed = sorted(set(accepted) - set(offered.get("profiles_supported", []))) + if unclaimed: + return f"profiles_accepted includes {unclaimed}, which the client did not claim" + return None diff --git a/adapters/src/acs/core/_schemas.py b/adapters/src/acs/core/_schemas.py new file mode 100644 index 00000000..59f010c2 --- /dev/null +++ b/adapters/src/acs/core/_schemas.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path + +_BUNDLED = Path(str(files("acs.core") / "schemas")) +_CHECKOUT = Path(__file__).resolve().parents[4] / "specification" / "v0.1.0" + + +def schemas_dir(override: str | None = None) -> Path: + """Return `override` if given, else the schemas bundled in the wheel, else a source checkout's specification/v0.1.0.""" + if override: + return Path(override) + for candidate in (_BUNDLED, _CHECKOUT): + if (candidate / "request-envelope.json").is_file(): + return candidate + raise FileNotFoundError( + "ACS schemas not found: install acs-adapters from a built wheel, " + "run from a repository checkout, or set ACS_SPEC_DIR") diff --git a/adapters/src/acs/core/_signing.py b/adapters/src/acs/core/_signing.py new file mode 100644 index 00000000..5063695b --- /dev/null +++ b/adapters/src/acs/core/_signing.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac + +from acs.core._canonical import jcs_canonicalize + + +def derive_session_key(input_key_material: bytes, session_id: str) -> bytes: + """HKDF-SHA256 with session_id as info and no salt (§10).""" + # HKDF-Extract with empty salt + prk = hmac.new(b"\x00" * 32, input_key_material, hashlib.sha256).digest() + # HKDF-Expand to 32 bytes with info = session_id + info = session_id.encode("utf-8") + t = hmac.new(prk, info + b"\x01", hashlib.sha256).digest() + return t # one 32-byte block is enough for HMAC-SHA256 + + +def container_key(envelope: dict) -> str: + if "method" in envelope: + return "params" + if "error" in envelope: + return "error" + return "result" + + +def sign_with_key(envelope: dict, key: bytes, *, key_id: str) -> dict: + """Sign in place with HMAC-SHA256 over JCS(envelope without its signature), labelled key_id (§10).""" + ckey = container_key(envelope) + container = envelope.get(ckey, {}) + unsigned_container = {k: v for k, v in container.items() if k != "signature"} + unsigned_envelope = {**envelope, ckey: unsigned_container} + + sig_bytes = hmac.new(key, jcs_canonicalize(unsigned_envelope), hashlib.sha256).digest() + container["signature"] = { + "algorithm": "HMAC-SHA256", + "value": base64.b64encode(sig_bytes).decode("ascii"), + "key_id": key_id, + } + envelope[ckey] = container + return envelope + + +def verify_with_key(envelope: dict, key: bytes) -> bool: + ckey = container_key(envelope) + container = envelope.get(ckey) or {} + if not isinstance(container, dict): + return False + sig = container.get("signature") + if not isinstance(sig, dict) or sig.get("algorithm") != "HMAC-SHA256": + return False + expected_b64 = sig.get("value") + if not expected_b64: + return False + unsigned_container = {k: v for k, v in container.items() if k != "signature"} + unsigned_envelope = {**envelope, ckey: unsigned_container} + try: + canonical = jcs_canonicalize(unsigned_envelope) + except Exception: # noqa: BLE001 + # RFC 8785 has no form for this envelope (an integer beyond 2**53, NaN, deep nesting), so no signature covers it. + return False + expected_bytes = hmac.new(key, canonical, hashlib.sha256).digest() + # Malformed base64 is a failed verification, not an exception. + try: + provided_bytes = base64.b64decode(expected_b64, validate=True) + except (binascii.Error, ValueError, TypeError): + return False + return hmac.compare_digest(expected_bytes, provided_bytes) diff --git a/adapters/src/acs/examples/__init__.py b/adapters/src/acs/examples/__init__.py new file mode 100644 index 00000000..d0170e76 --- /dev/null +++ b/adapters/src/acs/examples/__init__.py @@ -0,0 +1 @@ +"""Example policies for the example Guardian; not part of the client or the protocol.""" diff --git a/adapters/src/acs/examples/destructive_bash.py b/adapters/src/acs/examples/destructive_bash.py new file mode 100644 index 00000000..a69a288a --- /dev/null +++ b/adapters/src/acs/examples/destructive_bash.py @@ -0,0 +1,51 @@ +"""The example Guardian's toy policy: commands that destroy a filesystem or a disk.""" +from __future__ import annotations + +import re + +from acs.client import audit_event + + +DESTRUCTIVE_BASH_PATTERNS: tuple[re.Pattern, ...] = ( + # rm with r and f in one flag token (-rf, -vrf), then a path at /, ~ or $HOME. + re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*|--recursive\s+--force|--force\s+--recursive)\b.*?\s+(/|~|\$HOME)", re.IGNORECASE), + re.compile(r"\brm\s+(-rf|-fr|--recursive\s+--force|--force\s+--recursive)\s+(/|~|\$HOME)(\s|$)", re.IGNORECASE), + # r and f as separate flag tokens in any order; the lookaheads scan only the flags before the path. + re.compile(r"\brm(?=(?:\s+-[a-zA-Z-]+)*\s+-(?:[a-zA-Z]*r|-recursive\b))(?=(?:\s+-[a-zA-Z-]+)*\s+-(?:[a-zA-Z]*f|-force\b))(?:\s+-[a-zA-Z-]+)+\s+(/|~|\$HOME)", re.IGNORECASE), + re.compile(r"\brm\s+.*--no-preserve-root\b", re.IGNORECASE), + re.compile(r"\bmkfs(\.\w+)?\s+", re.IGNORECASE), + re.compile(r"\bdd\s+.*\bof=/dev/", re.IGNORECASE), + re.compile(r":\(\)\s*\{"), + re.compile(r">\s*/dev/(sd[a-z]|nvme|hd[a-z]|disk)", re.IGNORECASE), + # The path ends at whitespace, not at a word boundary, because no boundary + # sits between `/` or `~` and the space after it. + re.compile(r"\bfind\s+(/|~|\$HOME)\S*\s.*-delete\b", re.IGNORECASE), + re.compile(r"\bfind\s+(/|~|\$HOME)\S*\s.*-exec\s+rm\b", re.IGNORECASE), + re.compile(r"\bchmod\s+(-R\s+)?[0-7]*7{2,}[0-7]*\s+(/etc|/usr|/bin|/sbin)", re.IGNORECASE), +) + + +# Longer input is not a shell command, or is a regex-DoS attempt. +DESTRUCTIVE_SCAN_MAX_LEN = 8 * 1024 + + +def scan_destructive_bash_safely(cmd: str, *, max_len: int = DESTRUCTIVE_SCAN_MAX_LEN): + """None when nothing matches, the matching pattern, or "input_too_large", which the caller must not treat as safe.""" + if len(cmd) > max_len: + audit_event("destructive_scan_skipped_oversized", + cmd_length=len(cmd), max_len=max_len) + return "input_too_large" + for pat in DESTRUCTIVE_BASH_PATTERNS: + if pat.search(cmd): + return pat + return None + + +def matches_destructive_bash(cmd: str) -> str | re.Pattern | None: + """None when safe, the matching pattern, or "too_large", which the caller must treat as suspicious.""" + if len(cmd) > DESTRUCTIVE_SCAN_MAX_LEN: + return "too_large" + for pat in DESTRUCTIVE_BASH_PATTERNS: + if pat.search(cmd): + return pat + return None diff --git a/adapters/src/acs/guardian/__init__.py b/adapters/src/acs/guardian/__init__.py new file mode 100644 index 00000000..26fb6242 --- /dev/null +++ b/adapters/src/acs/guardian/__init__.py @@ -0,0 +1 @@ +"""The example Guardian used by the adapter test suites.""" diff --git a/adapters/src/acs/guardian/example_guardian.py b/adapters/src/acs/guardian/example_guardian.py new file mode 100755 index 00000000..bc790c2d --- /dev/null +++ b/adapters/src/acs/guardian/example_guardian.py @@ -0,0 +1,634 @@ +#!/usr/bin/env python3 +"""Example Guardian for testing the reference adapters, not a production Guardian; docs/guardian/README.md describes it.""" +from __future__ import annotations + +import argparse +import errno +import fcntl +import hashlib +import http.server +import json +import os +import socketserver +import sys +import threading +import time +from pathlib import Path +from typing import Any + +from acs.client import load_key_id, load_signing_material, sign_envelope, verify_signature +from acs.examples.destructive_bash import DESTRUCTIVE_SCAN_MAX_LEN, matches_destructive_bash +from acs.core import ( + ACS_VERSION, + DEFAULT_SKEW_WINDOW_MS, + MAX_REQUEST_BODY_BYTES, + derive_session_key, + iso8601_now, + jcs_canonicalize, + parse_iso8601, +) + +import datetime + +# With jsonschema available, every incoming envelope is validated against request-envelope.json before policy runs. +_SPEC_VALIDATION_AVAILABLE = False +_REQUEST_ENVELOPE_VALIDATOR = None +try: + from jsonschema import Draft202012Validator # type: ignore[import-not-found] + from jsonschema.validators import RefResolver # type: ignore[import-not-found] + from acs.core import schemas_dir + try: + _spec_dir = schemas_dir(os.environ.get("ACS_SPEC_DIR")) + except FileNotFoundError: + _spec_dir = Path("") + _envelope_schema_path = _spec_dir / "request-envelope.json" + if _envelope_schema_path.exists(): + with open(_envelope_schema_path) as _f: + _schema_obj = json.load(_f) + _REQUEST_ENVELOPE_VALIDATOR = Draft202012Validator( + _schema_obj, + resolver=RefResolver( + base_uri=(_spec_dir.as_uri() + "/request-envelope.json"), + referrer=_schema_obj, + ), + format_checker=Draft202012Validator.FORMAT_CHECKER, + ) + _SPEC_VALIDATION_AVAILABLE = True + else: + _spec_validation_unavailable_reason = ( + f"canonical schemas not found at {_spec_dir} " + "(set ACS_SPEC_DIR to your spec checkout)" + ) +except ImportError: + _spec_validation_unavailable_reason = ( + "jsonschema is not installed (pip install jsonschema)" + ) + +if not _SPEC_VALIDATION_AVAILABLE: + # Without validation, malformed envelopes reach policy unchecked. + print( + "[guardian] WARNING: envelope schema validation DISABLED — " + + _spec_validation_unavailable_reason + + ". Malformed envelopes will NOT be rejected before policy " + "evaluation.", + file=sys.stderr, + ) + + +SKEW_WINDOW_MS = int(os.environ.get("ACS_SKEW_WINDOW_MS", str(DEFAULT_SKEW_WINDOW_MS))) +ALLOW_SUBAGENT = os.environ.get("ACS_ALLOW_SUBAGENT", "0") == "1" +ON_DECISION_FAILURE = "proceed" if os.environ.get("ACS_ON_DECISION_FAILURE", "").strip().lower() == "proceed" else "deny" + + +PROTECTED_PATH_PREFIXES: tuple[str, ...] = ("/etc/", "/usr/", "/bin/", "/sbin/", "/boot/") + +# Acknowledged with an unconditional allow; subagentStart is absent because it is the confused-deputy gate. +INFORMATIONAL_METHODS = { + "steps/sessionStart", "steps/sessionEnd", "steps/userMessage", + "steps/toolCallResult", "steps/agentResponse", + "steps/preCompact", "steps/postCompact", + "steps/subagentStop", + "steps/knowledgeRetrieval", "steps/memoryStore", "steps/memoryContextRetrieval", + "steps/turnStart", "steps/turnEnd", "steps/agentTrigger", +} + + +# Replay and chain state persist per session under ACS_GUARDIAN_STATE_DIR, so §10.3 replay rejection survives a restart. + +_STATE_DIR_ENV = os.environ.get( + "ACS_GUARDIAN_STATE_DIR", + os.path.join(os.path.expanduser("~"), ".cache", "acs-guardian-state"), +) +PERSIST_ENABLED = bool(_STATE_DIR_ENV) +STATE_DIR = Path(_STATE_DIR_ENV) if PERSIST_ENABLED else None + + +def _state_path(session_id: str) -> Path | None: + if not PERSIST_ENABLED: + return None + key = hashlib.sha256(session_id.encode()).hexdigest()[:16] + return STATE_DIR / f"{key}.json" + + +class SessionState: + """A session's chain head and seen request_ids, persisted as 0600 JSON; a corrupt file loads as a fresh session.""" + + def __init__(self, session_id: str = "") -> None: + self.session_id = session_id + self.previous_hash: str | None = None # None for the first entry (§8.1) + self.seen_request_ids: dict[str, float] = {} + self.seen_nonces: dict[str, float] = {} + self.lock = threading.Lock() + self._load() + + def _load(self) -> None: + path = _state_path(self.session_id) + if path is None or not path.exists(): + return + try: + # Shared flock: never read a file mid-write by another instance. + with open(path) as f: + try: + fcntl.flock(f.fileno(), fcntl.LOCK_SH) + except OSError: + pass + try: + data = json.load(f) + finally: + try: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + except OSError: + pass + self.previous_hash = data.get("previous_hash") + sr = data.get("seen_request_ids", {}) + self.seen_request_ids = sr if isinstance(sr, dict) else {x: time.time() for x in sr} # legacy list: stamped now, so the entries live a full window + sn = data.get("seen_nonces", {}) + self.seen_nonces = sn if isinstance(sn, dict) else {x: time.time() for x in sn} # legacy list: stamped now, so the entries live a full window + except (OSError, json.JSONDecodeError): + pass + + def persist(self) -> None: + """Write atomically, merging with the on-disk state under an exclusive lock for Guardians sharing STATE_DIR; call with self.lock held.""" + path = _state_path(self.session_id) + if path is None: + return + try: + STATE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + os.chmod(STATE_DIR, 0o700) + except OSError: + pass + lock_path = path.with_suffix(".lock") + lock_fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o600) + try: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + except OSError as e: + if e.errno != errno.ENOLCK: + raise + + merged_seen = dict(self.seen_request_ids) + merged_nonces = dict(self.seen_nonces) + merged_prev = self.previous_hash + if path.exists(): + try: + with open(path) as rf: + disk = json.load(rf) + disk_seen = disk.get("seen_request_ids") or {} + if isinstance(disk_seen, list): + disk_seen = {x: time.time() for x in disk_seen} # legacy list: stamped now, so the entries live a full window + for k, v in disk_seen.items(): + # The earliest timestamp wins, so eviction keeps the full window. + if k not in merged_seen or merged_seen[k] > v: + merged_seen[k] = v + disk_nonces = disk.get("seen_nonces") or {} + if isinstance(disk_nonces, list): + disk_nonces = {x: time.time() for x in disk_nonces} # legacy list: stamped now, so the entries live a full window + for k, v in disk_nonces.items(): + if k not in merged_nonces or merged_nonces[k] > v: + merged_nonces[k] = v + # Either head will do: one Guardian keeps them identical, and HA mode is best-effort. + if not merged_prev and disk.get("previous_hash"): + merged_prev = disk["previous_hash"] + except (OSError, json.JSONDecodeError): + pass + + # Evict from the merged result, or dropped entries return from disk; the skew check already rejects anything older. + cutoff = time.time() - 2 * (SKEW_WINDOW_MS / 1000.0) + merged_seen = {k: v for k, v in merged_seen.items() + if v >= cutoff} + merged_nonces = {k: v for k, v in merged_nonces.items() + if v >= cutoff} + + self.seen_request_ids = merged_seen + self.seen_nonces = merged_nonces + self.previous_hash = merged_prev + + tmp = path.with_suffix(".json.tmp") + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump({ + "previous_hash": self.previous_hash, + "seen_request_ids": self.seen_request_ids, + "seen_nonces": self.seen_nonces, + }, f) + os.replace(tmp, path) + finally: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + except OSError: + pass + os.close(lock_fd) + except OSError: + pass + + +def evict_old_request_ids(st: "SessionState") -> int: + """Drop request_ids older than twice the skew window, which -32006 already rejects; call with st.lock held.""" + cutoff = time.time() - 2 * (SKEW_WINDOW_MS / 1000.0) + old = [k for k, ts in st.seen_request_ids.items() if ts < cutoff] + for k in old: + del st.seen_request_ids[k] + return len(old) + + +class GuardianState: + def __init__(self) -> None: + self.sessions: dict[str, SessionState] = {} + self.lock = threading.Lock() + + def get(self, session_id: str) -> SessionState: + with self.lock: + st = self.sessions.get(session_id) + if st is None: + st = SessionState(session_id=session_id) + self.sessions[session_id] = st + return st + + +STATE = GuardianState() + + +def _unwrap_arguments(wrapped: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for k, v in (wrapped or {}).items(): + out[k] = v["value"] if isinstance(v, dict) and "value" in v else v + return out + + +def compute_entry_hash(entry: dict, previous_hash: str | None) -> str: + """§8.2: lowercase hex SHA-256 of JCS(entry without its hashes) followed by the previous hash's 32 raw bytes.""" + entry_for_hash = {k: v for k, v in entry.items() if k not in ("entry_hash", "previous_hash")} + content_bytes = jcs_canonicalize(entry_for_hash) + prev_bytes = bytes.fromhex(previous_hash) if previous_hash else b"" + return hashlib.sha256(content_bytes + prev_bytes).hexdigest() + + +def append_to_chain(session_id: str, method: str, request_id: str, + payload_canonical: str, client_timestamp: str) -> str: + """Append a ContextEntry and return the chain head; the request's timestamp is used, so an observer can recompute the hash.""" + st = STATE.get(session_id) + with st.lock: + entry = { + "entry_id": request_id, + "step_id": request_id, + "step_type": method, + "request_hash": hashlib.sha256(payload_canonical.encode()).hexdigest(), + "timestamp": client_timestamp or iso8601_now(), + } + if st.previous_hash is not None: + entry["previous_hash"] = st.previous_hash + new_head = compute_entry_hash(entry, st.previous_hash) + st.previous_hash = new_head + st.persist() + return new_head + + +class GuardianError(Exception): + def __init__(self, code: int, message: str, *, signed: bool = True) -> None: + super().__init__(message) + self.code = code + self.message = message + # §10: the error for a key_id no configured secret matches is unsigned. + self.signed = signed + + +def check_replay(session_id: str, request_id: str) -> None: + if not request_id: + return + st = STATE.get(session_id) + with st.lock: + # Re-read, so this sees what other Guardian instances accepted. + st._load() + if request_id in st.seen_request_ids: + raise GuardianError(-32005, f"REPLAY_DETECTED: request_id {request_id} already seen in session") + if len(st.seen_request_ids) % 100 == 0: + evict_old_request_ids(st) + st.seen_request_ids[request_id] = time.time() + st.persist() + + +def check_skew(timestamp: str) -> None: + if not timestamp: + return + try: + ts = parse_iso8601(timestamp) + except (ValueError, AttributeError): + raise GuardianError(-32006, f"TIMESTAMP_OUT_OF_WINDOW: cannot parse timestamp {timestamp!r}") + now = datetime.datetime.now(datetime.timezone.utc) + delta_ms = abs((now - ts).total_seconds() * 1000) + if delta_ms > SKEW_WINDOW_MS: + raise GuardianError(-32006, f"TIMESTAMP_OUT_OF_WINDOW: {int(delta_ms)}ms > {SKEW_WINDOW_MS}ms") + + +def check_signature(envelope: dict, session_id: str) -> None: + if not verify_signature(envelope, session_id=session_id): + raise GuardianError(-32004, "SIGNATURE_INVALID") + + +def _major(version: str) -> str | None: + parts = str(version).split(".") + return parts[0] if parts and parts[0].isdigit() else None + + +def evaluate_handshake(params: dict, request_id: str) -> dict: + client_hello = params.get("payload") or {} + + # handshake.json requires these ClientHello fields; unknown fields are ignored. + missing = [f for f in ("acs_versions_supported", "methods_implemented", + "transports_supported", "provenance_producer") + if f not in client_hello] + if missing: + raise GuardianError(-32600, + f"Invalid Request: ClientHello missing required field(s): {missing}") + + # §3: accept a client sharing the Guardian's major version, and negotiate the Guardian's own version. + client_versions = client_hello.get("acs_versions_supported") or [] + our_major = _major(ACS_VERSION) + if not any(_major(v) == our_major for v in client_versions): + raise GuardianError(-32001, + f"UNSUPPORTED_VERSION: client majors " + f"{[_major(v) for v in client_versions]}, Guardian major {our_major}") + + # Answer only with what the client offered: a transport this Guardian serves, and the profiles it implements. + offered = client_hello.get("transports_supported") or [] + transport = next((t for t in ("http", "https") if t in offered), None) + if transport is None: + raise GuardianError(-32602, f"Invalid params: no offered transport this Guardian serves in {offered}") + server_hello = { + "negotiated_version": ACS_VERSION, + "methods_evaluated": client_hello.get("methods_implemented") or [], + "selected_transport": transport, + "signature_algorithms_supported": ["HMAC-SHA256"], + "timeout_config": {"default_ms": 5000}, + "skew_window_ms": SKEW_WINDOW_MS, + "on_decision_failure": ON_DECISION_FAILURE, + "policy_requires_provenance": False, + "profiles_accepted": [p for p in client_hello.get("profiles_supported") or [] if p == "acs-core"], + } + # handle_request wraps and signs this direct ServerHello as result. + return server_hello + + +def evaluate_ping(params: dict, request_id: str) -> dict: + echo = (params.get("payload") or {}).get("echo", "") + return { + "type": "final", + "acs_version": ACS_VERSION, + "request_id": request_id, + "decision": "allow", + "payload": { + "status": "ok", + "echo": echo, + "server_timestamp": iso8601_now(), + }, + } + + +def evaluate_step(method: str, params: dict, request_id: str, chain_hash: str) -> dict: + payload = params.get("payload") or {} + + base = { + "type": "final", + "acs_version": ACS_VERSION, + "request_id": request_id, + "chain_hash": chain_hash, + } + + if method == "steps/toolCallRequest": + tool = payload.get("tool") or {} + # Case-folded, so Claude Code's Bash, Cursor's Shell and NAT's shell meet one policy. + tool_name_raw = tool.get("name", "") + tool_name = tool_name_raw.lower() + args = _unwrap_arguments(payload.get("arguments") or {}) + + if tool_name == "task" and not ALLOW_SUBAGENT: + return {**base, "decision": "deny", + "reasoning": "Task tool (in-process subagent) is gated by default. Set ACS_ALLOW_SUBAGENT=1 to allow.", + "reason_codes": ["subagent_gated"]} + + if tool_name in ("bash", "shell"): + cmd = args.get("command", "") or "" + match = matches_destructive_bash(cmd) + if match == "too_large": + return {**base, "decision": "deny", + "reasoning": f"command length {len(cmd)} exceeds safe-scan cap " + f"({DESTRUCTIVE_SCAN_MAX_LEN}); cannot evaluate destructive patterns", + "reason_codes": ["input_too_large"]} + if match is not None: + return {**base, "decision": "deny", + "reasoning": f"destructive Bash pattern in: {cmd[:120]}", + "reason_codes": ["destructive_command"]} + + if tool_name == "write": + path = args.get("file_path", "") + if any(path.startswith(p) for p in PROTECTED_PATH_PREFIXES): + return {**base, "decision": "deny", + "reasoning": f"write to protected system path: {path}", + "reason_codes": ["protected_path"]} + + return {**base, "decision": "allow"} + + if method == "steps/subagentStart": + # Spawns are gated as strictly as the Task tool, and a fresh intent_derivation gets its own reason code. + derivation = (payload.get("intent_derivation") or "").lower() + if not ALLOW_SUBAGENT: + code = ("subagent_fresh_intent_gated" if derivation == "fresh" + else "subagent_gated") + return {**base, "decision": "deny", + "reasoning": ( + "subagent spawns are gated by default" + + (" — and intent_derivation 'fresh' claims " + "authority not derived from the parent" + if derivation == "fresh" else "") + + ". Set ACS_ALLOW_SUBAGENT=1 to allow."), + "reason_codes": [code]} + return {**base, "decision": "allow"} + + if method in INFORMATIONAL_METHODS: + return {**base, "decision": "allow"} + + return {**base, "decision": "deny", + "reasoning": f"unknown method: {method}", + "reason_codes": ["unknown_method"]} + + +def _error_envelope(request_id, session_id: str, code: int, message: str, *, signed: bool = True) -> dict: + """A JSON-RPC error, signed whenever the session key is derivable, since Baseline integrity covers errors.""" + envelope = {"jsonrpc": "2.0", "id": request_id, + "error": {"code": code, "message": message}} + if signed and session_id: + sign_envelope(envelope, session_id=session_id) + return envelope + + +def handle_request(request: dict) -> dict: + method = request.get("method", "") + request_id = request.get("id") + params = request.get("params") or {} + meta = params.get("metadata") or {} + session_id = meta.get("session_id") or "" + acs_request_id = params.get("request_id", "") + timestamp = params.get("timestamp", "") + + # system/ping is the only unsigned method (§13), so liveness probes survive a key outage. + if method == "system/ping": + result = evaluate_ping(params, acs_request_id) + envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} + return envelope + + # §10: a request under a key_id this Guardian holds no secret for gets an unsigned + # SIGNATURE_INVALID before any other check, since no response to it can be signed. + signature = params.get("signature") + if isinstance(signature, dict) and signature.get("key_id") != load_key_id(): + return _error_envelope(request_id, session_id, -32004, "SIGNATURE_INVALID: unknown key_id", signed=False) + + # Validated before policy; ping and handshake have their own payload shapes. + if (_SPEC_VALIDATION_AVAILABLE + and method not in ("system/ping", "handshake/hello")): + errors = list(_REQUEST_ENVELOPE_VALIDATOR.iter_errors(request)) + if errors: + paths = "; ".join( + f"{'.'.join(str(p) for p in e.absolute_path) or ''}: {e.message}" + for e in errors[:5] + ) + return _error_envelope(request_id, session_id, -32600, + f"Invalid Request: envelope failed schema: {paths}") + + if method == "handshake/hello": + # The handshake is signed too (§10): the session key is known before negotiation, and the chain roots at sessionStart. + try: + check_signature(request, session_id) + result = evaluate_handshake(params, acs_request_id) + except GuardianError as e: + return _error_envelope(request_id, session_id, e.code, e.message, signed=e.signed) + envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} + sign_envelope(envelope, session_id=session_id) + return envelope + + try: + check_signature(request, session_id) + check_skew(timestamp) + check_replay(session_id, acs_request_id) + except GuardianError as e: + return _error_envelope(request_id, session_id, e.code, e.message, signed=e.signed) + + payload_canonical = jcs_canonicalize(params).decode("utf-8") + chain_hash = append_to_chain(session_id, method, acs_request_id, payload_canonical, timestamp) + + try: + result = evaluate_step(method, params, acs_request_id, chain_hash) + except GuardianError as e: + return _error_envelope(request_id, session_id, e.code, e.message) + + envelope = {"jsonrpc": "2.0", "id": request_id, "result": result} + sign_envelope(envelope, session_id=session_id) + return envelope + + +class GuardianHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + # Oversized requests are rejected before the body is read. + try: + length = int(self.headers.get("Content-Length", "0")) + except (TypeError, ValueError): + self._respond(400, {"jsonrpc": "2.0", "id": None, + "error": {"code": -32600, "message": "Invalid Content-Length"}}) + return + if length > MAX_REQUEST_BODY_BYTES: + self._respond(413, {"jsonrpc": "2.0", "id": None, + "error": {"code": -32600, + "message": f"Request body {length} bytes exceeds {MAX_REQUEST_BODY_BYTES} cap"}}) + return + body = self.rfile.read(length).decode("utf-8") + try: + request = json.loads(body) + except json.JSONDecodeError: + self._respond(400, {"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "Parse error"}}) + return + + # A non-object, or a batch array, is not a v0.1 request: -32600. + if not isinstance(request, dict): + kind = "batch" if isinstance(request, list) else type(request).__name__ + self._respond(400, {"jsonrpc": "2.0", "id": None, + "error": {"code": -32600, + "message": f"Invalid Request: expected a JSON-RPC " + f"object, got {kind} (batches unsupported in v0.1)"}}) + return + + method = request.get("method", "") + params = request.get("params") or {} + meta = params.get("metadata") or {} + sys.stderr.write( + f"[guardian] {method} session={meta.get('session_id', '?')[:8]} " + f"req={params.get('request_id', '?')[:8]}\n" + ) + sys.stderr.flush() + + response = handle_request(request) + result = (response or {}).get("result") or {} + decision = result.get("decision") + if decision: + tail = "" + if decision == "deny": + tail = f" — {result.get('reasoning', '')[:80]}" + sys.stderr.write( + f"[guardian] → {decision}{tail}\n" + ) + sys.stderr.flush() + self._respond(200, response) + + def _respond(self, status: int, body: dict) -> None: + payload = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt: str, *args: Any) -> None: + return + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=8787) + parser.add_argument("--host", default="127.0.0.1") + args = parser.parse_args() + + # §10: every request and response is signed, so the Guardian starts only with a usable secret and label. + try: + load_signing_material() + except Exception as e: # noqa: BLE001 + sys.stderr.write( + f"[guardian] REFUSING TO START: {e}\n" + " Configure a secret of at least 32 bytes and the label that names it:\n" + " ACS_HMAC_SECRET_FILE=/path/to/key (preferred; chmod 600)\n" + " ACS_HMAC_KEY_ID=