Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
3bda06b
Block a failed gate hook, and deny a gate step that cannot be signed
bar-capsule Nov 1, 2026
19d0c2b
Quote every wired value and keep the settings file's link and mode
bar-capsule Nov 1, 2026
bca64b0
NAT: apply MODIFY in the slot NAT reads and send values in JSON form
bar-capsule Nov 1, 2026
94aa1bf
Cursor: deny ask and defer on preToolUse
bar-capsule Nov 1, 2026
d780045
Claude Code: print nothing on allow and withhold denied output
bar-capsule Nov 1, 2026
ce1fbc7
Cite spec sections instead of line numbers
bar-capsule Nov 1, 2026
9c0ae13
NAT: deny a MODIFY the function input does not read back
bar-capsule Nov 2, 2026
fa14c79
Point the conformance citations at the current schema lines
bar-capsule Nov 3, 2026
eb098ab
SECURITY.md: cite the tests that back each threat
bar-capsule Nov 3, 2026
8ded0d0
Cite §6 for the dispositions and §6.5 for an unapplicable MODIFY
bar-capsule Nov 3, 2026
b13e612
Describe the example Guardian as the code runs it
bar-capsule Nov 3, 2026
17ad4b0
Drop the unsigned-mode wording from the test harness and refusal test
bar-capsule Nov 3, 2026
fa2b419
Correct the adapters README's NAT, Cursor and example-policy rows
bar-capsule Nov 3, 2026
5396954
Fall back to the default on an unusable ACS_HANDSHAKE_TIMEOUT_SECONDS
bar-capsule Nov 3, 2026
01f40b0
Replace values RFC 8785 cannot encode before signing every step
bar-capsule Nov 3, 2026
c05839d
Treat a result the response schema rejects as malformed_response
bar-capsule Nov 3, 2026
3d0d262
Settle a sessionStart whose hook ended before recording it
bar-capsule Nov 3, 2026
c66ebad
Claude Code: key session state on the session, not on cwd
bar-capsule Nov 3, 2026
930ec51
Open a new ACS session when a host session resumes after sessionEnd
bar-capsule Nov 3, 2026
503d672
Close every turn: interrupted on the next prompt, error on StopFailure
bar-capsule Nov 3, 2026
59ea1ee
Send failed tool results to the Guardian
bar-capsule Nov 3, 2026
ec15fea
Withhold denied output from every tool the host lets us replace
bar-capsule Nov 3, 2026
cb1b748
Claude Code: deny an ask on SubagentHandback, which never prompts
bar-capsule Nov 3, 2026
362f7ce
Cursor: answer every gate with an explicit allow under ACS_DISABLED
bar-capsule Nov 3, 2026
b8e1840
Stamp turn_id on every in-turn step and align the wiring with the hosts
bar-capsule Nov 3, 2026
9e79148
Keep inline secrets out of settings backups and readable settings files
bar-capsule Nov 3, 2026
b187116
Test the unsignable step on every Cursor gate event
bar-capsule Nov 3, 2026
bc776d7
Correct the Claude Code and Cursor docs that disagree with the code
bar-capsule Nov 3, 2026
36b201f
Close a turn the Guardian denies at its start
bar-capsule Nov 3, 2026
903e608
NAT: make each root Runner one ACS session
bar-capsule Nov 3, 2026
ce2a9af
NAT: apply the verdict on a run's input and record it on its output
bar-capsule Nov 3, 2026
538170b
NAT: apply a MODIFY only to fields the function input declares
bar-capsule Nov 3, 2026
876f0bc
NAT: apply a MODIFY to a scalar input as NAT's chain passes it
bar-capsule Nov 3, 2026
7cb4254
NAT: withhold an output the signer still refuses
bar-capsule Nov 3, 2026
2490620
Align the NAT session tests with the response schema and normalization
bar-capsule Nov 3, 2026
039c101
Remove comments that restate the code
bar-capsule Nov 3, 2026
52be0a6
NAT: remove the configured session_id and ACS_SESSION_ID
bar-capsule Nov 3, 2026
05b1a8a
NAT: refuse calls of an ended or closed session before any bypass
bar-capsule Nov 3, 2026
dae1542
NAT: keep a run's session to the run's own Runners
bar-capsule Nov 3, 2026
5d9dba2
Refuse to sign an envelope whose keys collide after normalization
bar-capsule Nov 3, 2026
382dae2
Validate a decided result against the AcsResult schema
bar-capsule Nov 3, 2026
203f0d7
Redact the whole shell word of an inline secret in settings backups
bar-capsule Nov 3, 2026
6dd7eba
Mark a session ended before sessionEnd and give the hook time
bar-capsule Nov 3, 2026
ca860ed
Claude Code: withhold built-in output only in each tool's own schema
bar-capsule Nov 3, 2026
2d1e4d9
Cursor: answer {} when a tool gate proceeds
bar-capsule Nov 3, 2026
1a348f2
Claude Code: cite no toolCallRequest from an Agent result
bar-capsule Nov 3, 2026
1a771b2
Answer Cursor's events when Cursor runs the Claude Code hook
bar-capsule Nov 3, 2026
c5e939d
Claude Code: tell the user when ACS blocks or withholds
bar-capsule Nov 3, 2026
8d3564e
Cursor: name ACS in the messages a block shows
bar-capsule Nov 3, 2026
7674737
NAT: keep each call in the session of the run whose context made it
bar-capsule Nov 3, 2026
0bd1db5
Find Cursor's event in input too deep to parse
bar-capsule Nov 3, 2026
f23d405
Limit the MODIFY schema exception to MODIFY answers
bar-capsule Nov 3, 2026
b5c5a5d
Classify a JSON-RPC error code as a JSON integer
bar-capsule Nov 3, 2026
d6c8c0c
NAT: fail closed when keys would collide in the Guardian's view
bar-capsule Nov 3, 2026
b72bdfe
Keep a session open when its end cannot be recorded
bar-capsule Nov 3, 2026
f39e9ee
Wire the secret and audit paths as absolute paths
bar-capsule Nov 3, 2026
5c22d34
NAT: drop the lifecycle backstop advice for unwired functions
bar-capsule Nov 3, 2026
004e13d
Remove the handshake timeout test override and unused noqa markers
bar-capsule Nov 3, 2026
fcc58fe
Refuse to sign or verify without a session_id
bar-capsule Nov 4, 2026
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
30 changes: 13 additions & 17 deletions adapters/README.md

