Skip to content

Align the adapters with the current spec - #488

Closed
bar-capsule wants to merge 12 commits into
refimpl/adapters-12-live-run-fixesfrom
refimpl/adapters-13-spec-alignment
Closed

bar-capsule wants to merge 12 commits into
refimpl/adapters-12-live-run-fixesfrom
refimpl/adapters-13-spec-alignment

Conversation

@bar-capsule

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

Copy link
Copy Markdown
Collaborator

Stacked on #487. Next: #489. The full stack is listed in #22.

What changed

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

  • Reference implementation or adapter

I tested this

At this slice's top (a9727cd2), with claude removed from PATH and ACS_LIVE_CLAUDE and ACS_SPEC_DIR unset: 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, 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 session without a secret of at least 32 bytes and a key_id is refused, every request is signed, and a MODIFY the host cannot apply is a DENY.

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 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 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 missing ACS_HMAC_KEY_ID, each with its own cause (secret_missing, secret_too_short, key_id_missing). open_session refuses before it reads any stored record, so a stale session record cannot revive an unsigned session.
  • key_id is 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_signature rejects a signature whose key_id differs from the configured one and tries no other secret, which is the spec's MUST NOT fall back. signature_algorithms_supported is always ["HMAC-SHA256"], and the handshake-downgrade text in T11 is rewritten to match.
  • key_id_rejected() recognises an unsigned SIGNATURE_INVALID (-32004) and reports it as key_id_rejected rather than a forged error. That matches the spec's statement that the unknown-key_id error is unsigned because no configured secret matches it.
  • record_mcp_use no 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).

  1. acs-claude-code-wire and acs-cursor-wire now require --key-id and a secret, which is the right change. They still accept a --secret-env-inline value of any length, so a wire that succeeds produces hooks that refuse every session with secret_too_short. Checking the length at wire time, for --secret-file too, would surface that when the settings are written.
  2. MIN_SECRET_BYTES is checked against the secret's bytes after trailing whitespace is stripped. openssl rand -hex 32 gives 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 in SECURITY.md saying the check is a length floor and not a randomness check would keep a reader from trusting it more than it deserves.
  3. Two documentation leftovers at this head. SECURITY.md T11 ends "Verified by reading the code: sign_envelope() consults load_hmac_secret() only", which is no longer what the function does. And in docs/nat/workflow.yml.example the "at least 32 bytes" sentence sits under ACS_HMAC_KEY_ID and 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.

@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-13-spec-alignment 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