Repository navigation
Align the adapters with the current spec - #488
bar-capsule wants to merge 12 commits into
Conversation
ACS_HMAC_KEY_ID labels the secret (section 10). Requests are signed under it, and a response under another label fails verification. Signed-off-by: Bar Kaduri <bar@capsule.security>
open_session refuses a session whose secret is missing, shorter than 32 bytes or unlabeled, and sends nothing (section 10). The example Guardian refuses to start without a secret. Signed-off-by: Bar Kaduri <bar@capsule.security>
Every decision failure's audit carries failure_class and posture, and sessionEnd reports summary.decision_failures per class (section 6.4). Signed-off-by: Bar Kaduri <bar@capsule.security>
Failures count per NAT run, and each run's sessionEnd reports its own. Signed-off-by: Bar Kaduri <bar@capsule.security>
Both e2e_check.py scripts pass ACS_HMAC_KEY_ID, and the docs and comments describe signed requests only. Signed-off-by: Bar Kaduri <bar@capsule.security>
Each place a MODIFY becomes a DENY records modify_unsupported with that reason code (section 6.5). Signed-off-by: Bar Kaduri <bar@capsule.security>
A denied or refused sessionStart refuses the run's calls, and a run that raises or is cancelled sends sessionEnd. Signed-off-by: Bar Kaduri <bar@capsule.security>
A run's state stays until its sessionEnd is handled, so a refused run stays refused however many other runs finish. Signed-off-by: Bar Kaduri <bar@capsule.security>
Each run's steps carry a UUIDv5 session_id from the configured session_id and workflow_run_id, with its own handshake, sessionStart and sessionEnd. sessionEnd also goes out after a start without a decision. Signed-off-by: Bar Kaduri <bar@capsule.security>
A run counts its exchanges in flight; its sessionEnd waits for them, and later exchanges are refused, cancelled runs included. Signed-off-by: Bar Kaduri <bar@capsule.security>
A call outside any run opens the configured session with sessionStart, and closing the middleware sends its sessionEnd and removes its records. Signed-off-by: Bar Kaduri <bar@capsule.security>
An exchange goes out only for an open session, and shutdown waits for a session still opening before it ends it. Signed-off-by: Bar Kaduri <bar@capsule.security>
drk-ram
left a comment
There was a problem hiding this comment.
Reviewed at head a9727cd27383fef0552c8aa9760a48aad39ec62c (stacked on #487, base refimpl/adapters-12-live-run-fixes, which is e42f277). I read the whole 63-file diff of this slice. I am approving, with three small notes.
Signing material (client/_secrets.py, _signing.py, _handshake.py). This matches §10 as written in docs/spec/instrument/specification.md.
load_signing_material()refuses a missing secret, a secret under 32 bytes, and a missingACS_HMAC_KEY_ID, each with its own cause (secret_missing,secret_too_short,key_id_missing).open_sessionrefuses before it reads any stored record, so a stale session record cannot revive an unsigned session.key_idis a configured, opaque label and is no longer hardcoded as"default", as the spec's "an implementation takes the label from its configuration" requires.verify_signaturerejects a signature whosekey_iddiffers from the configured one and tries no other secret, which is the spec's MUST NOT fall back.signature_algorithms_supportedis always["HMAC-SHA256"], and the handshake-downgrade text in T11 is rewritten to match.key_id_rejected()recognises an unsignedSIGNATURE_INVALID(-32004) and reports it askey_id_rejectedrather than a forged error. That matches the spec's statement that the unknown-key_iderror is unsigned because no configured secret matches it.record_mcp_useno longer needs to ask whether a secret exists, because every session has one.
Failure classes (_config.py). FAILURE_CLASSES is the same eight-member enum as acs.failure.class in trace/otel-mapping.json and decision_failures.propertyNames in hooks/session-end.json. FailPosture.resolve() now stamps every resolution with failure_class and posture. A signed refusal is stamped fail-closed even under a fail-open posture, and a session gate restating an earlier failure carries decision_failure=False, so nothing is counted twice. count_decision_failure() never raises, so a count cannot stop a deny. sessionEnd carries summary.decision_failures, and gate(handshake, method) lets sessionEnd through after a failed start so that the start's own failure is the thing reported.
MODIFY (§6.5). A MODIFY the host cannot apply (redactions, no usable parameter_overrides, an unmodifiable prompt or tool result, updated_input outside preToolUse or on an MCP tool) becomes a DENY, and it is audited under reason_codes: ["modify_unsupported"] through audit_modify_unsupported(). That is the audit the spec requires for a client that receives a MODIFY it cannot apply. Cursor's MCP carve-out is right: Cursor ignores updated_input for an HTTP MCP server's tool, and _mcp_output_replaceable() refuses to promise a replacement when tool_output is {"redacted": true}.
NAT. One run per ACS session with sessionStart first and sessionEnd last, a close protocol (_close_run waits for in-flight exchanges, then _exchange refuses new ones), and _forget_run removing the run's state. I read _step, _exchange, _run_stopped and close() looking for a path where a call opens a session after its sessionEnd. The finished check in _step sits inside state.lock, and _exchange checks closed under state.idle. I did not find one.
Verification (Windows, Python 3.11.9, uv sync --frozen --extra nat --group test). The harness needs two local edits on Windows, which I made in my working copy only: a thread-based line read in launch_guardian() in place of select.select() on a pipe, and an fcntl stub for example_guardian.py. With them, pytest -q on a9727cd gives 494 passed, 36 failed, 3 skipped. The same run on its base e42f277 gives 452 passed, 37 failed. Every failure on a9727cd is also a failure on the base (POSIX file-mode, symlink and path assertions, connect-refused timing), with one exception: test_key_id.py::ExampleGuardianLabel::test_a_guardian_with_a_secret_and_no_label_does_not_start failed once while three suites ran at once. It passes alone (tests/common/test_key_id.py: 14 passed, 7 subtests), so I count that as load, and it is not a regression. Two NAT cases that fail on the base (test_emission.py::test_lifecycle_observer_emits_session_hooks and ::test_sequential_session_invariants) pass here. I did not run the Linux-only lint or wheel checks.
Notes (non-blocking).
acs-claude-code-wireandacs-cursor-wirenow require--key-idand a secret, which is the right change. They still accept a--secret-env-inlinevalue of any length, so a wire that succeeds produces hooks that refuse every session withsecret_too_short. Checking the length at wire time, for--secret-filetoo, would surface that when the settings are written.MIN_SECRET_BYTESis checked against the secret's bytes after trailing whitespace is stripped.openssl rand -hex 32gives 64 bytes, so the documented command is fine, but a 32-character passphrase also passes. §10 says HKDF does no key stretching and that a passphrase can be recovered offline from one signature. One sentence inSECURITY.mdsaying the check is a length floor and not a randomness check would keep a reader from trusting it more than it deserves.- Two documentation leftovers at this head.
SECURITY.mdT11 ends "Verified by reading the code:sign_envelope()consultsload_hmac_secret()only", which is no longer what the function does. And indocs/nat/workflow.yml.examplethe "at least 32 bytes" sentence sits underACS_HMAC_KEY_IDand reads as if it describes the label. #489 touches both, so please fix them there, and check the T11 sentence still matches the code when you do.
Approving a9727cd2.
|
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 #487. Next: #489. The full stack is listed in #22.
What changed
ACS_HMAC_KEY_IDnames the HMAC secret, every request is signed, and a session without a secret of at least 32 bytes and a label is refused (§10, Fix the HMAC key derivation, define key_id, and record an unresolved request_id_ref #272).failure_class, andsessionEndreportssummary.decision_failures(§6.4, Record every decision failure and count them at session end #273).modify_unsupported(§6.5).sessionStartfirst andsessionEndlast. A run whosesessionStartis denied is refused, and a failed run ends with reasonerror.Which issue does this implement
Part of #134
Base branch
Stacked on #487, not on
integration. It lands through #22; see the stack in #22.Type of change
I tested this
At this slice's top (
a9727cd2), withclauderemoved from PATH andACS_LIVE_CLAUDEandACS_SPEC_DIRunset: 516 passed and 3 expected skips with the NAT extra, 429 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 session without a secret of at least 32 bytes and a
key_idis refused, every request is signed, and a MODIFY the host cannot apply is a DENY.