Repository navigation
Hold the fail posture in values the adapters pass down - #479
bar-capsule wants to merge 1 commit into
Conversation
Config and FailPosture carry the section 6.4 posture from the entry point, and acs.client.exchange returns Decided, Undecided or Refused. A hook that cannot load its adapter audits and applies ACS_DEFAULT_DENY. Signed-off-by: Bar Kaduri <bar@capsule.security>
drk-ram
left a comment
There was a problem hiding this comment.
Reviewed at head 1b68a89c7ebef0963f3ca5c1d01cd313fc1f1991.
This is the PR I spent the most time on, since it touches the §6.4 fail-posture logic directly, and I think it's a solid improvement over what it replaces.
Design
FailPosture/Config/Resolution(frozen dataclasses inacs.client._config) replace the per-adapter mutable module globals (_SERVER_DENY,_DECISION_TIMEOUT_S) that the linked finding called out as order-dependent.FailPosture.negotiated()applies "deny wins over proceed" and the negotiated timeout in one place, andFailPosture.resolve()is the single function that turns anUndecided/Refusedoutcome into a proceed/denyResolution- I traced every call site acrossclaude_code,cursor, andnatadapters and they all route through it now instead of three divergent copies of the same if/else.acs.client.exchange/classify_response(_exchange.py) centralizes the HTTP-status, binding, and signature classification that used to be duplicated (and had drifted slightly) acrossclaude_code/adapter.py,cursor/adapter.py, andnat/adapter.py. I diffed the old per-adapter_guardian_roundtrip/pre_invoke/post_invokeerror-handling branches againstclassify_responseline by line: the important invariant - a Guardian refusal (is_guardian_refusal) or a binding/signature failure returnsRefusedand fails closed regardless of posture, while a transport/timeout/malformed-response failure returnsUndecidedand goes through the posture - is preserved exactly in all three call sites after the refactor, including the NAT adapter's separate_settle_pre/_settle_post(pre_invoke blocks, post_invoke redacts).acs.integrations._bootstrap.run_hookis a nice consolidation: previously each of the three adapters had its own ~50-line hand-rolled "if the import fails, write an audit line by hand withsys.stdout/os.fdopenand applyACS_DEFAULT_DENY" block using only the standard library (sinceacs.core/acs.clientthemselves can't be imported on this path). Now that logic lives once in_bootstrap.pyand each hook only supplies its own_event_name/_denycallables.
Hardening noticed in the supporting modules
core/_signing.py'sverify_with_keyandclient/_signing.py'sverify_signatureboth gainedisinstance(container, dict)/isinstance(sig, dict)guards and a try/except aroundjcs_canonicalizefor envelopes RFC 8785 can't represent (e.g. values beyond 2**53) - good defense against a crafted response crashing verification into an unhandled exception rather than returningFalse.core/_errors.pyextendedGUARDIAN_ERROR_CAUSEwith the-32000/-32003/-32007and-3260xcodes and tightenedguardian_error_cause/is_guardian_refusalto reject non-int codes (including bools, via the explicitnot isinstance(code, bool)check) - this matters becausecodecomes straight off an untrusted JSON-RPC body.
Minor, non-blocking
_settle's deny-message label is picked by comparingresolution.audit_typeagainst the string"guardian_refusal_fail_closed"rather than branching on theOutcometype itself. It works today, but it's a bit stringly-typed given the rest of this PR is clearly oriented around using dataclass variants instead of string tags for this exact reason. Not a blocker, just something to watch if more audit types accumulate.
I spot-checked tests/client/test_exchange.py and tests/client/test_config.py and they cover the classification matrix (each HTTP status / binding mismatch / signature failure / error code combination) thoroughly.
Approving.
|
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 #478. Next: #480. The full stack is listed in #22.
What changed
ACS_GUARDIAN_URL,ACS_DEFAULT_DENY,ACS_HANDSHAKEandACS_DISABLEDonce, at the entry point, into aConfig, and carry the §6.4 posture as aFailPosturevalue. The secret, the host allowlist and the audit destination are still read where they are used.FailPosture.resolveis the one place a step without a usable decision is settled. A Guardian refusal fails closed under either posture.acs.client.exchangereturnsDecided,UndecidedorRefusedwith a cause and never raises; it replaces each adapter'scall_guardian. A JSON-RPC body gets the same binding and signature checks whatever HTTP status carries it.hook.py. When the adapter cannot import (rfc8785missing), it auditsadapter_unavailableand appliesACS_DEFAULT_DENYin the host's deny shape. The Claude Code and Cursor adapters use the sharedKNOWN_DECISIONS; NAT follows in Share the round trip, sign once, and return a Decision #482.Ariel's findings this answers
KNOWN_DECISIONSin Claude Code and Cursorrfc8785the hook exited 1 with no output, which the host treats as proceed.Where this differs from Ariel's proposal
Configholds the settings that decide where steps go and how failures settle; Let the handshake decide the session #480 and Use what the handshake negotiated #481 addACS_STARTUP_POSTUREandACS_REQUIRE_PROFILES. The secret, the host allowlist and the audit destination are still read where they are used.FailPosture.resolvereturns aResolution(proceed or deny, with its audit record) for the adapter to write in the host's shape, andDecidedcarries the verified response. TheDecisionvalue arrives in Share the round trip, sign once, and return a Decision #482.Which issue does this implement
Part of #134
Base branch
Stacked on #478, not on
integration. It lands through #22; see the stack in #22.Type of change
I tested this
At this slice's top (
1b68a89c), withclauderemoved from PATH andACS_LIVE_CLAUDEandACS_SPEC_DIRunset: 343 passed and 3 expected skips with the NAT extra, 299 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 the installed-wheel check on Python 3.11 and 3.12.Checklist
git commit -s(required by the DCO)Security
A hook that cannot load its adapter records an audit line and applies
ACS_DEFAULT_DENY, and every step without a decision is settled in one place.