Skip to content

Answer Rock's review - #489

Closed
bar-capsule wants to merge 59 commits into
refimpl/adapters-13-spec-alignmentfrom
refimpl/adapters-14-review-fixes
Closed

bar-capsule wants to merge 59 commits into
refimpl/adapters-13-spec-alignmentfrom
refimpl/adapters-14-review-fixes

Conversation

@bar-capsule

@bar-capsule bar-capsule commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

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):

  1. A gate hook that fails blocks the action, an exception that escapes is denied, and a bad ACS_HANDSHAKE_TIMEOUT_SECONDS falls back to 5 seconds with an audit record.
  2. Before signing, every step replaces what RFC 8785 cannot encode (a lone surrogate becomes U+FFFD, an integer past 2^53 - 1 becomes a string), so the Guardian sees it. A step that still can't be signed is denied on a gate and withheld on a result.
  3. Claude Code sends agentResponse with the model's reply, from Stop.
  4. Tool output the Guardian denies or modifies is kept from the model: in Claude Code for every tool, in the shape of its own tool_response, and in Cursor for MCP results through updated_mcp_tool_output.
  5. On allow, Claude Code prints nothing and Cursor answers {}, so each host's own permission prompt, and Cursor's sandbox, still apply.
  6. NAT applies MODIFY in the slot NAT reads, only to fields the input declares (a scalar input included), signs values in JSON form, withholds output the Guardian did not release, and denies any other MODIFY as modify_unsupported. Tests drive allow, deny and modify through NAT's FunctionMiddlewareChain.
  7. Cursor denies ask and defer on preToolUse with an audit line.
  8. The wire tools quote every value and keep the settings file's link and mode; backups are 0600 with any inline secret redacted, and a settings file becomes 0600 once it holds an inline secret.
  9. Spec citations name sections instead of lines, schema line citations match the schemas, and the docs match the code.

From our pre-push review:

  • NAT: each root Runner is one ACS session with a fresh id; nested Runners join it, and a client's workflow-run-id header or replayed steps neither merge nor end it. A session ends once, on success, error, cancel or close(), and holds its own hook order and counts. The configured session_id and ACS_SESSION_ID are removed.
  • NAT: a verdict on the workflow input blocks the run's calls, and a verdict on its output is recorded.
  • Claude Code and Cursor: a host session that continues after sessionEnd gets a new ACS session; a sessionStart whose hook was killed is settled under the posture; session state follows the session across a cd.
  • Failed tool results reach the Guardian (PostToolUseFailure, postToolUseFailure), and every turn ends: interrupted, error on StopFailure, and denied_at_start after a denied turnStart. Cursor's stop is wired.
  • A Guardian answer missing what response-envelope.json requires is a counted malformed_response.
  • SubagentHandback in auto mode turns an ask into a deny; Cursor's ACS_DISABLED prints an explicit allow; turn_id is on every in-turn step; the ClientHello declares the body size post() enforces; the wire tools expand only ~; onFailure: block is set only where a failed hook stops the action.
  • Keys that collide once normalized fail closed, so the signed view never drops a value, and every decided Guardian answer is checked against response-envelope.json.
  • Claude Code withholds a built-in tool's output only in that tool's own output schema (Bash, Read, Grep, Glob, WebFetch, WebSearch, Write, Edit, Agent); any other tool is blocked and recorded as unenforceable_decision. An Agent result cites no toolCallRequest.
  • A host session is marked ended before its sessionEnd exchange, and both wire tools give sessionEnd 15 seconds, so a resume after a slow Guardian still opens a new ACS session.
  • Settings backups redact the whole inline secret, however shlex.quote wrote it.
  • Cursor answers {} when a tool gate proceeds, so Cursor's sandbox and approval still decide, and acs-claude-code-hook answers Cursor's events through the Cursor adapter when Cursor runs it from .claude/settings.json.
  • NAT refuses a call of an ended or closed session before any cached decision or bypass, and each call stays in the session of the run whose context made it: NAT reports a run's end in the Runner's context, so later calls and tasks created after the run use the middleware's own session while work the run left behind stays refused, and only a workflow's own start opens or joins a run session.
  • acs-claude-code-hook finds 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.
  • A signed JSON-RPC error code written as an integral number, such as -32000.0, refuses like the integer it is.
  • NAT denies a call, or withholds an output, whose keys would become one JSON key in the form the Guardian is sent, such as 1 and "1", or None and "None" inside a Pydantic model, under either posture.
  • A host session whose end cannot be recorded stays open, with no sessionEnd, so the next hook continues it rather than a sealed one.
  • The wire tools write --secret-file and --audit-file as absolute paths.
  • Comments and docstrings are kept only where a reader needs them.

