Skip to content

prototype(gate_store,cmcp_gate): support binary secrets, enforce canonical base64url, and wrap malformed store responses in GateStoreUnavailable (#606) - #609

Merged
lywinged merged 1 commit into
agentrust-io:mainfrom
drk-ram:drk-ram-prototype-gate-store-cmcp-gate-hardening
Oct 23, 2026
Merged

lywinged merged 1 commit into
agentrust-io:mainfrom
drk-ram:drk-ram-prototype-gate-store-cmcp-gate-hardening

Conversation

@drk-ram

@drk-ram drk-ram commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Closes #606.

Fixes three fail-closed and input-validation gaps in prototype/gate_store.py and prototype/cmcp_gate.py:

  1. Binary bytes secrets and length/type enforcement (prototype/gate_store.py):

    • GateStoreServer.__init__ and RemoteGateStore.__init__ both enforce type(secret) is bytes and len(secret) >= 16, raising ValueError("store secret must be at least 16 bytes") before opening any SQLite file or socket.
    • RemoteGateStore encodes secret on the wire as _b64(secret) (unpadded base64url, matching the wire protocol comment at prototype/gate_store.py:250) and GateStoreServer._Handler.handle compares request.get("secret") (requiring isinstance(secret, str)) against _b64(self.server.secret) via hmac.compare_digest, so non-UTF-8 binary secrets (b"\x80\xff\x00\xfe\x91\xa2\xb3\xc4" * 4) work without raising UnicodeDecodeError.
    • prototype.gate_store.main (python -m prototype.gate_store) also accepts TRACE_GATE_STORE_SECRET_B64 (decoded via _unb64) so subprocess deployments can pass arbitrary binary secrets without UTF-8 encoding constraints, while retaining TRACE_GATE_STORE_SECRET compatibility.
  2. Canonical unpadded base64url, JSON wire checks, and GateStoreUnavailable response wrapping (prototype/gate_store.py):

    • _unb64 verifies _b64(raw) == text after base64.b64decode(..., altchars=b"-_", validate=True), rejecting padded ("AAAA====", "AA==", "AAA=") and non-canonical ("AB") strings with ValueError("bytes field").
    • _recv decodes frames as UTF-8 (raw.decode("utf-8")), rejects duplicate JSON object keys (object_pairs_hook=_pairs), UTF-8 BOMs, and non-finite JSON constants (parse_constant=_no_constants), and converts RecursionError to ValueError("malformed json").
    • RemoteGateStore._call, mint_challenge, consume_challenge, bump_admission, admission, receipted, reserve_call, receipt_head, and receipts validate response shapes, field types, and sha256:<64 lowercase hex> digests (_digest) and wrap any TypeError or ValueError (including binascii.Error) in GateStoreUnavailable, so malformed store replies do not leak ValueError or TypeError into cMCP's refusal-receipt path.
  3. CMCPTraceGate configuration and method input validation (prototype/cmcp_gate.py):

    • CMCPTraceGate.__init__ validates database (isinstance(database, (str, Path)) and non-empty string when provided) and store (callable GateStore methods when provided) with ProfileError("gate_store_configuration_invalid"), and validates context (isinstance(..., VerificationContext)), gateway_key (isinstance(..., Ed25519PrivateKey)), gateway_issuer (v.Identifier), and clock (callable) with ProfileError("gate_configuration_invalid").
    • challenge, _prove, admit, begin, recheck, receipt, and refusal validate session_id, call_id, and reason against v.Identifier, isinstance(handle, CallAdmission), type(allowed) is bool, and isinstance(action, dict) (plus JCS serializability via rfc8785.dumps), raising ProfileError("input_invalid").
    • What is not tested: SQLiteGateStore direct method calls with unvalidated types (callers reach it through CMCPTraceGate or GateStoreServer._dispatch), and live cmcp_runtime proxy tests in tests/test_cmcp_trace_gate.py and tests/test_multihost_replay.py when the sibling cmcp_runtime package is not installed in the test environment.

Cold-reader review (2b0d982 -> b5b7433):
A separate agent session given only the repository at 2b0d982, git diff 295c36d..2b0d982, #606, and the draft description found three issues:

  1. RemoteGateStore.receipt_head and consume_challenge checked _str(...) on digest fields rather than sha256:[0-9a-f]{64}, so a wire reply {"ok":[null,"garbage"]} from receipt_head would pass RemoteGateStore and later raise pydantic.ValidationError (ValueError) inside CMCPTraceGate.receipt(). Changed: added _digest (sha256:[0-9a-f]{64}) in prototype/gate_store.py and applied it in _dispatch, RemoteGateStore.consume_challenge, and RemoteGateStore.receipt_head, plus added regression cases {"ok":[null,"garbage"]} and {"ok":["not-a-digest","s1","not-a-digest",1,2]}.
  2. python -m prototype.gate_store only read TRACE_GATE_STORE_SECRET via .encode(), which cannot carry arbitrary non-UTF-8 bytes in a subprocess environment variable. Changed: added TRACE_GATE_STORE_SECRET_B64 support in prototype.gate_store.main and added subprocess test test_cli_server_supports_binary_secret_via_b64_env.
  3. CMCPTraceGate.__init__ did not validate a non-None store object for the GateStore method interface, and the description called _recv "strict JSON wire framing" without naming the specific checks. Changed: validated _STORE_METHODS on store in CMCPTraceGate.__init__ (with test case {"database": None, "store": object()}), and narrowed the description to list the exact JSON checks performed.

Type of change

  • Editorial (typo, link fix, clarification: no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

Spec section

None (unreleased prototype/gate_store.py and prototype/cmcp_gate.py).

Tests for a fix

tests/test_gate_store_and_cmcp_gate_hardening.py adds 76 test cases (10 test functions). Running python tools/review_bar.py --base origin/main --head HEAD (295c36d..b5b7433) reports:

  • 76 fail on behaviour, 0 fail on a missing name or file, 0 pass or skip on 295c36d, and 76 passed on b5b7433:
    • test_remote_and_server_support_non_utf8_binary_secret (1 case): fails on 295c36d with UnicodeDecodeError in RemoteGateStore.__init__.
    • test_cli_server_supports_binary_secret_via_b64_env (1 case): fails on 295c36d with ValueError: store secret must be at least 16 bytes because TRACE_GATE_STORE_SECRET_B64 is ignored.
    • test_remote_gate_store_rejects_short_or_non_bytes_secret (5 cases: b"", b"short", b"x" * 15, bytearray(b"a" * 16), None, plus "a" * 16): fails on 295c36d (DID NOT RAISE ValueError / AttributeError on None).
    • test_gate_store_server_rejects_non_bytes_or_short_secret (3 cases: "a" * 16, bytearray(b"a" * 16), [1] * 16, plus b"x" * 15): fails on 295c36d (DID NOT RAISE ValueError).
    • test_unb64_rejects_padded_and_noncanonical_base64url (4 cases: "AAAA====", "AA==", "AAA=", "AB"): fails on 295c36d (DID NOT RAISE ValueError).
    • test_recv_rejects_duplicate_keys_nonfinite_bom_and_deep_nesting (5 cases: b'{"ok":1,"ok":2}', b'{"ok":NaN}', b'{"ok":Infinity}', b'\xef\xbb\xbf{"ok":1}', b"[" * 200_000 + b"]" * 200_000): fails on 295c36d (DID NOT RAISE ValueError or RecursionError).
    • test_remote_gate_store_wraps_malformed_responses_in_gate_store_unavailable (26 cases across _call, mint_challenge, consume_challenge, bump_admission, admission, receipted, reserve_call, receipt_head, and receipts): fails on 295c36d (DID NOT RAISE GateStoreUnavailable, binascii.Error, TypeError, IndexError, KeyError, or ValueError).
    • test_cmcp_trace_gate_init_validates_configuration (12 cases across context, gateway_key, gateway_issuer, clock, database, and store): fails on 295c36d (DID NOT RAISE ProfileError).
    • test_cmcp_trace_gate_rejects_invalid_session_and_call_identifiers (5 cases: "", "bad id", "-leading-dash", 123, None): fails on 295c36d (DID NOT RAISE ProfileError or sqlite3.IntegrityError).
    • test_cmcp_trace_gate_rejects_non_dict_or_non_jcs_action (5 cases: ["list"], "string", 123, None, {"nan": float("nan")}): fails on 295c36d (DID NOT RAISE ProfileError).
    • test_cmcp_trace_gate_recheck_and_receipt_validate_handle_and_reason (8 cases across recheck and receipt): fails on 295c36d (AttributeError, AssertionError, DID NOT RAISE ProfileError, or pydantic_core.ValidationError).

Validation commands run on b5b7433:

  • pytest tests/test_gate_store_and_cmcp_gate_hardening.py -q -> 76 passed
  • python tools/review_bar.py --base origin/main --head HEAD -> 76 fail on behaviour, 0 fail on a missing name or file, 0 pass or skip
  • python tools/sweep_public_surface.py --strict -> 89 parameters swept, 140 accepted junk values, 0 unfiled leak(s)
  • python tools/check_dashes.py -> no dashes outside examples/, spec/trace-v0.1.md
  • ruff check src tests scripts prototype -> All checks passed!
  • mypy src/agentrust_trace -> Success: no issues found in 16 source files
  • mypy --strict prototype/gate_store.py prototype/cmcp_gate.py -> Success: no issues found in 2 source files

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated (for any normative change)
  • Breaking changes marked with <!-- CHANGED: #NNN: description --> in spec text
  • Backward compatibility statement included (for breaking changes)

@drk-ram
drk-ram requested review from a team, lywinged and rajnisht7 as code owners October 22, 2026 03:57
@drk-ram
drk-ram force-pushed the drk-ram-prototype-gate-store-cmcp-gate-hardening branch from 430224f to 4b1e421 Compare October 22, 2026 04:25
…and gate inputs (agentrust-io#606)

- Require `type(secret) is bytes` and `len(secret) >= 16` in both
  `GateStoreServer` and `RemoteGateStore`, and authenticate `_b64(secret)` on
  the wire so non-UTF-8 binary secrets do not raise `UnicodeDecodeError`.
- Enforce canonical unpadded base64url in `gate_store._unb64`, reject duplicate
  JSON keys, UTF-8 BOMs, non-finite JSON constants, and deep recursion in
  `gate_store._recv`, and wrap malformed store responses in
  `GateStoreUnavailable`.
- Validate `context`, `gateway_key`, `gateway_issuer`, `clock`, `database`,
  `session_id`, `call_id`, `reason`, `handle`, and `isinstance(action, dict)`
  in `CMCPTraceGate` with `ProfileError`.

Closes agentrust-io#606

Signed-off-by: Raghuvar Durga <177662415+drk-ram@users.noreply.github.com>
Co-authored-by: Raghuvar Durga <177662415+drk-ram@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@drk-ram
drk-ram force-pushed the drk-ram-prototype-gate-store-cmcp-gate-hardening branch from 4b1e421 to b5b7433 Compare October 22, 2026 04:36

@dinakarjs dinakarjs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed b5b7433. Linux/Python 3.12.14: the new hardening suite passes 76 cases. I then enabled the otherwise skipped gateway composition tests using cMCP source 1206ad22949c4e31d041fb82cda2ac5a3545c1f5, its hash-locked dev environment, and this TRACE checkout on PYTHONPATH: hardening + test_cmcp_trace_gate.py + test_multihost_replay.py gives 117 passed with no skips. Proxy environment variables were removed for controlled loopback tests. Targeted Ruff passes.

The malformed reply paths preserve GateStoreUnavailable and single-attempt semantics, while the real TCP/CLI controls exercise non-UTF8 secrets. No additional blocker found in the bounded change. One compatibility note: the wire secret changes from raw UTF-8 to base64url even for ASCII keys, so old and new prototype clients/servers must be upgraded together; retaining TRACE_GATE_STORE_SECRET as an environment input does not preserve mixed-version wire compatibility. Please state that in prototype deployment guidance. I did not rerun every author's guard-removal trial or establish a production replicated-store guarantee. Required maintainer approval remains separate. Reviewed with OpenAI Codex.

@lywinged lywinged 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.

REVIEW on #609, head b5b7433: Approve

Measured on b5b7433 with the hash-pinned requirements, on 3.11 and on 3.12, the steps ci.yml runs: ruff with prototype added, as the description runs it, mypy over src/agentrust_trace and the dash check clean; 4756 passed and 48 skipped here; the experiment 116 passed and 4 skipped; the portability run 4755 passed and 49 skipped. The new test file collects 76 cases in 11 functions, the description's 76; the description says 9 functions, and 26 cases for the response test, which collects 27. Against the merge base 295c36d the review bar sorts all 76 as failing on behaviour, which is the description's count. The change does what #606 asks, all three of its items, and the CHANGELOG entry under Unreleased says what the code does, short of the sentence asked for in item 1 below.

  1. The wire secret, which dinakarjs's note names. Measured with python -m prototype.gate_store and an ASCII secret: the merge base's server with this head's RemoteGateStore, and this head's server with the merge base's client, both refuse with "unauthenticated", while each side with its own peer works. So a deployment that upgrades one side first loses the store until the other follows, and "retaining TRACE_GATE_STORE_SECRET compatibility" in the description is about the environment variable, not the wire. The CHANGELOG already names the new form, "authenticate _b64(secret) across the wire". What it and the description do not say is that the old form is refused, so a server and its clients move together. Two facts for the decision. A server that accepts either form, the base64url string or the raw secret as before, passes all 76 new cases and the whole suite, 4756, so nothing in the matrix pins the incompatibility, and compatibility costs one or in the handler. And the prototype is unreleased, so the sentence dinakarjs asked for may be all it needs.

  2. Confirmed, on the whole suite on a copy, one mutation at a time, 4756 passing before any, each guard holding its own cases and nothing else. The _unb64 round-trip check removed fails the four _unb64 cases and the padded admission reply, five. The client's secret checks removed fails the five cases of test_remote_gate_store_rejects_short_or_non_bytes_secret. The duplicate-key hook removed from _recv fails the duplicate-key frame and the reply with ok twice, two. _digest put back to _str on the challenge and receipt-head replies, the description's cold-reader finding reverted, fails exactly its two regression cases. The store-interface check removed fails the object() case alone, and the bool check on allowed fails the allowed=1 case alone. canonical_digest is digest(rfc8785.dumps(value)), so hashing _action_bytes(action) gives the value it gave before. Identifier admits trace_refused, the one reason the gate writes itself, and cedar_allowed, the one its callers pass, and Receipt.reason was already typed Identifier, so the new check on reason changes the exception class and the timing, not what is admitted.

  3. Small things. The description cites the wire-protocol comment at prototype/gate_store.py:250; it is at 277 on this head and 276 on the merge base. "Editorial" is checked under Type of change for a change that makes the store refuse input it accepted; the template has no box for this kind of change, and leaving them all unchecked with a sentence under the list, as #611's description does, reads right. python -m prototype.gate_store with a non-canonical TRACE_GATE_STORE_SECRET_B64 exits with a ValueError traceback from _unb64 rather than a message, as the plain variable already does for a short value. A prototype command line, so a nit. And mypy --strict prototype/gate_store.py prototype/cmcp_gate.py does not give the description's "Success" here: with the pinned mypy 2.4.0 it follows the import into prototype/verifier_token.py and reports line 167, on the merge base as on this head, nothing in the two files. The description's run may have silenced imports, as #610's command does.

Approve. Item 1 is a sentence in the description and the CHANGELOG, or the one-line compatibility if mixed versions should keep working; nothing here waits on either.

@lywinged
lywinged merged commit f238277 into agentrust-io:main Oct 23, 2026
11 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

prototype(gate_store,cmcp_gate): support binary secrets, enforce canonical base64url, and wrap malformed store responses in GateStoreUnavailable

3 participants