Skip to content

Add a Python Guardian reference implementation - #183

Draft
dtduc-git wants to merge 4 commits into
GenAI-Security-Project:integrationfrom
dtduc-git:refimpl/python-guardian
Draft

dtduc-git wants to merge 4 commits into
GenAI-Security-Project:integrationfrom
dtduc-git:refimpl/python-guardian

Conversation

@dtduc-git

@dtduc-git dtduc-git commented Sep 22, 2026 •

Copy link
Copy Markdown

What changed

Adds a Python reference Guardian implementation under
reference-implementations/agt-python/: the ACS-Core wire contract
(JSON-RPC envelopes, handshake negotiation, the 19 hooks, wrapped MCP, the
five dispositions, the SessionContext chain with a published chain_hash,
replay and skew protection, the HMAC-SHA256 baseline signature), a policy
engine interface with a small built-in engine, and a test suite that validates
envelopes against this repository's schemas by relative path.

Which issue does this implement

Contributes to #86

  • This is an editorial correction (typo, grammar, link, formatting) with no change in
    meaning, so it needs no issue

Base branch

  • integration, because this touches the specification, schemas, a reference
    implementation, an adapter, tests, or CI
  • main, because every changed path is on the documentation lane allowlist in
    CONTRIBUTING.md

Type of change

  • Specification change (schema, hooks, events, AgBOM)
  • Reference implementation or adapter
  • Documentation
  • Tooling or CI
  • Governance (licensing, security policy, contributor docs)

I tested this

  • I synced my branch with the base branch before opening this
  • uv run pytest -v passes on my machine (149 tests, from
    reference-implementations/agt-python/)
  • uv run mkdocs build --strict passes on my machine

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

  • This change has no security impact

Coordination

I posted on #78 on 2026-09-22 asking whether that sample is still active, and
flagged the structural question (samples/ vs reference-implementations/)
to @rocklambros; no reply yet at the time of opening. This is a draft until
the workstream decides where Python lives.
It currently follows the
agt-go precedent under reference-implementations/agt-python/. It builds on
#78's FastMCP instrumentation idea rather than duplicating it — a host adapter
is not in this PR.

What it implements, and what it does not