Added on 4 November:

  • Signing refuses an envelope with no session_id, and a response without one does not verify, instead of using a key derived from request_id (§10). A gate whose request cannot be signed sends nothing and is denied.
  • The NAT tests set ACS_HANDSHAKE_TIMEOUT_SECONDS instead of patching a module-level override, and 46 noqa: E402 markers 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

  • Reference implementation or adapter

I tested this

At this slice's top (fcc58fe3), with claude removed from PATH and ACS_LIVE_CLAUDE and ACS_SPEC_DIR unset: 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, including lint-imports and the installed-wheel check on Python 3.11 and 3.12.

Checklist

  • Commits are signed off with git commit -s (required by the DCO)
  • Prose follows STYLE.md
  • No secrets, tokens, or internal URLs in the diff

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.

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>
@github-project-automation github-project-automation Bot moved this to Needs triage in ACS Project Tracker Nov 3, 2026
@bar-capsule
bar-capsule added this pull request to stack #490 November 3, 2026 20:10
@bar-capsule
bar-capsule removed this pull request from stack #490 November 3, 2026 20:19
@bar-capsule
bar-capsule added this pull request to stack #491 November 3, 2026 20:20
bar-capsule added a commit to bar-capsule/ACS that referenced this pull request Nov 3, 2026
…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>
@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Nov 4, 2026
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 drk-ram left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_envelope raises when the envelope has none instead of falling back to request_id, and verify_signature returns False for a response checked without one (that second part is from reading the code). I ran sign_envelope on an envelope with no session_id and got ValueError, and with one the signature carries the label from ACS_HMAC_KEY_ID. A gate whose request cannot be signed is denied through round_trip(gate=True) with posture.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 _normalize directly on a request with each of those and got what the PR describes, with an envelope_normalized audit 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 against response-envelope.json's AcsResult with the bundled schemas, so a verdict that fails it is a counted malformed_response. A restrictive or unreadable verdict is Refused and is never carried out as an allow. The MODIFY-composition exception is correct, because §6.3 makes that a DENY the adapter applies. The rfc3987_syntax shortcut in _bootstrap.py is sound: the schemas use only date-time, uri and uuid formats, and I confirmed uri is still enforced with that module blocked.
  • The pending startup claim from #487 is now settled. open_session resolves a session_start_incomplete under the session's own posture, counts it as observed_agent_error, and records the outcome, so a killed hook no longer refuses the session for good.
  • Host-session lifecycle. current_session_id and mark_session_ended give a new ACS session to a host session that continues after sessionEnd, and the end is marked before the exchange so a cancelled hook still leaves the next one a new session. session_end_not_sent covers 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 ask with updatedInput (documented in mapping.md), and SubagentHandback in auto mode, which cannot prompt, turns an ASK into a DENY with an audit record.
  • Withholding output. _withhold replaces the output in the tool's own schema for Bash, mcp__*, Read, Grep, Glob, WebFetch, WebSearch, Write, Edit and Agent, and records unenforceable_decision for anything else. For Cursor, _post_tool_withhold uses updated_mcp_tool_output only where Cursor documents it and skips an HTTP MCP server's {"redacted": true} output.
  • Settings backups. write_atomically writes a 0600 backup, keeps a symlink and the mode, and goes to 0600 when an inline secret is written. _redact_command handles values shlex.quote builds 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).

  1. 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 in docs/guardian/README.md saying 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.
  2. Backup redaction covers inline secrets in a hook command and not an env block. _redacted() rewrites any string containing ACS_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 an env block, 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 named ACS_HMAC_SECRET as well, or say in the docs and the PR that the redaction covers inline command values.
  3. The withholding schemas are tied to Claude Code 2.1.317. The code comment and mapping.md say 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.
  4. 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.

@rocklambros

Copy link
Copy Markdown
Collaborator

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.

@rocklambros rocklambros closed this Nov 5, 2026
@github-project-automation github-project-automation Bot moved this from In progress to Done in ACS Project Tracker Nov 5, 2026
@bar-capsule
bar-capsule deleted the refimpl/adapters-14-review-fixes branch November 8, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants