Skip to content

Hold the fail posture in values the adapters pass down - #479

Closed
bar-capsule wants to merge 1 commit into
refimpl/adapters-03-live-tests-opt-infrom
refimpl/adapters-04-fail-posture
Closed

bar-capsule wants to merge 1 commit into
refimpl/adapters-03-live-tests-opt-infrom
refimpl/adapters-04-fail-posture

Conversation

@bar-capsule

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

Copy link
Copy Markdown
Collaborator

Stacked on #478. Next: #480. The full stack is listed in #22.

What changed

  • The adapters read ACS_GUARDIAN_URL, ACS_DEFAULT_DENY, ACS_HANDSHAKE and ACS_DISABLED once, at the entry point, into a Config, and carry the §6.4 posture as a FailPosture value. The secret, the host allowlist and the audit destination are still read where they are used. FailPosture.resolve is the one place a step without a usable decision is settled. A Guardian refusal fails closed under either posture.
  • acs.client.exchange returns Decided, Undecided or Refused with a cause and never raises; it replaces each adapter's call_guardian. A JSON-RPC body gets the same binding and signature checks whatever HTTP status carries it.
  • The installed hook commands run a standard-library hook.py. When the adapter cannot import (rfc8785 missing), it audits adapter_unavailable and applies ACS_DEFAULT_DENY in the host's deny shape. The Claude Code and Cursor adapters use the shared KNOWN_DECISIONS; NAT follows in Share the round trip, sign once, and return a Decision #482.

Ariel's findings this answers

Where this differs from Ariel's proposal

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

  • Reference implementation or adapter

I tested this

At this slice's top (1b68a89c), with claude removed from PATH and ACS_LIVE_CLAUDE and ACS_SPEC_DIR unset: 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

  • 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 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.

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 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 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 in acs.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, and FailPosture.resolve() is the single function that turns an Undecided/Refused outcome into a proceed/deny Resolution - I traced every call site across claude_code, cursor, and nat adapters 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) across claude_code/adapter.py, cursor/adapter.py, and nat/adapter.py. I diffed the old per-adapter _guardian_roundtrip/pre_invoke/post_invoke error-handling branches against classify_response line by line: the important invariant - a Guardian refusal (is_guardian_refusal) or a binding/signature failure returns Refused and fails closed regardless of posture, while a transport/timeout/malformed-response failure returns Undecided and 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_hook is 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 with sys.stdout/os.fdopen and apply ACS_DEFAULT_DENY" block using only the standard library (since acs.core/acs.client themselves can't be imported on this path). Now that logic lives once in _bootstrap.py and each hook only supplies its own _event_name/_deny callables.

Hardening noticed in the supporting modules

  • core/_signing.py's verify_with_key and client/_signing.py's verify_signature both gained isinstance(container, dict) / isinstance(sig, dict) guards and a try/except around jcs_canonicalize for 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 returning False.
  • core/_errors.py extended GUARDIAN_ERROR_CAUSE with the -32000/-32003/-32007 and -3260x codes and tightened guardian_error_cause/is_guardian_refusal to reject non-int codes (including bools, via the explicit not isinstance(code, bool) check) - this matters because code comes straight off an untrusted JSON-RPC body.

Minor, non-blocking

  • _settle's deny-message label is picked by comparing resolution.audit_type against the string "guardian_refusal_fail_closed" rather than branching on the Outcome type 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.

@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-04-fail-posture 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