Every ACS-Core item in docs/spec/conformance.md is implemented, so the
ServerHello accepts acs-core. Not claimed: acs-trace, acs-inspect,
acs-provenance, acs-crypto, acs-audit; agbom/* is refused with
-32003; Intent/IBAC is not implemented (Intent is optional and
IBAC-conditional, and ask_details passes through). README.md carries the
per-item status table naming the tests that cover each row.

Evidence

  • The ten black-box probes of the conformance harness's external mode are
    ported (tests/test_external_probes.py), nine of them signature-verified;
    the external mode itself is contributed in Add a Go Guardian reference implementation #169, which is not merged, so
    this port is the local gate until it lands.
  • The HKDF/JCS/HMAC vectors from Add a Go Guardian reference implementation #169 are vendored, with provenance recorded
    in tests/testdata/README.md, and pinned byte for byte.
  • Envelopes are validated against specification/v0.1.0/ by relative path, so
    a schema change fails the suite.
  • CI: .github/workflows/reference-implementation-python.yml is path-scoped
    to this tree; the existing TypeScript workflow's path filter is narrowed to
    its own tree so a Python-only change does not pay for a Bun install.

One spec inconsistency this PR works around

The examples in docs/spec/instrument/extend_mcp.md place the wrapped MCP
message directly in the ACS envelope's params — a shape
request-envelope.json rejects (params requires acs_version,
request_id, timestamp, metadata, and payload). This tree follows the
schema (the MCP message is carried intact in params.payload), as the Go port
in #169 does, and says so in its README. Worth an upstream issue if you agree.

@github-project-automation github-project-automation Bot moved this to Needs triage in ACS Project Tracker Sep 22, 2026
@acs-board-reconciler acs-board-reconciler Bot moved this from Needs triage to In progress in ACS Project Tracker Sep 23, 2026
@rocklambros rocklambros changed the title Add a Python Guardian reference implementation Add a Python Guardian reference implementation . Oct 5, 2026
@rocklambros rocklambros changed the title Add a Python Guardian reference implementation . Add a Python Guardian reference implementation Oct 5, 2026
@Mayur021

Mayur021 commented Oct 5, 2026

Copy link
Copy Markdown

Same check I ran on #169, and not a review of the Python implementation beyond it. Each of the reference-Guardian bugs in the #160 to #167 range has a named mechanism here, and the dispatch ladder at the top of server.py makes them easy to find: -32600 for JSON-RPC envelope shape, with JCS canonicalizability answered at the same code (#163); -32003 at the session lookup when no handshake negotiated the session (#164); the negotiated-method check, under the session lock and after the replay test, also -32003 (#160); a ClientHello sharing no transport refused as SESSION_REFUSED rather than negotiated down to one the client never offered (#165); the session bindings comparing agent_id and answering a signed DENY with agent_id_not_bound (#166); and steps/sessionStart carrying its own ALLOW/DENY pair in the method table (#167). Per-session seen_request_ids then gives replay detection at -32005, which is #186, and recording ids on arrival so that a replay of a refused request still counts is the right call.

The broader thing is the relationship with #169. This tree does not just borrow its HKDF table: the README says its documented choices for the wire details ACS leaves undefined "are the ones this tree follows, so the two are intended to interoperate", and that shows up in the canonicalization locations, the wrapped MCP checks, the routing order and the error rules as well as in crypto.py, with tests/test_crypto_vectors.py pinning the agreement against that implementation's vectors. So two independent ports have converged on the hash, the salt, the info input and the output length, and separately landed on the same agent_id_not_bound string. That is most of what #118 asks for, settled in practice ahead of the text.

The fragile part is that the dependency only points one way. This tree records it carefully, in the README and in five module docstrings. The Go tree records nothing in return, so if its table or its routing order moves before it merges, nothing on that side signals that another implementation is pinned to it, and these vectors are where it would surface. Naming both in #118 would make the convergence something the spec owns rather than something two open PRs maintain by hand.

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

@dtduc-git, I know this is still a draft while placement gets decided, but I ran it against current integration (185ef4e) and found a few things you'll want to fix before you take it out of draft. The first two are security bugs, and I reproduced both over HTTP.

  1. An unsigned system/ping gets back a Guardian-signed ALLOW. In server.py:506-510 the ping answer is signed whenever metadata.session_id is present, because key comes from the session id alone (server.py:143-144) and the ping's own signature is never checked. So anyone who can reach /acs can send an unsigned ping with a victim's session_id, id and request_id, and get back a signed decision: "allow" that verifies under that session's key. Someone on the network path can then swap it for a real signed DENY. The comment there says "if one authenticated", so I think the intent was right and the check just got lost. Please sign the answer only when the ping's own signature verified (self._signature_ok(raw, params, key)), and add tests showing that an unsigned ping and a bad-signature ping both get answers with no result.signature. The Go port in PR #169 already works this way.

  2. Deep nesting turns a deny into an error, and the error lets the tool run. I sent a signed toolCallRequest for rm -rf / with one argument nested N levels deep. At 450 levels I got a signed deny. At 500 levels (about 4 KB) I got an unsigned -32603, and at 985 the connection dropped with no answer. The ServerHello said on_decision_failure: proceed, so the agent runs the command. The cause is copy.deepcopy in canonical.py:34, and server.py:124-125 only catches ValueError. Please use a shallow copy in _without_signature, catch RecursionError in handle(), and once the signature has verified in _step, turn any exception into a signed deny with evaluation_failed, the same way engine failures already work. A regression test at 600 levels would pin it. The existing test only tries 10,000 levels, which the parser rejects first.

  3. Version negotiation accepts any 0.x. handshake.py:99-103 returns 0.1.0 if the client offers anything with major 0, even 0.9.0 alone, and a step with acs_version: "7.3.0" in an open session was allowed. §4 says a mismatch ends with -32001. Please pick only a version the client actually offered, and refuse a step whose acs_version differs from the session's negotiated version.

  4. The acs-core claim is from before PR #21. The branch is 13 commits behind, and PR #21 changed the floor in docs/spec/conformance.md. MODIFY is now SHOULD, §6.5 needs a modify_unsupported substitution (ALLOW at postCompact), and §9.2 needs the substitution recorded as an audit event. There's no modify_unsupported anywhere in src/, and _substitute_permitted only prints to stderr. Please rebase, update the README table to the current bullets, and either add the §6.5 path or drop acs-core from SUPPORTED_PROFILES until it's there.

  5. The narrowed path filter switches off the Rust CI in PR #187. This PR changes .github/workflows/reference-implementation.yml from reference-implementations/** to reference-implementations/agt/**. PR #187 adds its agt-rust job to that same file, and agt/** doesn't match agt-rust/. Git won't flag it, because the hunks don't overlap. I merged both onto integration and confirmed that a Rust-only change would trigger nothing. Keep "reference-implementations/**" and add "!reference-implementations/agt-python/**" right after it in both paths lists. That still keeps Python-only changes off the Bun install.

  6. Two claims need fixing. First, the PR says Closes #86, but issue #86 is "Port the AGT reference implementation to Python", and this tree has no AGT. The engine is BuiltinEngine in engine.py. Please change it to Contributes to #86 so the roadmap doesn't mark the AGT port as delivered. The agt-python name has the same problem, so worth settling when placement is decided. Second, the README says this tree follows the Go port's chain choices, but chain.py:91 uses uuid.uuid4() for entry_id where Go uses request_id, and the entry has no turn_id. When I fed the same steps to both Guardians, I got different chain heads. Either align with Go or say where they differ.

These aren't blockers:

  • json.loads at server.py:759 takes the last value of a duplicate key, but Go refuses the request. An object_pairs_hook that raises fixes that.
  • schemas.py:114 builds validators with no format_checker, so uuid and date-time are never checked.
  • The 10-second timeout at server.py:697-700 limits idle time per read, not the total request. I held 200 slow connections open for 30s with no auth.
  • The Python workflow doesn't pin uv or Python. CI ran 3.12.3, and requires-python says 3.11.

The vendored vectors in tests/testdata/envelope-vectors.json are still byte-identical to the ones in PR #169, so that part is fine.

An independent implementation of the ACS v0.1.0 wire contract under
reference-implementations/agt-python/: JSON-RPC envelopes, handshake
negotiation, the hook taxonomy, the five dispositions, the SessionContext
chain with a published chain_hash, replay and skew protection, and the
HMAC-SHA256 baseline signature.

Wrapped MCP is not implemented yet, so the ServerHello answers
profiles_accepted: [] rather than claiming acs-core; the README's status
table says exactly which parts are implemented.

The suite validates envelopes against this repository's schemas by relative
path, pins the cross-implementation signature vectors from the open Go port
(GenAI-Security-Project#169), ports the conformance harness's ten external probes, and pins every
hook's schema to its method name. A path-scoped workflow runs it in CI.

Signed-off-by: Duke - Duc Dinh <dtduc.contact@gmail.com>
protocols/MCP/* and the explicit-version wrapped:mcp-<version>/* form now
route to an MCP handler that reads the wrapped JSON-RPC 2.0 message (a
request or the response it answers), checks it, and hands it to the policy
engine intact; a malformed message is INVALID_PARAMS. A wrapped method is
negotiated only when the ClientHello declares the protocol in
wrapped_protocols, and the explicit-version form only at a declared version.

The method table now carries hooks.md's per-hook permitted dispositions: a
disposition a hook cannot carry is substituted (DENY where the hook permits
it, ALLOW with a reason at audit-only hooks and postCompact), including on
every Guardian-authored denial, so no DENY is ever sent at a hook that
permits none. Decisions are validated against modifications.json,
ask-details.json, and defer-details.json, plus §6.3's disjoint-target rule
with the wrapped call's own parameter-override pointer.

steps/sessionEnd closes the session (a later step is answered with a
session_closed denial, replay history survives), a key_id or agent_id other
than the session's is answered with a signed denial rather than an error, and
an undefined method is METHOD_NOT_FOUND while agbom/* stays
CAPABILITY_NOT_NEGOTIATED. The handshake now enforces the timestamp skew like
any request, responses are signed under the deployment's key_id throughout,
the transport refuses a missing or malformed Content-Length and closes
cleanly, and the TypeScript workflow's path filter is narrowed to its own
tree so a Python-only change does not pay for a Bun install.

Signed-off-by: Duke - Duc Dinh <dtduc.contact@gmail.com>
One recv() is not the whole response: http.server flushes headers and body
as two writes, and Linux split them across segments, so the tests failed on
CI while passing on macOS. The loop ending is itself the proof the server
closed the connection.

Signed-off-by: Duke - Duc Dinh <dtduc.contact@gmail.com>
…en, align key_id and the chain with the Go port

- ping: an answer is signed only when the ping itself verified; a bad
  signature or an unknown key_id never resolves a key
- over-deep bodies fail closed at every depth the JSON parser accepts:
  the dispatch ladder runs with signed refusals and denials, verification
  and hashing run on one serialized enlarged thread, and raw text past the
  parse boundary is refused before it is parsed
- key_id comes from configuration with no default (GenAI-Security-Project#118); a step naming
  another label is refused -32004, unsigned
- section 6.5: a MODIFY the Observed Agent cannot apply is substituted
  (DENY modify_unsupported; ALLOW at postCompact) and recorded with the
  intended modification in the audit event
- the chain follows the Go port's choices: entry_id = request_id,
  metadata.turn_id carried, write-clock timestamp; README interop updated
- a step's acs_version must share the session's negotiated major version
  (-32001); duplicate JSON members are a parse error; the TypeScript
  workflow's path filter keeps future agt-rust changes covered; CI pins
  uv 0.9.9 and Python 3.12

153 tests pass.

Signed-off-by: Duke - Duc Dinh <dtduc.contact@gmail.com>
@dtduc-git
dtduc-git force-pushed the refimpl/python-guardian branch from a606405 to 5e7f4bb Compare October 14, 2026 16:42
@dtduc-git

Copy link
Copy Markdown
Author

Thanks Rock — all addressed except the handler timeout you flagged as a non-blocker (see the end), rebased onto integration (153 tests green). Point by point:

  1. Ping: _ping now signs only when the ping itself verified (self._signature_ok(raw, params, key)); an unsigned, bad-signature, or unknown-key_id ping is answered unsigned. Tests assert no result.signature in all three cases, and a signed ping's answer still verifies.

  2. Deep nesting: fixed end-to-end, not just at the repro. The deepcopy is gone (shallow copy in canonical.py). A verified step deeper than 128 levels is never policy-evaluated: it clears the same ladder (replay, session bindings, negotiated method) and then gets evaluation_failed fail-closed — DENY, or ALLOW where the hook permits no DENY — with its chain entry; deep pings are answered, and refusals keep their own codes. Verification and hashing for those bodies run on a single serialized thread with an enlarged stack and recursion limit, and raw text past 10,000 structural levels is refused (-32700) at the byte level before the parser runs. Regression tests now sit at your 500 and 985 depths (500 broke the old code, 985 dropped the connection), plus 600, 3,000 and 9,000. On a verified step, a non-registry exception is a signed DENY carrying evaluation_failed, the way engine failures already work.

  3. One pushback on the handshake version — spec first: §3's forward-compat row specifies "Accept X.Y.Z matching major version", and that is what this tree implements; the Go port reads it the same way (handshake.SameMajor). Literal §3 does accept 0.9.0 on a 0.1.0 session — if the intent is SemVer's 0.x instability rule, that's a spec clarification I would rather open separately than encode in one implementation. What you were right about is selection: the ServerHello now never names a version the client did not offer (ours when offered, otherwise the highest offered sharing the major). And the real gap you pointed at — no step-level check — is closed: a step whose acs_version does not share the session's major is -32001 with the session's version in data.supported_versions.

  4. acs-core: rebased onto the PR Set the ACS-Core floor: MODIFY and system/ping to SHOULD, Wrapped MCP and subagentStart as conditional MUSTs #21 floor; the §6.5 path is implemented (DENY modify_unsupported, ALLOW at postCompact, audit event naming the intended modification); the README table is updated to the current bullets.

  5. Workflow: exactly your fix — kept reference-implementations/** and put !reference-implementations/agt-python/** right after it in both paths lists.

  6. Closes → Contributes (PR body updated).

  7. Chain: aligned with the Go port on the choices this tree implements — entry_id = request_id, metadata.turn_id carried, and the timestamp is the write clock (context-entry.json's own wording). For the steps both trees implement, entries now differ only in timestamp — and therefore entry_hash — because write times differ by definition; the intent-extension entry the Go port writes for approver grants is outside this tree's scope (Intent is not claimed), and the README interop section says so now.

Non-blockers: duplicate members are now a parse error (-32700), and uv and Python are pinned in the Python workflow. The handler timeout is unchanged (a per-read idle bound, not a total request budget); happy to open a follow-up issue for a total deadline if you want it tracked.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

4 participants