Large diffs are not rendered by default.

80 changes: 41 additions & 39 deletions adapters/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ 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. This document and the tests under
`tests/common/test_security.py` are the falsifiers for every claim
below.
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 (`claude-code/`, `cursor/`) | Shell-spawned by the framework per hook event. Runs as the framework's user. Reads framework JSON from stdin. |
| Adapter middleware (`nat/`) | In-process inside the agent's Python runtime. Same address space as the agent. |
| 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. |
Expand All @@ -43,22 +42,24 @@ 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:* covered indirectly by `HmacSigning::test_signed_request_accepted`
(uses session-specific key derivation).
*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,
in-memory; bounded by session lifetime.
*Test:* `ReplayRejection::test_duplicate_request_id_rejected`
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:* `TimestampSkew::test_ancient_timestamp_rejected`,
`test_future_timestamp_rejected`
*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
Expand All @@ -69,14 +70,14 @@ 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::test_file_scheme_rejected` and
five other scheme rejections.
*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, matches the handshake's `max_payload_size_bytes`) before
(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`

Expand All @@ -93,19 +94,25 @@ 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
inject a fake `parent_step_id` into the next `subagentStart` payload.
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. Each
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 that is
modified, damaged, copied from another endpoint 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.
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`
Expand Down Expand Up @@ -139,18 +146,17 @@ 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.
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 if a signature is
absent AND the adapter has `ACS_HMAC_SECRET` configured. The adapter
rejects the response and fails per `ACS_DEFAULT_DENY` posture (audit
event in either case).
*No dedicated test* — covered indirectly by the conformance suite's
signed round-trip tests (Core07_BaselineIntegrity), which would fail
if signature absence were silently accepted.
*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)

Expand Down Expand Up @@ -188,7 +194,7 @@ 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:30`); we don't implement those profiles.
(`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
Expand Down Expand Up @@ -222,26 +228,22 @@ ACS's scope.

| Threat | Mitigation | Test |
|---|---|---|
| T1 envelope tampering | HMAC-SHA256 over JCS canonical input | `HmacSigning::test_tampered_request_rejected` |
| T2 cross-session lift | per-session HKDF key derivation | (covered by T1 verification path) |
| T3 in-session replay | per-session `request_id` set + -32005 | `ReplayRejection::test_duplicate_request_id_rejected` |
| T4 timestamp skew | skew-window check + -32006 | `TimestampSkew::test_ancient_timestamp_rejected` |
| 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 sig absent + secret set | 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).
(An earlier version of this paragraph said to open a labeled public
issue, which contradicted CONTRIBUTING.md and pointed at a CODEOWNERS
file that doesn't exist — a researcher following the nearest
instruction file would have posted a public zero-day. PR #22 review.)
Loading
Loading