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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions adapters/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ below.
| Adapter middleware (`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). |
| 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 <json>` lines on adapter stderr. |

Expand Down Expand Up @@ -134,10 +134,10 @@ 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 iff its own `ACS_HMAC_SECRET` /
`_FILE` is set, regardless of what `signature_algorithms_supported`
the handshake advertised. The handshake's advertised algorithm list
is informational; it does not control the adapter's signing behavior.
*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_hmac_secret()` only.

Expand Down
48 changes: 21 additions & 27 deletions adapters/docs/claude_code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This adapter is that command. On every hook event Claude Code spawns 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`).
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.
Expand Down Expand Up @@ -50,7 +50,7 @@ chmod 600 ~/.acs/hmac.key
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_SECRET_FILE=~/.acs/hmac.key ACS_HMAC_KEY_ID=2026-11 \
acs-example-guardian --port 8787
```

Expand All @@ -66,12 +66,14 @@ Keep this terminal open. You should see `[guardian] listening on 127.0.0.1:8787`
# 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
--secret-file=~/.acs/hmac.key \
--key-id=2026-11

# Apply it (creates ~/.claude/settings.json.bak.<timestamp>)
acs-claude-code-wire \
--guardian-url=http://127.0.0.1:8787/acs \
--secret-file=~/.acs/hmac.key \
--key-id=2026-11 \
--write
```

Expand Down Expand Up @@ -174,53 +176,44 @@ Claude should refuse and surface the Guardian's `reasoning` field. The example G

### Smoke #4 — audit-cause differentiation

Verifies that the adapter's audit log distinguishes "Guardian unreachable" (ops issue) from "Guardian rejected the envelope" (client/operator bug).
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`.

Unsigned envelope to a signing-required Guardian:
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="" \
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 stderr — note the `cause` field, and that a signature refusal
fails CLOSED regardless of posture (a refusal is an alive Guardian
rejecting the envelope; spec issue #32):

```
acs-adapter: Guardian returned JSON-RPC error -32004 (signature_invalid_response): SIGNATURE_INVALID
ACS_AUDIT {"acs_audit_event": "guardian_refusal_fail_closed", "cause": "signature_invalid_response", ...}
```

And stdout carries a real deny:
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:

```
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "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)"}}
```

Guardian unreachable (different cause, same disposition):
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:
Expected: the handshake gets no answer, so the session has no negotiated Guardian, and the hook's startup posture settles the step (fail-open when the hook sets no `ACS_DEFAULT_DENY`); stdout is empty, which Claude Code reads as proceed:

```
acs-adapter: Guardian unreachable: <urlopen error ...>
ACS_AUDIT {"acs_audit_event": "fail_open_bypass", "cause": "transport_failure", ...}
ACS_AUDIT {"acs_audit_event": "session_without_negotiated_guardian", "cause": "transport_failure", "failure_class": "handshake_failure", "posture": "fail_open", ...}
ACS_AUDIT {"acs_audit_event": "unguarded_session_bypass", "cause": "transport_failure", ..., "hook": "PreToolUse", ...}
```

Distinct `acs_audit_event` AND distinct `cause`: refusals are always
`guardian_refusal_fail_closed`; failures carry the posture
(`fail_open_bypass` / `decision_failure_fail_closed`). Operators grep
on either to triage.
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 (`unguarded_session_bypass`, or `session_not_started_fail_closed` under a deny posture). 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)

Expand Down Expand Up @@ -265,6 +258,7 @@ The adapter is configured by environment variables, typically set per-hook by `a
| `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` | `0` | Fail-open with audit (§6.4 default). Set to `1` for fail-closed. The ServerHello's `on_decision_failure: deny` also flips it (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` | follows `ACS_DEFAULT_DENY` | `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. |
Expand All @@ -288,14 +282,14 @@ Honest, item-by-item against the ACS-Core baseline in `docs/spec/conformance.md`
| 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`) | ✓ claims `acs-core` when a signing secret is configured and no profile without one. 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. |
| 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/test_envelope_schema.py`); format checking enforces `uuid` and `date-time`. |
| Hook taxonomy (Core minimum set) | ✓ `sessionStart`, `userMessage`, `toolCallRequest`, `toolCallResult`, `agentResponse`, `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`); MODIFY partial (`PreToolUse` with `parameter_overrides`, merged onto the original input). **Post-execution and lifecycle hooks (`PostToolUse`, `Notification → agentResponse`, `Stop → turnEnd`, `SessionEnd`) are observation-only** — Claude Code fires them after the side effect / message / turn has occurred; a Guardian `deny` on those cannot undo it. See `mapping.md`. |
| Unknown-disposition fail posture | ✓ default-deny honored on unknown verdicts when `ACS_DEFAULT_DENY=1`; spec-default fail-open path emits audit event. |
| SessionContext + published `chain_hash` | ✓ session_id propagated; Guardian computes rolling SHA-256 chain per §8.2 (`adapters/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 when `ACS_HMAC_SECRET[_FILE]` is set; Guardian rejects unsigned/tampered with SIGNATURE_INVALID -32004. |
| 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; spec-default fail-open posture emits structured `ACS_AUDIT` event on every bypass; 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. |
Expand Down
Loading
Loading