Repository navigation
Answer Rock's review - #489
bar-capsule wants to merge 59 commits into
Conversation
Gate hooks deny in the host's shape under a fail-closed posture for every unusable answer, and acs-claude-code-wire sets onFailure block on them. A gate step whose envelope cannot be signed is denied. Signed-off-by: Bar Kaduri <bar@capsule.security>
The wire tools shell-quote every value in the hook command and write settings atomically, keeping a symlink and the file mode and creating new files 0600. Signed-off-by: Bar Kaduri <bar@capsule.security>
parameter_overrides go only into the slot the input travels in, and an override that fits neither is DENY with modify_unsupported. Arguments and outputs are signed in JSON form, and ask or defer on a result withholds it. Signed-off-by: Bar Kaduri <bar@capsule.security>
Cursor enforces ask only on beforeShellExecution and beforeMCPExecution, so ask and defer on preToolUse deny. Decisions Cursor cannot apply to a result are audited as unenforceable_decision. Signed-off-by: Bar Kaduri <bar@capsule.security>
An allow prints nothing, so Claude Code's permission rules still decide, and MODIFY answers ask with updatedInput. Stop sends agentResponse before turnEnd, and a denied result is replaced through updatedToolOutput. Signed-off-by: Bar Kaduri <bar@capsule.security>
Citations name the spec section or ACS-Core bullet, and CitationGuard checks the bullet names. README and SECURITY.md text matches the code. Signed-off-by: Bar Kaduri <bar@capsule.security>
Every override is read back from the value the function receives, and a mismatch is DENY with modify_unsupported (section 6.5). Signed-off-by: Bar Kaduri <bar@capsule.security>
The DENY/MODIFY/ASK/DEFER requirements are at response-envelope.json:114-117 and chain_hash at :89-91; the ledger is issue #33; the smoke report and the suite docstring name the commands that run the suite. Signed-off-by: Bar Kaduri <bar@capsule.security>
T2, T3, T4 and T12 name existing tests, T5 counts its cases, O4 cites the conformance.md guarantee paragraph, T8 names the state the store holds, and the component table uses the integrations paths. Signed-off-by: Bar Kaduri <bar@capsule.security>
Signed-off-by: Bar Kaduri <bar@capsule.security>
The README states the start-up secret requirement, request checks, signing, the rolling chain persisted under ACS_GUARDIAN_STATE_DIR, and the full policy table including the subagent gate. Signed-off-by: Bar Kaduri <bar@capsule.security>
Signed-off-by: Bar Kaduri <bar@capsule.security>
NAT reads and rewrites the input in modified_args[0] or modified_kwargs, Cursor blocks with exit code 0, and a new adapter registers its suite in run_conformance.py. Signed-off-by: Bar Kaduri <bar@capsule.security>
The value is read when the handshake runs, so a bad one no longer crashes the hook at import; it is replaced by 5 seconds and recorded as invalid_setting. An adapter that raises while loading is settled closed. Signed-off-by: Bar Kaduri <bar@capsule.security>
Lone surrogates become U+FFFD and integers past 2**53 - 1, NaN and infinities become strings, audited as envelope_normalized, so the Guardian sees the step. A result that still cannot be signed is withheld on Claude Code PostToolUse and Cursor postToolUse. Signed-off-by: Bar Kaduri <bar@capsule.security>
A result missing type, acs_version, request_id, a known decision, or the reasoning and details its decision requires is a counted decision failure: an allow follows the posture, any other verdict is denied. Signed-off-by: Bar Kaduri <bar@capsule.security>
A start claimed by a hook process that was killed or timed out is recorded once as a sessionStart without a decision, counted as observed_agent_error, and each later hook follows its fail posture instead of being refused. Signed-off-by: Bar Kaduri <bar@capsule.security>
Turn state and decision-failure counts follow the session across a cd, and agent_id comes from CLAUDE_PROJECT_DIR or the session's first cwd, so one session keeps one agent, one open turn and one count. Signed-off-by: Bar Kaduri <bar@capsule.security>
sessionEnd marks the ACS session ended and removes its records. A later hook of the same Claude Code session or Cursor conversation gets a new session_id, handshake and sessionStart, so nothing reaches an ended session. Signed-off-by: Bar Kaduri <bar@capsule.security>
A turn still open when the next prompt arrives gets a turnEnd with outcome interrupted. Claude Code StopFailure sends turnEnd with outcome error, and acs-cursor-wire wires stop. Decisions on steps that already happened are recorded, a MODIFY as modify_unsupported. Signed-off-by: Bar Kaduri <bar@capsule.security>
Claude Code PostToolUseFailure maps to toolCallResult with exit_status failure and its error text, and both wire tools wire the failure hook. A decision other than allow reaches the agent as context and is recorded as unenforceable_decision. Signed-off-by: Bar Kaduri <bar@capsule.security>
Claude Code replaces a built-in tool's output in the shape of its own tool_response. Cursor replaces a denied, asked, deferred or unreadable MCP result through updated_mcp_tool_output, and a MODIFY there needs a JSON object. Signed-off-by: Bar Kaduri <bar@capsule.security>
An ASK there is a deny recorded as unenforceable_decision, and a MODIFY is an allow carrying updatedInput, so the delivered report is the rewritten one. Signed-off-by: Bar Kaduri <bar@capsule.security>
failClosed blocks a hook that prints nothing, so the kill switch now prints the permission or prompt allow shape, also when the adapter cannot load. Signed-off-by: Bar Kaduri <bar@capsule.security>
subagentStart and Cursor preCompact inside a turn carry metadata.turn_id. The ClientHello declares the 1 MiB body post() enforces, the wire tools expand only ~ in paths, and onFailure block is set only on PreToolUse and UserPromptSubmit, where a failed hook can stop the action. Signed-off-by: Bar Kaduri <bar@capsule.security>
The wire tools write each backup 0600 with an inline ACS_HMAC_SECRET replaced by <redacted>, and make a settings file 0600 once it holds one. Signed-off-by: Bar Kaduri <bar@capsule.security>
preToolUse, beforeShellExecution, beforeMCPExecution and subagentStart each deny a step whose envelope cannot be signed, under the fail-open posture. Signed-off-by: Bar Kaduri <bar@capsule.security>
Cursor blocks a prompt with exit code 0, Claude Code's defer is described as the -p-only feature it is, the unsigned-mode text and dead test paths are gone, and unusable verdicts cite issue #32 rather than section 6.3. Signed-off-by: Bar Kaduri <bar@capsule.security>
A turnStart the Guardian does not allow is followed by turnEnd with outcome denied_at_start for the same turn_id, in Claude Code and Cursor. Signed-off-by: Bar Kaduri <bar@capsule.security>
Each root Runner gets a fresh session_id through a context variable set at its WORKFLOW_START; nested Runners join it, and replayed steps or a shared workflow-run-id header neither end nor merge it. A session ends once, at the Runner's exporter cleanup or close(), with its own hook order and counts. Signed-off-by: Bar Kaduri <bar@capsule.security>
The troubleshooting row now says an unwired function's calls reach the Guardian neither as a decision nor as an observation, and the lifecycle row says it reports workflow boundaries only. Signed-off-by: Bar Kaduri <bar@capsule.security>
…urity-Project#489) Brings this branch to the tree of refimpl/adapters-14-review-fixes. Signed-off-by: Bar Kaduri <bar@capsule.security>
The NAT tests set ACS_HANDSHAKE_TIMEOUT_SECONDS instead of patching _HANDSHAKE_TIMEOUT_S, and 46 noqa: E402 markers that suppress nothing are removed. Behavior is unchanged. Signed-off-by: Bar Kaduri <bar@capsule.security>
The session key derives from metadata.session_id (§10), so an envelope without one is not signed and a response without one does not verify, instead of using a key derived from request_id. A gate whose request cannot be signed sends nothing and is denied. Signed-off-by: Bar Kaduri <bar@capsule.security>
drk-ram
left a comment
There was a problem hiding this comment.
Reviewed at head fcc58fe325e6ab116c5497cb6b74b95d7318a7e8 (stacked on #488, base refimpl/adapters-13-spec-alignment, which is a9727cd). This slice is 91 files, so I will say what I read line by line and what I took from tests. I am approving, with four notes.
Read line by line: client/* (_exchange.py, _handshake.py, _signing.py, _store.py, _config.py, _secrets.py), most of core/* (_canonical.py, _decision.py, _envelope.py, _signing.py), integrations/_bootstrap.py, _settings_file.py, claude_code/hook.py, the Claude Code PreToolUse and PostToolUse translation and withholding, the Cursor post-tool translation, and the NAT session model (_Session, _open, _exchange, _end_session, _json_safe). Taken from the tests: the remaining Claude Code and Cursor adapter plumbing, the NAT lifecycle and event-loop paths, the wire tools beyond the diff above, and the conformance harness. Test counts stay level: test_acs_core_conformance.py has 71 tests at both heads and test_security.py has 47, so the 478 lines removed from the conformance file are comments and docstrings, not coverage.
What checks out.
- Signing needs a
session_id(§10).sign_enveloperaises when the envelope has none instead of falling back torequest_id, andverify_signaturereturns False for a response checked without one (that second part is from reading the code). I ransign_envelopeon an envelope with nosession_idand gotValueError, and with one the signature carries the label fromACS_HMAC_KEY_ID. A gate whose request cannot be signed is denied throughround_trip(gate=True)withposture.closed(), so it is denied even under a fail-open posture. - Normalisation before signing (
_signable): a lone surrogate becomes U+FFFD, an integer past 2^53 − 1 becomes a string, NaN and infinities become strings, and two keys that normalise to one fail closed. I called_normalizedirectly on a request with each of those and got what the PR describes, with anenvelope_normalizedaudit record that counts each kind. The key-collision case raises, so the signed view can never drop a value. - Response validation.
result_problem()validates every decision againstresponse-envelope.json'sAcsResultwith the bundled schemas, so a verdict that fails it is a countedmalformed_response. A restrictive or unreadable verdict isRefusedand is never carried out as an allow. The MODIFY-composition exception is correct, because §6.3 makes that a DENY the adapter applies. Therfc3987_syntaxshortcut in_bootstrap.pyis sound: the schemas use onlydate-time,urianduuidformats, and I confirmeduriis still enforced with that module blocked. - The
pendingstartup claim from #487 is now settled.open_sessionresolves asession_start_incompleteunder the session's own posture, counts it asobserved_agent_error, and records the outcome, so a killed hook no longer refuses the session for good. - Host-session lifecycle.
current_session_idandmark_session_endedgive a new ACS session to a host session that continues aftersessionEnd, and the end is marked before the exchange so a cancelled hook still leaves the next one a new session.session_end_not_sentcovers a marker that cannot be written. - Allow prints nothing at the Claude Code request gate, so Claude Code's own permission rules and prompt still decide. MODIFY goes out as
askwithupdatedInput(documented inmapping.md), andSubagentHandbackin auto mode, which cannot prompt, turns an ASK into a DENY with an audit record. - Withholding output.
_withholdreplaces the output in the tool's own schema forBash,mcp__*,Read,Grep,Glob,WebFetch,WebSearch,Write,EditandAgent, and recordsunenforceable_decisionfor anything else. For Cursor,_post_tool_withholdusesupdated_mcp_tool_outputonly where Cursor documents it and skips an HTTP MCP server's{"redacted": true}output. - Settings backups.
write_atomicallywrites a 0600 backup, keeps a symlink and the mode, and goes to 0600 when an inline secret is written._redact_commandhandles valuesshlex.quotebuilds from several fragments.
Verification (Windows, Python 3.11.9, uv sync --frozen --extra nat --group test). The harness needs two edits to run on Windows, a thread-based line read in launch_guardian() and an fcntl stub in example_guardian.py. I made them in my working copy only. On fcc58fe, pytest -v gives 614 passed, 49 failed, 3 skipped. I ran the same suite with the same edits on the base of #487 (c59c00e), and on #487's head, #488's head and this head. Against that baseline, every failure on this head is either one that already fails there or sits in a test that asserts something Windows does not do: 0600 and 0700 modes, chmod 0o500 on a directory as a write barrier, symlink handling, /-style paths in test_hook_command.py, and 127.0.0.1:1 giving "connection refused" quickly (Windows reports decision_timeout after the timeout, not transport_failure). The new test_session_gate.py cases on lone surrogates reach the same refused-port behaviour, and they do record envelope_normalized before it. tests/common/test_key_id.py::ExampleGuardianLabel failed once while the machine was busy and passes on its own (17 passed, 7 subtests). No NAT test fails on this head, and the two that failed on the earlier heads (test_emission.py lifecycle and sequential-session cases) now pass. I did not run lint-imports or the installed-wheel check, which need Linux. The PR's checks show green.
Notes (non-blocking).
- Numbers past 2^53 − 1 reach the Guardian as strings, and nothing in the envelope says so. A policy that compares a numeric argument (
amount > N) is evaluated against a string, whose result depends on the engine's type rules, and a Guardian cannot tell a normalised integer from an agent that sent the same digits as a string. The audit record helps the operator afterwards and does not help the policy at decision time. A line indocs/guardian/README.mdsaying so, or a marker the Guardian can key on, would help. Failing closed on a gate, as the PR does for key collisions, is the stricter choice if you prefer it. - Backup redaction covers inline secrets in a hook command and not an
envblock._redacted()rewrites any string containingACS_HMAC_SECRET=. I ran it on a settings file with"env": {"ACS_HMAC_SECRET": "…"}and the value was written to the backup unchanged. Claude Code settings do take anenvblock, and the wire tools never write one, so only a hand-edited file is affected. The backup is 0600 either way. Either redact a key namedACS_HMAC_SECRETas well, or say in the docs and the PR that the redaction covers inline command values. - The withholding schemas are tied to Claude Code 2.1.317. The code comment and
mapping.mdsay so, and also say that Claude Code ignores a replacement that does not match and shows the original output. If a later release changes one of those shapes, the adapter still believes it knows the shape, so it audits nothing and the result goes through. Please keep the opt-in live test (ACS_LIVE_CLAUDE) in the release checklist and say in the README which Claude Code versions it was run against. - Comment and docstring trimming. Roughly 1,950 source lines are removed, most of them module docstrings and rationale comments (for example in
_canonical.py,_exchange.py,_secrets.py,_urls.py,_transport.py). Most of the security rationale survives in shortened form, but it made this diff hard to review. In later slices, a separate commit or PR for pure comment edits would help.
Approving fcc58fe3.
|
Closing this slice because PR #22 (the reference adapters for Claude Code, Cursor and NAT) merged into integration as 2630ec2, and that merge carries every slice in this stack. The top of the stack had the same files as PR #22's final commit, so nothing in this slice was left behind. Thanks, Bar, for cutting the adapters into pieces this size, because it made the review possible. |
Stacked on #488. Next: #495. The full stack is listed in #22.
What changed
Rock's 31 October review, asks 1 to 10 (ask 3 is in #488):
ACS_HANDSHAKE_TIMEOUT_SECONDSfalls back to 5 seconds with an audit record.agentResponsewith the model's reply, fromStop.tool_response, and in Cursor for MCP results throughupdated_mcp_tool_output.{}, so each host's own permission prompt, and Cursor's sandbox, still apply.modify_unsupported. Tests drive allow, deny and modify through NAT'sFunctionMiddlewareChain.askanddeferonpreToolUsewith an audit line.From our pre-push review:
workflow-run-idheader or replayed steps neither merge nor end it. A session ends once, on success, error, cancel orclose(), and holds its own hook order and counts. The configuredsession_idandACS_SESSION_IDare removed.sessionEndgets a new ACS session; asessionStartwhose hook was killed is settled under the posture; session state follows the session across acd.PostToolUseFailure,postToolUseFailure), and every turn ends:interrupted,erroronStopFailure, anddenied_at_startafter a deniedturnStart. Cursor'sstopis wired.response-envelope.jsonrequires is a countedmalformed_response.SubagentHandbackin auto mode turns an ask into a deny; Cursor'sACS_DISABLEDprints an explicit allow;turn_idis on every in-turn step; the ClientHello declares the body sizepost()enforces; the wire tools expand only~;onFailure: blockis set only where a failed hook stops the action.response-envelope.json.unenforceable_decision. An Agent result cites notoolCallRequest.sessionEndexchange, and both wire tools givesessionEnd15 seconds, so a resume after a slow Guardian still opens a new ACS session.shlex.quotewrote it.{}when a tool gate proceeds, so Cursor's sandbox and approval still decide, andacs-claude-code-hookanswers Cursor's events through the Cursor adapter when Cursor runs it from.claude/settings.json.acs-claude-code-hookfinds Cursor's event at the top level even when the event is too deep to parse, and the MODIFY schema exception applies only to MODIFY answers.-32000.0, refuses like the integer it is.1and"1", orNoneand"None"inside a Pydantic model, under either posture.sessionEnd, so the next hook continues it rather than a sealed one.--secret-fileand--audit-fileas absolute paths.Added on 4 November:
session_id, and a response without one does not verify, instead of using a key derived fromrequest_id(§10). A gate whose request cannot be signed sends nothing and is denied.ACS_HANDSHAKE_TIMEOUT_SECONDSinstead of patching a module-level override, and 46noqa: E402markers that suppressed nothing are removed.Which issue does this implement
Part of #134
Base branch
Stacked on #488, not on
integration. It lands through #22; see the stack in #22.Type of change
I tested this
At this slice's top (
fcc58fe3), withclauderemoved from PATH andACS_LIVE_CLAUDEandACS_SPEC_DIRunset: 637 passed and 3 expected skips with the NAT extra, 504 passed and 3 expected skips without it. The skips are the two opt-in live Claude Code tests and the manual Cursor procedure. CI passes on this PR, includinglint-importsand the installed-wheel check on Python 3.11 and 3.12.Checklist
git commit -s(required by the DCO)Security
A failed gate hook blocks, denied output is withheld in each supported tool's own schema, failed tool results reach the Guardian, a malformed Guardian answer is a counted decision failure, a colliding key fails closed, Cursor's sandbox and approval still decide on an allow, a workspace wired for Claude Code is governed in Cursor, and each NAT root Runner is one session that refuses calls once it ends. A signature needs the envelope's session_id, so an envelope without one is not signed and a response without one does not verify.