From a16146d50b187505b160b7d5ddf03cc94e132a05 Mon Sep 17 00:00:00 2001 From: Duke - Duc Dinh Date: Tue, 22 Sep 2026 21:33:55 +0700 Subject: [PATCH 1/4] Add a Python reference Guardian implementation 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 (#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 --- .../reference-implementation-python.yml | 54 ++ .../agt-python/.gitignore | 5 + .../agt-python/README.md | 125 ++++ .../agt-python/pyproject.toml | 33 + .../agt-python/src/acs_guardian/__init__.py | 11 + .../agt-python/src/acs_guardian/canonical.py | 54 ++ .../agt-python/src/acs_guardian/chain.py | 121 ++++ .../agt-python/src/acs_guardian/crypto.py | 56 ++ .../agt-python/src/acs_guardian/engine.py | 112 ++++ .../agt-python/src/acs_guardian/errors.py | 65 ++ .../agt-python/src/acs_guardian/handshake.py | 146 +++++ .../agt-python/src/acs_guardian/main.py | 76 +++ .../agt-python/src/acs_guardian/schemas.py | 165 +++++ .../agt-python/src/acs_guardian/server.py | 583 ++++++++++++++++++ .../agt-python/src/acs_guardian/session.py | 73 +++ .../agt-python/tests/conftest.py | 27 + .../agt-python/tests/helpers.py | 132 ++++ .../agt-python/tests/test_chain.py | 49 ++ .../agt-python/tests/test_crypto_vectors.py | 60 ++ .../agt-python/tests/test_external_probes.py | 92 +++ .../agt-python/tests/test_handshake.py | 105 ++++ .../agt-python/tests/test_server.py | 393 ++++++++++++ .../agt-python/tests/testdata/README.md | 15 + .../tests/testdata/envelope-vectors.json | 62 ++ reference-implementations/agt-python/uv.lock | 280 +++++++++ 25 files changed, 2894 insertions(+) create mode 100644 .github/workflows/reference-implementation-python.yml create mode 100644 reference-implementations/agt-python/.gitignore create mode 100644 reference-implementations/agt-python/README.md create mode 100644 reference-implementations/agt-python/pyproject.toml create mode 100644 reference-implementations/agt-python/src/acs_guardian/__init__.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/canonical.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/chain.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/crypto.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/engine.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/errors.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/handshake.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/main.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/schemas.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/server.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/session.py create mode 100644 reference-implementations/agt-python/tests/conftest.py create mode 100644 reference-implementations/agt-python/tests/helpers.py create mode 100644 reference-implementations/agt-python/tests/test_chain.py create mode 100644 reference-implementations/agt-python/tests/test_crypto_vectors.py create mode 100644 reference-implementations/agt-python/tests/test_external_probes.py create mode 100644 reference-implementations/agt-python/tests/test_handshake.py create mode 100644 reference-implementations/agt-python/tests/test_server.py create mode 100644 reference-implementations/agt-python/tests/testdata/README.md create mode 100644 reference-implementations/agt-python/tests/testdata/envelope-vectors.json create mode 100644 reference-implementations/agt-python/uv.lock diff --git a/.github/workflows/reference-implementation-python.yml b/.github/workflows/reference-implementation-python.yml new file mode 100644 index 00000000..285d916d --- /dev/null +++ b/.github/workflows/reference-implementation-python.yml @@ -0,0 +1,54 @@ +# Runs the Python reference implementation's own tests. +# +# The suite in reference-implementations/agt-python/ validates envelopes against +# the repository's schemas by relative path, pins the cross-implementation +# signature vectors, and replays the conformance harness's external probes, so a +# schema, crypto, or wire drift fails here rather than at the conformance run. +# +# Scoped by path: a docs-only change should not pay for a Python environment. +name: Reference implementation (Python) + +on: + pull_request: + paths: + - "reference-implementations/agt-python/**" + - "specification/**" + - ".github/workflows/reference-implementation-python.yml" + push: + branches: ["main", "integration"] + paths: + - "reference-implementations/agt-python/**" + - "specification/**" + - ".github/workflows/reference-implementation-python.yml" + workflow_dispatch: + +permissions: {} + +concurrency: + group: reference-implementation-python-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + agt-python: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: reference-implementations/agt-python + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes with git, so the checkout's token has no reason + # to stay behind in .git/config. + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Install dependencies from the lockfile + run: uv sync --locked + + - name: Run the tests + run: uv run pytest -v diff --git a/reference-implementations/agt-python/.gitignore b/reference-implementations/agt-python/.gitignore new file mode 100644 index 00000000..38b08005 --- /dev/null +++ b/reference-implementations/agt-python/.gitignore @@ -0,0 +1,5 @@ +# Python environment and build artifacts from `uv sync` / `uv run pytest`. +.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ diff --git a/reference-implementations/agt-python/README.md b/reference-implementations/agt-python/README.md new file mode 100644 index 00000000..0047ad2d --- /dev/null +++ b/reference-implementations/agt-python/README.md @@ -0,0 +1,125 @@ +# ACS Python reference Guardian + +An independent Python implementation of the **OWASP Agent Control Standard +(ACS) v0.1.0** wire contract: JSON-RPC 2.0 envelopes, the `handshake/hello` +capability negotiation, the `steps/*` hook taxonomy, the five dispositions, the +SessionContext audit chain with a published `chain_hash`, replay and skew +protection, and the HMAC-SHA256 baseline signature. + +It is written against `docs/spec/` and `specification/v0.1.0/`, not against +another implementation. The TypeScript tree in `../agt/` is the original +reference; it is narrower (two gates, no signing yet — see issue #70) and its +audit chain is a projection with its own hash rule, so this tree and that one +do not produce the same `entry_hash`. A full Go port is contributed in the open +PR #169 (`reference-implementations/agt-go/`, not merged at the time of +writing); its documented choices for the wire details ACS leaves undefined are +the ones this tree follows, so the two are intended to interoperate. + +## Status + +| ACS-Core item (docs/spec/conformance.md) | Status | Where | +| --- | --- | --- | +| Handshake, version/transport/method negotiation, refusals (`-32001`, `-32002`, `-32000`) | Met | `handshake.py`, `tests/test_handshake.py` | +| Request/response envelopes, JSON-RPC error taxonomy | Met | `schemas.py`, `server.py`, `tests/test_server.py` | +| Hook taxonomy: all 19 `steps/*` hooks dispatched through one generic path, each schema pinned to its method | Met | `schemas.py` (`_HOOK_SCHEMAS`), `tests/test_server.py` | +| Dispositions: all five, with required fields, schema-validated | Met | `engine.py`, `server.py`, `tests/test_server.py` | +| SessionContext: append-only chain, §8.2 hash rule, published `chain_hash` | Met | `chain.py`, `tests/test_chain.py` | +| Replay protection: duplicate `request_id`/`nonce`, timestamp skew (`-32005`, `-32006`) | Met | `server.py`, `tests/test_server.py` | +| Baseline integrity: HKDF-SHA256 session key, HMAC-SHA256 signature | Met | `crypto.py`, `tests/test_crypto_vectors.py` | +| Liveness, `system/ping` | Met | `server.py`, `tests/test_server.py` | +| **Wrapped MCP, `protocols/MCP/*`** | **Not implemented** | not negotiated until it is; see below | +| Decision honoring (§6.4) | Observed Agent responsibility | the host adapter's, not this tree's | +| Trace pillar (OTel/OCSF), AgBOM (`agbom/*`) | Not claimed | v1 roadmap items | + +**The ACS-Core claim is deliberately partial**: `protocols/MCP/*` is a +mandatory ACS-Core item and this tree does not implement it yet, so the +ServerHello answers `profiles_accepted: []` — the honest wire signal, since a +client reads that field, not this table. The work is tracked as the next +increment; until then this tree does not claim `acs-core`, `acs-trace`, +`acs-inspect`, `acs-provenance`, `acs-crypto`, or `acs-audit`, and the rows +above say exactly which parts are implemented. + +ACS v0.1.0 has no certification mechanism: this tree self-declares what it +implements and each claimed row names the tests that cover it. + +## Run it + +```bash +cd reference-implementations/agt-python +uv sync +printf 'change-me-32-bytes-minimum-secret' > /tmp/acs-secret +uv run acs-guardian serve --secret-file /tmp/acs-secret --port 8787 +``` + +Then POST an envelope to `http://127.0.0.1:8787/acs`. Configuration comes from +flags or the environment: `ACS_GUARDIAN_HMAC_SECRET_FILE`, +`ACS_GUARDIAN_KEY_ID`, `ACS_GUARDIAN_HOST`, `ACS_GUARDIAN_PORT`, +`ACS_ON_DECISION_FAILURE`, `ACS_SPEC_ROOT`. + +The schemas are read from `specification/v0.1.0/` by relative path, so the +console script works from a source checkout. An out-of-tree install must set +`ACS_SPEC_ROOT`; the Guardian refuses to start when the schemas are missing +rather than failing per request. + +## Test it + +```bash +uv run pytest -v +``` + +`tests/test_external_probes.py` ports the ten black-box probes of the +conformance harness's external mode — envelope refusal, pre-session refusal, +transport refusal, handshake, an allowed call, three replay checks, invalid +params, and an unnegotiated method. Nine of the ten are signature-verified; +the first sends a bare envelope with no session, which the harness does not +sign either. The external mode itself is contributed in the open PR #169 (not +merged at the time of writing); until it lands, this test is the local gate. +Once it lands, the official mode runs against this Guardian from +`reference-implementations/agt`: + +```bash +ACS_CONFORMANCE_GUARDIAN_URL=http://127.0.0.1:8787/acs \ +ACS_CONFORMANCE_HMAC_SECRET_FILE=/tmp/acs-secret \ +bun run conformance +``` + +## Interoperability choices + +ACS v0.1.0 leaves several wire details undefined; two implementations must +make the same choice to interoperate. This tree follows the choices the Go port +documents in its "Behavior that ACS v0.1.0 leaves undefined" table: + +- HKDF-SHA256, no salt, UTF-8 `session_id` as the `info` input, 32-byte key. +- An error response to an authenticated request carries its signature at + `error.signature`; a `ServerHello` at `result.signature`. +- `request_hash` is always written, over the JCS canonicalization of the + `params` object **as received** — the signature is part of `params` by the + time the Guardian sees it, so an external verifier must keep the signed + envelope, not the pre-signature one, to recompute the hash. +- `entry_hash` is `SHA-256(JCS(entry minus entry_hash/previous_hash) || + prev_hash_bytes)`; optional members are omitted from the hashed content when + absent, never written as `null`. +- A ContextEntry's `timestamp` is the originating request's `timestamp`, not + the Guardian's wall clock at write time: two Guardians cannot agree on each + other's write time, and the field is inside the hashed content, so a + deterministic input is what makes cross-implementation `entry_hash` + comparison possible at all. +- A refusal that is not a version or provenance refusal during the handshake is + `SESSION_REFUSED` (`-32000`): nothing has been exercised, so + `CAPABILITY_NOT_NEGOTIATED` (`-32003`) does not apply until hook traffic + starts. + +## Non-goals + +- **Not a policy engine.** Policy content is a deployment's; the built-in + engine demonstrates the §12.1 interface and denies a short list of + unambiguous destructive commands. Nothing more. +- **Not a conformance suite or registry.** The community's suite owns the + vectors; this tree is an implementation under test. +- **No session eviction.** The default store is in-memory and keeps sessions + for the process lifetime, so replay history survives `steps/sessionEnd` (a + step after sessionEnd is still a replay). A long-lived deployment passes its + own `SessionStore` (with TTL or capacity limits) as `GuardianConfig.session_store`; + that is the seam. +- **No host adapters.** Honoring decisions is the Observed Agent's job; the + TypeScript tree ships the Claude Code and OpenCode adapters. diff --git a/reference-implementations/agt-python/pyproject.toml b/reference-implementations/agt-python/pyproject.toml new file mode 100644 index 00000000..92001b03 --- /dev/null +++ b/reference-implementations/agt-python/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "acs-guardian" +version = "0.1.0" +description = "Python reference Guardian implementation for the OWASP Agent Control Standard (ACS) v0.1.0" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +dependencies = [ + "jsonschema>=4.25.0", + # Used directly to build the $id-resolving schema registry; it is also a + # jsonschema dependency, but an undeclared direct import breaks when that + # dependency graph changes. + "referencing>=0.35.0", + "rfc8785>=0.1.4", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[project.scripts] +acs-guardian = "acs_guardian.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/acs_guardian"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/reference-implementations/agt-python/src/acs_guardian/__init__.py b/reference-implementations/agt-python/src/acs_guardian/__init__.py new file mode 100644 index 00000000..460b9871 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/__init__.py @@ -0,0 +1,11 @@ +"""Python reference Guardian implementation for OWASP ACS v0.1.0. + +An independent implementation of the ACS wire contract +(docs/spec/conformance.md): JSON-RPC 2.0 envelopes, the capability +negotiation handshake, the hook taxonomy, the five dispositions, the +SessionContext audit chain, replay protection, and the HMAC-SHA256 baseline +signature. Wrapped MCP is not implemented yet; see README.md for the exact +status of the ACS-Core claim. +""" + +__version__ = "0.1.0" diff --git a/reference-implementations/agt-python/src/acs_guardian/canonical.py b/reference-implementations/agt-python/src/acs_guardian/canonical.py new file mode 100644 index 00000000..365c762a --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/canonical.py @@ -0,0 +1,54 @@ +"""The ACS §10 signing input: JCS canonicalization with the signature removed. + +The signed input is the RFC 8785 (JCS) canonicalization of the request or +response envelope with the ``signature`` field removed, encoded as UTF-8 +(Specification §10). A request carries its signature at ``params.signature``; +a response carries it at ``result.signature`` or ``error.signature`` (the +location the Go reference implementation uses for the two shapes +``response-envelope.json`` defines no signature field for: a ``ServerHello`` +result and an error). + +Canonicalization is delegated to the ``rfc8785`` package rather than +hand-rolled: JCS number formatting follows ECMAScript ``Number::toString`` +(``1.0`` canonicalizes to ``1``, not ``1.0``), and an implementation that +gets that wrong produces signatures only it can verify. +""" + +from __future__ import annotations + +import copy +from typing import Any + +import rfc8785 + +REQUEST_SIGNATURE_HOLDER = "params" +RESPONSE_SIGNATURE_HOLDERS = ("result", "error") + + +def canonical_bytes(value: Any) -> bytes: + """RFC 8785 (JCS) canonicalization, UTF-8 encoded.""" + return rfc8785.dumps(value) + + +def _without_signature(envelope: dict[str, Any], holder: str) -> dict[str, Any]: + unsigned = copy.deepcopy(envelope) + container = unsigned.get(holder) + if isinstance(container, dict): + container.pop("signature", None) + return unsigned + + +def request_signing_input(envelope: dict[str, Any]) -> bytes: + """Canonical input for a request envelope: the envelope minus ``params.signature``.""" + return canonical_bytes(_without_signature(envelope, REQUEST_SIGNATURE_HOLDER)) + + +def response_signing_input(envelope: dict[str, Any]) -> bytes: + """Canonical input for a response envelope: the envelope minus its signature holder. + + A response carries exactly one of ``result`` or ``error`` + (``response-envelope.json``'s ``oneOf``), so the holder is whichever is + present. + """ + holder = "result" if "result" in envelope else "error" + return canonical_bytes(_without_signature(envelope, holder)) diff --git a/reference-implementations/agt-python/src/acs_guardian/chain.py b/reference-implementations/agt-python/src/acs_guardian/chain.py new file mode 100644 index 00000000..679792e0 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/chain.py @@ -0,0 +1,121 @@ +"""SessionContext audit chain (Specification §8.1, §8.2). + +``entry_hash = lowercase-hex(SHA-256(content_bytes || prev_hash_bytes))`` where +``content_bytes`` is the UTF-8 encoding of the JCS canonicalization of the +entry with ``entry_hash`` and ``previous_hash`` REMOVED, and +``prev_hash_bytes`` is the raw 32-byte decoding of ``previous_hash`` or the +empty byte string for the first entry. + +The chain is cross-implementation verifiable: any conformant Guardian must +produce the same ``entry_hash`` for the same entry content, or chain-mismatch +detection between implementations breaks. That is why this module hashes the +canonical form rather than the stored dict, and why a test pins the vector. +""" + +from __future__ import annotations + +import hashlib +import uuid +from dataclasses import dataclass, field +from typing import Any + +from .canonical import canonical_bytes + +ZERO_HASH_LENGTH = 64 + + +@dataclass +class ChainEntry: + """One append-only ContextEntry, stored with its hash.""" + + entry_id: str + step_id: str + step_type: str + request_hash: str | None + timestamp: str | None + previous_hash: str | None + entry_hash: str + + def to_dict(self) -> dict[str, Any]: + entry: dict[str, Any] = { + "entry_id": self.entry_id, + "step_id": self.step_id, + "step_type": self.step_type, + "entry_hash": self.entry_hash, + } + if self.request_hash is not None: + entry["request_hash"] = self.request_hash + if self.timestamp is not None: + entry["timestamp"] = self.timestamp + if self.previous_hash is not None: + entry["previous_hash"] = self.previous_hash + return entry + + +def request_hash(params: dict[str, Any]) -> str: + """Lowercase-hex SHA-256 of the JCS-canonicalized request ``params`` object (§8.1).""" + return hashlib.sha256(canonical_bytes(params)).hexdigest() + + +def compute_entry_hash(entry: dict[str, Any], previous_hash: str | None) -> str: + """The §8.2 rule, over the entry as stored (minus the two hash fields).""" + content = {key: value for key, value in entry.items() if key not in ("entry_hash", "previous_hash")} + prev_bytes = bytes.fromhex(previous_hash) if previous_hash else b"" + return hashlib.sha256(canonical_bytes(content) + prev_bytes).hexdigest() + + +@dataclass +class AuditChain: + """The append-only ContextEntry chain for one session.""" + + entries: list[ChainEntry] = field(default_factory=list) + + @property + def head(self) -> str | None: + return self.entries[-1].entry_hash if self.entries else None + + def append( + self, + *, + step_id: str, + step_type: str, + params: dict[str, Any] | None = None, + timestamp: str | None = None, + ) -> ChainEntry: + previous = self.head + # Optional members are omitted from the hashed content when absent, + # never written as null: the spec's rule is "included in the entry's + # content when present", and a null placeholder would make two + # implementations hash different bytes for the same entry. + entry: dict[str, Any] = { + "entry_id": str(uuid.uuid4()), + "step_id": step_id, + "step_type": step_type, + } + if params is not None: + entry["request_hash"] = request_hash(params) + if timestamp is not None: + entry["timestamp"] = timestamp + entry_hash = compute_entry_hash(entry, previous) + appended = ChainEntry( + entry_id=entry["entry_id"], + step_id=entry["step_id"], + step_type=entry["step_type"], + request_hash=entry.get("request_hash"), + timestamp=entry.get("timestamp"), + previous_hash=previous, + entry_hash=entry_hash, + ) + self.entries.append(appended) + return appended + + +def verify_chain(entries: list[dict[str, Any]]) -> bool: + """Recompute a serialized chain's hashes; used by tests and external auditors.""" + previous: str | None = None + for entry in entries: + expected = compute_entry_hash(entry, previous) + if entry.get("entry_hash") != expected: + return False + previous = expected + return True diff --git a/reference-implementations/agt-python/src/acs_guardian/crypto.py b/reference-implementations/agt-python/src/acs_guardian/crypto.py new file mode 100644 index 00000000..3fd7c943 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/crypto.py @@ -0,0 +1,56 @@ +"""ACS §10 baseline cryptography: HKDF-SHA256 session keys, HMAC-SHA256 signatures. + +ACS v0.1.0 requires the result but leaves part of the algorithm undefined +(§10 says "HKDF-derived" without fixing the hash, salt, ``info`` input, or +output length). Two implementations must make the same choice to +interoperate, so this module makes the same choices the Go reference +implementation documents in its "Behavior that ACS v0.1.0 leaves undefined" +table (open PR #169, not merged at the time of writing): HKDF-SHA256, no +salt, the UTF-8 bytes of ``session_id`` as the ``info`` input, and a 32-byte +key. ``tests/test_crypto_vectors.py`` pins the agreement against that +implementation's vectors. + +Everything here is stdlib: HKDF over HMAC is a few lines, and pulling a crypto +dependency for it would widen the supply chain for no correctness gain. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac + +ALGORITHM = "HMAC-SHA256" +SESSION_KEY_BYTES = 32 + + +def derive_session_key(ikm: bytes, session_id: str) -> bytes: + """HKDF-SHA256(ikm, salt=HashLen zeros, info=session_id, L=32). + + An absent salt is a HashLen string of zeros per RFC 5869 §2.2; the + ``cryptography``-style "no salt" reading is the same bytes, which is why + this matches both the Node harness's empty-buffer call and the Go + implementation's vectors. + """ + salt = b"\x00" * hashlib.sha256().digest_size + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + # T(1) is the whole OKM when L <= HashLen (32 bytes here). + return hmac.new(prk, session_id.encode("utf-8") + b"\x01", hashlib.sha256).digest()[:SESSION_KEY_BYTES] + + +def sign(key: bytes, signing_input: bytes) -> str: + """Base64 HMAC-SHA256 over the §10 canonical input.""" + return base64.b64encode(hmac.new(key, signing_input, hashlib.sha256).digest()).decode("ascii") + + +def verify(key: bytes, signing_input: bytes, signature_b64: object) -> bool: + """Constant-time verification. A malformed signature is a failed one, never a raise.""" + if not isinstance(signature_b64, str): + return False + try: + received = base64.b64decode(signature_b64, validate=True) + except (binascii.Error, ValueError): + return False + expected = hmac.new(key, signing_input, hashlib.sha256).digest() + return hmac.compare_digest(received, expected) diff --git a/reference-implementations/agt-python/src/acs_guardian/engine.py b/reference-implementations/agt-python/src/acs_guardian/engine.py new file mode 100644 index 00000000..92d7889c --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/engine.py @@ -0,0 +1,112 @@ +"""The policy engine interface (Specification §12.1). + +ACS defines the interface to the deterministic-layer engine, not the engine: +input is the request plus session context, output is a decision envelope plus +an optional ``delegate_to: agent``. This module defines that interface and +ships one small built-in engine so the Guardian does something observable +without a deployment-supplied policy. + +The built-in engine is deliberately narrow: allow by default, deny a short +list of destructive shell patterns in ``steps/toolCallRequest``. It exists to +demonstrate the interface, not to be anyone's production policy -- policy +content is explicitly out of scope for the standard (CONTRIBUTING.md), and a +deployment replaces this engine with its own. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + +ALLOW = "allow" +DENY = "deny" +MODIFY = "modify" +ASK = "ask" +DEFER = "defer" + +DISPOSITIONS = (ALLOW, DENY, MODIFY, ASK, DEFER) + + +@dataclass +class EvaluationRequest: + """What the Guardian hands the engine: the method, payload, and session facts.""" + + method: str + payload: dict[str, Any] + session_id: str + agent_id: str + request_id: str + + +@dataclass +class Evaluation: + """A decision envelope's policy-authored fields (response-envelope.json AcsResult).""" + + decision: str + reasoning: str | None = None + reason_codes: list[str] = field(default_factory=list) + policy_references: list[dict[str, Any]] = field(default_factory=list) + modifications: dict[str, Any] | None = None + ask_details: dict[str, Any] | None = None + defer_details: dict[str, Any] | None = None + + +class PolicyEngine(Protocol): + """The §12.1 seam. Implementations must be side-effect free per evaluation.""" + + def evaluate(self, request: EvaluationRequest) -> Evaluation: ... + + +# The AGT stock destructive-pattern rule's shape, kept small on purpose: these +# are the patterns a reference demo can defend as unambiguous, not a policy. +_DESTRUCTIVE_PATTERNS = ( + "rm -rf /", + "rm -fr /", + "mkfs", + "dd if=", + ":(){:|:&};:", + "shutdown", + "reboot", +) + + +def _flatten_strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + found: list[str] = [] + for item in value.values(): + found.extend(_flatten_strings(item)) + return found + if isinstance(value, list): + found = [] + for item in value: + found.extend(_flatten_strings(item)) + return found + return [] + + +class BuiltinEngine: + """Allow by default; deny destructive shell commands on tool calls.""" + + def evaluate(self, request: EvaluationRequest) -> Evaluation: + if request.method != "steps/toolCallRequest": + return Evaluation(decision=ALLOW) + candidates = [request.payload.get("raw_command", "")] + candidates.extend(_flatten_strings(request.payload.get("arguments", {}))) + for text in candidates: + if not isinstance(text, str): + continue + lowered = text.lower() + for pattern in _DESTRUCTIVE_PATTERNS: + if pattern in lowered: + return Evaluation( + decision=DENY, + reasoning=( + f"tool call matches the destructive-command pattern {pattern!r}; " + "denied by the built-in engine" + ), + reason_codes=["destructive_shell_command_blocked"], + policy_references=[{"policy_id": "builtin", "rule_id": "destructive_command"}], + ) + return Evaluation(decision=ALLOW) diff --git a/reference-implementations/agt-python/src/acs_guardian/errors.py b/reference-implementations/agt-python/src/acs_guardian/errors.py new file mode 100644 index 00000000..3130c1d4 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/errors.py @@ -0,0 +1,65 @@ +"""The JSON-RPC and ACS error registry (Specification §17, §17.1). + +Every mandated refusal maps to a fixed code so an SDK can branch on the code +without parsing prose. The constants below are the full registry — a reference +implementation is the natural place for it — and the Guardian raises a subset +today: ``PARSE_ERROR``, ``INVALID_REQUEST``, ``INVALID_PARAMS``, +``INTERNAL_ERROR``, ``SESSION_REFUSED``, ``UNSUPPORTED_VERSION``, +``PROVENANCE_REQUIRED``, ``CAPABILITY_NOT_NEGOTIATED``, ``SIGNATURE_INVALID``, +``REPLAY_DETECTED`` and ``TIMESTAMP_OUT_OF_WINDOW``. ``METHOD_NOT_FOUND`` and +``CHAIN_MISMATCH`` are defined but not yet raised (an unnegotiated method is +``CAPABILITY_NOT_NEGOTIATED``, and no cross-check path submits a chain head +yet). + +The Guardian mints no codes of its own: the TypeScript reference implementation +uses three private codes from the reserved band (-32010, -32011, -32020) for +"envelope invalid", "method not dispatched", and "evaluation failed"; this +implementation answers those cases with the registry's own codes (-32600, +-32601/-32003, -32603) instead, because the §17 table already names them and a +port that invents codes is a port other implementations cannot branch on. +""" + +from __future__ import annotations + +from typing import Any + +# JSON-RPC 2.0 (Specification §17). +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 + +# ACS registry (Specification §17.1). +SESSION_REFUSED = -32000 +UNSUPPORTED_VERSION = -32001 +PROVENANCE_REQUIRED = -32002 +CAPABILITY_NOT_NEGOTIATED = -32003 +SIGNATURE_INVALID = -32004 +REPLAY_DETECTED = -32005 +TIMESTAMP_OUT_OF_WINDOW = -32006 +CHAIN_MISMATCH = -32007 + + +class AcsError(Exception): + """A refusal that maps to a JSON-RPC error response. + + ``signable`` records whether the request authenticated: a request that did + not establish a key (no session_id, or a failed signature) gets an + unsigned answer, because signing it would claim a key relationship that + does not exist (the Go reference implementation's rule). + """ + + def __init__( + self, + code: int, + message: str, + *, + data: dict[str, Any] | None = None, + signable: bool = True, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data + self.signable = signable diff --git a/reference-implementations/agt-python/src/acs_guardian/handshake.py b/reference-implementations/agt-python/src/acs_guardian/handshake.py new file mode 100644 index 00000000..d57ea64a --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/handshake.py @@ -0,0 +1,146 @@ +"""Capability negotiation (Specification §4). + +The Guardian selects a version, a transport, and the method set it will +evaluate, and answers with a ServerHello. Two rules matter more than they look: + +- ``methods_evaluated`` is the intersection of what the client declared and + what this Guardian dispatches, and it is the *only* thing that makes a + method enforceable for the session. A method absent from it is + ALLOW-by-default to a conformant client (handshake.json), so naming a method + the dispatch cannot answer claims enforcement that does not exist. +- A ClientHello that shares no transport with this Guardian is refused + (SESSION_REFUSED, -32000), not negotiated down to a transport the client + never offered. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .errors import ( + PROVENANCE_REQUIRED, + SESSION_REFUSED, + UNSUPPORTED_VERSION, + AcsError, +) +from .schemas import HOOK_METHODS + +SUPPORTED_VERSIONS = ("0.1.0",) +# "https" is accepted as a client transport because a deployment commonly +# terminates TLS in front of the Guardian; the selected transport is still the +# one the client offered, and the listener this tree ships speaks plain HTTP. +SUPPORTED_TRANSPORTS = ("http", "https") +# Empty until Wrapped MCP (protocols/MCP/*) is implemented: it is a mandatory +# ACS-Core item (docs/spec/conformance.md), and `profiles_accepted` is read by +# a machine, so an empty list is the honest wire answer while the +# implementation is partial. Restore ("acs-core",) with the MCP increment. +SUPPORTED_PROFILES: tuple[str, ...] = () +SIGNATURE_ALGORITHMS = ("HMAC-SHA256",) +DEFAULT_TIMEOUT_MS = 5000 +DEFAULT_SKEW_WINDOW_MS = 300_000 + +# Every method this Guardian dispatches. system/ping is here because it is +# dispatched; wrapped MCP and agbom/* are not yet, so they are not negotiated. +IMPLEMENTED_METHODS: tuple[str, ...] = (*HOOK_METHODS, "system/ping") + + +@dataclass +class NegotiatedTerms: + negotiated_version: str + methods_evaluated: tuple[str, ...] + selected_transport: str + profiles_accepted: tuple[str, ...] + on_decision_failure: str + timeout_ms: int + skew_window_ms: int + + +def _select_version(client_versions: list[str]) -> str: + for supported in SUPPORTED_VERSIONS: + major = supported.split(".")[0] + if any(version.split(".")[0] == major for version in client_versions): + return supported + raise AcsError( + UNSUPPORTED_VERSION, + "no common ACS version", + data={"supported_versions": list(SUPPORTED_VERSIONS)}, + ) + + +def _select_transport(client_transports: list[str]) -> str: + for transport in SUPPORTED_TRANSPORTS: + if transport in client_transports: + return transport + raise AcsError( + SESSION_REFUSED, + "no common transport", + data={"reason": "no_common_transport", "supported_transports": list(SUPPORTED_TRANSPORTS)}, + ) + + +def negotiate( + client_hello: dict[str, Any], + *, + on_decision_failure: str = "proceed", + timeout_ms: int = DEFAULT_TIMEOUT_MS, + skew_window_ms: int = DEFAULT_SKEW_WINDOW_MS, + policy_requires_provenance: bool = False, +) -> NegotiatedTerms: + """Build the session terms from a ClientHello, or refuse with a registry code. + + The refusal order is version, transport, provenance, methods: a version + mismatch terminates with ``UNSUPPORTED_VERSION`` (§4) regardless of what + else the ClientHello got wrong, so the client's recovery action + (re-handshake with a version from ``data.supported_versions``) is the one + it is told to take. + """ + negotiated_version = _select_version( + [v for v in client_hello.get("acs_versions_supported", []) if isinstance(v, str)] + ) + selected_transport = _select_transport( + [t for t in client_hello.get("transports_supported", []) if isinstance(t, str)] + ) + + if policy_requires_provenance and client_hello.get("provenance_producer") != "deterministic": + raise AcsError( + PROVENANCE_REQUIRED, + "policy requires provenance and the client declared provenance_producer: none", + ) + + client_methods = [m for m in client_hello.get("methods_implemented", []) if isinstance(m, str)] + methods_evaluated = tuple(m for m in client_methods if m in IMPLEMENTED_METHODS) + if not methods_evaluated: + # SESSION_REFUSED, not CAPABILITY_NOT_NEGOTIATED: nothing has been + # exercised yet, so there is no capability to have failed to negotiate + # (§17.1); the session simply cannot be governed usefully. + raise AcsError( + SESSION_REFUSED, + "ClientHello declares no method this Guardian evaluates", + data={"reason": "no_common_method", "implemented_methods": list(IMPLEMENTED_METHODS)}, + ) + + client_profiles = [p for p in client_hello.get("profiles_supported", []) if isinstance(p, str)] + return NegotiatedTerms( + negotiated_version=negotiated_version, + methods_evaluated=methods_evaluated, + selected_transport=selected_transport, + profiles_accepted=tuple(p for p in client_profiles if p in SUPPORTED_PROFILES), + on_decision_failure=on_decision_failure, + timeout_ms=timeout_ms, + skew_window_ms=skew_window_ms, + ) + + +def server_hello(terms: NegotiatedTerms) -> dict[str, Any]: + """The ServerHello result object (handshake.json $defs/ServerHello).""" + return { + "negotiated_version": terms.negotiated_version, + "methods_evaluated": list(terms.methods_evaluated), + "selected_transport": terms.selected_transport, + "signature_algorithms_supported": list(SIGNATURE_ALGORITHMS), + "timeout_config": {"default_ms": terms.timeout_ms}, + "skew_window_ms": terms.skew_window_ms, + "on_decision_failure": terms.on_decision_failure, + "profiles_accepted": list(terms.profiles_accepted), + } diff --git a/reference-implementations/agt-python/src/acs_guardian/main.py b/reference-implementations/agt-python/src/acs_guardian/main.py new file mode 100644 index 00000000..028212db --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/main.py @@ -0,0 +1,76 @@ +"""Command-line entry point. + +Environment variables mirror the deployment surface the conformance harness +configures: ``ACS_GUARDIAN_HMAC_SECRET_FILE`` (the shared key material), +``ACS_GUARDIAN_KEY_ID``, ``ACS_GUARDIAN_HOST``, ``ACS_GUARDIAN_PORT``, and +``ACS_ON_DECISION_FAILURE``. Flags override the environment. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from .schemas import SPEC_ROOT_ENV +from .server import Guardian, GuardianConfig, serve + +DEFAULT_KEY_ID = "conformance" + + +def _build_config(args: argparse.Namespace) -> GuardianConfig: + secret_file = args.secret_file or os.environ.get("ACS_GUARDIAN_HMAC_SECRET_FILE") + if not secret_file: + raise SystemExit( + "no HMAC secret: pass --secret-file or set ACS_GUARDIAN_HMAC_SECRET_FILE " + "(the file's bytes are the HKDF input keying material, 32 bytes minimum)" + ) + secret = Path(secret_file).read_bytes() + return GuardianConfig( + secret=secret, + key_id=args.key_id or os.environ.get("ACS_GUARDIAN_KEY_ID", DEFAULT_KEY_ID), + on_decision_failure=args.posture or os.environ.get("ACS_ON_DECISION_FAILURE", "proceed"), + timeout_ms=args.timeout_ms, + skew_window_ms=args.skew_window_ms, + policy_requires_provenance=args.require_provenance, + spec_root=Path(args.spec_root) if args.spec_root else None, + host=args.host or os.environ.get("ACS_GUARDIAN_HOST", "127.0.0.1"), + # `is not None`, not `or`: --port 0 asks the OS for a free port and is falsy. + port=args.port if args.port is not None else int(os.environ.get("ACS_GUARDIAN_PORT", "8787")), + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="acs-guardian", description="ACS v0.1.0 reference Guardian") + sub = parser.add_subparsers(dest="command", required=True) + + serve_parser = sub.add_parser("serve", help="serve the ACS wire endpoint") + serve_parser.add_argument("--secret-file", help="file holding the HKDF input keying material") + serve_parser.add_argument("--key-id", help="key_id carried in signatures (default: conformance)") + serve_parser.add_argument("--host", help="bind address (default: 127.0.0.1)") + serve_parser.add_argument("--port", type=int, help="bind port (default: 8787)") + serve_parser.add_argument("--posture", choices=["proceed", "deny"], help="on_decision_failure") + serve_parser.add_argument("--timeout-ms", type=int, default=5000) + serve_parser.add_argument("--skew-window-ms", type=int, default=300_000) + serve_parser.add_argument("--require-provenance", action="store_true") + serve_parser.add_argument("--spec-root", help=f"override the schema root (default: ${SPEC_ROOT_ENV} or the repository's)") + + args = parser.parse_args(argv) + if args.command == "serve": + config = _build_config(args) + guardian = Guardian(config) + server = serve(guardian) + print(f"acs-guardian serving {server.url}", file=sys.stderr) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/reference-implementations/agt-python/src/acs_guardian/schemas.py b/reference-implementations/agt-python/src/acs_guardian/schemas.py new file mode 100644 index 00000000..440e7a34 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/schemas.py @@ -0,0 +1,165 @@ +"""JSON Schema validation against the repository's v0.1.0 schemas. + +The schemas are loaded from ``specification/v0.1.0/`` in this repository by +relative path -- the same contract the TypeScript reference implementation's +``SCHEMA_ROOT`` relies on -- so the implementation is always validated against +the schemas under review rather than a copy that can drift. ``ACS_SPEC_ROOT`` +overrides the location for an out-of-tree deployment. + +Three validation points, each mapping to a registry code: + +- the envelope shape (``request-envelope.json``) -> ``-32600``; +- the method's hook payload (``hooks/.json``) -> ``-32602``; +- the response the Guardian is about to send (``response-envelope.json``) -> + reported, never fatal (the outbound counterpart of the inbound checks; a + response that fails it is logged, not replaced, because replacing it could + turn a governed step into an ungoverned one). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import ValidationError +from referencing import Registry, Resource + +from .errors import INVALID_PARAMS, INVALID_REQUEST, AcsError + +SPEC_ROOT_ENV = "ACS_SPEC_ROOT" + +_HANDSHAKE_SCHEMA_ID = "https://genai-security-project.github.io/agent-control-standard/schema/v0.1.0/handshake.json" + +# method -> hook payload schema, relative to specification/v0.1.0. +_HOOK_SCHEMAS = { + "steps/sessionStart": "hooks/session-start.json", + "steps/agentTrigger": "hooks/agent-trigger.json", + "steps/turnStart": "hooks/turn-start.json", + "steps/userMessage": "hooks/user-message.json", + "steps/agentResponse": "hooks/agent-response.json", + "steps/knowledgeRetrieval": "hooks/knowledge-retrieval.json", + "steps/memoryContextRetrieval": "hooks/memory-context-retrieval.json", + "steps/memoryStore": "hooks/memory-store.json", + "steps/toolCallRequest": "hooks/tool-call-request.json", + "steps/toolCallResult": "hooks/tool-call-result.json", + "steps/preCompact": "hooks/pre-compact.json", + "steps/postCompact": "hooks/post-compact.json", + "steps/subagentStart": "hooks/subagent-start.json", + "steps/subagentStop": "hooks/subagent-stop.json", + "steps/skillRegister": "hooks/skill-register.json", + "steps/skillLoad": "hooks/skill-load.json", + "steps/skillUnload": "hooks/skill-unload.json", + "steps/turnEnd": "hooks/turn-end.json", + "steps/sessionEnd": "hooks/session-end.json", + "system/ping": "hooks/system-ping.json", + "agbom/snapshot": "hooks/agbom-snapshot.json", + "agbom/changed": "hooks/agbom-changed.json", +} + +HOOK_METHODS = tuple(method for method in _HOOK_SCHEMAS if method.startswith("steps/")) + + +def default_spec_root() -> Path: + override = os.environ.get(SPEC_ROOT_ENV) + if override: + return Path(override) + # src/acs_guardian/schemas.py -> reference-implementations/agt-python -> repository root. + return Path(__file__).resolve().parents[4] / "specification" / "v0.1.0" + + +def _first_error_message(error: ValidationError) -> tuple[str, str]: + pointer = "/" + "/".join(str(part) for part in error.absolute_path) if error.absolute_path else "" + return f"{error.message} at {pointer or ''}", pointer + + +class SchemaRegistry: + """Loads the v0.1.0 schemas once and validates envelopes, params, payloads, responses.""" + + def __init__(self, spec_root: Path | None = None) -> None: + self.spec_root = spec_root or default_spec_root() + if not (self.spec_root / "request-envelope.json").is_file(): + # Fail fast and loudly: a Guardian that boots without schemas would + # answer every handshake with a confusing -32602 instead. + raise FileNotFoundError( + f"ACS schemas not found under {self.spec_root}; run from the repository " + f"or set {SPEC_ROOT_ENV} to a specification/v0.1.0 directory" + ) + self._validators: dict[str, Draft202012Validator] = {} + registry = Registry() + schemas: dict[str, dict[str, Any]] = {} + for path in sorted(self.spec_root.rglob("*.json")): + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(schema, dict): + continue + schema_id = schema.get("$id") + if not isinstance(schema_id, str): + continue + registry = registry.with_resource(schema_id, Resource.from_contents(schema)) + schemas[schema_id] = schema + self._registry = registry + self._schemas = schemas + + def _validator(self, relative_path: str) -> Draft202012Validator: + """Compile once per schema: the enforcement path is synchronous and hot.""" + cached = self._validators.get(relative_path) + if cached is None: + path = self.spec_root / relative_path + schema = json.loads(path.read_text(encoding="utf-8")) + cached = Draft202012Validator(schema, registry=self._registry) + self._validators[relative_path] = cached + return cached + + def validate_request(self, envelope: Any) -> None: + """The JSON-RPC envelope shape. Failure is INVALID_REQUEST (-32600).""" + if not isinstance(envelope, dict): + raise AcsError(INVALID_REQUEST, "request is not a JSON-RPC object", signable=False) + for error in self._validator("request-envelope.json").iter_errors(envelope): + message, _ = _first_error_message(error) + raise AcsError(INVALID_REQUEST, f"request envelope invalid: {message}", signable=False) + + def validate_client_hello(self, client_hello: Any) -> None: + """The ClientHello shape (handshake.json $defs/ClientHello). Failure is INVALID_PARAMS.""" + schema = self._schemas.get(_HANDSHAKE_SCHEMA_ID, {}).get("$defs", {}).get("ClientHello") + if not isinstance(schema, dict): + raise AcsError(INVALID_PARAMS, "handshake schema is not available") + for error in Draft202012Validator(schema, registry=self._registry).iter_errors(client_hello): + message, _ = _first_error_message(error) + raise AcsError(INVALID_PARAMS, f"ClientHello invalid: {message}") + + def validate_payload(self, method: str, payload: Any) -> None: + """The method's hook payload. Failure is INVALID_PARAMS (-32602).""" + relative = _HOOK_SCHEMAS.get(method) + if relative is None: + raise AcsError(INVALID_PARAMS, f"no payload schema for method {method}") + for error in self._validator(relative).iter_errors(payload): + message, pointer = _first_error_message(error) + raise AcsError( + INVALID_PARAMS, + f"payload invalid for {method}: {message}", + data={"pointer": pointer, "method": method}, + ) + + def hook_schema_id(self, method: str) -> str | None: + """The ``$id`` of a hook's schema, so tests can pin the method -> file mapping.""" + relative = _HOOK_SCHEMAS.get(method) + if relative is None: + return None + schema = self._validator(relative).schema + schema_id = schema.get("$id") if isinstance(schema, dict) else None + return schema_id if isinstance(schema_id, str) else None + + def validate_response(self, response: Any) -> str | None: + """Check the outbound response; return a finding, never raise (§ the module doc).""" + try: + for error in self._validator("response-envelope.json").iter_errors(response): + message, _ = _first_error_message(error) + return message + except Exception as exc: # a registry that will not build is reported, not thrown + return f"response could not be checked: {exc}" + return None diff --git a/reference-implementations/agt-python/src/acs_guardian/server.py b/reference-implementations/agt-python/src/acs_guardian/server.py new file mode 100644 index 00000000..5ea6d13b --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/server.py @@ -0,0 +1,583 @@ +"""The ACS wire boundary: one JSON-RPC 2.0 endpoint, POST /acs. + +The spec mandates no URL path convention -- ``/acs`` is this implementation's +choice -- so dispatch is by the JSON-RPC ``method`` field, never by path. + +Dispatch order, and why it is this order (each step is asserted by the +conformance probes in ``tests/test_external_probes.py``): + +1. JSON-RPC envelope shape -> ``-32600``. +2. Signature verification -> ``-32004`` (unsigned answer: the request + established no key). +3. Session lookup -> ``-32003`` when no handshake negotiated the session. +4. Replay -> ``-32005``, *before* the capability and payload checks, so a + replay of a refused request is still reported as a replay. +5. Negotiated-method check -> ``-32003``. +6. Timestamp skew -> ``-32006``. +7. Hook payload schema -> ``-32602``. +8. Evaluation, audit-chain append, signed decision. + +Every response to an authenticated request is signed with the session key at +``result.signature`` / ``error.signature``, including error responses: the +conformance harness verifies the signature on every signed request it sends, +refusals included. A request that did not authenticate gets an unsigned +answer, because signing it would claim a key relationship that does not exist. +""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from .canonical import request_signing_input, response_signing_input +from .chain import AuditChain +from .crypto import ALGORITHM, derive_session_key, sign, verify +from .engine import ALLOW, DENY, Evaluation, EvaluationRequest, PolicyEngine +from .errors import ( + CAPABILITY_NOT_NEGOTIATED, + INVALID_PARAMS, + INVALID_REQUEST, + PARSE_ERROR, + REPLAY_DETECTED, + SESSION_REFUSED, + SIGNATURE_INVALID, + TIMESTAMP_OUT_OF_WINDOW, + AcsError, +) +from .handshake import IMPLEMENTED_METHODS, negotiate, server_hello +from .schemas import SchemaRegistry +from .session import Session, SessionStore + +ACS_PATH = "/acs" +MAX_REQUEST_BODY_BYTES = 1_048_576 +ACS_VERSION = "0.1.0" + + +@dataclass +class GuardianConfig: + """Deployment configuration. The secret is deployment-provided key material (§10).""" + + secret: bytes + key_id: str = "conformance" + on_decision_failure: str = "proceed" + timeout_ms: int = 5000 + skew_window_ms: int = 300_000 + policy_requires_provenance: bool = False + spec_root: Path | None = None + engine: PolicyEngine | None = None + # A deployment that needs eviction (TTL, capacity) supplies its own store; + # the default keeps sessions for the process lifetime. + session_store: SessionStore | None = None + host: str = "127.0.0.1" + port: int = 8787 + + +class Guardian: + """The protocol logic, transport-independent so tests can drive it directly.""" + + def __init__(self, config: GuardianConfig) -> None: + if config.on_decision_failure not in ("proceed", "deny"): + raise ValueError(f"on_decision_failure must be proceed or deny, got {config.on_decision_failure!r}") + if len(config.secret) < 32: + raise ValueError("the HMAC secret must be at least 32 bytes") + self.config = config + self.schemas = SchemaRegistry(config.spec_root) + self.store = config.session_store if config.session_store is not None else SessionStore() + if config.engine is None: + from .engine import BuiltinEngine + + self.engine: PolicyEngine = BuiltinEngine() + else: + self.engine = config.engine + + # -- transport-independent entry point --------------------------------- + + def handle(self, raw: Any) -> dict[str, Any]: + if isinstance(raw, list): + return self._error(None, INVALID_REQUEST, "batching is not supported; send one request per POST") + try: + # The authoritative envelope check: request-envelope.json, required + # fields included (acs_version, request_id, timestamp, metadata). + self.schemas.validate_request(raw) + except AcsError as error: + return self._error(_best_effort_id(raw), error.code, error.message, error.data) + rpc_id = raw.get("id") + if not isinstance(rpc_id, (str, int)) or isinstance(rpc_id, bool): + rpc_id = None + method = raw.get("method") + if not isinstance(method, str): + return self._error(rpc_id, INVALID_REQUEST, "method must be a string") + + key: bytes | None = None + key_id: str | None = None + params = raw.get("params") + if isinstance(params, dict): + metadata = params.get("metadata") + if isinstance(metadata, dict) and isinstance(metadata.get("session_id"), str): + key = derive_session_key(self.config.secret, metadata["session_id"]) + signature = params.get("signature") + if isinstance(signature, dict) and isinstance(signature.get("key_id"), str): + key_id = signature["key_id"] + + try: + if method == "handshake/hello": + return self._handshake(raw, rpc_id, params, key, key_id) + return self._step(raw, rpc_id, method, params, key, key_id) + except AcsError as error: + if error.signable and key is not None: + return self._error(rpc_id, error.code, error.message, error.data, key=key, key_id=key_id) + return self._error(rpc_id, error.code, error.message, error.data) + except Exception as error: # noqa: BLE001 - nothing may escape as a non-JSON-RPC answer + from .errors import INTERNAL_ERROR + + # Details go to the operator's log, not the wire: an exception + # message can carry filesystem paths and schema internals. + # + # Deliberately unsigned: this route is a bug in the Guardian, and + # the request may not have authenticated (the key is derived from + # metadata.session_id, not from a verified signature), so signing + # would claim a key relationship that may not exist. + print(f"guardian failed to handle a request: {error!r}", file=sys.stderr) + return self._error(rpc_id, INTERNAL_ERROR, "guardian failed to handle the request") + + # -- handshake --------------------------------------------------------- + + def _handshake( + self, + raw: dict[str, Any], + rpc_id: str | int | None, + params: Any, + key: bytes | None, + key_id: str | None, + ) -> dict[str, Any]: + if not isinstance(params, dict): + raise AcsError(INVALID_PARAMS, "params must be an object", signable=False) + session_id, request_id = self._session_and_request_id(params, signable=False) + if key is None or not self._signature_ok(raw, params, key): + raise AcsError(SIGNATURE_INVALID, "handshake request signature is missing or invalid", signable=False) + client_hello = params.get("payload") + if not isinstance(client_hello, dict): + raise AcsError(INVALID_PARAMS, "handshake payload must be a ClientHello object") + self.schemas.validate_client_hello(client_hello) + + existing = self.store.get(session_id) + if existing is not None: + with existing.lock: + if isinstance(request_id, str) and request_id in existing.seen_request_ids: + raise AcsError(REPLAY_DETECTED, "request_id was already seen in this session") + # Recorded before the refusal, like a step's id: a replay of a + # refused handshake is still a replay (the probes assert the + # same rule for refused steps). + if isinstance(request_id, str): + existing.seen_request_ids.add(request_id) + raise AcsError( + SESSION_REFUSED, + "session is already negotiated; v0.1 defines no renegotiation", + data={"reason": "renegotiation_not_supported"}, + ) + + terms = negotiate( + client_hello, + on_decision_failure=self.config.on_decision_failure, + timeout_ms=self.config.timeout_ms, + skew_window_ms=self.config.skew_window_ms, + policy_requires_provenance=self.config.policy_requires_provenance, + ) + session = self._new_session(params, session_id, request_id, key_id, terms) + if not self.store.create_if_absent(session): + # Lost a race with a concurrent handshake for the same session_id. + raced = self.store.get(session_id) + if raced is not None: + with raced.lock: + if isinstance(request_id, str) and request_id not in raced.seen_request_ids: + raced.seen_request_ids.add(request_id) + raise AcsError( + SESSION_REFUSED, + "session is already negotiated; v0.1 defines no renegotiation", + data={"reason": "renegotiation_not_supported"}, + ) + + response = {"jsonrpc": "2.0", "id": rpc_id, "result": server_hello(terms)} + return self._sign(response, key, session.key_id) + + def _new_session( + self, + params: dict[str, Any], + session_id: str, + request_id: Any, + key_id: str | None, + terms: Any, + ) -> Session: + return Session( + session_id=session_id, + agent_id=str((params.get("metadata") or {}).get("agent_id", "")), + key_id=key_id or self.config.key_id, + negotiated_version=terms.negotiated_version, + methods_evaluated=terms.methods_evaluated, + selected_transport=terms.selected_transport, + timeout_config={"default_ms": terms.timeout_ms}, + on_decision_failure=terms.on_decision_failure, + skew_window_ms=terms.skew_window_ms, + profiles_accepted=terms.profiles_accepted, + chain=AuditChain(), + seen_request_ids={request_id} if isinstance(request_id, str) else set(), + seen_nonces=set(), + ) + + # -- steps/*, system/*, and anything else ------------------------------ + + def _step( + self, + raw: dict[str, Any], + rpc_id: str | int | None, + method: str, + params: Any, + key: bytes | None, + key_id: str | None, + ) -> dict[str, Any]: + if not isinstance(params, dict): + raise AcsError(INVALID_PARAMS, "params must be an object", signable=False) + session_id, request_id = self._session_and_request_id(params, signable=False) + + # Every request but system/ping must be signed (§10, §13). Ping is + # exempt so liveness probing survives key-resolution failures. + if method != "system/ping": + if key is None or not self._signature_ok(raw, params, key): + raise AcsError(SIGNATURE_INVALID, "request signature is missing or invalid", signable=False) + + if method == "system/ping": + return self._ping(rpc_id, params, key, key_id) + + session = self.store.get(session_id) + if session is None: + raise AcsError( + CAPABILITY_NOT_NEGOTIATED, + f"no negotiated session for {session_id}; handshake/hello is required first", + data={"method": method}, + ) + if key_id != session.key_id: + raise AcsError(SIGNATURE_INVALID, "signature key_id does not match the negotiated session", signable=False) + + # One session's steps are serialized: the replay check, the chain + # append, and the decision must be one atomic step, or two concurrent + # copies of a request both pass the check and both append. + with session.lock: + if isinstance(request_id, str): + if request_id in session.seen_request_ids: + raise AcsError(REPLAY_DETECTED, "request_id was already seen in this session") + session.seen_request_ids.add(request_id) + nonce = params.get("nonce") + if isinstance(nonce, str): + if nonce in session.seen_nonces: + raise AcsError(REPLAY_DETECTED, "nonce was already seen in this session") + session.seen_nonces.add(nonce) + + if method not in session.methods_evaluated: + raise AcsError( + CAPABILITY_NOT_NEGOTIATED, + f"method {method} was not negotiated for this session", + data={"method": method}, + ) + + self._check_timestamp(params.get("timestamp"), session.skew_window_ms) + self.schemas.validate_payload(method, params.get("payload")) + + evaluation = self._evaluate(method, params, session, request_id) + + # The chain entry is appended on arrival of an evaluated step, + # before the verdict is delivered: a denied step is still a step + # the session took (§8.6). + session.chain.append( + step_id=str(request_id), + step_type=method, + params=params, + timestamp=params.get("timestamp"), + ) + + result = self._decision_result(session, request_id, evaluation) + + response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} + return self._sign(response, key, session.key_id) + + def _evaluate( + self, + method: str, + params: dict[str, Any], + session: Session, + request_id: Any, + ) -> Evaluation: + try: + evaluation = self.engine.evaluate( + EvaluationRequest( + method=method, + payload=params.get("payload") or {}, + session_id=session.session_id, + agent_id=session.agent_id, + request_id=str(request_id), + ) + ) + except Exception as error: # noqa: BLE001 - an engine failure is a deny, never a crash + print(f"policy engine failed on {method}: {error!r}", file=sys.stderr) + return _evaluation_failed() + # A decision that cannot be expressed conformantly is not sent as one + # (response-envelope.json requires reasoning on deny/modify/ask/defer, + # and the disposition's own details object): it fails closed instead. + if not _disposition_complete(evaluation): + print(f"policy engine returned an incomplete {evaluation.decision} decision on {method}", file=sys.stderr) + return _evaluation_failed() + return evaluation + + @staticmethod + def _decision_result(session: Session, request_id: Any, evaluation: Evaluation) -> dict[str, Any]: + result: dict[str, Any] = { + "type": "final", + "acs_version": session.negotiated_version, + "request_id": request_id, + "decision": evaluation.decision, + "chain_hash": session.chain.head, + } + if evaluation.reasoning is not None: + result["reasoning"] = evaluation.reasoning + if evaluation.reason_codes: + result["reason_codes"] = evaluation.reason_codes + if evaluation.policy_references: + result["policy_references"] = evaluation.policy_references + if evaluation.modifications is not None: + result["modifications"] = evaluation.modifications + if evaluation.ask_details is not None: + result["ask_details"] = evaluation.ask_details + if evaluation.defer_details is not None: + result["defer_details"] = evaluation.defer_details + return result + + def _ping( + self, + rpc_id: str | int | None, + params: dict[str, Any], + key: bytes | None, + key_id: str | None, + ) -> dict[str, Any]: + payload = params.get("payload") if isinstance(params.get("payload"), dict) else {} + result = { + "type": "final", + "acs_version": str(params.get("acs_version", ACS_VERSION)), + "request_id": params.get("request_id"), + "decision": ALLOW, + "payload": { + "status": "ok", + "echo": payload.get("echo"), + "server_timestamp": _now_iso(), + }, + } + response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} + # Ping never requires a signature; if one authenticated, answering + # signed costs nothing and keeps the harness's verification uniform. + session_id = (params.get("metadata") or {}).get("session_id") if isinstance(params.get("metadata"), dict) else None + if key is not None and isinstance(session_id, str): + return self._sign(response, key, key_id or self.config.key_id) + return response + + # -- helpers ----------------------------------------------------------- + + @staticmethod + def _session_and_request_id(params: dict[str, Any], *, signable: bool) -> tuple[str, Any]: + metadata = params.get("metadata") + session_id = metadata.get("session_id") if isinstance(metadata, dict) else None + if not isinstance(session_id, str): + raise AcsError(INVALID_PARAMS, "params.metadata.session_id is required", signable=signable) + request_id = params.get("request_id") + if not isinstance(request_id, str): + raise AcsError(INVALID_PARAMS, "params.request_id is required", signable=signable) + return session_id, request_id + + @staticmethod + def _signature_ok(raw: dict[str, Any], params: dict[str, Any], key: bytes) -> bool: + signature = params.get("signature") + if not isinstance(signature, dict) or signature.get("algorithm") != ALGORITHM: + return False + return verify(key, request_signing_input(raw), signature.get("value")) + + @staticmethod + def _check_timestamp(timestamp: Any, skew_window_ms: int) -> None: + if not isinstance(timestamp, str): + raise AcsError(TIMESTAMP_OUT_OF_WINDOW, "params.timestamp is required", data={"skew_window_ms": skew_window_ms}) + try: + moment = datetime.fromisoformat(timestamp) + except ValueError: + raise AcsError( + TIMESTAMP_OUT_OF_WINDOW, + "params.timestamp is not an ISO 8601 date-time", + data={"skew_window_ms": skew_window_ms}, + ) from None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + window = timedelta(milliseconds=skew_window_ms) + if abs(datetime.now(timezone.utc) - moment) > window: + raise AcsError( + TIMESTAMP_OUT_OF_WINDOW, + "params.timestamp is outside the negotiated skew window", + data={"skew_window_ms": skew_window_ms}, + ) + + def _sign(self, response: dict[str, Any], key: bytes, key_id: str) -> dict[str, Any]: + holder = response["result"] if "result" in response else response["error"] + holder["signature"] = { + "algorithm": ALGORITHM, + "key_id": key_id, + "value": sign(key, response_signing_input(response)), + } + finding = self.schemas.validate_response(response) + if finding is not None: + print(f"guardian sent a response that fails response-envelope.json: {finding}", file=sys.stderr) + return response + + def _error( + self, + rpc_id: str | int | None, + code: int, + message: str, + data: dict[str, Any] | None = None, + *, + key: bytes | None = None, + key_id: str | None = None, + ) -> dict[str, Any]: + error: dict[str, Any] = {"code": code, "message": message} + if data is not None: + error["data"] = data + response = {"jsonrpc": "2.0", "id": rpc_id, "error": error} + if key is not None: + return self._sign(response, key, key_id or self.config.key_id) + return response + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _reject_non_finite(token: str) -> Any: + raise ValueError(f"non-finite JSON number is not permitted: {token}") + + +def _best_effort_id(raw: Any) -> str | int | None: + """The JSON-RPC id for an error response, when one can be determined (§ response-envelope).""" + if isinstance(raw, dict): + rpc_id = raw.get("id") + if isinstance(rpc_id, (str, int)) and not isinstance(rpc_id, bool): + return rpc_id + return None + + +def _disposition_complete(evaluation: Evaluation) -> bool: + """Whether the decision carries every field response-envelope.json requires for it.""" + from .engine import DISPOSITIONS + + if evaluation.decision not in DISPOSITIONS: + return False + if evaluation.decision in (DENY, "modify", "ask", "defer") and not evaluation.reasoning: + return False + if evaluation.decision == "modify" and evaluation.modifications is None: + return False + if evaluation.decision == "ask" and evaluation.ask_details is None: + return False + if evaluation.decision == "defer" and evaluation.defer_details is None: + return False + return True + + +def _evaluation_failed() -> Evaluation: + return Evaluation( + decision=DENY, + reasoning="the Guardian could not produce a conformant decision for this step; failing closed", + reason_codes=["evaluation_failed"], + ) + + +# -- HTTP transport -------------------------------------------------------- + + +class _AcsHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "acs-guardian/0.1.0" + # Bounds how long a client can hold a worker thread mid-request (a + # Content-Length larger than the body, say). The body cap bounds memory; + # this bounds time. + timeout = 10 + + @property + def guardian(self) -> Guardian: + return self.server.guardian # type: ignore[attr-defined] + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's own naming + if self.path != ACS_PATH: + self._respond_text(404, "Not Found") + return + length_header = self.headers.get("Content-Length") + try: + declared = int(length_header) if length_header is not None else 0 + except ValueError: + declared = 0 + if declared > MAX_REQUEST_BODY_BYTES: + # Refused before the body is read; the connection closes so the + # unread body cannot be misread as the next request on keep-alive. + self.close_connection = True + self._respond_json( + { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": INVALID_REQUEST, + "message": f"request body exceeds the {MAX_REQUEST_BODY_BYTES}-byte limit", + }, + } + ) + return + body = self.rfile.read(declared) + try: + # parse_constant: JSON.parse accepts NaN/Infinity, JCS (RFC 8785) + # does not, and a non-finite number that reached signature + # verification would raise out of canonicalization rather than + # fail it. + raw = json.loads(body.decode("utf-8"), parse_constant=_reject_non_finite) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + self._respond_json({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": "Parse error"}}) + return + self._respond_json(self.guardian.handle(raw)) + + def _respond_json(self, payload: dict[str, Any]) -> None: + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _respond_text(self, status: int, text: str) -> None: + body = text.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - signature fixed upstream + print(f"{self.address_string()} {format % args}", file=sys.stderr) + + +class GuardianServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, guardian: Guardian) -> None: + super().__init__((guardian.config.host, guardian.config.port), _AcsHandler) + self.guardian = guardian + self.url = f"http://{guardian.config.host}:{self.server_address[1]}{ACS_PATH}" + + +def serve(guardian: Guardian) -> GuardianServer: + """Start the HTTP server; the caller owns shutdown.""" + return GuardianServer(guardian) diff --git a/reference-implementations/agt-python/src/acs_guardian/session.py b/reference-implementations/agt-python/src/acs_guardian/session.py new file mode 100644 index 00000000..47a71f72 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/session.py @@ -0,0 +1,73 @@ +"""Per-session state: negotiated terms, replay bookkeeping, audit chain. + +One ``Session`` per ``session_id``, created by a successful handshake. The +replay bookkeeping is deliberately part of the session (Specification §10.3: +"duplicate ``request_id`` values within the session"), not a process-wide set, +so two sessions cannot collide on an id and a session's history is dropped +with it. + +``seen_request_ids`` records ids on arrival -- including ids of requests the +Guardian then refuses for a capability reason -- because a replay of a refused +request is still a replay, and the conformance probes assert exactly that +(-32003 on first sight, -32005 on the second). +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Any + +from .chain import AuditChain + + +@dataclass +class Session: + session_id: str + agent_id: str + key_id: str + negotiated_version: str + methods_evaluated: tuple[str, ...] + selected_transport: str + timeout_config: dict[str, Any] + on_decision_failure: str + skew_window_ms: int + profiles_accepted: tuple[str, ...] + chain: AuditChain = field(default_factory=AuditChain) + seen_request_ids: set[str] = field(default_factory=set) + seen_nonces: set[str] = field(default_factory=set) + # Guards the session's mutable state against the HTTP server's threads. + # The store's own lock protects the session table, not session contents: + # without this, two concurrent copies of one request both pass the + # check-then-act replay test and both append to the chain. + lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False) + + +class SessionStore: + """Thread-safe in-memory session table (the reference deployment's store).""" + + def __init__(self) -> None: + self._sessions: dict[str, Session] = {} + self._lock = threading.Lock() + + def get(self, session_id: str) -> Session | None: + with self._lock: + return self._sessions.get(session_id) + + def create_if_absent(self, session: Session) -> bool: + """Create the session unless one already exists; False means it raced. + + The handshake path checks for an existing session before negotiation + and calls this after it, so two concurrent handshakes for one + ``session_id`` cannot both install a session. + """ + with self._lock: + if session.session_id in self._sessions: + return False + self._sessions[session.session_id] = session + return True + + def drop(self, session_id: str) -> None: + """Eviction hook for a deployment that owns the store; see README non-goals.""" + with self._lock: + self._sessions.pop(session_id, None) diff --git a/reference-implementations/agt-python/tests/conftest.py b/reference-implementations/agt-python/tests/conftest.py new file mode 100644 index 00000000..919b78ff --- /dev/null +++ b/reference-implementations/agt-python/tests/conftest.py @@ -0,0 +1,27 @@ +"""Test fixtures: an in-process Guardian and a live HTTP server on a random port.""" + +from __future__ import annotations + +import threading + +import pytest + +from acs_guardian.server import Guardian, GuardianConfig, serve + +from helpers import SECRET + + +@pytest.fixture() +def guardian() -> Guardian: + # port 0: the HTTP fixture below binds a free port rather than a fixed one. + return Guardian(GuardianConfig(secret=SECRET, port=0)) + + +@pytest.fixture() +def server(guardian: Guardian): + httpd = serve(guardian) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield httpd + httpd.shutdown() + httpd.server_close() diff --git a/reference-implementations/agt-python/tests/helpers.py b/reference-implementations/agt-python/tests/helpers.py new file mode 100644 index 00000000..144a9daf --- /dev/null +++ b/reference-implementations/agt-python/tests/helpers.py @@ -0,0 +1,132 @@ +"""Shared test helpers: the envelope builder and the harness's signing contract. + +These mirror the external conformance mode contributed in the open PR #169 +(``packages/conformance/src/external-guardian.ts`` in that branch; not merged +at the time of writing), so the probe test and the official harness measure +the same wire contract with the same primitives. +""" + +from __future__ import annotations + +import json +import urllib.request +import uuid +from datetime import datetime, timezone +from typing import Any + +from acs_guardian.canonical import request_signing_input, response_signing_input +from acs_guardian.crypto import derive_session_key, sign, verify + +SECRET = bytes(range(32)) +KEY_ID = "conformance" +AGENT_ID = "conformance-observed-agent" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def envelope( + method: str, + session_id: str, + payload: dict[str, Any], + *, + request_id: str | None = None, + agent_id: str = AGENT_ID, + timestamp: str | None = None, +) -> dict[str, Any]: + request_id = request_id or str(uuid.uuid4()) + return { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": { + "acs_version": "0.1.0", + "request_id": request_id, + "timestamp": timestamp or now_iso(), + "metadata": {"agent_id": agent_id, "session_id": session_id}, + "payload": payload, + }, + } + + +def sign_request(request: dict[str, Any], session_id: str, secret: bytes = SECRET, key_id: str = KEY_ID) -> dict[str, Any]: + key = derive_session_key(secret, session_id) + request["params"]["signature"] = { + "algorithm": "HMAC-SHA256", + "value": sign(key, request_signing_input(request)), + "key_id": key_id, + } + return request + + +def verify_response( + response: dict[str, Any], + session_id: str, + expected_id: Any, + secret: bytes = SECRET, + key_id: str = KEY_ID, +) -> None: + assert response.get("jsonrpc") == "2.0", f"uncorrelated response: {response}" + assert response.get("id") == expected_id, f"uncorrelated response: {response}" + holder_name = "result" if "error" not in response else "error" + holder = response.get(holder_name) or {} + signature = holder.get("signature") + assert isinstance(signature, dict), f"no signature on {holder_name}: {response}" + assert signature.get("algorithm") == "HMAC-SHA256", f"wrong algorithm: {signature}" + assert signature.get("key_id") == key_id, f"wrong key_id: {signature}" + key = derive_session_key(secret, session_id) + assert verify(key, response_signing_input(response), signature.get("value")), ( + f"response signature does not verify: {response}" + ) + + +def http_post(url: str, body: Any) -> dict[str, Any]: + data = json.dumps(body).encode("utf-8") + request = urllib.request.Request( + url, data=data, headers={"content-type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(request) as response: + return json.loads(response.read().decode("utf-8")) + + +def expect_error(response: dict[str, Any], code: int) -> None: + assert response.get("error", {}).get("code") == code and "result" not in response, ( + f"expected error {code}, got {response}" + ) + + +def expect_decision(response: dict[str, Any], disposition: str, request_id: Any) -> None: + result = response.get("result") or {} + assert "error" not in response, f"expected a decision, got {response}" + assert result.get("decision") == disposition, f"expected {disposition}, got {response}" + assert result.get("request_id") == request_id, f"decision not correlated: {response}" + + +def signed_post(url: str, request: dict[str, Any], session_id: str) -> dict[str, Any]: + signed = sign_request(request, session_id) + response = http_post(url, signed) + verify_response(response, session_id, request["id"]) + return response + + +def handshake( + url: str, + session_id: str, + methods: list[str], + *, + transports: list[str] | None = None, + versions: list[str] | None = None, +) -> dict[str, Any]: + hello = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": versions or ["0.1.0"], + "methods_implemented": methods, + "transports_supported": transports or ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ) + return signed_post(url, hello, session_id) diff --git a/reference-implementations/agt-python/tests/test_chain.py b/reference-implementations/agt-python/tests/test_chain.py new file mode 100644 index 00000000..698334a5 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_chain.py @@ -0,0 +1,49 @@ +"""The §8.2 chain rule, checked independently of the implementation that computes it.""" + +from __future__ import annotations + +import hashlib + +from acs_guardian.canonical import canonical_bytes +from acs_guardian.chain import AuditChain, verify_chain + + +def _independent_entry_hash(entry: dict, previous_hash: str | None) -> str: + """The §8.2 rule written out again, from the prose, not via chain.py.""" + content = {key: value for key, value in entry.items() if key not in ("entry_hash", "previous_hash")} + prev_bytes = bytes.fromhex(previous_hash) if previous_hash else b"" + return hashlib.sha256(canonical_bytes(content) + prev_bytes).hexdigest() + + +def test_first_entry_hashes_over_the_empty_previous_hash() -> None: + chain = AuditChain() + entry = chain.append(step_id="s-1", step_type="steps/sessionStart", params={"a": 1}, timestamp="2026-09-22T00:00:00Z") + assert entry.previous_hash is None + assert entry.entry_hash == _independent_entry_hash(entry.to_dict(), None) + assert verify_chain([e.to_dict() for e in chain.entries]) + + +def test_chain_links_entries_and_verifies() -> None: + chain = AuditChain() + first = chain.append(step_id="s-1", step_type="steps/sessionStart", params={"a": 1}) + second = chain.append(step_id="s-2", step_type="steps/toolCallRequest", params={"b": 2}) + assert second.previous_hash == first.entry_hash + assert chain.head == second.entry_hash + assert second.entry_hash == _independent_entry_hash(second.to_dict(), first.entry_hash) + assert verify_chain([e.to_dict() for e in chain.entries]) + + +def test_request_hash_commits_to_the_params_object() -> None: + chain = AuditChain() + params = {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}} + entry = chain.append(step_id="s-1", step_type="steps/toolCallRequest", params=params) + assert entry.request_hash == hashlib.sha256(canonical_bytes(params)).hexdigest() + + +def test_tampering_with_a_stored_entry_breaks_verification() -> None: + chain = AuditChain() + chain.append(step_id="s-1", step_type="steps/sessionStart") + chain.append(step_id="s-2", step_type="steps/toolCallRequest") + serialized = [entry.to_dict() for entry in chain.entries] + serialized[0]["step_id"] = "s-forged" + assert not verify_chain(serialized) diff --git a/reference-implementations/agt-python/tests/test_crypto_vectors.py b/reference-implementations/agt-python/tests/test_crypto_vectors.py new file mode 100644 index 00000000..26d74b30 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_crypto_vectors.py @@ -0,0 +1,60 @@ +"""The Go implementation's vectors, pinned byte for byte. + +``tests/testdata/envelope-vectors.json`` is the vector file the Go reference +implementation contributes in the open PR #169 +(``internal/envelope/testdata/vectors.json``). #169 is not merged at the time +of writing, so the file is vendored here with attribution: these are that +author's vectors, and this test asserts this implementation agrees with them +on HKDF inputs, JCS form, and the HMAC signature. That is the interoperability +claim expressed as bytes — a divergence fails here rather than at the +conformance run. When #169 merges, this test should load the file from the Go +tree instead of the copy. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from acs_guardian.canonical import request_signing_input, response_signing_input +from acs_guardian.crypto import derive_session_key, sign, verify + +VECTORS = json.loads((Path(__file__).parent / "testdata" / "envelope-vectors.json").read_text(encoding="utf-8")) + + +def test_hkdf_session_key_matches_the_vectors() -> None: + key = derive_session_key(bytes.fromhex(VECTORS["ikm_hex"]), VECTORS["session_id"]) + assert key.hex() == VECTORS["session_key_hex"] + + +def test_request_canonical_form_and_signature_match_the_vectors() -> None: + signed = VECTORS["request"]["signed"] + assert request_signing_input(signed).decode("utf-8") == VECTORS["request"]["signing_input"] + key = derive_session_key(bytes.fromhex(VECTORS["ikm_hex"]), VECTORS["session_id"]) + signature = signed["params"]["signature"] + assert sign(key, request_signing_input(signed)) == signature["value"] + assert verify(key, request_signing_input(signed), signature["value"]) + + +def test_response_canonical_form_and_signature_match_the_vectors() -> None: + signed = VECTORS["response"]["signed"] + assert response_signing_input(signed).decode("utf-8") == VECTORS["response"]["signing_input"] + key = derive_session_key(bytes.fromhex(VECTORS["ikm_hex"]), VECTORS["session_id"]) + holder = signed["result"] if "result" in signed else signed["error"] + assert sign(key, response_signing_input(signed)) == holder["signature"]["value"] + + +def test_a_tampered_envelope_fails_verification() -> None: + signed = json.loads(json.dumps(VECTORS["request"]["signed"])) + signed["method"] = "steps/sessionStart" + key = derive_session_key(bytes.fromhex(VECTORS["ikm_hex"]), VECTORS["session_id"]) + assert not verify(key, request_signing_input(signed), signed["params"]["signature"]["value"]) + + +def test_jcs_number_formatting_follows_ecmascript() -> None: + """The JCS rule an implementation is most likely to get wrong (§10 pins RFC 8785).""" + from acs_guardian.canonical import canonical_bytes + + assert canonical_bytes({"n": 1.0}) == b'{"n":1}' + assert canonical_bytes({"n": 1e21}) == b'{"n":1e+21}' + assert canonical_bytes({"b": True, "a": "x"}) == b'{"a":"x","b":true}' diff --git a/reference-implementations/agt-python/tests/test_external_probes.py b/reference-implementations/agt-python/tests/test_external_probes.py new file mode 100644 index 00000000..26eb75b6 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_external_probes.py @@ -0,0 +1,92 @@ +"""The external conformance probes, ported from the harness. + +``packages/conformance/src/external-guardian.ts`` in the Go port's #169 runs +ten black-box probes against a live Guardian: envelope refusal, pre-session +refusal, transport refusal, a full handshake, an allowed call, replay +detection (three times, including replays of refused requests), invalid-params +refusal, and unnegotiated-method refusal. Every signed request's response is +signature-verified, so a Guardian that answers correctly but signs wrong +fails here. + +This test is the local acceptance gate until #169 merges and the official +mode can run against this implementation (README.md documents that run). +""" + +from __future__ import annotations + +import uuid + +from helpers import ( + envelope, + expect_decision, + expect_error, + handshake, + http_post, + signed_post, +) + + +def test_the_ten_external_probes(server) -> None: + url = server.url + + # 1. Invalid JSON-RPC is refused at the JSON-RPC boundary. + invalid = http_post(url, {"jsonrpc": "1.0", "method": "steps/toolCallRequest", "id": "bad", "params": {}}) + expect_error(invalid, -32600) + + # 2. A step before any handshake is CAPABILITY_NOT_NEGOTIATED. + before_session = str(uuid.uuid4()) + request = envelope( + "steps/toolCallRequest", + before_session, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls -la"}}, "raw_command": "ls -la"}, + ) + expect_error(signed_post(url, request, before_session), -32003) + + # 3. A handshake offering only stdio is SESSION_REFUSED. + transport_session = str(uuid.uuid4()) + refused = envelope( + "handshake/hello", + transport_session, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["stdio"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ) + expect_error(signed_post(url, refused, transport_session), -32000) + + # 4. A valid handshake returns a correlated, signed ServerHello. + session_id = str(uuid.uuid4()) + methods = ["steps/toolCallRequest", "steps/toolCallResult"] + hello = handshake(url, session_id, methods) + result = hello["result"] + assert result["negotiated_version"] == "0.1.0" + assert isinstance(result["methods_evaluated"], list) + assert result["methods_evaluated"] == methods + + # 5. An allowed tool call returns a correlated ALLOW decision. + allowed = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls -la"}}, "raw_command": "ls -la"}, + ) + expect_decision(signed_post(url, allowed, session_id), "allow", allowed["id"]) + + # 6. Replaying it is REPLAY_DETECTED. + expect_error(signed_post(url, allowed, session_id), -32005) + + # 7. A tool call whose payload fails its hook schema is INVALID_PARAMS. + invalid_payload = envelope("steps/toolCallRequest", session_id, {"tool": {"name": "Bash"}}) + expect_error(signed_post(url, invalid_payload, session_id), -32602) + + # 8. Replaying the invalid one is still a replay, not a second -32602. + expect_error(signed_post(url, invalid_payload, session_id), -32005) + + # 9. A method the handshake did not negotiate is CAPABILITY_NOT_NEGOTIATED. + unnegotiated = envelope("steps/userMessage", session_id, {"content": [{"type": "text", "value": "hello"}]}) + expect_error(signed_post(url, unnegotiated, session_id), -32003) + + # 10. And its replay is REPLAY_DETECTED. + expect_error(signed_post(url, unnegotiated, session_id), -32005) diff --git a/reference-implementations/agt-python/tests/test_handshake.py b/reference-implementations/agt-python/tests/test_handshake.py new file mode 100644 index 00000000..63cf30b1 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_handshake.py @@ -0,0 +1,105 @@ +"""Capability negotiation (§4): intersection rules and refusals.""" + +from __future__ import annotations + +import pytest + +from acs_guardian.errors import ( + PROVENANCE_REQUIRED, + SESSION_REFUSED, + UNSUPPORTED_VERSION, + AcsError, +) +from acs_guardian.handshake import negotiate, server_hello + + +def _hello(**overrides): + hello = { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest", "steps/toolCallResult"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + } + hello.update(overrides) + return hello + + +def test_methods_evaluated_is_the_intersection_preserving_client_order() -> None: + terms = negotiate( + _hello( + methods_implemented=[ + "steps/userMessage", + "steps/toolCallRequest", + "protocols/MCP/tools/call", # not implemented yet: not negotiated + "steps/toolCallResult", + ] + ) + ) + assert terms.methods_evaluated == ("steps/userMessage", "steps/toolCallRequest", "steps/toolCallResult") + + +def test_no_common_version_is_unsupported_version() -> None: + with pytest.raises(AcsError) as caught: + negotiate(_hello(acs_versions_supported=["9.9.9"])) + assert caught.value.code == UNSUPPORTED_VERSION + assert caught.value.data == {"supported_versions": ["0.1.0"]} + + +def test_same_major_version_negotiates_this_guardians_minor() -> None: + assert negotiate(_hello(acs_versions_supported=["0.7.3"])).negotiated_version == "0.1.0" + + +def test_version_mismatch_wins_over_every_other_refusal() -> None: + """§4: a version mismatch terminates with -32001, whatever else is wrong.""" + with pytest.raises(AcsError) as caught: + negotiate(_hello(acs_versions_supported=["9.9.9"], methods_implemented=["protocols/MCP/tools/call"])) + assert caught.value.code == UNSUPPORTED_VERSION + + +def test_https_is_an_acceptable_client_transport() -> None: + assert negotiate(_hello(transports_supported=["https"])).selected_transport == "https" + + +def test_no_common_transport_is_session_refused() -> None: + with pytest.raises(AcsError) as caught: + negotiate(_hello(transports_supported=["stdio"])) + assert caught.value.code == SESSION_REFUSED + assert caught.value.data["reason"] == "no_common_transport" + + +def test_provenance_requirement_refuses_a_none_producer() -> None: + with pytest.raises(AcsError) as caught: + negotiate(_hello(), policy_requires_provenance=True) + assert caught.value.code == PROVENANCE_REQUIRED + + +def test_provenance_requirement_accepts_a_deterministic_producer() -> None: + terms = negotiate(_hello(provenance_producer="deterministic"), policy_requires_provenance=True) + assert terms.negotiated_version == "0.1.0" + + +def test_no_shared_method_refuses_the_session() -> None: + # -32000, not -32003: nothing was exercised, so no capability failed to + # negotiate; the session itself cannot be governed usefully (§17.1). + with pytest.raises(AcsError) as caught: + negotiate(_hello(methods_implemented=["protocols/MCP/tools/call"])) + assert caught.value.code == SESSION_REFUSED + assert caught.value.data["reason"] == "no_common_method" + + +def test_profiles_accepted_is_the_intersection() -> None: + # Empty today: the Guardian accepts no profile until Wrapped MCP (a + # mandatory ACS-Core item) is implemented — the honest wire signal. + terms = negotiate(_hello(profiles_supported=["acs-core", "acs-trace"])) + assert terms.profiles_accepted == () + + +def test_server_hello_carries_the_negotiated_terms() -> None: + hello = server_hello(negotiate(_hello(), on_decision_failure="deny", timeout_ms=1234, skew_window_ms=60000)) + assert hello["negotiated_version"] == "0.1.0" + assert hello["selected_transport"] == "http" + assert hello["on_decision_failure"] == "deny" + assert hello["timeout_config"] == {"default_ms": 1234} + assert hello["skew_window_ms"] == 60000 + assert hello["signature_algorithms_supported"] == ["HMAC-SHA256"] diff --git a/reference-implementations/agt-python/tests/test_server.py b/reference-implementations/agt-python/tests/test_server.py new file mode 100644 index 00000000..a606daff --- /dev/null +++ b/reference-implementations/agt-python/tests/test_server.py @@ -0,0 +1,393 @@ +"""Guardian behavior beyond the probes: signing rules, ping, chain, refusals.""" + +from __future__ import annotations + +import hashlib +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from helpers import ( + AGENT_ID, + SECRET, + envelope, + expect_decision, + expect_error, + handshake, + http_post, + now_iso, + signed_post, + sign_request, + verify_response, +) + +from acs_guardian.canonical import canonical_bytes, response_signing_input +from acs_guardian.crypto import derive_session_key, sign +from acs_guardian.engine import ASK, DEFER, MODIFY, Evaluation +from acs_guardian.errors import INVALID_PARAMS, AcsError +from acs_guardian.schemas import HOOK_METHODS +from acs_guardian.server import Guardian, GuardianConfig + + +def _session(server, methods=("steps/toolCallRequest", "steps/toolCallResult", "system/ping")) -> str: + session_id = str(uuid.uuid4()) + handshake(server.url, session_id, list(methods)) + return session_id + + +def test_a_decision_carries_a_verifiable_chain_hash_and_a_signed_result(server) -> None: + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ) + response = signed_post(server.url, request, session_id) + result = response["result"] + assert len(result["chain_hash"]) == 64 + assert result["type"] == "final" + assert result["acs_version"] == "0.1.0" + + +def test_the_chain_head_advances_across_steps(server) -> None: + session_id = _session(server) + heads = [] + for command in ("ls", "pwd"): + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": command}}, "raw_command": command}, + ) + heads.append(signed_post(server.url, request, session_id)["result"]["chain_hash"]) + assert heads[0] != heads[1] + + +def test_destructive_command_is_denied_by_the_builtin_engine(server) -> None: + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "rm -rf /"}}, "raw_command": "rm -rf /"}, + ) + response = signed_post(server.url, request, session_id) + expect_decision(response, "deny", request["id"]) + assert response["result"]["reasoning"] + assert response["result"]["reason_codes"] == ["destructive_shell_command_blocked"] + + +def test_a_missing_signature_is_refused_and_the_answer_is_unsigned(server) -> None: + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ) + response = http_post(server.url, request) + expect_error(response, -32004) + assert "signature" not in response["error"] + + +def test_a_wrong_signature_is_refused(server) -> None: + session_id = _session(server) + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + ) + request["params"]["signature"]["value"] = "AAAA" + expect_error(http_post(server.url, request), -32004) + + +def test_an_old_timestamp_is_outside_the_skew_window(server) -> None: + session_id = _session(server) + stale = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat().replace("+00:00", "Z") + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + timestamp=stale, + ) + response = signed_post(server.url, request, session_id) + expect_error(response, -32006) + assert response["error"]["data"]["skew_window_ms"] == 300_000 + + +def test_ping_is_always_allowed_and_does_not_touch_the_chain(server) -> None: + session_id = _session(server) + request = envelope("system/ping", session_id, {"echo": "hi"}) + response = signed_post(server.url, request, session_id) + result = response["result"] + assert result["decision"] == "allow" + assert result["payload"]["status"] == "ok" + assert result["payload"]["echo"] == "hi" + assert "chain_hash" not in result + + +def test_ping_needs_no_signature(server) -> None: + session_id = _session(server) + response = http_post(server.url, envelope("system/ping", session_id, {})) + assert response["result"]["decision"] == "allow" + + +def test_a_second_handshake_is_refused_but_its_replay_is_a_replay(server) -> None: + session_id = _session(server) + again = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ) + expect_error(signed_post(server.url, again, session_id), -32000) + expect_error(signed_post(server.url, again, session_id), -32005) + + +def test_batching_is_refused_with_invalid_request(server) -> None: + response = http_post(server.url, [{"jsonrpc": "2.0", "method": "system/ping", "id": 1, "params": {}}]) + expect_error(response, -32600) + + +def test_parse_error_gets_the_jsonrpc_code(server) -> None: + import json + import urllib.request + + request = urllib.request.Request( + server.url, data=b"{not json", headers={"content-type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(request) as response: + payload = json.loads(response.read().decode()) + expect_error(payload, -32700) + + +def test_an_unimplemented_but_negotiated_method_cannot_happen_and_an_unknown_namespace_does_not(server) -> None: + # steps/nonexistent is not in IMPLEMENTED_METHODS, so the handshake could + # not have negotiated it; a client that sends it anyway gets -32003. + session_id = _session(server) + request = envelope("steps/nonexistent", session_id, {}) + expect_error(signed_post(server.url, request, session_id), -32003) + + +def test_the_server_hello_signature_verifies_with_the_session_key(server) -> None: + session_id = str(uuid.uuid4()) + hello = handshake(server.url, session_id, ["steps/toolCallRequest"]) + # signed_post already verifies; this asserts the key it verified against is + # the session-derived one, not a constant. + key = derive_session_key(bytes(range(32)), session_id) + holder = hello["result"] + assert holder["signature"]["value"] == sign(key, response_signing_input(hello)) + + +def test_a_replayed_nonce_is_replay_detected(server) -> None: + """A fresh request_id with a seen nonce is still a replay (§10.3).""" + session_id = _session(server) + nonce = "0123456789abcdef0123456789abcdef" + first = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ) + first["params"]["nonce"] = nonce + expect_decision(signed_post(server.url, first, session_id), "allow", first["id"]) + + second = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "pwd"}}, "raw_command": "pwd"}, + ) + second["params"]["nonce"] = nonce + expect_error(signed_post(server.url, second, session_id), -32005) + + +def test_an_envelope_missing_a_required_field_is_invalid_request(server) -> None: + """The envelope schema is authoritative, not the manual jsonrpc check. + + The answer is unsigned: envelope validation runs before any key is + established, and a request that did not authenticate gets an unsigned + answer. + """ + session_id = _session(server) + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + ) + del request["params"]["acs_version"] + response = http_post(server.url, request) + expect_error(response, -32600) + assert "signature" not in response["error"] + + +def test_request_hash_commits_to_the_params_including_the_signature(server) -> None: + """The interop choice: request_hash hashes params as received, signature included.""" + session_id = _session(server) + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + ) + response = signed_post(server.url, request, session_id) + expected = hashlib.sha256(canonical_bytes(request["params"])).hexdigest() + session = server.guardian.store.get(session_id) + assert session.chain.entries[-1].request_hash == expected + assert response["result"]["chain_hash"] == session.chain.head + + +class _StubEngine: + """Returns one fixed decision, so every disposition's wire shape is exercised.""" + + def __init__(self, evaluation: Evaluation) -> None: + self.evaluation = evaluation + + def evaluate(self, request) -> Evaluation: # noqa: ANN001 - the Protocol's shape + return self.evaluation + + +@pytest.mark.parametrize( + "evaluation", + [ + Evaluation(decision="allow"), + Evaluation(decision="deny", reasoning="blocked by the stub"), + Evaluation( + decision=MODIFY, + reasoning="redacted by the stub", + modifications={"redactions": [{"path": "/outputs/0/value", "replacement": "[redacted]"}]}, + ), + Evaluation( + decision=ASK, + reasoning="needs approval", + ask_details={ + "approver": {"type": "human", "id": "ops", "endpoint": "https://approver.example/acs"}, + "question": "Approve this tool call?", + "timeout_seconds": 300, + }, + ), + Evaluation( + decision=DEFER, + reasoning="not yet reachable", + defer_details={ + "reason": "insufficient_context", + "resolution_method": "additional_context", + "resolution_timeout_ms": 60000, + "timeout_decision": "deny", + }, + ), + ], +) +def test_all_five_dispositions_produce_schema_valid_decisions(evaluation: Evaluation) -> None: + guardian = Guardian(GuardianConfig(secret=SECRET, port=0, engine=_StubEngine(evaluation))) + session_id = str(uuid.uuid4()) + hello = sign_request( + envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ), + session_id, + ) + handshake_response = guardian.handle(hello) + verify_response(handshake_response, session_id, hello["id"]) + + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == evaluation.decision + # The outbound check the Guardian itself runs must find nothing: a + # conformant decision, or the Guardian would have replaced it with a deny. + assert guardian.schemas.validate_response(response) is None + + +def test_an_incomplete_engine_decision_fails_closed() -> None: + """A modify without modifications cannot be sent as one; it becomes a deny.""" + guardian = Guardian( + GuardianConfig(secret=SECRET, port=0, engine=_StubEngine(Evaluation(decision=MODIFY, reasoning="no payload"))) + ) + session_id = str(uuid.uuid4()) + hello = sign_request( + envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ), + session_id, + ) + guardian.handle(hello) + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + assert guardian.schemas.validate_response(response) is None + + +def _kebab(method: str) -> str: + """steps/toolCallRequest -> tool-call-request; system/ping -> system-ping.""" + name = method.split("/", 1)[1] + out = [] + for index, char in enumerate(name): + if char.isupper() and index > 0: + out.append("-") + out.append(char.lower()) + return "".join(out) + + +@pytest.mark.parametrize("method", HOOK_METHODS) +def test_every_hook_payload_schema_loads_and_is_the_right_schema(guardian: Guardian, method: str) -> None: + """Each hook's schema exists, compiles, and is pinned to the method name. + + The method -> file mapping is checked against an independent derivation + (camelCase -> kebab-case), so a swapped pair in the table (turnStart's + method pointing at turn-end.json, say) fails here rather than silently + validating against the wrong schema. + """ + from acs_guardian.schemas import _HOOK_SCHEMAS + + relative = f"hooks/{_kebab(method)}.json" + assert _HOOK_SCHEMAS[method] == relative + # And the file's own $id says it is that schema, so a wrong file at the + # right path cannot pass either. + schema_id = guardian.schemas.hook_schema_id(method) + assert schema_id is not None and schema_id.endswith(f"/{relative}") + try: + guardian.schemas.validate_payload(method, {}) + except AcsError as error: + assert error.code == INVALID_PARAMS diff --git a/reference-implementations/agt-python/tests/testdata/README.md b/reference-implementations/agt-python/tests/testdata/README.md new file mode 100644 index 00000000..3def3645 --- /dev/null +++ b/reference-implementations/agt-python/tests/testdata/README.md @@ -0,0 +1,15 @@ +# Provenance of `envelope-vectors.json` + +These vectors are not generated by this tree. They are copied byte for byte +from the Go reference implementation contributed in the open pull request +[#169](https://github.com/GenAI-Security-Project/agent-control-standard/pull/169) +by @President (fork `disciplinedware/agent-control-standard`, branch +`refimpl/go-port`), at `reference-implementations/agt-go/internal/envelope/testdata/vectors.json`. + +They pin the interoperability contract ACS v0.1.0 leaves implicit: the HKDF +inputs (SHA-256, no salt, `info = session_id`, 32 bytes), the JCS form of the +signed input, and the HMAC-SHA256 signature over it. `tests/test_crypto_vectors.py` +asserts this implementation agrees with them. + +When #169 merges, that file becomes the in-tree source and this copy should be +deleted and the test pointed at `../agt-go/internal/envelope/testdata/vectors.json`. diff --git a/reference-implementations/agt-python/tests/testdata/envelope-vectors.json b/reference-implementations/agt-python/tests/testdata/envelope-vectors.json new file mode 100644 index 00000000..afe5cbf2 --- /dev/null +++ b/reference-implementations/agt-python/tests/testdata/envelope-vectors.json @@ -0,0 +1,62 @@ +{ + "ikm_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "key_id": "k1", + "session_id": "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30", + "session_key_hex": "4e86230ba407bc623132e84189722dc2b4ab73f217ff67e0492c82fdf0d66309", + "request": { + "signed": { + "jsonrpc": "2.0", + "method": "steps/toolCallRequest", + "id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "params": { + "acs_version": "0.1.0", + "request_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "timestamp": "2026-09-19T10:00:00Z", + "metadata": { + "agent_id": "agent", + "session_id": "5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30" + }, + "payload": { + "tool": { + "name": "Bash" + }, + "arguments": { + "command": { + "value": "echo rm -rf /" + } + }, + "raw_command": "echo rm -rf /" + }, + "signature": { + "algorithm": "HMAC-SHA256", + "value": "yJ3BXf/Hfc3jSnSMgOoNVGQ+5LKBKakvctDwE52lrM0=", + "key_id": "k1" + } + } + }, + "signing_input": "{\"id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"jsonrpc\":\"2.0\",\"method\":\"steps/toolCallRequest\",\"params\":{\"acs_version\":\"0.1.0\",\"metadata\":{\"agent_id\":\"agent\",\"session_id\":\"5c3f1b8e-9a0d-4c2e-8f4b-2d6a7e9c1b30\"},\"payload\":{\"arguments\":{\"command\":{\"value\":\"echo rm -rf /\"}},\"raw_command\":\"echo rm -rf /\",\"tool\":{\"name\":\"Bash\"}},\"request_id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"timestamp\":\"2026-09-19T10:00:00Z\"}}" + }, + "response": { + "signed": { + "jsonrpc": "2.0", + "id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "result": { + "type": "final", + "acs_version": "0.1.0", + "request_id": "8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10", + "decision": "deny", + "reasoning": "blocked", + "reason_codes": [ + "destructive_shell_command_blocked" + ], + "chain_hash": "14e6d50a913e8295606bc0d86ee9cde870040e0e0a319b13ae15836128d0d111", + "signature": { + "algorithm": "HMAC-SHA256", + "value": "Shh4i+RWlfCNfAoFlE7gp5QUoyCI8jz3HMKHzH/2gG0=", + "key_id": "k1" + } + } + }, + "signing_input": "{\"id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"jsonrpc\":\"2.0\",\"result\":{\"acs_version\":\"0.1.0\",\"chain_hash\":\"14e6d50a913e8295606bc0d86ee9cde870040e0e0a319b13ae15836128d0d111\",\"decision\":\"deny\",\"reason_codes\":[\"destructive_shell_command_blocked\"],\"reasoning\":\"blocked\",\"request_id\":\"8d0b6b0e-4d6b-4f7a-9d1c-1f1c7f0e2a10\",\"type\":\"final\"}}" + } +} diff --git a/reference-implementations/agt-python/uv.lock b/reference-implementations/agt-python/uv.lock new file mode 100644 index 00000000..fe420170 --- /dev/null +++ b/reference-implementations/agt-python/uv.lock @@ -0,0 +1,280 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "acs-guardian" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "jsonschema" }, + { name = "referencing" }, + { name = "rfc8785" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "jsonschema", specifier = ">=4.25.0" }, + { name = "referencing", specifier = ">=0.35.0" }, + { name = "rfc8785", specifier = ">=0.1.4" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From f53152d6ae7348b0faf47317f683f9ac5382929e Mon Sep 17 00:00:00 2001 From: Duke - Duc Dinh Date: Tue, 22 Sep 2026 22:14:20 +0700 Subject: [PATCH 2/4] Add Wrapped MCP and the hook disposition rules to the Python Guardian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocols/MCP/* and the explicit-version wrapped:mcp-/* 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 --- .../workflows/reference-implementation.yml | 6 + .../agt-python/README.md | 69 ++- .../agt-python/src/acs_guardian/__init__.py | 9 +- .../agt-python/src/acs_guardian/canonical.py | 2 +- .../agt-python/src/acs_guardian/chain.py | 9 +- .../agt-python/src/acs_guardian/engine.py | 21 +- .../agt-python/src/acs_guardian/errors.py | 11 +- .../agt-python/src/acs_guardian/handshake.py | 74 ++- .../agt-python/src/acs_guardian/mcp.py | 111 ++++ .../agt-python/src/acs_guardian/methods.py | 118 +++++ .../agt-python/src/acs_guardian/schemas.py | 10 + .../agt-python/src/acs_guardian/server.py | 335 ++++++++++--- .../agt-python/src/acs_guardian/session.py | 4 + .../agt-python/tests/test_chain.py | 17 + .../agt-python/tests/test_handshake.py | 17 +- .../agt-python/tests/test_server.py | 473 +++++++++++++++++- .../agt-python/tests/test_wrapped_mcp.py | 148 ++++++ 17 files changed, 1308 insertions(+), 126 deletions(-) create mode 100644 reference-implementations/agt-python/src/acs_guardian/mcp.py create mode 100644 reference-implementations/agt-python/src/acs_guardian/methods.py create mode 100644 reference-implementations/agt-python/tests/test_wrapped_mcp.py diff --git a/.github/workflows/reference-implementation.yml b/.github/workflows/reference-implementation.yml index 6222485a..843ab825 100644 --- a/.github/workflows/reference-implementation.yml +++ b/.github/workflows/reference-implementation.yml @@ -18,6 +18,9 @@ on: pull_request: paths: - "reference-implementations/**" + # Python-only changes are covered by reference-implementation-python.yml, + # so they do not pay for a Bun install here. + - "!reference-implementations/agt-python/**" # The conformance checker and the trace exporter read the schemas and # the Trace mappings at runtime, so a spec-only change can break them. - "specification/**" @@ -26,6 +29,9 @@ on: branches: ["main", "integration"] paths: - "reference-implementations/**" + # Python-only changes are covered by reference-implementation-python.yml, + # so they do not pay for a Bun install here. + - "!reference-implementations/agt-python/**" # The conformance checker and the trace exporter read the schemas and # the Trace mappings at runtime, so a spec-only change can break them. - "specification/**" diff --git a/reference-implementations/agt-python/README.md b/reference-implementations/agt-python/README.md index 0047ad2d..eeb59ab1 100644 --- a/reference-implementations/agt-python/README.md +++ b/reference-implementations/agt-python/README.md @@ -27,20 +27,17 @@ the ones this tree follows, so the two are intended to interoperate. | Replay protection: duplicate `request_id`/`nonce`, timestamp skew (`-32005`, `-32006`) | Met | `server.py`, `tests/test_server.py` | | Baseline integrity: HKDF-SHA256 session key, HMAC-SHA256 signature | Met | `crypto.py`, `tests/test_crypto_vectors.py` | | Liveness, `system/ping` | Met | `server.py`, `tests/test_server.py` | -| **Wrapped MCP, `protocols/MCP/*`** | **Not implemented** | not negotiated until it is; see below | +| Wrapped MCP, `protocols/MCP/*` and `wrapped:mcp-/*` | Met | `mcp.py`, `methods.py`, `tests/test_wrapped_mcp.py` | +| Dispositions a hook does not permit are substituted, never sent | Met | `methods.py` (`PERMITTED`), `server.py` | | Decision honoring (§6.4) | Observed Agent responsibility | the host adapter's, not this tree's | -| Trace pillar (OTel/OCSF), AgBOM (`agbom/*`) | Not claimed | v1 roadmap items | +| Trace pillar (OTel/OCSF), AgBOM (`agbom/*`) | Not claimed | v1 roadmap items; `agbom/*` is refused with `-32003` | +| Intent handling (IBAC) | Not claimed | Intent is optional and IBAC-conditional; `ask_details` passes through | -**The ACS-Core claim is deliberately partial**: `protocols/MCP/*` is a -mandatory ACS-Core item and this tree does not implement it yet, so the -ServerHello answers `profiles_accepted: []` — the honest wire signal, since a -client reads that field, not this table. The work is tracked as the next -increment; until then this tree does not claim `acs-core`, `acs-trace`, -`acs-inspect`, `acs-provenance`, `acs-crypto`, or `acs-audit`, and the rows -above say exactly which parts are implemented. - -ACS v0.1.0 has no certification mechanism: this tree self-declares what it -implements and each claimed row names the tests that cover it. +Every ACS-Core item in `docs/spec/conformance.md` is implemented, so the +ServerHello accepts `acs-core`. ACS v0.1.0 has no certification mechanism: +this tree self-declares what it implements and each claimed row names the +tests that cover it. `acs-trace`, `acs-inspect`, `acs-provenance`, +`acs-crypto`, and `acs-audit` are not claimed. ## Run it @@ -72,10 +69,12 @@ conformance harness's external mode — envelope refusal, pre-session refusal, transport refusal, handshake, an allowed call, three replay checks, invalid params, and an unnegotiated method. Nine of the ten are signature-verified; the first sends a bare envelope with no session, which the harness does not -sign either. The external mode itself is contributed in the open PR #169 (not -merged at the time of writing); until it lands, this test is the local gate. -Once it lands, the official mode runs against this Guardian from -`reference-implementations/agt`: +sign either. `tests/test_wrapped_mcp.py` covers the wrapped MCP contract, and +`tests/test_server.py` the hook dispositions, §6.3 modification composition, +session close, and the session bindings. The external mode itself is +contributed in the open PR #169 (not merged at the time of writing); until it +lands, the probe port is the local gate. Once it lands, the official mode runs +against this Guardian from `reference-implementations/agt`: ```bash ACS_CONFORMANCE_GUARDIAN_URL=http://127.0.0.1:8787/acs \ @@ -104,10 +103,32 @@ documents in its "Behavior that ACS v0.1.0 leaves undefined" table: other's write time, and the field is inside the hashed content, so a deterministic input is what makes cross-implementation `entry_hash` comparison possible at all. -- A refusal that is not a version or provenance refusal during the handshake is - `SESSION_REFUSED` (`-32000`): nothing has been exercised, so - `CAPABILITY_NOT_NEGOTIATED` (`-32003`) does not apply until hook traffic - starts. +- A wrapped MCP message's method must equal the part after `protocols/MCP/` + for a request (a response carries no method and is wrapped under the method + it answers). A `parameter_overrides` key names a member of the wrapped + message's `params.arguments`; a native hook's names a tool argument (a + `/arguments/` pointer). +- Method routing follows the standard's own distinctions: an undefined method + is `METHOD_NOT_FOUND` (`-32601`), a defined but never-negotiated method + (`agbom/*`) and a defined method the handshake did not negotiate are + `CAPABILITY_NOT_NEGOTIATED` (`-32003`). An empty `methods_evaluated` is a + valid ServerHello, not a refusal. +- `steps/sessionEnd` closes the session: a later step is answered with a DENY + `session_closed`, while replay history survives so a replayed step is still + `REPLAY_DETECTED`. A key_id or agent_id other than the session's is answered + with a signed DENY (`key_not_bound`, `agent_id_not_bound`), not an error — + an error would leave the agent to its failure posture. + +### A deliberate deviation from an `extend_mcp.md` example + +The examples in `docs/spec/instrument/extend_mcp.md` show a wrapped MCP +message placed directly in the ACS envelope's `params`, with no +`acs_version`/`request_id`/`timestamp`/`metadata`/`payload` — a shape +`request-envelope.json` rejects (`params` requires all five). The spec +contradicts itself there, and this tree follows the schema: the MCP message is +carried intact in `params.payload` (as the Go port in open PR #169 does). The +inconsistency is worth an upstream issue; this tree does not silently pick a +side without saying so. ## Non-goals @@ -117,9 +138,9 @@ documents in its "Behavior that ACS v0.1.0 leaves undefined" table: - **Not a conformance suite or registry.** The community's suite owns the vectors; this tree is an implementation under test. - **No session eviction.** The default store is in-memory and keeps sessions - for the process lifetime, so replay history survives `steps/sessionEnd` (a - step after sessionEnd is still a replay). A long-lived deployment passes its - own `SessionStore` (with TTL or capacity limits) as `GuardianConfig.session_store`; - that is the seam. + for the process lifetime, so replay history survives `steps/sessionEnd` even + though a step after it is denied with `session_closed`. A long-lived + deployment passes its own `SessionStore` (with TTL or capacity limits) as + `GuardianConfig.session_store`; that is the seam. - **No host adapters.** Honoring decisions is the Observed Agent's job; the TypeScript tree ships the Claude Code and OpenCode adapters. diff --git a/reference-implementations/agt-python/src/acs_guardian/__init__.py b/reference-implementations/agt-python/src/acs_guardian/__init__.py index 460b9871..b14641af 100644 --- a/reference-implementations/agt-python/src/acs_guardian/__init__.py +++ b/reference-implementations/agt-python/src/acs_guardian/__init__.py @@ -1,11 +1,10 @@ """Python reference Guardian implementation for OWASP ACS v0.1.0. -An independent implementation of the ACS wire contract +An independent implementation of the ACS-Core wire contract (docs/spec/conformance.md): JSON-RPC 2.0 envelopes, the capability -negotiation handshake, the hook taxonomy, the five dispositions, the -SessionContext audit chain, replay protection, and the HMAC-SHA256 baseline -signature. Wrapped MCP is not implemented yet; see README.md for the exact -status of the ACS-Core claim. +negotiation handshake, the 19 hooks and wrapped MCP, the five dispositions, +the SessionContext audit chain, replay protection, and the HMAC-SHA256 +baseline signature. See README.md for the status table and non-goals. """ __version__ = "0.1.0" diff --git a/reference-implementations/agt-python/src/acs_guardian/canonical.py b/reference-implementations/agt-python/src/acs_guardian/canonical.py index 365c762a..40db7c7a 100644 --- a/reference-implementations/agt-python/src/acs_guardian/canonical.py +++ b/reference-implementations/agt-python/src/acs_guardian/canonical.py @@ -4,7 +4,7 @@ response envelope with the ``signature`` field removed, encoded as UTF-8 (Specification §10). A request carries its signature at ``params.signature``; a response carries it at ``result.signature`` or ``error.signature`` (the -location the Go reference implementation uses for the two shapes +location the Go port in open PR #169 uses for the two shapes ``response-envelope.json`` defines no signature field for: a ``ServerHello`` result and an error). diff --git a/reference-implementations/agt-python/src/acs_guardian/chain.py b/reference-implementations/agt-python/src/acs_guardian/chain.py index 679792e0..237de26e 100644 --- a/reference-implementations/agt-python/src/acs_guardian/chain.py +++ b/reference-implementations/agt-python/src/acs_guardian/chain.py @@ -111,9 +111,16 @@ def append( def verify_chain(entries: list[dict[str, Any]]) -> bool: - """Recompute a serialized chain's hashes; used by tests and external auditors.""" + """Recompute a serialized chain's hashes; used by tests and external auditors. + + The stored ``previous_hash`` is checked against the recomputed one, not + just overwritten by it: a chain whose linkage was rewritten must fail, + which is the property an auditor runs this for. + """ previous: str | None = None for entry in entries: + if entry.get("previous_hash") != previous: + return False expected = compute_entry_hash(entry, previous) if entry.get("entry_hash") != expected: return False diff --git a/reference-implementations/agt-python/src/acs_guardian/engine.py b/reference-implementations/agt-python/src/acs_guardian/engine.py index 92d7889c..dc5bf9d6 100644 --- a/reference-implementations/agt-python/src/acs_guardian/engine.py +++ b/reference-implementations/agt-python/src/acs_guardian/engine.py @@ -86,14 +86,29 @@ def _flatten_strings(value: Any) -> list[str]: return [] +def _tool_call_texts(method: str, payload: dict[str, Any]) -> list[str] | None: + """The strings a destructive-command rule reads, or None when this is not a tool call. + + A native ``steps/toolCallRequest`` carries its arguments at ``arguments`` + and a raw command at ``raw_command``; a wrapped MCP ``tools/call`` carries + the intact message, whose arguments live under ``params.arguments``. + """ + if method == "steps/toolCallRequest": + return [payload.get("raw_command", ""), *_flatten_strings(payload.get("arguments", {}))] + if method.endswith("/tools/call"): + params = payload.get("params") + if isinstance(params, dict): + return _flatten_strings(params.get("arguments", {})) + return None + + class BuiltinEngine: """Allow by default; deny destructive shell commands on tool calls.""" def evaluate(self, request: EvaluationRequest) -> Evaluation: - if request.method != "steps/toolCallRequest": + candidates = _tool_call_texts(request.method, request.payload) + if candidates is None: return Evaluation(decision=ALLOW) - candidates = [request.payload.get("raw_command", "")] - candidates.extend(_flatten_strings(request.payload.get("arguments", {}))) for text in candidates: if not isinstance(text, str): continue diff --git a/reference-implementations/agt-python/src/acs_guardian/errors.py b/reference-implementations/agt-python/src/acs_guardian/errors.py index 3130c1d4..22f231bd 100644 --- a/reference-implementations/agt-python/src/acs_guardian/errors.py +++ b/reference-implementations/agt-python/src/acs_guardian/errors.py @@ -6,10 +6,11 @@ today: ``PARSE_ERROR``, ``INVALID_REQUEST``, ``INVALID_PARAMS``, ``INTERNAL_ERROR``, ``SESSION_REFUSED``, ``UNSUPPORTED_VERSION``, ``PROVENANCE_REQUIRED``, ``CAPABILITY_NOT_NEGOTIATED``, ``SIGNATURE_INVALID``, -``REPLAY_DETECTED`` and ``TIMESTAMP_OUT_OF_WINDOW``. ``METHOD_NOT_FOUND`` and -``CHAIN_MISMATCH`` are defined but not yet raised (an unnegotiated method is -``CAPABILITY_NOT_NEGOTIATED``, and no cross-check path submits a chain head -yet). +``REPLAY_DETECTED``, ``TIMESTAMP_OUT_OF_WINDOW`` and ``METHOD_NOT_FOUND``. +``METHOD_NOT_FOUND`` answers a method the standard does not define; +``CAPABILITY_NOT_NEGOTIATED`` answers a defined one the handshake did not +negotiate. ``CHAIN_MISMATCH`` is defined but not yet raised: no cross-check +path submits a chain head yet. The Guardian mints no codes of its own: the TypeScript reference implementation uses three private codes from the reserved band (-32010, -32011, -32020) for @@ -47,7 +48,7 @@ class AcsError(Exception): ``signable`` records whether the request authenticated: a request that did not establish a key (no session_id, or a failed signature) gets an unsigned answer, because signing it would claim a key relationship that - does not exist (the Go reference implementation's rule). + does not exist (the Go port's rule in open PR #169). """ def __init__( diff --git a/reference-implementations/agt-python/src/acs_guardian/handshake.py b/reference-implementations/agt-python/src/acs_guardian/handshake.py index d57ea64a..9ed41988 100644 --- a/reference-implementations/agt-python/src/acs_guardian/handshake.py +++ b/reference-implementations/agt-python/src/acs_guardian/handshake.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from typing import Any +from . import mcp, methods from .errors import ( PROVENANCE_REQUIRED, SESSION_REFUSED, @@ -31,17 +32,19 @@ # terminates TLS in front of the Guardian; the selected transport is still the # one the client offered, and the listener this tree ships speaks plain HTTP. SUPPORTED_TRANSPORTS = ("http", "https") -# Empty until Wrapped MCP (protocols/MCP/*) is implemented: it is a mandatory -# ACS-Core item (docs/spec/conformance.md), and `profiles_accepted` is read by -# a machine, so an empty list is the honest wire answer while the -# implementation is partial. Restore ("acs-core",) with the MCP increment. -SUPPORTED_PROFILES: tuple[str, ...] = () +# Every ACS-Core item in docs/spec/conformance.md is implemented (handshake, +# envelopes, the 19 hooks, all five dispositions, the chain, replay, the HMAC +# baseline, liveness, wrapped MCP), so the Guardian accepts acs-core. Trace, +# Inspect, Provenance, Crypto, and Audit are not claimed. +SUPPORTED_PROFILES: tuple[str, ...] = ("acs-core",) SIGNATURE_ALGORITHMS = ("HMAC-SHA256",) DEFAULT_TIMEOUT_MS = 5000 DEFAULT_SKEW_WINDOW_MS = 300_000 -# Every method this Guardian dispatches. system/ping is here because it is -# dispatched; wrapped MCP and agbom/* are not yet, so they are not negotiated. +# Every native method this Guardian dispatches. Wrapped MCP methods are +# negotiated separately: a handler reads them, and the client must declare the +# protocol in wrapped_protocols. agbom/* is defined but never negotiated (the +# Inspect profile is not implemented). IMPLEMENTED_METHODS: tuple[str, ...] = (*HOOK_METHODS, "system/ping") @@ -56,6 +59,43 @@ class NegotiatedTerms: skew_window_ms: int +def declared_wrapped_protocols(client_hello: dict[str, Any]) -> dict[str, tuple[str, ...]]: + declared: dict[str, tuple[str, ...]] = {} + entries = client_hello.get("wrapped_protocols") + if not isinstance(entries, list): + return declared + for entry in entries: + if not isinstance(entry, dict): + continue + protocol, version = entry.get("protocol"), entry.get("version") + if not isinstance(protocol, str) or not isinstance(version, str): + continue + declared[protocol.upper()] = (*declared.get(protocol.upper(), ()), version) + return declared + + +def evaluates(name: str, declared: dict[str, tuple[str, ...]]) -> bool: + """Whether this Guardian will evaluate a method the client declared (handshake.evaluated).""" + if name in IMPLEMENTED_METHODS: + return True + split = methods.split_wrapped(name) + if split is None: + return False + protocol, version, inner = split + if protocol.upper() != mcp.PROTOCOL: + return False + if not mcp.supports(inner): + return False + versions = declared.get(protocol.upper(), ()) + if not versions: + # The protocol must be declared in wrapped_protocols; a method naming + # an undeclared protocol is not negotiated. + return False + # The protocols/ form pins no version; the explicit-version form is + # negotiated only at a declared version. + return version is None or version in versions + + def _select_version(client_versions: list[str]) -> str: for supported in SUPPORTED_VERSIONS: major = supported.split(".")[0] @@ -108,17 +148,17 @@ def negotiate( "policy requires provenance and the client declared provenance_producer: none", ) + declared = declared_wrapped_protocols(client_hello) client_methods = [m for m in client_hello.get("methods_implemented", []) if isinstance(m, str)] - methods_evaluated = tuple(m for m in client_methods if m in IMPLEMENTED_METHODS) - if not methods_evaluated: - # SESSION_REFUSED, not CAPABILITY_NOT_NEGOTIATED: nothing has been - # exercised yet, so there is no capability to have failed to negotiate - # (§17.1); the session simply cannot be governed usefully. - raise AcsError( - SESSION_REFUSED, - "ClientHello declares no method this Guardian evaluates", - data={"reason": "no_common_method", "implemented_methods": list(IMPLEMENTED_METHODS)}, - ) + evaluated: list[str] = [] + for name in client_methods: + if name not in evaluated and evaluates(name, declared): + evaluated.append(name) + # An empty intersection is a valid ServerHello: the client is told the + # Guardian evaluates none of its methods, and a method absent from + # methods_evaluated is ALLOW-by-default to it (handshake.json). The + # Guardian does not refuse the session over it. + methods_evaluated = tuple(evaluated) client_profiles = [p for p in client_hello.get("profiles_supported", []) if isinstance(p, str)] return NegotiatedTerms( diff --git a/reference-implementations/agt-python/src/acs_guardian/mcp.py b/reference-implementations/agt-python/src/acs_guardian/mcp.py new file mode 100644 index 00000000..2499d3a6 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/mcp.py @@ -0,0 +1,111 @@ +"""The wrapped MCP handler: ``protocols/MCP/*`` (extend_mcp.md). + +A wrapped call carries one MCP JSON-RPC 2.0 message intact in +``params.payload``: a request or notification the Observed Agent is about to +send, or the response it received, which it wraps under the method the +response answers. The message reaches the policy engine intact; this module +is the only protocol-specific part of a wrapped call — the envelope, the +session, the chain, the decision and the signature are the Guardian's, +shared with the native hooks. + +``read`` implements the JSON-RPC 2.0 shape checks every MCP message shares; +the same checks the Go port contributed in the open PR #169 +(``reference-implementations/agt-go/internal/mcp/mcp.go``) makes, so the two +refuse the same messages. The method name is not compared for a response: a +response carries no ``method``, and it is wrapped under the method of the +request it answers. +""" + +from __future__ import annotations + +import re +from typing import Any + +from .errors import INVALID_PARAMS, AcsError + +PROTOCOL = "MCP" + +# Every MCP method name: slash-separated segments of letters, digits and +# underscores (initialize, tools/call, notifications/resources/list_changed). +_METHOD_NAME = re.compile(r"^[A-Za-z0-9_]+(/[A-Za-z0-9_]+)*$") + + +def supports(method: str) -> bool: + """Whether the handler reads this MCP method (any well-formed name).""" + return bool(_METHOD_NAME.match(method)) + + +def read(method: str, payload: Any) -> dict[str, Any]: + """Decode and check a wrapped MCP message, or raise INVALID_PARAMS (-32602). + + ``method`` is the part after ``protocols/MCP/``. A request's own method + must equal it; a response must carry an id and exactly one of ``result`` + (an object) and ``error`` (an object with an integer code and a string + message). + """ + if not isinstance(payload, dict): + raise AcsError(INVALID_PARAMS, "the wrapped MCP message must be an object", data={"method": method}) + if payload.get("jsonrpc") != "2.0": + raise AcsError( + INVALID_PARAMS, 'the wrapped MCP message must have jsonrpc "2.0"', data={"method": method} + ) + has_id = "id" in payload + if has_id: + message_id = payload["id"] + # A JSON null id is not a string or a number, and is reserved for + # responses to unparseable requests, so it is refused here too. + if isinstance(message_id, bool) or not isinstance(message_id, (str, int)): + raise AcsError( + INVALID_PARAMS, "the wrapped MCP message's id must be a string or a number", data={"method": method} + ) + + is_response = "method" not in payload + if not is_response: + if payload.get("method") != method: + raise AcsError( + INVALID_PARAMS, + f"the wrapped MCP message is {payload.get('method')}, and the envelope wraps {method}", + data={"method": method}, + ) + if "result" in payload or "error" in payload: + raise AcsError( + INVALID_PARAMS, "a wrapped MCP request carries neither result nor error", data={"method": method} + ) + if "params" in payload and not isinstance(payload["params"], dict): + raise AcsError( + INVALID_PARAMS, "the wrapped MCP message's params must be an object", data={"method": method} + ) + return payload + + if not has_id: + raise AcsError( + INVALID_PARAMS, + "a wrapped MCP response must carry the id of the request it answers", + data={"method": method}, + ) + has_result = "result" in payload + has_error = "error" in payload + if has_result == has_error: + raise AcsError( + INVALID_PARAMS, + "a wrapped MCP response carries exactly one of result and error", + data={"method": method}, + ) + if has_result and not isinstance(payload["result"], dict): + raise AcsError( + INVALID_PARAMS, "a wrapped MCP response's result must be an object", data={"method": method} + ) + if has_error: + error = payload["error"] + if ( + not isinstance(error, dict) + or not isinstance(error.get("code"), int) + or isinstance(error.get("code"), bool) + or not isinstance(error.get("message"), str) + ): + raise AcsError( + INVALID_PARAMS, + "a wrapped MCP response's error must be an object with an integer code and a string message", + data={"method": method}, + ) + return payload diff --git a/reference-implementations/agt-python/src/acs_guardian/methods.py b/reference-implementations/agt-python/src/acs_guardian/methods.py new file mode 100644 index 00000000..aace2339 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/methods.py @@ -0,0 +1,118 @@ +"""The method table: every method the standard defines, how the Guardian routes +it, and which dispositions each hook permits. + +The disposition rules are hooks.md's per-hook "Decision:" lines: + +- audit-only hooks (``subagentStop``, ``skillUnload``, ``turnEnd``, + ``sessionEnd``) permit ALLOW only — the action has already happened; +- ``postCompact`` permits ALLOW and MODIFY but not DENY — the compaction has + already happened; +- ``toolCallRequest`` and every wrapped call permit all five; +- everything else permits ALLOW, DENY, and (except the hooks hooks.md lists + without it) MODIFY. + +Wrapped calls are ``protocols//`` and the explicit-version form +``wrapped:-/`` (§3). The protocol name is matched +case-insensitively; the method within it is the handler's to read. +""" + +from __future__ import annotations + +from .engine import ALLOW, ASK, DEFER, DENY, MODIFY + +HANDSHAKE = "handshake/hello" +PING = "system/ping" +AGBOM_METHODS = ("agbom/snapshot", "agbom/changed") +PROTOCOLS_PREFIX = "protocols/" +WRAPPED_PREFIX = "wrapped:" + +ROUTE_HANDSHAKE = "handshake" +ROUTE_PING = "ping" +ROUTE_HOOK = "hook" +ROUTE_WRAPPED = "wrapped" +ROUTE_UNSUPPORTED = "unsupported" +ROUTE_UNDEFINED = "undefined" + +_ALL_FIVE = (ALLOW, DENY, MODIFY, ASK, DEFER) + +# hooks.md "Decision:" per native hook. +PERMITTED: dict[str, tuple[str, ...]] = { + "steps/sessionStart": (ALLOW, DENY), + "steps/agentTrigger": (ALLOW, DENY, MODIFY), + "steps/turnStart": (ALLOW, DENY), + "steps/userMessage": (ALLOW, DENY, MODIFY), + "steps/agentResponse": (ALLOW, DENY, MODIFY), + "steps/knowledgeRetrieval": (ALLOW, DENY, MODIFY), + "steps/memoryContextRetrieval": (ALLOW, DENY, MODIFY), + "steps/memoryStore": (ALLOW, DENY, MODIFY), + "steps/toolCallRequest": _ALL_FIVE, + "steps/toolCallResult": (ALLOW, DENY, MODIFY), + "steps/preCompact": (ALLOW, DENY), + "steps/postCompact": (ALLOW, MODIFY), + "steps/subagentStart": (ALLOW, DENY), + "steps/subagentStop": (ALLOW,), + "steps/skillRegister": (ALLOW, DENY), + "steps/skillLoad": (ALLOW, DENY), + "steps/skillUnload": (ALLOW,), + "steps/turnEnd": (ALLOW,), + "steps/sessionEnd": (ALLOW,), +} + +def route(name: str) -> str: + """How the Guardian answers a method name.""" + if name == HANDSHAKE: + return ROUTE_HANDSHAKE + if name == PING: + return ROUTE_PING + if name in AGBOM_METHODS: + # Defined by the standard, never negotiated by this Guardian: the + # Inspect profile is not implemented, so agbom/* is CapabilityNotNegotiated. + return ROUTE_UNSUPPORTED + if name.startswith(PROTOCOLS_PREFIX) or name.startswith(WRAPPED_PREFIX): + return ROUTE_WRAPPED + if name in PERMITTED: + return ROUTE_HOOK + return ROUTE_UNDEFINED + + +def split_wrapped(name: str) -> tuple[str, str | None, str] | None: + """Split ``protocols//`` or ``wrapped:-/``. + + Returns ``(protocol, version, method)``; version is None for the + ``protocols/`` form, which pins none. + """ + if name.startswith(WRAPPED_PREFIX): + rest = name[len(WRAPPED_PREFIX) :] + explicit = True + elif name.startswith(PROTOCOLS_PREFIX): + rest = name[len(PROTOCOLS_PREFIX) :] + explicit = False + else: + return None + protocol, _, method = rest.partition("/") + version: str | None = None + if explicit: + protocol, separator, version = protocol.partition("-") + if not separator or not version: + return None + if not protocol or not method: + return None + return protocol, version, method + + +def permitted_dispositions(name: str) -> tuple[str, ...]: + """The dispositions a hook permits; a wrapped call permits all five.""" + return PERMITTED.get(name, _ALL_FIVE) + + +def override_pointer(name: str, argument: str) -> str: + """The JSON pointer a ``parameter_overrides`` key addresses in this method's payload. + + Native hooks address the tool argument of that name (modifications.json); + a wrapped MCP call addresses a member of the wrapped message's + ``params.arguments`` — the wrapped form of the same rule. + """ + escaped = argument.replace("~", "~0").replace("/", "~1") + if route(name) == ROUTE_WRAPPED: + return f"/params/arguments/{escaped}" + return f"/arguments/{escaped}" diff --git a/reference-implementations/agt-python/src/acs_guardian/schemas.py b/reference-implementations/agt-python/src/acs_guardian/schemas.py index 440e7a34..e95fcbf3 100644 --- a/reference-implementations/agt-python/src/acs_guardian/schemas.py +++ b/reference-implementations/agt-python/src/acs_guardian/schemas.py @@ -145,6 +145,16 @@ def validate_payload(self, method: str, payload: Any) -> None: data={"pointer": pointer, "method": method}, ) + def check(self, relative_path: str, instance: Any) -> str | None: + """Validate an instance against a schema by relative path; return a finding or None.""" + try: + for error in self._validator(relative_path).iter_errors(instance): + message, _ = _first_error_message(error) + return message + except Exception as exc: # a schema that will not load is a finding, not a raise + return f"could not be checked: {exc}" + return None + def hook_schema_id(self, method: str) -> str | None: """The ``$id`` of a hook's schema, so tests can pin the method -> file mapping.""" relative = _HOOK_SCHEMAS.get(method) diff --git a/reference-implementations/agt-python/src/acs_guardian/server.py b/reference-implementations/agt-python/src/acs_guardian/server.py index 5ea6d13b..a9851cdf 100644 --- a/reference-implementations/agt-python/src/acs_guardian/server.py +++ b/reference-implementations/agt-python/src/acs_guardian/server.py @@ -3,19 +3,26 @@ The spec mandates no URL path convention -- ``/acs`` is this implementation's choice -- so dispatch is by the JSON-RPC ``method`` field, never by path. -Dispatch order, and why it is this order (each step is asserted by the -conformance probes in ``tests/test_external_probes.py``): +Dispatch order, and why it is this order (the probes in +``tests/test_external_probes.py`` assert the parts of it that are on their +wire; the rest is asserted by ``tests/test_server.py`` and +``tests/test_wrapped_mcp.py``): -1. JSON-RPC envelope shape -> ``-32600``. +1. JSON-RPC envelope shape -> ``-32600``, and JCS canonicalizability -> + ``-32600`` (a number outside the JCS domain cannot be signed by anyone). 2. Signature verification -> ``-32004`` (unsigned answer: the request established no key). -3. Session lookup -> ``-32003`` when no handshake negotiated the session. -4. Replay -> ``-32005``, *before* the capability and payload checks, so a - replay of a refused request is still reported as a replay. -5. Negotiated-method check -> ``-32003``. -6. Timestamp skew -> ``-32006``. -7. Hook payload schema -> ``-32602``. -8. Evaluation, audit-chain append, signed decision. +3. Method routing: an undefined method -> ``-32601``; a method the standard + defines and this Guardian never negotiates (``agbom/*``) -> ``-32003``. +4. Session lookup -> ``-32003`` when no handshake negotiated the session. +5. Under the session lock: replay -> ``-32005`` (before everything below, so + a replay of a refused request is still a replay), then the session + bindings and the closed check, answered as signed DENYs, then the + negotiated-method check -> ``-32003``, then timestamp skew -> ``-32006``. +6. Payload: a wrapped MCP message read and checked -> ``-32602``; a native + hook's payload validated against its schema -> ``-32602``. +7. Evaluation, the hook's permitted dispositions, §6's required fields and + §6.3's composition, the audit-chain append, a signed decision. Every response to an authenticated request is signed with the session key at ``result.signature`` / ``error.signature``, including error responses: the @@ -27,6 +34,7 @@ from __future__ import annotations import json +import re import sys from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone @@ -34,7 +42,8 @@ from pathlib import Path from typing import Any -from .canonical import request_signing_input, response_signing_input +from . import mcp, methods +from .canonical import canonical_bytes, request_signing_input, response_signing_input from .chain import AuditChain from .crypto import ALGORITHM, derive_session_key, sign, verify from .engine import ALLOW, DENY, Evaluation, EvaluationRequest, PolicyEngine @@ -42,6 +51,7 @@ CAPABILITY_NOT_NEGOTIATED, INVALID_PARAMS, INVALID_REQUEST, + METHOD_NOT_FOUND, PARSE_ERROR, REPLAY_DETECTED, SESSION_REFUSED, @@ -49,7 +59,7 @@ TIMESTAMP_OUT_OF_WINDOW, AcsError, ) -from .handshake import IMPLEMENTED_METHODS, negotiate, server_hello +from .handshake import negotiate, server_hello from .schemas import SchemaRegistry from .session import Session, SessionStore @@ -106,6 +116,18 @@ def handle(self, raw: Any) -> dict[str, Any]: self.schemas.validate_request(raw) except AcsError as error: return self._error(_best_effort_id(raw), error.code, error.message, error.data) + try: + # ACS pins JCS (§10): an envelope whose numbers fall outside the + # JCS domain (1e400, an integer past 2^53) cannot be signed or + # verified by anyone, so it is invalid here rather than an + # internal error later when canonicalization raises. + canonical_bytes(raw) + except ValueError as error: + return self._error( + _best_effort_id(raw), + INVALID_REQUEST, + f"request envelope is not JCS-canonicalizable: {error}", + ) rpc_id = raw.get("id") if not isinstance(rpc_id, (str, int)) or isinstance(rpc_id, bool): rpc_id = None @@ -130,7 +152,10 @@ def handle(self, raw: Any) -> dict[str, Any]: return self._step(raw, rpc_id, method, params, key, key_id) except AcsError as error: if error.signable and key is not None: - return self._error(rpc_id, error.code, error.message, error.data, key=key, key_id=key_id) + # Signed under the deployment's key_id, like every response: + # an error answered before a session exists has no session + # key to name. + return self._error(rpc_id, error.code, error.message, error.data, key=key) return self._error(rpc_id, error.code, error.message, error.data) except Exception as error: # noqa: BLE001 - nothing may escape as a non-JSON-RPC answer from .errors import INTERNAL_ERROR @@ -160,21 +185,30 @@ def _handshake( session_id, request_id = self._session_and_request_id(params, signable=False) if key is None or not self._signature_ok(raw, params, key): raise AcsError(SIGNATURE_INVALID, "handshake request signature is missing or invalid", signable=False) + # §10.3 applies to every request, the handshake included: a recorded + # ClientHello must not be replayable after the in-memory session (and + # with it the replay history) is gone. + self._check_timestamp(params.get("timestamp"), self.config.skew_window_ms) client_hello = params.get("payload") if not isinstance(client_hello, dict): raise AcsError(INVALID_PARAMS, "handshake payload must be a ClientHello object") self.schemas.validate_client_hello(client_hello) + nonce = params.get("nonce") existing = self.store.get(session_id) if existing is not None: with existing.lock: if isinstance(request_id, str) and request_id in existing.seen_request_ids: raise AcsError(REPLAY_DETECTED, "request_id was already seen in this session") + if isinstance(nonce, str) and nonce in existing.seen_nonces: + raise AcsError(REPLAY_DETECTED, "nonce was already seen in this session") # Recorded before the refusal, like a step's id: a replay of a # refused handshake is still a replay (the probes assert the # same rule for refused steps). if isinstance(request_id, str): existing.seen_request_ids.add(request_id) + if isinstance(nonce, str): + existing.seen_nonces.add(nonce) raise AcsError( SESSION_REFUSED, "session is already negotiated; v0.1 defines no renegotiation", @@ -188,7 +222,7 @@ def _handshake( skew_window_ms=self.config.skew_window_ms, policy_requires_provenance=self.config.policy_requires_provenance, ) - session = self._new_session(params, session_id, request_id, key_id, terms) + session = self._new_session(params, session_id, request_id, key_id, terms, nonce) if not self.store.create_if_absent(session): # Lost a race with a concurrent handshake for the same session_id. raced = self.store.get(session_id) @@ -196,6 +230,8 @@ def _handshake( with raced.lock: if isinstance(request_id, str) and request_id not in raced.seen_request_ids: raced.seen_request_ids.add(request_id) + if isinstance(nonce, str) and nonce not in raced.seen_nonces: + raced.seen_nonces.add(nonce) raise AcsError( SESSION_REFUSED, "session is already negotiated; v0.1 defines no renegotiation", @@ -203,7 +239,7 @@ def _handshake( ) response = {"jsonrpc": "2.0", "id": rpc_id, "result": server_hello(terms)} - return self._sign(response, key, session.key_id) + return self._sign(response, key, self.config.key_id) def _new_session( self, @@ -212,6 +248,7 @@ def _new_session( request_id: Any, key_id: str | None, terms: Any, + nonce: Any = None, ) -> Session: return Session( session_id=session_id, @@ -226,7 +263,7 @@ def _new_session( profiles_accepted=terms.profiles_accepted, chain=AuditChain(), seen_request_ids={request_id} if isinstance(request_id, str) else set(), - seen_nonces=set(), + seen_nonces={nonce} if isinstance(nonce, str) else set(), ) # -- steps/*, system/*, and anything else ------------------------------ @@ -253,6 +290,19 @@ def _step( if method == "system/ping": return self._ping(rpc_id, params, key, key_id) + # The method table decides the route before the session is read + # (the order the Go port in open PR #169 routes by): a method the + # standard does not define is + # METHOD_NOT_FOUND, one it defines and this Guardian never negotiates + # (agbom/*) is CAPABILITY_NOT_NEGOTIATED. + route = methods.route(method) + if route == methods.ROUTE_UNDEFINED: + raise AcsError(METHOD_NOT_FOUND, f"ACS v0.1 defines no method {method}", data={"method": method}) + if route == methods.ROUTE_UNSUPPORTED: + raise AcsError( + CAPABILITY_NOT_NEGOTIATED, f"this Guardian does not negotiate {method}", data={"method": method} + ) + session = self.store.get(session_id) if session is None: raise AcsError( @@ -260,12 +310,11 @@ def _step( f"no negotiated session for {session_id}; handshake/hello is required first", data={"method": method}, ) - if key_id != session.key_id: - raise AcsError(SIGNATURE_INVALID, "signature key_id does not match the negotiated session", signable=False) - # One session's steps are serialized: the replay check, the chain - # append, and the decision must be one atomic step, or two concurrent - # copies of a request both pass the check and both append. + # One session's steps are serialized: the replay check, the binding + # checks, the chain append, and the decision must be one atomic step, + # or two concurrent copies of a request both pass the check and both + # append. with session.lock: if isinstance(request_id, str): if request_id in session.seen_request_ids: @@ -277,6 +326,28 @@ def _step( raise AcsError(REPLAY_DETECTED, "nonce was already seen in this session") session.seen_nonces.add(nonce) + # A session answers only the key and the agent that opened it. + # Both are answered as a signed DENY, not an error: an error would + # leave the Observed Agent to its failure posture, which by + # default proceeds. + if key_id != session.key_id: + return self._answer_denial( + rpc_id, session, request_id, key, key_id, method, + "key_not_bound", + "the request is signed with a key_id other than the one that opened this session", + ) + if params.get("metadata", {}).get("agent_id") != session.agent_id: + return self._answer_denial( + rpc_id, session, request_id, key, key_id, method, + "agent_id_not_bound", + "the request names an agent other than the agent that opened this session", + ) + if session.closed: + return self._answer_denial( + rpc_id, session, request_id, key, key_id, method, + "session_closed", + "the session has ended; no step enters after steps/sessionEnd", + ) if method not in session.methods_evaluated: raise AcsError( CAPABILITY_NOT_NEGOTIATED, @@ -285,9 +356,7 @@ def _step( ) self._check_timestamp(params.get("timestamp"), session.skew_window_ms) - self.schemas.validate_payload(method, params.get("payload")) - - evaluation = self._evaluate(method, params, session, request_id) + evaluation = self._evaluate(method, params, session, request_id, route) # The chain entry is appended on arrival of an evaluated step, # before the verdict is delivered: a denied step is still a step @@ -298,11 +367,47 @@ def _step( params=params, timestamp=params.get("timestamp"), ) + if method == "steps/sessionEnd": + session.closed = True result = self._decision_result(session, request_id, evaluation) response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} - return self._sign(response, key, session.key_id) + return self._sign(response, key, self.config.key_id) + + def _answer_denial( + self, + rpc_id: str | int | None, + session: Session, + request_id: Any, + key: bytes | None, + key_id: str | None, + method: str, + reason_code: str, + reasoning: str, + ) -> dict[str, Any]: + """A Guardian-authored DENY that is not an evaluated step: no chain entry. + + The step never reached the chain, so the answer carries no + ``chain_hash`` at all — not the previous step's head, which is what + the field would name if it were sent, and not a null, which fails + response-envelope.json. The denial still passes through the hook's + permitted dispositions: a hook that permits no DENY is answered with + an ALLOW carrying the reason code instead. + """ + evaluation = _substitute_permitted( + method, Evaluation(decision=DENY, reasoning=reasoning, reason_codes=[reason_code]) + ) + response = { + "jsonrpc": "2.0", + "id": rpc_id, + "result": self._decision_result(session, request_id, evaluation, include_chain_hash=False), + } + # Signed under the deployment's key_id, like every other response: + # key_id names the key a verifier resolves, and that is the one this + # Guardian holds. The session's key_id is a binding the request is + # checked against, not a second label for the same key. + return self._sign(response, key, self.config.key_id) def _evaluate( self, @@ -310,12 +415,23 @@ def _evaluate( params: dict[str, Any], session: Session, request_id: Any, + route: str, ) -> Evaluation: + if route == methods.ROUTE_WRAPPED: + split = methods.split_wrapped(method) + assert split is not None # route() returned Wrapped, so this parses + # The wrapped MCP message reaches the engine intact; a malformed + # one is INVALID_PARAMS (-32602). + engine_payload = mcp.read(split[2], params.get("payload")) + else: + self.schemas.validate_payload(method, params.get("payload")) + engine_payload = params.get("payload") or {} + try: evaluation = self.engine.evaluate( EvaluationRequest( method=method, - payload=params.get("payload") or {}, + payload=engine_payload, session_id=session.session_id, agent_id=session.agent_id, request_id=str(request_id), @@ -323,24 +439,36 @@ def _evaluate( ) except Exception as error: # noqa: BLE001 - an engine failure is a deny, never a crash print(f"policy engine failed on {method}: {error!r}", file=sys.stderr) - return _evaluation_failed() + return _evaluation_failed(method) + + # A disposition the hook does not permit is not sent as one: the hook + # cannot carry it (hooks.md "Decision"). + evaluation = _substitute_permitted(method, evaluation) + # A decision that cannot be expressed conformantly is not sent as one # (response-envelope.json requires reasoning on deny/modify/ask/defer, - # and the disposition's own details object): it fails closed instead. - if not _disposition_complete(evaluation): - print(f"policy engine returned an incomplete {evaluation.decision} decision on {method}", file=sys.stderr) - return _evaluation_failed() + # the disposition's own details object, and §6.3's modification + # composition): it fails closed instead. + if not _decision_fit(self.schemas, method, evaluation): + print(f"policy engine returned an unfit {evaluation.decision} decision on {method}", file=sys.stderr) + return _evaluation_failed(method) return evaluation @staticmethod - def _decision_result(session: Session, request_id: Any, evaluation: Evaluation) -> dict[str, Any]: + def _decision_result( + session: Session, request_id: Any, evaluation: Evaluation, *, include_chain_hash: bool = True + ) -> dict[str, Any]: result: dict[str, Any] = { "type": "final", "acs_version": session.negotiated_version, "request_id": request_id, "decision": evaluation.decision, - "chain_hash": session.chain.head, } + # The head "after this step's ContextEntry was appended" + # (response-envelope.json): omitted, never the previous step's head and + # never null, when this step wrote no entry. + if include_chain_hash and session.chain.head is not None: + result["chain_hash"] = session.chain.head if evaluation.reasoning is not None: result["reasoning"] = evaluation.reasoning if evaluation.reason_codes: @@ -379,7 +507,7 @@ def _ping( # signed costs nothing and keeps the harness's verification uniform. session_id = (params.get("metadata") or {}).get("session_id") if isinstance(params.get("metadata"), dict) else None if key is not None and isinstance(session_id, str): - return self._sign(response, key, key_id or self.config.key_id) + return self._sign(response, key, self.config.key_id) return response # -- helpers ----------------------------------------------------------- @@ -444,14 +572,13 @@ def _error( data: dict[str, Any] | None = None, *, key: bytes | None = None, - key_id: str | None = None, ) -> dict[str, Any]: error: dict[str, Any] = {"code": code, "message": message} if data is not None: error["data"] = data response = {"jsonrpc": "2.0", "id": rpc_id, "error": error} if key is not None: - return self._sign(response, key, key_id or self.config.key_id) + return self._sign(response, key, self.config.key_id) return response @@ -472,28 +599,92 @@ def _best_effort_id(raw: Any) -> str | int | None: return None -def _disposition_complete(evaluation: Evaluation) -> bool: - """Whether the decision carries every field response-envelope.json requires for it.""" +def _decision_fit(schemas: SchemaRegistry, method: str, evaluation: Evaluation) -> bool: + """Whether the decision satisfies §6's required fields and §6.3's composition rules. + + The disposition-specific objects are validated against their own schemas + (modifications.json, ask-details.json, defer-details.json) rather than + field-by-field: the schemas are the contract, and they express rules a + hand-written check would miss (an empty ``redactions`` array beside + ``modified_content`` is still "carrying" it). + """ from .engine import DISPOSITIONS if evaluation.decision not in DISPOSITIONS: return False - if evaluation.decision in (DENY, "modify", "ask", "defer") and not evaluation.reasoning: + if evaluation.decision != ALLOW and not evaluation.reasoning: return False - if evaluation.decision == "modify" and evaluation.modifications is None: - return False - if evaluation.decision == "ask" and evaluation.ask_details is None: + if evaluation.decision == "modify": + if schemas.check("modifications.json", evaluation.modifications) is not None: + return False + return _modification_targets_disjoint(method, evaluation.modifications) + if evaluation.decision == "ask": + return schemas.check("ask-details.json", evaluation.ask_details) is None + if evaluation.decision == "defer": + return schemas.check("defer-details.json", evaluation.defer_details) is None + return True + + +def _modification_targets_disjoint(method: str, modifications: Any) -> bool: + """§6.3: no redaction path addresses the same field as an override key, or an ancestor/descendant. + + The only §6.3 rule a JSON Schema cannot express; the exclusivity of + ``modified_content`` and the presence rules are the schema's. + """ + if not isinstance(modifications, dict): return False - if evaluation.decision == "defer" and evaluation.defer_details is None: + # Absent keys are the empty containers; the schema has already refused a + # present-but-empty one beside modified_content ("carrying" is presence). + redactions = modifications.get("redactions", []) + overrides = modifications.get("parameter_overrides", {}) + if not isinstance(redactions, list) or not isinstance(overrides, dict): return False + for redaction in redactions: + path = redaction.get("path") if isinstance(redaction, dict) else None + if not isinstance(path, str): + return False + for argument in overrides: + if _pointers_overlap(path, methods.override_pointer(method, str(argument))): + return False return True -def _evaluation_failed() -> Evaluation: +def _pointers_overlap(a: str, b: str) -> bool: + """Whether one JSON pointer addresses the same field as the other, or an ancestor/descendant.""" + return a == b or b.startswith(a + "/") or a.startswith(b + "/") or a == "" or b == "" + + +def _substitute_permitted(method: str, evaluation: Evaluation) -> Evaluation: + """Coerce a decision the hook does not permit into one it can carry (hooks.md "Decision"). + + DENY when the hook permits a denial; ALLOW otherwise, because the action + has already happened and the step stands with an audit reason. The + original reason codes are kept beside ``disposition_not_permitted``. + """ + permitted = methods.permitted_dispositions(method) + if evaluation.decision in permitted: + return evaluation + detail = f"{evaluation.decision} is not permitted at {method}, which permits {'/'.join(permitted)}" + print(f"{detail}; substituting", file=sys.stderr) + codes = [*evaluation.reason_codes, "disposition_not_permitted"] + reasoning = f"{detail}. {evaluation.reasoning or ''}".strip() + if DENY in permitted: + return Evaluation(decision=DENY, reasoning=reasoning, reason_codes=codes) return Evaluation( - decision=DENY, - reasoning="the Guardian could not produce a conformant decision for this step; failing closed", - reason_codes=["evaluation_failed"], + decision=ALLOW, + reasoning=f"{reasoning} The action has already happened, so the step stands.", + reason_codes=codes, + ) + + +def _evaluation_failed(method: str) -> Evaluation: + return _substitute_permitted( + method, + Evaluation( + decision=DENY, + reasoning="the Guardian could not produce a conformant decision for this step; failing closed", + reason_codes=["evaluation_failed"], + ), ) @@ -514,13 +705,36 @@ def guardian(self) -> Guardian: def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's own naming if self.path != ACS_PATH: + # Closing, not reading: an unread body on a keep-alive connection + # would be parsed as the next request. + self.close_connection = True self._respond_text(404, "Not Found") return length_header = self.headers.get("Content-Length") - try: - declared = int(length_header) if length_header is not None else 0 - except ValueError: - declared = 0 + declared = 0 + # RFC 9110's Content-Length is 1*DIGIT: no sign, no whitespace, no + # underscores, no Unicode digits. `int()` would accept all of those, + # and a proxy that frames the body differently than this server reads + # it is exactly the desync this refusal prevents. Duplicate headers + # are refused for the same reason. + declared_values = self.headers.get_all("Content-Length") or [] + if length_header is None or len(declared_values) != 1 or not re.fullmatch(r"[0-9]+", length_header): + # No body framing this server supports (chunked is not read): + # refusing and closing beats reading nothing and letting the + # unread body desync the next keep-alive request. + self.close_connection = True + self._respond_json( + { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": INVALID_REQUEST, + "message": "a single, non-negative integer Content-Length header is required", + }, + } + ) + return + declared = int(length_header) if declared > MAX_REQUEST_BODY_BYTES: # Refused before the body is read; the connection closes so the # unread body cannot be misread as the next request on keep-alive. @@ -538,12 +752,15 @@ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's own naming return body = self.rfile.read(declared) try: - # parse_constant: JSON.parse accepts NaN/Infinity, JCS (RFC 8785) - # does not, and a non-finite number that reached signature - # verification would raise out of canonicalization rather than - # fail it. + # parse_constant rejects the literal NaN/Infinity tokens JSON.parse + # accepts; numeric overflow (1e400, integers past 2^53) is caught + # by the JCS canonicalizability check in Guardian.handle, which + # runs before anything tries to sign the envelope. raw = json.loads(body.decode("utf-8"), parse_constant=_reject_non_finite) - except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + except (json.JSONDecodeError, UnicodeDecodeError, ValueError, RecursionError): + # RecursionError: deeply nested JSON (~1000 levels, a few KB) is + # under the body cap but past the parser's limit; it must be a + # parse error, not a dropped connection. self._respond_json({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": "Parse error"}}) return self._respond_json(self.guardian.handle(raw)) @@ -553,6 +770,10 @@ def _respond_json(self, payload: dict[str, Any]) -> None: self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) + if self.close_connection: + # Say so: an HTTP/1.1 client is otherwise entitled to reuse the + # connection and would meet an EOF it was not told to expect. + self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) @@ -561,6 +782,8 @@ def _respond_text(self, status: int, text: str) -> None: self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) + if self.close_connection: + self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) diff --git a/reference-implementations/agt-python/src/acs_guardian/session.py b/reference-implementations/agt-python/src/acs_guardian/session.py index 47a71f72..78229583 100644 --- a/reference-implementations/agt-python/src/acs_guardian/session.py +++ b/reference-implementations/agt-python/src/acs_guardian/session.py @@ -36,6 +36,10 @@ class Session: chain: AuditChain = field(default_factory=AuditChain) seen_request_ids: set[str] = field(default_factory=set) seen_nonces: set[str] = field(default_factory=set) + # steps/sessionEnd closes the session: no later step is evaluated (the Go + # reference answers one with a session_closed denial), while replay + # history survives so a replayed step is still REPLAY_DETECTED. + closed: bool = False # Guards the session's mutable state against the HTTP server's threads. # The store's own lock protects the session table, not session contents: # without this, two concurrent copies of one request both pass the diff --git a/reference-implementations/agt-python/tests/test_chain.py b/reference-implementations/agt-python/tests/test_chain.py index 698334a5..df001dd5 100644 --- a/reference-implementations/agt-python/tests/test_chain.py +++ b/reference-implementations/agt-python/tests/test_chain.py @@ -47,3 +47,20 @@ def test_tampering_with_a_stored_entry_breaks_verification() -> None: serialized = [entry.to_dict() for entry in chain.entries] serialized[0]["step_id"] = "s-forged" assert not verify_chain(serialized) + + +def test_rewriting_the_linkage_breaks_verification() -> None: + """A chain whose previous_hash was rewritten must fail, not be silently re-linked.""" + chain = AuditChain() + chain.append(step_id="s-1", step_type="steps/sessionStart") + chain.append(step_id="s-2", step_type="steps/toolCallRequest") + serialized = [entry.to_dict() for entry in chain.entries] + assert verify_chain(serialized) + + serialized[1]["previous_hash"] = "0" * 64 + assert not verify_chain(serialized) + + # And a first entry that claims a predecessor fails too. + serialized = [entry.to_dict() for entry in chain.entries] + serialized[0]["previous_hash"] = "0" * 64 + assert not verify_chain(serialized) diff --git a/reference-implementations/agt-python/tests/test_handshake.py b/reference-implementations/agt-python/tests/test_handshake.py index 63cf30b1..f7e1aaa6 100644 --- a/reference-implementations/agt-python/tests/test_handshake.py +++ b/reference-implementations/agt-python/tests/test_handshake.py @@ -79,20 +79,17 @@ def test_provenance_requirement_accepts_a_deterministic_producer() -> None: assert terms.negotiated_version == "0.1.0" -def test_no_shared_method_refuses_the_session() -> None: - # -32000, not -32003: nothing was exercised, so no capability failed to - # negotiate; the session itself cannot be governed usefully (§17.1). - with pytest.raises(AcsError) as caught: - negotiate(_hello(methods_implemented=["protocols/MCP/tools/call"])) - assert caught.value.code == SESSION_REFUSED - assert caught.value.data["reason"] == "no_common_method" +def test_no_shared_method_negotiates_an_empty_set() -> None: + # An empty methods_evaluated is a valid ServerHello: every method is then + # ALLOW-by-default to the client, which is told so. Not a refusal. + assert negotiate(_hello(methods_implemented=["protocols/MCP/tools/call"])).methods_evaluated == () def test_profiles_accepted_is_the_intersection() -> None: - # Empty today: the Guardian accepts no profile until Wrapped MCP (a - # mandatory ACS-Core item) is implemented — the honest wire signal. + # acs-core is accepted (every ACS-Core item is implemented); the optional + # profiles are not claimed. terms = negotiate(_hello(profiles_supported=["acs-core", "acs-trace"])) - assert terms.profiles_accepted == () + assert terms.profiles_accepted == ("acs-core",) def test_server_hello_carries_the_negotiated_terms() -> None: diff --git a/reference-implementations/agt-python/tests/test_server.py b/reference-implementations/agt-python/tests/test_server.py index a606daff..3c1eb743 100644 --- a/reference-implementations/agt-python/tests/test_server.py +++ b/reference-implementations/agt-python/tests/test_server.py @@ -133,6 +133,98 @@ def test_ping_needs_no_signature(server) -> None: assert response["result"]["decision"] == "allow" +def test_a_stale_handshake_is_outside_the_skew_window(server) -> None: + """§10.3 applies to the handshake too: a recorded ClientHello must not replay.""" + session_id = str(uuid.uuid4()) + stale = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat().replace("+00:00", "Z") + hello = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + timestamp=stale, + ) + expect_error(signed_post(server.url, hello, session_id), -32006) + + +def test_a_handshake_nonce_replay_is_replay_detected(server) -> None: + session_id = str(uuid.uuid4()) + nonce = "0123456789abcdef0123456789abcdef" + hello = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ) + hello["params"]["nonce"] = nonce + signed_post(server.url, hello, session_id) + + again = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ) + again["params"]["nonce"] = nonce + expect_error(signed_post(server.url, again, session_id), -32005) + + +def test_a_bad_content_length_is_refused_and_the_connection_closes(server) -> None: + import json + import socket + + with socket.create_connection(("127.0.0.1", server.server_address[1]), timeout=5) as sock: + sock.sendall(b"POST /acs HTTP/1.1\r\nHost: x\r\nContent-Length: -5\r\n\r\n") + raw = sock.recv(4096).decode() + assert sock.recv(4096) == b"" # the server closed, as it said it would + head, _, body = raw.partition("\r\n\r\n") + assert "200" in head.splitlines()[0] + assert "connection: close" in head.lower() + expect_error(json.loads(body), -32600) + + +def test_a_request_without_content_length_is_refused(server) -> None: + import json + import socket + + with socket.create_connection(("127.0.0.1", server.server_address[1]), timeout=5) as sock: + sock.sendall(b"POST /acs HTTP/1.1\r\nHost: x\r\n\r\n") + raw = sock.recv(4096).decode() + assert sock.recv(4096) == b"" + _, _, body = raw.partition("\r\n\r\n") + expect_error(json.loads(body), -32600) + + +def test_deeply_nested_json_is_a_parse_error_not_a_dropped_connection(server) -> None: + import json + import urllib.request + + # ~20 KB, far under the body cap, and far past the parser's ~1000-level + # recursion limit. + body = b"[" * 10_000 + b"]" * 10_000 + request = urllib.request.Request( + server.url, data=body, headers={"content-type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(request) as response: + payload = json.loads(response.read().decode()) + expect_error(payload, -32700) + + def test_a_second_handshake_is_refused_but_its_replay_is_a_replay(server) -> None: session_id = _session(server) again = envelope( @@ -167,11 +259,59 @@ def test_parse_error_gets_the_jsonrpc_code(server) -> None: expect_error(payload, -32700) -def test_an_unimplemented_but_negotiated_method_cannot_happen_and_an_unknown_namespace_does_not(server) -> None: - # steps/nonexistent is not in IMPLEMENTED_METHODS, so the handshake could - # not have negotiated it; a client that sends it anyway gets -32003. +def test_literal_nan_tokens_are_a_parse_error(server) -> None: + import json + import urllib.request + + request = urllib.request.Request( + server.url, data=b'{"jsonrpc":"2.0","method":"system/ping","id":"x","params":{"n":NaN}}', + headers={"content-type": "application/json"}, method="POST", + ) + with urllib.request.urlopen(request) as response: + payload = json.loads(response.read().decode()) + expect_error(payload, -32700) + + +def test_a_number_outside_the_jcs_domain_is_invalid_request(server) -> None: + """1e400 parses to inf, which JCS cannot represent; the envelope is invalid.""" + import json + import urllib.request + + body = { + "jsonrpc": "2.0", + "method": "system/ping", + "id": "x", + "params": { + "acs_version": "0.1.0", + "request_id": str(uuid.uuid4()), + "timestamp": now_iso(), + "metadata": {"agent_id": AGENT_ID, "session_id": str(uuid.uuid4())}, + "payload": {}, + }, + } + encoded = json.dumps(body).encode().replace(b'"payload": {}', b'"payload": {"n": 1e400}') + request = urllib.request.Request( + server.url, data=encoded, headers={"content-type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(request) as response: + payload = json.loads(response.read().decode()) + expect_error(payload, -32600) + assert "JCS-canonicalizable" in payload["error"]["message"] + + +def test_a_method_the_standard_does_not_define_is_method_not_found(server) -> None: + # The method table routes before the session is read (the Go port's order): + # an undefined method is -32601, not -32003, which is for a defined method + # the handshake did not negotiate. session_id = _session(server) request = envelope("steps/nonexistent", session_id, {}) + expect_error(signed_post(server.url, request, session_id), -32601) + + +def test_an_agbom_method_is_capability_not_negotiated(server) -> None: + # Defined by the standard, never negotiated by this Guardian (no Inspect). + session_id = _session(server) + request = envelope("agbom/snapshot", session_id, {}) expect_error(signed_post(server.url, request, session_id), -32003) @@ -360,7 +500,7 @@ def test_an_incomplete_engine_decision_fails_closed() -> None: def _kebab(method: str) -> str: - """steps/toolCallRequest -> tool-call-request; system/ping -> system-ping.""" + """steps/toolCallRequest -> tool-call-request (the namespace is dropped).""" name = method.split("/", 1)[1] out = [] for index, char in enumerate(name): @@ -370,6 +510,331 @@ def _kebab(method: str) -> str: return "".join(out) +def _guardian_handshake(engine, methods=("steps/toolCallRequest",), *, key_id: str | None = None) -> tuple[Guardian, str]: + guardian = Guardian(GuardianConfig(secret=SECRET, port=0, engine=engine)) + session_id = str(uuid.uuid4()) + hello = sign_request( + envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": list(methods), + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ), + session_id, + **({"key_id": key_id} if key_id is not None else {}), + ) + response = guardian.handle(hello) + if key_id is not None: + # The response label is the deployment's, not the client-chosen + # binding the session was opened with. + assert response["result"]["signature"]["key_id"] == "conformance" + return guardian, session_id + + +def _guardian_wrapped(engine) -> tuple[Guardian, str]: + guardian = Guardian(GuardianConfig(secret=SECRET, port=0, engine=engine)) + session_id = str(uuid.uuid4()) + hello = sign_request( + envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["protocols/MCP/tools/call"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + "wrapped_protocols": [{"protocol": "MCP", "version": "2025-06-18"}], + }, + ), + session_id, + ) + guardian.handle(hello) + return guardian, session_id + + +def _tool_call( + guardian: Guardian, + session_id: str, + *, + method: str = "steps/toolCallRequest", + payload=None, + key_id: str | None = None, +): + request = sign_request( + envelope( + method, + session_id, + payload + if payload is not None + else {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + **({"key_id": key_id} if key_id is not None else {}), + ) + return request, guardian.handle(request) + + +@pytest.mark.parametrize( + "method, disposition, expected", + [ + # turnEnd is audit-only: a denial cannot be honored, the turn ended. + ("steps/turnEnd", "deny", "allow"), + # sessionStart permits ALLOW/DENY: a modify is substituted with a denial. + ("steps/sessionStart", MODIFY, "deny"), + ], +) +def test_a_disposition_the_hook_does_not_permit_is_substituted(method, disposition, expected) -> None: + evaluation = Evaluation(decision=disposition, reasoning="stub", modifications={"modified_content": "x"}) + guardian, session_id = _guardian_handshake(_StubEngine(evaluation), methods=(method,)) + payload = {"turn_id": "t1", "outcome": "completed"} if method == "steps/turnEnd" else {} + request, response = _tool_call(guardian, session_id, method=method, payload=payload) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == expected + assert response["result"]["reason_codes"] == ["disposition_not_permitted"] + assert guardian.schemas.validate_response(response) is None + + +def test_an_evaluation_failure_at_an_audit_only_hook_is_an_allow() -> None: + """A deny cannot be honored at postCompact or turnEnd: the failure becomes an ALLOW.""" + + class Exploding: + def evaluate(self, request): # noqa: ANN001 - the Protocol's shape + raise RuntimeError("engine down") + + guardian, session_id = _guardian_handshake(Exploding(), methods=("steps/turnEnd",)) + request, response = _tool_call(guardian, session_id, method="steps/turnEnd", payload={"turn_id": "t", "outcome": "completed"}) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "allow" + assert response["result"]["reason_codes"] == ["evaluation_failed", "disposition_not_permitted"] + assert guardian.schemas.validate_response(response) is None + + +@pytest.mark.parametrize( + "modifications", + [ + # modified_content stands alone (§6.3). + {"modified_content": "x", "redactions": [{"path": "/outputs/0"}]}, + # A redaction and an override may not address the same field... + { + "redactions": [{"path": "/arguments/command"}], + "parameter_overrides": {"command": "ls"}, + }, + # ...nor an ancestor or descendant of it. + { + "redactions": [{"path": "/arguments"}], + "parameter_overrides": {"command": "ls"}, + }, + # An empty modifications carries no edit. + {}, + ], +) +def test_a_modification_that_breaks_section_6_3_fails_closed(modifications) -> None: + evaluation = Evaluation(decision=MODIFY, reasoning="stub", modifications=modifications) + guardian, session_id = _guardian_handshake(_StubEngine(evaluation)) + request, response = _tool_call(guardian, session_id) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + assert guardian.schemas.validate_response(response) is None + + +def test_a_disjoint_redaction_and_override_is_kept() -> None: + evaluation = Evaluation( + decision=MODIFY, + reasoning="stub", + modifications={ + "redactions": [{"path": "/outputs/0/value"}], + "parameter_overrides": {"command": "ls"}, + }, + ) + guardian, session_id = _guardian_handshake(_StubEngine(evaluation)) + request, response = _tool_call(guardian, session_id) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "modify" + assert guardian.schemas.validate_response(response) is None + + +def test_a_modification_that_breaks_section_6_3_on_a_wrapped_call_fails_closed() -> None: + """A wrapped call's override pointer is /params/arguments/.""" + evaluation = Evaluation( + decision=MODIFY, + reasoning="stub", + modifications={ + "redactions": [{"path": "/params/arguments/command"}], + "parameter_overrides": {"command": "ls"}, + }, + ) + guardian, session_id = _guardian_wrapped(_StubEngine(evaluation)) + request = sign_request( + envelope( + "protocols/MCP/tools/call", + session_id, + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"arguments": {"command": "rm -rf /"}}}, + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + + +def test_a_wrapped_override_pointer_escapes_json_pointer_characters() -> None: + """An argument named a/b addresses /params/arguments/a~1b, so this pair overlaps.""" + evaluation = Evaluation( + decision=MODIFY, + reasoning="stub", + modifications={ + "redactions": [{"path": "/params/arguments/a~1b"}], + "parameter_overrides": {"a/b": "x"}, + }, + ) + guardian, session_id = _guardian_wrapped(_StubEngine(evaluation)) + request = sign_request( + envelope( + "protocols/MCP/tools/call", + session_id, + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"arguments": {"a/b": "y"}}}, + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + + +def test_a_disjoint_wrapped_override_is_kept() -> None: + evaluation = Evaluation( + decision=MODIFY, + reasoning="stub", + modifications={ + "redactions": [{"path": "/params/arguments/body"}], + "parameter_overrides": {"to": "audit@example.com"}, + }, + ) + guardian, session_id = _guardian_wrapped(_StubEngine(evaluation)) + request = sign_request( + envelope( + "protocols/MCP/tools/call", + session_id, + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"arguments": {"to": "x", "body": "y"}}}, + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "modify" + assert guardian.schemas.validate_response(response) is None + + +def test_a_step_after_session_end_is_denied_and_a_replay_is_still_a_replay() -> None: + guardian, session_id = _guardian_handshake( + _StubEngine(Evaluation(decision="allow")), methods=("steps/sessionEnd", "steps/toolCallRequest") + ) + end, response = _tool_call(guardian, session_id, method="steps/sessionEnd", payload={"reason": "completed"}) + verify_response(response, session_id, end["id"]) + assert response["result"]["decision"] == "allow" + + # A step after the session ended is denied (toolCallRequest permits DENY), + # and carries no chain_hash: it wrote no entry, though the sessionEnd + # above did (so the chain is not empty). + request, response = _tool_call(guardian, session_id) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["session_closed"] + assert "chain_hash" not in response["result"] + + # ...but at sessionEnd, an audit-only hook, the denial is substituted with + # an ALLOW carrying the reason: the action already happened. + again, response = _tool_call(guardian, session_id, method="steps/sessionEnd", payload={"reason": "cancelled"}) + verify_response(response, session_id, again["id"]) + assert response["result"]["decision"] == "allow" + assert response["result"]["reason_codes"] == ["session_closed", "disposition_not_permitted"] + + # The same request again is a replay, reported before the closed check. + response = guardian.handle(end) + verify_response(response, session_id, end["id"]) + expect_error(response, -32005) + + +def test_a_wrong_key_id_is_a_signed_denial_not_an_error() -> None: + # The session is opened with a client-chosen key_id, so the session's + # key_id differs from the deployment's ("conformance"): the response label + # must be the deployment's, and the binding must still be the client's. + guardian, session_id = _guardian_handshake( + _StubEngine(Evaluation(decision="allow")), key_id="client-chosen" + ) + # A step first, so the chain is not empty: the denial still carries no + # chain_hash, because it wrote no entry — the previous step's head would + # be a false claim about this step, not a harmless omission. + ok, response = _tool_call(guardian, session_id, key_id="client-chosen") + verify_response(response, session_id, ok["id"]) + assert response["result"]["chain_hash"] + + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + key_id="some-other-key", + ) + response = guardian.handle(request) + # Signed under the deployment's key_id: key_id names the key a verifier + # resolves, and that is the one this Guardian holds. + verify_response(response, session_id, request["id"]) + assert response["result"]["signature"]["key_id"] == "conformance" + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["key_not_bound"] + # The step never reached the chain, so no chain_hash is sent (a null + # would fail response-envelope.json). + assert "chain_hash" not in response["result"] + assert guardian.schemas.validate_response(response) is None + + # The id was recorded on arrival, so the same request again is a replay. + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + expect_error(response, -32005) + + +def test_ping_is_signed_under_the_deployments_key_id() -> None: + guardian, session_id = _guardian_handshake( + _StubEngine(Evaluation(decision="allow")), methods=("system/ping",), key_id="client-chosen" + ) + request = sign_request( + envelope("system/ping", session_id, {"echo": "hi"}), session_id, key_id="client-chosen" + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["signature"]["key_id"] == "conformance" + + +def test_a_wrong_agent_id_is_a_signed_denial_not_an_error() -> None: + guardian, session_id = _guardian_handshake(_StubEngine(Evaluation(decision="allow"))) + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + agent_id="some-other-agent", + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["agent_id_not_bound"] + assert guardian.schemas.validate_response(response) is None + + @pytest.mark.parametrize("method", HOOK_METHODS) def test_every_hook_payload_schema_loads_and_is_the_right_schema(guardian: Guardian, method: str) -> None: """Each hook's schema exists, compiles, and is pinned to the method name. diff --git a/reference-implementations/agt-python/tests/test_wrapped_mcp.py b/reference-implementations/agt-python/tests/test_wrapped_mcp.py new file mode 100644 index 00000000..3077be61 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_wrapped_mcp.py @@ -0,0 +1,148 @@ +"""Wrapped MCP: protocols/MCP/* (extend_mcp.md; the handler contract the +Go port in open PR #169 defines at internal/mcp/mcp.go).""" + +from __future__ import annotations + +import uuid + +import pytest + +from helpers import envelope, expect_decision, expect_error, handshake, signed_post + +from acs_guardian import mcp +from acs_guardian.errors import INVALID_PARAMS, AcsError + +MCP_VERSION = "2025-06-18" + + +def _mcp_request(method: str, arguments: dict, *, request_id: int = 1) -> dict: + return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": {"name": "run", "arguments": arguments}} + + +def _mcp_response(request_id: int, *, result: dict | None = None, error: dict | None = None) -> dict: + message: dict = {"jsonrpc": "2.0", "id": request_id} + if result is not None: + message["result"] = result + if error is not None: + message["error"] = error + return message + + +def _wrapped_session(server, methods: list[str], *, versions: list[str] | None = None) -> str: + session_id = str(uuid.uuid4()) + hello = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": methods, + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + "wrapped_protocols": [{"protocol": "MCP", "version": v} for v in (versions or [MCP_VERSION])], + }, + ) + response = signed_post(server.url, hello, session_id) + return session_id + + +def test_wrapped_tools_call_is_evaluated_and_allowed(server) -> None: + session_id = _wrapped_session(server, ["protocols/MCP/tools/call"]) + request = envelope("protocols/MCP/tools/call", session_id, _mcp_request("tools/call", {"command": "ls -la"})) + response = signed_post(server.url, request, session_id) + expect_decision(response, "allow", request["id"]) + assert response["result"]["chain_hash"] + + +def test_wrapped_tools_call_hits_the_same_destructive_rule(server) -> None: + session_id = _wrapped_session(server, ["protocols/MCP/tools/call"]) + request = envelope("protocols/MCP/tools/call", session_id, _mcp_request("tools/call", {"command": "rm -rf /"})) + response = signed_post(server.url, request, session_id) + expect_decision(response, "deny", request["id"]) + assert response["result"]["reason_codes"] == ["destructive_shell_command_blocked"] + + +def test_a_wrapped_response_is_evaluated(server) -> None: + session_id = _wrapped_session(server, ["protocols/MCP/tools/call"]) + request = envelope( + "protocols/MCP/tools/call", session_id, _mcp_response(1, result={"content": [{"type": "text", "value": "ok"}]}) + ) + expect_decision(signed_post(server.url, request, session_id), "allow", request["id"]) + + +def test_an_explicit_version_method_is_gated_by_wrapped_protocols(server) -> None: + method = f"wrapped:mcp-{MCP_VERSION}/tools/call" + declared = _wrapped_session(server, [method], versions=[MCP_VERSION]) + request = envelope(method, declared, _mcp_request("tools/call", {"command": "ls"})) + expect_decision(signed_post(server.url, request, declared), "allow", request["id"]) + + # A version the ClientHello never declared is not negotiated: -32003. + undeclared = _wrapped_session(server, [method], versions=["2024-11-05"]) + request = envelope(method, undeclared, _mcp_request("tools/call", {"command": "ls"})) + expect_error(signed_post(server.url, request, undeclared), -32003) + + +def test_a_wrapped_method_without_a_declared_protocol_is_not_negotiated(server) -> None: + session_id = str(uuid.uuid4()) + hello = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["protocols/MCP/tools/call"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + # no wrapped_protocols + }, + ) + response = signed_post(server.url, hello, session_id) + assert response["result"]["methods_evaluated"] == [] + request = envelope("protocols/MCP/tools/call", session_id, _mcp_request("tools/call", {"command": "ls"})) + expect_error(signed_post(server.url, request, session_id), -32003) + + +def test_a_malformed_wrapped_message_is_invalid_params(server) -> None: + session_id = _wrapped_session(server, ["protocols/MCP/tools/call"]) + request = envelope("protocols/MCP/tools/call", session_id, {"jsonrpc": "1.0", "method": "tools/call"}) + expect_error(signed_post(server.url, request, session_id), -32602) + + +@pytest.mark.parametrize( + "message, expected", + [ + ({"jsonrpc": "2.0", "method": "tools/call"}, None), # a request without an id: a notification + ({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {}}, None), + ({"jsonrpc": "2.0", "id": "a", "method": "tools/call", "params": {"arguments": {}}}, None), + ({"jsonrpc": "2.0", "id": 1, "result": {}}, None), + ({"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "nope"}}, None), + ({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, "wraps tools/call"), + ({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "result": {}}, "neither result nor error"), + ({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": []}, "params must be an object"), + ({"jsonrpc": "2.0", "id": None, "method": "tools/call"}, "id must be a string or a number"), + ({"jsonrpc": "2.0", "result": {}}, "must carry the id"), + ({"jsonrpc": "2.0", "id": 1}, "exactly one of result and error"), + ({"jsonrpc": "2.0", "id": 1, "result": {}, "error": {"code": 1, "message": "x"}}, "exactly one of result"), + ({"jsonrpc": "2.0", "id": 1, "result": []}, "result must be an object"), + ({"jsonrpc": "2.0", "id": 1, "error": {"code": "x", "message": "y"}}, "integer code"), + ({"jsonrpc": "2.0", "id": 1, "error": {"message": "y"}}, "integer code"), + ({"jsonrpc": "1.0", "id": 1, "method": "tools/call"}, 'jsonrpc "2.0"'), + ("not an object", "must be an object"), + ], +) +def test_the_wrapped_message_rules(message, expected) -> None: + if expected is None: + assert mcp.read("tools/call", message) == message + else: + with pytest.raises(AcsError) as caught: + mcp.read("tools/call", message) + assert caught.value.code == INVALID_PARAMS + assert expected in caught.value.message + + +def test_mcp_supports_well_formed_method_names_only() -> None: + assert mcp.supports("tools/call") + assert mcp.supports("notifications/resources/list_changed") + assert not mcp.supports("tools//call") + assert not mcp.supports("tools/call/") + assert not mcp.supports("") From 1aa0e9bdc0cb879628ddc218567f7ab97135a200 Mon Sep 17 00:00:00 2001 From: Duke - Duc Dinh Date: Tue, 22 Sep 2026 23:23:24 +0700 Subject: [PATCH 3/4] Read the refusal socket tests to EOF, not one recv 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 --- .../agt-python/tests/test_server.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/reference-implementations/agt-python/tests/test_server.py b/reference-implementations/agt-python/tests/test_server.py index 3c1eb743..a7645f96 100644 --- a/reference-implementations/agt-python/tests/test_server.py +++ b/reference-implementations/agt-python/tests/test_server.py @@ -184,14 +184,23 @@ def test_a_handshake_nonce_replay_is_replay_detected(server) -> None: expect_error(signed_post(server.url, again, session_id), -32005) +def _recv_all(sock) -> bytes: + """Read a socket to EOF; the server closing is what ends the loop.""" + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + def test_a_bad_content_length_is_refused_and_the_connection_closes(server) -> None: import json import socket with socket.create_connection(("127.0.0.1", server.server_address[1]), timeout=5) as sock: sock.sendall(b"POST /acs HTTP/1.1\r\nHost: x\r\nContent-Length: -5\r\n\r\n") - raw = sock.recv(4096).decode() - assert sock.recv(4096) == b"" # the server closed, as it said it would + raw = _recv_all(sock).decode() head, _, body = raw.partition("\r\n\r\n") assert "200" in head.splitlines()[0] assert "connection: close" in head.lower() @@ -204,8 +213,7 @@ def test_a_request_without_content_length_is_refused(server) -> None: with socket.create_connection(("127.0.0.1", server.server_address[1]), timeout=5) as sock: sock.sendall(b"POST /acs HTTP/1.1\r\nHost: x\r\n\r\n") - raw = sock.recv(4096).decode() - assert sock.recv(4096) == b"" + raw = _recv_all(sock).decode() _, _, body = raw.partition("\r\n\r\n") expect_error(json.loads(body), -32600) From 5e7f4bbe823f4a3515aae96021ff5c2930ebb9be Mon Sep 17 00:00:00 2001 From: Duke - Duc Dinh Date: Wed, 14 Oct 2026 23:03:46 +0700 Subject: [PATCH 4/4] Answer the review: close the ping oracle and the deep-nesting fail-open, 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 (#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 --- .../reference-implementation-python.yml | 10 + .../agt-python/README.md | 65 +- .../agt-python/src/acs_guardian/canonical.py | 14 +- .../agt-python/src/acs_guardian/chain.py | 18 +- .../agt-python/src/acs_guardian/engine.py | 6 + .../agt-python/src/acs_guardian/handshake.py | 50 +- .../agt-python/src/acs_guardian/main.py | 13 +- .../agt-python/src/acs_guardian/server.py | 850 +++++++++++++++--- .../agt-python/src/acs_guardian/session.py | 1 - .../agt-python/tests/conftest.py | 4 +- .../agt-python/tests/test_chain.py | 17 + .../agt-python/tests/test_handshake.py | 24 +- .../agt-python/tests/test_main.py | 46 + .../agt-python/tests/test_server.py | 771 ++++++++++++++-- 14 files changed, 1681 insertions(+), 208 deletions(-) create mode 100644 reference-implementations/agt-python/tests/test_main.py diff --git a/.github/workflows/reference-implementation-python.yml b/.github/workflows/reference-implementation-python.yml index 285d916d..9be2fe06 100644 --- a/.github/workflows/reference-implementation-python.yml +++ b/.github/workflows/reference-implementation-python.yml @@ -46,6 +46,16 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # Pinned like the repository's other workflows: the interpreter + # series is fixed at 3.12 because json's nesting limit is + # version-sensitive and the deep-nesting regression tests live + # near it. + version: "0.9.9" + python-version: "3.12" + # setup-uv defaults this input to the job token; the version is + # pinned, so it needs no API call. + github-token: "" - name: Install dependencies from the lockfile run: uv sync --locked diff --git a/reference-implementations/agt-python/README.md b/reference-implementations/agt-python/README.md index eeb59ab1..b57fbd52 100644 --- a/reference-implementations/agt-python/README.md +++ b/reference-implementations/agt-python/README.md @@ -23,6 +23,7 @@ the ones this tree follows, so the two are intended to interoperate. | Request/response envelopes, JSON-RPC error taxonomy | Met | `schemas.py`, `server.py`, `tests/test_server.py` | | Hook taxonomy: all 19 `steps/*` hooks dispatched through one generic path, each schema pinned to its method | Met | `schemas.py` (`_HOOK_SCHEMAS`), `tests/test_server.py` | | Dispositions: all five, with required fields, schema-validated | Met | `engine.py`, `server.py`, `tests/test_server.py` | +| MODIFY for an Observed Agent that cannot apply it (§6.5): DENY `modify_unsupported`, ALLOW at `postCompact`, audit event | Met | `server.py`, `engine.py` (`Evaluation.client_cannot_apply_modify`), `tests/test_server.py` | | SessionContext: append-only chain, §8.2 hash rule, published `chain_hash` | Met | `chain.py`, `tests/test_chain.py` | | Replay protection: duplicate `request_id`/`nonce`, timestamp skew (`-32005`, `-32006`) | Met | `server.py`, `tests/test_server.py` | | Baseline integrity: HKDF-SHA256 session key, HMAC-SHA256 signature | Met | `crypto.py`, `tests/test_crypto_vectors.py` | @@ -45,13 +46,18 @@ tests that cover it. `acs-trace`, `acs-inspect`, `acs-provenance`, cd reference-implementations/agt-python uv sync printf 'change-me-32-bytes-minimum-secret' > /tmp/acs-secret -uv run acs-guardian serve --secret-file /tmp/acs-secret --port 8787 +uv run acs-guardian serve --secret-file /tmp/acs-secret --key-id 2026-10 --port 8787 ``` Then POST an envelope to `http://127.0.0.1:8787/acs`. Configuration comes from flags or the environment: `ACS_GUARDIAN_HMAC_SECRET_FILE`, -`ACS_GUARDIAN_KEY_ID`, `ACS_GUARDIAN_HOST`, `ACS_GUARDIAN_PORT`, -`ACS_ON_DECISION_FAILURE`, `ACS_SPEC_ROOT`. +`ACS_GUARDIAN_KEY_ID` (required; the label this deployment gives its one +configured secret — there is no default, so a request naming a label this +Guardian does not hold is refused `-32004`, never verified with the wrong +secret), `ACS_GUARDIAN_HOST`, `ACS_GUARDIAN_PORT`, `ACS_ON_DECISION_FAILURE`, +`ACS_SPEC_ROOT`. Audit events the standard requires of the Guardian (today, +the §6.5 substitution) go to a deployment-supplied sink; the default writes +one JSON line per event to stderr. The schemas are read from `specification/v0.1.0/` by relative path, so the console script works from a source checkout. An out-of-tree install must set @@ -98,11 +104,21 @@ documents in its "Behavior that ACS v0.1.0 leaves undefined" table: - `entry_hash` is `SHA-256(JCS(entry minus entry_hash/previous_hash) || prev_hash_bytes)`; optional members are omitted from the hashed content when absent, never written as `null`. -- A ContextEntry's `timestamp` is the originating request's `timestamp`, not - the Guardian's wall clock at write time: two Guardians cannot agree on each - other's write time, and the field is inside the hashed content, so a - deterministic input is what makes cross-implementation `entry_hash` - comparison possible at all. +- A ContextEntry's `entry_id` is the step's `request_id` and its `turn_id` is + the envelope's `metadata.turn_id` when the framework set one (turnStart + requires it propagated onto every per-step entry, and `context-entry.json` + names no member for it, so the envelope field's name is used). Its + `timestamp` is the Guardian's write clock, UTC RFC 3339, per + `context-entry.json` ("when the entry was written"). The + `request_id`/`turn_id` choices align what the entries commit to across + implementations for the same step; the two Guardians still disagree on + `timestamp` — and therefore on `entry_hash` — because write times differ + by definition. +- The ServerHello names a version the client offered: the Guardian's own when + it was offered, otherwise the highest offered version sharing its major + (§3's forward-compat rule, the Go port's selection). A step whose + `acs_version` does not share the session's negotiated major version is + `-32001` with the session's version in `data.supported_versions`. - A wrapped MCP message's method must equal the part after `protocols/MCP/` for a request (a response carries no method and is wrapped under the method it answers). A `parameter_overrides` key names a member of the wrapped @@ -115,9 +131,36 @@ documents in its "Behavior that ACS v0.1.0 leaves undefined" table: valid ServerHello, not a refusal. - `steps/sessionEnd` closes the session: a later step is answered with a DENY `session_closed`, while replay history survives so a replayed step is still - `REPLAY_DETECTED`. A key_id or agent_id other than the session's is answered - with a signed DENY (`key_not_bound`, `agent_id_not_bound`), not an error — - an error would leave the agent to its failure posture. + `REPLAY_DETECTED`. An `agent_id` other than the session's is answered with a + signed DENY (`agent_id_not_bound`), not an error — an error would leave the + agent to its failure posture. A signature whose `key_id` is not this + deployment's configured label never resolves a key, so the request is + refused `-32004`, unsigned: the Guardian does not verify with a secret the + label does not name. + +### Implementation limits (not wire-visible on conformant traffic) + +- A request nested past 128 object/array levels takes the normal dispatch + ladder with a fail-closed bias: if the body's own signature verifies + against its session, it gets that route's signed answer — the ping's + ALLOW, a signed refusal (`-32000`, `-32601`, `-32003`, `-32005`, + `-32602`), or a signed decision (`evaluation_failed`, or the hook's ALLOW + substitution where no DENY is permitted) with its chain entry; the + agent-binding and closed checks answer signed denials that write no + entry. An unverified body gets `-32600`, unsigned, and an internal + failure on a non-step route is an unsigned `-32603`, like the normal + path's. The verification and hashing for such bodies run on a single + serialized thread sized to the parse boundary: at most 10,000 structural + levels, and raw text past that is refused as a parse error before the + parser sees it (an interpreter's parser may refuse a body even earlier). +- Duplicate object members are a parse error (`-32700`), like the Go port: + two parsers must not disagree about one signed body's meaning. +- Once a step's signature has verified, a failure outside the error registry + (an internal exception on the governed path) is a signed DENY + (`evaluation_failed`), never an error; the step's chain entry is appended + when the failure pre-empted it, and `chain_hash` is sent only when this + step's entry is in the chain. Registry refusals (replay, skew, version, + schema, capability) keep their own signed error codes, per the spec. ### A deliberate deviation from an `extend_mcp.md` example diff --git a/reference-implementations/agt-python/src/acs_guardian/canonical.py b/reference-implementations/agt-python/src/acs_guardian/canonical.py index 40db7c7a..8c6d8dc8 100644 --- a/reference-implementations/agt-python/src/acs_guardian/canonical.py +++ b/reference-implementations/agt-python/src/acs_guardian/canonical.py @@ -16,7 +16,6 @@ from __future__ import annotations -import copy from typing import Any import rfc8785 @@ -31,10 +30,19 @@ def canonical_bytes(value: Any) -> bytes: def _without_signature(envelope: dict[str, Any], holder: str) -> dict[str, Any]: - unsigned = copy.deepcopy(envelope) + """The envelope minus the signature, copied shallowly on purpose. + + A ``copy.deepcopy`` here raised ``RecursionError`` on a signed request + nested a few hundred levels deep (the parser accepts such a body, so it + arrives), and an exception on the signing path is an unsigned answer the + Observed Agent can turn into a run with its ``on_decision_failure`` + posture. Only the top level and the holder are copied; nothing below is + touched, and the caller's envelope is never mutated. + """ + unsigned = dict(envelope) container = unsigned.get(holder) if isinstance(container, dict): - container.pop("signature", None) + unsigned[holder] = {key: value for key, value in container.items() if key != "signature"} return unsigned diff --git a/reference-implementations/agt-python/src/acs_guardian/chain.py b/reference-implementations/agt-python/src/acs_guardian/chain.py index 237de26e..4f51f00d 100644 --- a/reference-implementations/agt-python/src/acs_guardian/chain.py +++ b/reference-implementations/agt-python/src/acs_guardian/chain.py @@ -15,7 +15,6 @@ from __future__ import annotations import hashlib -import uuid from dataclasses import dataclass, field from typing import Any @@ -32,6 +31,7 @@ class ChainEntry: step_id: str step_type: str request_hash: str | None + turn_id: str | None timestamp: str | None previous_hash: str | None entry_hash: str @@ -45,6 +45,8 @@ def to_dict(self) -> dict[str, Any]: } if self.request_hash is not None: entry["request_hash"] = self.request_hash + if self.turn_id is not None: + entry["turn_id"] = self.turn_id if self.timestamp is not None: entry["timestamp"] = self.timestamp if self.previous_hash is not None: @@ -81,19 +83,30 @@ def append( step_type: str, params: dict[str, Any] | None = None, timestamp: str | None = None, + turn_id: str | None = None, ) -> ChainEntry: previous = self.head # Optional members are omitted from the hashed content when absent, # never written as null: the spec's rule is "included in the entry's # content when present", and a null placeholder would make two # implementations hash different bytes for the same entry. + # + # entry_id is the step's request_id, like the Go port: a random id + # would make two Guardians disagree on every entry_hash for the same + # step, for no benefit (entry_id is only unique within a session, and + # a request_id already is). entry: dict[str, Any] = { - "entry_id": str(uuid.uuid4()), + "entry_id": step_id, "step_id": step_id, "step_type": step_type, } if params is not None: entry["request_hash"] = request_hash(params) + if turn_id is not None: + # turnStart requires metadata.turn_id propagated onto every + # per-step ContextEntry; context-entry.json names no member for + # it, so the envelope field's name is used, like the Go port. + entry["turn_id"] = turn_id if timestamp is not None: entry["timestamp"] = timestamp entry_hash = compute_entry_hash(entry, previous) @@ -102,6 +115,7 @@ def append( step_id=entry["step_id"], step_type=entry["step_type"], request_hash=entry.get("request_hash"), + turn_id=entry.get("turn_id"), timestamp=entry.get("timestamp"), previous_hash=previous, entry_hash=entry_hash, diff --git a/reference-implementations/agt-python/src/acs_guardian/engine.py b/reference-implementations/agt-python/src/acs_guardian/engine.py index dc5bf9d6..07a9722e 100644 --- a/reference-implementations/agt-python/src/acs_guardian/engine.py +++ b/reference-implementations/agt-python/src/acs_guardian/engine.py @@ -49,6 +49,12 @@ class Evaluation: modifications: dict[str, Any] | None = None ask_details: dict[str, Any] | None = None defer_details: dict[str, Any] | None = None + # §6.5: what the Observed Agent cannot apply, which v0.1 leaves to + # deployment-defined means. The engine is where the deployment's knowledge + # of its Observed Agents lives, so it reports the capability per + # evaluation, like the Go port's PolicyDecision.Client. The zero value is + # an Observed Agent that applies every disposition. + client_cannot_apply_modify: bool = False class PolicyEngine(Protocol): diff --git a/reference-implementations/agt-python/src/acs_guardian/handshake.py b/reference-implementations/agt-python/src/acs_guardian/handshake.py index 9ed41988..f657e3d8 100644 --- a/reference-implementations/agt-python/src/acs_guardian/handshake.py +++ b/reference-implementations/agt-python/src/acs_guardian/handshake.py @@ -33,9 +33,10 @@ # one the client offered, and the listener this tree ships speaks plain HTTP. SUPPORTED_TRANSPORTS = ("http", "https") # Every ACS-Core item in docs/spec/conformance.md is implemented (handshake, -# envelopes, the 19 hooks, all five dispositions, the chain, replay, the HMAC -# baseline, liveness, wrapped MCP), so the Guardian accepts acs-core. Trace, -# Inspect, Provenance, Crypto, and Audit are not claimed. +# envelopes, the 19 hooks, all five dispositions, the §6.5 substitution for a +# MODIFY-incapable client, the chain, replay, the HMAC baseline, liveness, +# wrapped MCP), so the Guardian accepts acs-core. Trace, Inspect, Provenance, +# Crypto, and Audit are not claimed. SUPPORTED_PROFILES: tuple[str, ...] = ("acs-core",) SIGNATURE_ALGORITHMS = ("HMAC-SHA256",) DEFAULT_TIMEOUT_MS = 5000 @@ -96,11 +97,50 @@ def evaluates(name: str, declared: dict[str, tuple[str, ...]]) -> bool: return version is None or version in versions +def _version_tuple(version: str) -> tuple[int, int, int] | None: + """``X.Y.Z`` as integers, or None when it is not a well-formed version. + + Each part must be ASCII digits, at most 18 of them: ``int()`` alone would + accept signs, whitespace, underscores and non-ASCII digits, and it raises + on very long digit strings (CPython's int-conversion limit) where a + malformed version must simply be malformed. + """ + parts = version.split(".") + if len(parts) != 3 or any( + not part.isascii() or not part.isdigit() or len(part) > 18 for part in parts + ): + return None + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def same_major(version: str, ours: str) -> bool: + """§3's forward-compat rule: two versions interoperate on a matching major. + + Both sides must be a well-formed ``X.Y.Z`` — three dot-separated + ASCII-numeric parts — and share the major component. The Go port's + ``SameMajor`` parses only the major part and ignores the rest, so it + tolerates a little more garbage on the wire; nothing conformant reaches + that difference. + """ + parsed, base = _version_tuple(version), _version_tuple(ours) + return parsed is not None and base is not None and parsed[0] == base[0] + + def _select_version(client_versions: list[str]) -> str: + """Our version when offered; otherwise the highest offered one sharing our major. + + §3 accepts an ``X.Y.Z`` matching the major version, and the ServerHello + must name a version the client offered: answering "0.1.0" to a client + that offered only 0.9.0 claims interoperability the client never asked + for. This is the Go port's selection. + """ for supported in SUPPORTED_VERSIONS: - major = supported.split(".")[0] - if any(version.split(".")[0] == major for version in client_versions): + if supported in client_versions: return supported + ours = SUPPORTED_VERSIONS[0] + candidates = [version for version in client_versions if same_major(version, ours)] + if candidates: + return max(candidates, key=lambda version: _version_tuple(version) or (0, 0, 0)) raise AcsError( UNSUPPORTED_VERSION, "no common ACS version", diff --git a/reference-implementations/agt-python/src/acs_guardian/main.py b/reference-implementations/agt-python/src/acs_guardian/main.py index 028212db..ef7be475 100644 --- a/reference-implementations/agt-python/src/acs_guardian/main.py +++ b/reference-implementations/agt-python/src/acs_guardian/main.py @@ -16,8 +16,6 @@ from .schemas import SPEC_ROOT_ENV from .server import Guardian, GuardianConfig, serve -DEFAULT_KEY_ID = "conformance" - def _build_config(args: argparse.Namespace) -> GuardianConfig: secret_file = args.secret_file or os.environ.get("ACS_GUARDIAN_HMAC_SECRET_FILE") @@ -26,10 +24,17 @@ def _build_config(args: argparse.Namespace) -> GuardianConfig: "no HMAC secret: pass --secret-file or set ACS_GUARDIAN_HMAC_SECRET_FILE " "(the file's bytes are the HKDF input keying material, 32 bytes minimum)" ) + key_id = args.key_id or os.environ.get("ACS_GUARDIAN_KEY_ID") + if not key_id: + raise SystemExit( + "no key_id: pass --key-id or set ACS_GUARDIAN_KEY_ID. It is the label this " + "deployment gives its one configured secret (an opaque string such as " + "'2026-10'); there is no default, so every deployment chooses its own." + ) secret = Path(secret_file).read_bytes() return GuardianConfig( secret=secret, - key_id=args.key_id or os.environ.get("ACS_GUARDIAN_KEY_ID", DEFAULT_KEY_ID), + key_id=key_id, on_decision_failure=args.posture or os.environ.get("ACS_ON_DECISION_FAILURE", "proceed"), timeout_ms=args.timeout_ms, skew_window_ms=args.skew_window_ms, @@ -47,7 +52,7 @@ def main(argv: list[str] | None = None) -> int: serve_parser = sub.add_parser("serve", help="serve the ACS wire endpoint") serve_parser.add_argument("--secret-file", help="file holding the HKDF input keying material") - serve_parser.add_argument("--key-id", help="key_id carried in signatures (default: conformance)") + serve_parser.add_argument("--key-id", help="the label naming this deployment's configured secret (required)") serve_parser.add_argument("--host", help="bind address (default: 127.0.0.1)") serve_parser.add_argument("--port", type=int, help="bind port (default: 8787)") serve_parser.add_argument("--posture", choices=["proceed", "deny"], help="on_decision_failure") diff --git a/reference-implementations/agt-python/src/acs_guardian/server.py b/reference-implementations/agt-python/src/acs_guardian/server.py index a9851cdf..e27b2569 100644 --- a/reference-implementations/agt-python/src/acs_guardian/server.py +++ b/reference-implementations/agt-python/src/acs_guardian/server.py @@ -10,19 +10,35 @@ 1. JSON-RPC envelope shape -> ``-32600``, and JCS canonicalizability -> ``-32600`` (a number outside the JCS domain cannot be signed by anyone). -2. Signature verification -> ``-32004`` (unsigned answer: the request - established no key). + A body nested past ``MAX_JSON_DEPTH`` is answered fail-closed before any + recursive consumer (schema validation, canonicalization) can meet it. +2. The signature's ``key_id`` must name the configured secret: a request + naming another label never resolves a key, so it is refused ``-32004`` + (unsigned, like every answer that established no key) instead of being + verified with the deployment's secret. 3. Method routing: an undefined method -> ``-32601``; a method the standard defines and this Guardian never negotiates (``agbom/*``) -> ``-32003``. 4. Session lookup -> ``-32003`` when no handshake negotiated the session. 5. Under the session lock: replay -> ``-32005`` (before everything below, so a replay of a refused request is still a replay), then the session bindings and the closed check, answered as signed DENYs, then the - negotiated-method check -> ``-32003``, then timestamp skew -> ``-32006``. + negotiated-method check -> ``-32003``, then a step whose ``acs_version`` + does not share the session's negotiated major version -> ``-32001`` + (§3's forward-compat rule; the ServerHello names a version the client + offered, and a step may not move off its major), then timestamp skew -> + ``-32006``. 6. Payload: a wrapped MCP message read and checked -> ``-32602``; a native hook's payload validated against its schema -> ``-32602``. -7. Evaluation, the hook's permitted dispositions, §6's required fields and - §6.3's composition, the audit-chain append, a signed decision. +7. Evaluation, the hook's permitted dispositions, §6's required fields, + §6.3's composition and §6.5's substitution, the audit-chain append, a + signed decision. + +Once a step's signature has verified, a failure outside the registry — an +internal exception on the governed path — is answered with a signed DENY +carrying ``evaluation_failed``, never an error: an error without a decision +leaves the Observed Agent to its ``on_decision_failure`` posture, which by +default proceeds. Registry refusals (replay, skew, version, schema, +capability) keep their own signed error codes, as the spec defines them. Every response to an authenticated request is signed with the session key at ``result.signature`` / ``error.signature``, including error responses: the @@ -36,19 +52,21 @@ import json import re import sys -from dataclasses import dataclass, field +import threading +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import Any +from typing import Any, Callable from . import mcp, methods from .canonical import canonical_bytes, request_signing_input, response_signing_input from .chain import AuditChain from .crypto import ALGORITHM, derive_session_key, sign, verify -from .engine import ALLOW, DENY, Evaluation, EvaluationRequest, PolicyEngine +from .engine import ALLOW, DENY, MODIFY, Evaluation, EvaluationRequest, PolicyEngine from .errors import ( CAPABILITY_NOT_NEGOTIATED, + INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, @@ -57,15 +75,44 @@ SESSION_REFUSED, SIGNATURE_INVALID, TIMESTAMP_OUT_OF_WINDOW, + UNSUPPORTED_VERSION, AcsError, ) -from .handshake import negotiate, server_hello +from .handshake import negotiate, same_major, server_hello from .schemas import SchemaRegistry from .session import Session, SessionStore ACS_PATH = "/acs" MAX_REQUEST_BODY_BYTES = 1_048_576 ACS_VERSION = "0.1.0" +# Bounds every recursive consumer of a request (schema validation, the +# built-in engine's payload walk): a few hundred levels of nesting is enough +# to exhaust Python's default recursion limit on some builds and stack +# depths, turning a denial into an unsigned error the agent may proceed on. +# 128 is far past any real hook payload. +MAX_JSON_DEPTH = 128 +# The C JSON parser accepts roughly ten thousand nesting levels, but JCS +# canonicalization is pure-Python recursion the default limit rejects around +# a thousand. Over-deep bodies therefore verify and hash on a thread sized +# for the parser's maximum. +DEEP_RECURSION_LIMIT = 30_000 +DEEP_THREAD_STACK_BYTES = 32 * 1024 * 1024 +# Refused at the parse boundary: a body nested deeper than the enlarged +# thread's limit could never be canonicalized or verified by this Guardian, +# whatever the interpreter's parser happens to accept. +DEEP_PARSE_LIMIT = 10_000 +_DEEP_LOCK = threading.Lock() + +# A deployment's own audit record, e.g. a SIEM line. The standard requires +# audit events for some decisions (the §6.5 substitution, say) and leaves the +# sink to the deployment; the default writes one JSON object per line to +# stderr. It must not block and must not raise: a failed audit sink cannot be +# allowed to turn a governed step into an ungoverned one. +AuditHook = Callable[[dict[str, Any]], None] + + +def _stderr_audit(event: dict[str, Any]) -> None: + print(json.dumps(event, separators=(",", ":"), ensure_ascii=False), file=sys.stderr) @dataclass @@ -73,7 +120,12 @@ class GuardianConfig: """Deployment configuration. The secret is deployment-provided key material (§10).""" secret: bytes - key_id: str = "conformance" + # The label the deployment gives this one configured secret. No default: + # the label is how a verifier resolves the secret, and "default" or + # "conformance" as a fallback invites every deployment onto one label and + # makes a rotation need a code change. The #118 decision: key_id is an + # opaque deployment-chosen label, reserved values: none. + key_id: str on_decision_failure: str = "proceed" timeout_ms: int = 5000 skew_window_ms: int = 300_000 @@ -83,6 +135,7 @@ class GuardianConfig: # A deployment that needs eviction (TTL, capacity) supplies its own store; # the default keeps sessions for the process lifetime. session_store: SessionStore | None = None + audit: AuditHook | None = None host: str = "127.0.0.1" port: int = 8787 @@ -95,7 +148,10 @@ def __init__(self, config: GuardianConfig) -> None: raise ValueError(f"on_decision_failure must be proceed or deny, got {config.on_decision_failure!r}") if len(config.secret) < 32: raise ValueError("the HMAC secret must be at least 32 bytes") + if not config.key_id: + raise ValueError("key_id must be a non-empty label naming this deployment's secret") self.config = config + self.audit: AuditHook = config.audit if config.audit is not None else _stderr_audit self.schemas = SchemaRegistry(config.spec_root) self.store = config.session_store if config.session_store is not None else SessionStore() if config.engine is None: @@ -110,19 +166,27 @@ def __init__(self, config: GuardianConfig) -> None: def handle(self, raw: Any) -> dict[str, Any]: if isinstance(raw, list): return self._error(None, INVALID_REQUEST, "batching is not supported; send one request per POST") + if _exceeds_depth(raw, MAX_JSON_DEPTH): + return self._over_deep(raw) try: # The authoritative envelope check: request-envelope.json, required # fields included (acs_version, request_id, timestamp, metadata). self.schemas.validate_request(raw) except AcsError as error: return self._error(_best_effort_id(raw), error.code, error.message, error.data) + except RecursionError as error: # defensive: the depth cap above should pre-empt this + return self._error( + _best_effort_id(raw), + INVALID_REQUEST, + f"request envelope could not be validated: {error}", + ) try: # ACS pins JCS (§10): an envelope whose numbers fall outside the # JCS domain (1e400, an integer past 2^53) cannot be signed or # verified by anyone, so it is invalid here rather than an # internal error later when canonicalization raises. canonical_bytes(raw) - except ValueError as error: + except (ValueError, RecursionError) as error: return self._error( _best_effort_id(raw), INVALID_REQUEST, @@ -140,16 +204,25 @@ def handle(self, raw: Any) -> dict[str, Any]: params = raw.get("params") if isinstance(params, dict): metadata = params.get("metadata") - if isinstance(metadata, dict) and isinstance(metadata.get("session_id"), str): - key = derive_session_key(self.config.secret, metadata["session_id"]) signature = params.get("signature") if isinstance(signature, dict) and isinstance(signature.get("key_id"), str): key_id = signature["key_id"] + # The key resolves only for the configured label: a request naming + # another key_id is not verified with this deployment's secret + # (#118: a verifier MUST reject an unknown key_id and MUST NOT + # fall back to another secret). It stays key-less, so its answer + # is unsigned -- ping, exempt from signing, is still answered. + if ( + key_id == self.config.key_id + and isinstance(metadata, dict) + and isinstance(metadata.get("session_id"), str) + ): + key = derive_session_key(self.config.secret, metadata["session_id"]) try: if method == "handshake/hello": - return self._handshake(raw, rpc_id, params, key, key_id) - return self._step(raw, rpc_id, method, params, key, key_id) + return self._handshake(raw, rpc_id, params, key) + return self._step(raw, rpc_id, method, params, key) except AcsError as error: if error.signable and key is not None: # Signed under the deployment's key_id, like every response: @@ -158,8 +231,6 @@ def handle(self, raw: Any) -> dict[str, Any]: return self._error(rpc_id, error.code, error.message, error.data, key=key) return self._error(rpc_id, error.code, error.message, error.data) except Exception as error: # noqa: BLE001 - nothing may escape as a non-JSON-RPC answer - from .errors import INTERNAL_ERROR - # Details go to the operator's log, not the wire: an exception # message can carry filesystem paths and schema internals. # @@ -170,6 +241,223 @@ def handle(self, raw: Any) -> dict[str, Any]: print(f"guardian failed to handle a request: {error!r}", file=sys.stderr) return self._error(rpc_id, INTERNAL_ERROR, "guardian failed to handle the request") + def _over_deep(self, raw: Any) -> dict[str, Any]: + """Fail closed on a body nested past ``MAX_JSON_DEPTH``. + + The guard fires before schema validation and canonicalization, both of + which recurse. Nothing here trusts the ``key_id`` label or the + ``session_id`` alone — both travel on every request and are not + secrets; trusting them would turn this route into a signing oracle. + The body's own signature is verified first, shallowly enough for any + depth the parser accepts (canonicalization that hits the default + recursion limit is retried on an enlarged thread). A verified step is + answered like any other verified failure — a signed, recorded + decision — while a body that does not verify gets the plain error, + unsigned. + """ + rpc_id = _best_effort_id(raw) + params = raw.get("params") if isinstance(raw, dict) else None + metadata = params.get("metadata") if isinstance(params, dict) else None + session_id = metadata.get("session_id") if isinstance(metadata, dict) else None + method = raw.get("method") if isinstance(raw, dict) else None + if isinstance(params, dict) and isinstance(session_id, str): + signature = params.get("signature") + key_id = signature.get("key_id") if isinstance(signature, dict) else None + # The session is looked up before any verification work: the deep + # thread serializes, and a request with no live session has no + # decision to record anyway, so it has no reason to queue there. + session = self.store.get(session_id) + if session is not None and key_id == self.config.key_id: + key = derive_session_key(self.config.secret, session_id) + if self._signature_ok_any_depth(raw, params, key): + method_name = method if isinstance(method, str) else "" + try: + return self._over_deep_decision(rpc_id, session, params, method) + except Exception as error: # noqa: BLE001 - a hook fails closed; other routes report + print(f"guardian failed on a verified over-deep step: {error!r}", file=sys.stderr) + if methods.route(method_name) not in (methods.ROUTE_HOOK, methods.ROUTE_WRAPPED): + # A ping or a refusal route has no decision to + # fail closed with; report the bug as the normal + # path would, unsigned. + return self._error(rpc_id, INTERNAL_ERROR, "guardian failed to handle the request") + request_id = params.get("request_id") + if not isinstance(request_id, str): + return self._error( + rpc_id, INVALID_PARAMS, "params.request_id is required", key=key + ) + return self._fail_closed( + rpc_id, method_name, params, session_id, request_id, key, None + ) + return self._error( + rpc_id, + INVALID_REQUEST, + f"request nesting exceeds the {MAX_JSON_DEPTH}-level implementation limit", + ) + + @staticmethod + def _signature_ok_any_depth(raw: dict[str, Any], params: dict[str, Any], key: bytes) -> bool: + """Verify a signature on a body of any depth the JSON parser accepts. + + JCS canonicalization of a body nested a few thousand levels raises + ``RecursionError`` on the default limit while the body itself is + well-formed, so the retry runs on an enlarged thread. A + canonicalization failure there is a request no one could have signed + the same way this Guardian would read it, so it verifies False. + """ + try: + return Guardian._signature_ok(raw, params, key) + except RecursionError: + pass + except ValueError: + return False + ok, verified = _in_deep_thread(lambda: Guardian._signature_ok(raw, params, key)) + return ok and bool(verified) + + def _over_deep_decision( + self, + rpc_id: str | int | None, + session: Session, + params: dict[str, Any], + method: Any, + ) -> dict[str, Any]: + """A signed, recorded decision for a verified step that is over-deep. + + The dispatch ladder mirrors the normal path: the route decides first + — a ping answered without touching the chain or replay history, an + undefined or unsupported method its own signed refusal (neither + consuming the request_id, as the normal path refuses both before a + session is read), a handshake refused as a renegotiation with its + request_id and nonce recorded first, like ``_handshake`` — then + replay (-32005), then the agent binding and the closed check (signed + denials that write no entry), then the negotiated-method check, then + the step's chain entry — hashed on an enlarged thread when the body + is deeper than the default stack canonicalizes — with ``chain_hash`` + sent only when that entry is in the chain. The version and skew + checks are not repeated: the answer here is fail-closed regardless of + their outcome. + """ + request_id = params.get("request_id") + nonce = params.get("nonce") + method_name = method if isinstance(method, str) else "" + key = derive_session_key(self.config.secret, session.session_id) + if not isinstance(request_id, str): + # request-envelope.json requires a string request_id; a body + # without one must not reach the chain (its entry would have no + # id to deduplicate) or the response (a nested id could not be + # hashed). The normal path refuses this before anything else + # exists to do. + return self._error(rpc_id, INVALID_PARAMS, "params.request_id is required", key=key) + with session.lock: + # The route decides first, like the normal path: a method the + # standard does not define or this Guardian never negotiates is + # refused without consuming the request_id (the normal path + # refuses both before a session is read), while the handshake + # branch consumes its ids before refusing, like _handshake. + route = methods.route(method_name) + if route == methods.ROUTE_PING: + return self._signed_response_any_depth(_ping_result(rpc_id, params), key) + if route == methods.ROUTE_HANDSHAKE: + # A refused handshake consumes its request_id and nonce + # before the refusal, in _handshake's check-both-then-record + # order: a replay of the handshake is a replay, and a later + # step reusing either is one too. + if request_id in session.seen_request_ids: + return self._error( + rpc_id, REPLAY_DETECTED, "request_id was already seen in this session", key=key + ) + if isinstance(nonce, str) and nonce in session.seen_nonces: + return self._error( + rpc_id, REPLAY_DETECTED, "nonce was already seen in this session", key=key + ) + session.seen_request_ids.add(request_id) + if isinstance(nonce, str): + session.seen_nonces.add(nonce) + return self._error( + rpc_id, + SESSION_REFUSED, + "session is already negotiated; v0.1 defines no renegotiation", + data={"reason": "renegotiation_not_supported"}, + key=key, + ) + if route == methods.ROUTE_UNDEFINED: + return self._error( + rpc_id, METHOD_NOT_FOUND, f"ACS v0.1 defines no method {method_name}", data={"method": method_name}, key=key + ) + if route == methods.ROUTE_UNSUPPORTED: + return self._error( + rpc_id, + CAPABILITY_NOT_NEGOTIATED, + f"this Guardian does not negotiate {method_name}", + data={"method": method_name}, + key=key, + ) + if request_id in session.seen_request_ids: + return self._error( + rpc_id, REPLAY_DETECTED, "request_id was already seen in this session", key=key + ) + session.seen_request_ids.add(request_id) + if isinstance(nonce, str): + if nonce in session.seen_nonces: + return self._error( + rpc_id, REPLAY_DETECTED, "nonce was already seen in this session", key=key + ) + session.seen_nonces.add(nonce) + if params.get("metadata", {}).get("agent_id") != session.agent_id: + return self._answer_denial( + rpc_id, session, request_id, key, method_name, + "agent_id_not_bound", + "the request names an agent other than the agent that opened this session", + ) + if session.closed: + return self._answer_denial( + rpc_id, session, request_id, key, method_name, + "session_closed", + "the session has ended; no step enters after steps/sessionEnd", + ) + # After the bindings and the closed check, like _step: a closed + # session or a foreign agent gets its signed denial, not a + # capability error the posture may turn into a proceed. + if method_name not in session.methods_evaluated: + return self._error( + rpc_id, + CAPABILITY_NOT_NEGOTIATED, + f"method {method_name} was not negotiated for this session", + data={"method": method_name}, + key=key, + ) + appended = False + try: + self._append_step_entry(session, request_id, method_name, params) + appended = True + except Exception as error: # noqa: BLE001 - the decision goes out regardless + print(f"guardian could not append an over-deep step to the chain: {error!r}", file=sys.stderr) + if method_name == "steps/sessionEnd": + session.closed = True + evaluation = _evaluation_failed(method_name) + result = self._decision_result(session, request_id, evaluation, include_chain_hash=appended) + response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} + return self._sign(response, key, self.config.key_id) + + @staticmethod + def _append_step_entry(session: Session, request_id: Any, method_name: str, params: dict[str, Any]) -> None: + """Append one step's chain entry, canonicalizing on an enlarged thread if needed.""" + + def append() -> None: + session.chain.append( + step_id=str(request_id), + step_type=method_name, + params=params, + timestamp=_now_iso(), + turn_id=_turn_id(params), + ) + + try: + append() + except RecursionError: + ok, error = _in_deep_thread(append) + if not ok: + raise error + # -- handshake --------------------------------------------------------- def _handshake( @@ -178,7 +466,6 @@ def _handshake( rpc_id: str | int | None, params: Any, key: bytes | None, - key_id: str | None, ) -> dict[str, Any]: if not isinstance(params, dict): raise AcsError(INVALID_PARAMS, "params must be an object", signable=False) @@ -222,7 +509,7 @@ def _handshake( skew_window_ms=self.config.skew_window_ms, policy_requires_provenance=self.config.policy_requires_provenance, ) - session = self._new_session(params, session_id, request_id, key_id, terms, nonce) + session = self._new_session(params, session_id, request_id, terms, nonce) if not self.store.create_if_absent(session): # Lost a race with a concurrent handshake for the same session_id. raced = self.store.get(session_id) @@ -246,14 +533,12 @@ def _new_session( params: dict[str, Any], session_id: str, request_id: Any, - key_id: str | None, terms: Any, nonce: Any = None, ) -> Session: return Session( session_id=session_id, agent_id=str((params.get("metadata") or {}).get("agent_id", "")), - key_id=key_id or self.config.key_id, negotiated_version=terms.negotiated_version, methods_evaluated=terms.methods_evaluated, selected_transport=terms.selected_transport, @@ -275,7 +560,6 @@ def _step( method: str, params: Any, key: bytes | None, - key_id: str | None, ) -> dict[str, Any]: if not isinstance(params, dict): raise AcsError(INVALID_PARAMS, "params must be an object", signable=False) @@ -288,90 +572,161 @@ def _step( raise AcsError(SIGNATURE_INVALID, "request signature is missing or invalid", signable=False) if method == "system/ping": - return self._ping(rpc_id, params, key, key_id) - - # The method table decides the route before the session is read - # (the order the Go port in open PR #169 routes by): a method the - # standard does not define is - # METHOD_NOT_FOUND, one it defines and this Guardian never negotiates - # (agbom/*) is CAPABILITY_NOT_NEGOTIATED. - route = methods.route(method) - if route == methods.ROUTE_UNDEFINED: - raise AcsError(METHOD_NOT_FOUND, f"ACS v0.1 defines no method {method}", data={"method": method}) - if route == methods.ROUTE_UNSUPPORTED: - raise AcsError( - CAPABILITY_NOT_NEGOTIATED, f"this Guardian does not negotiate {method}", data={"method": method} - ) + return self._ping(raw, rpc_id, params, key) - session = self.store.get(session_id) - if session is None: - raise AcsError( - CAPABILITY_NOT_NEGOTIATED, - f"no negotiated session for {session_id}; handshake/hello is required first", - data={"method": method}, - ) - - # One session's steps are serialized: the replay check, the binding - # checks, the chain append, and the decision must be one atomic step, - # or two concurrent copies of a request both pass the check and both - # append. - with session.lock: - if isinstance(request_id, str): - if request_id in session.seen_request_ids: - raise AcsError(REPLAY_DETECTED, "request_id was already seen in this session") - session.seen_request_ids.add(request_id) - nonce = params.get("nonce") - if isinstance(nonce, str): - if nonce in session.seen_nonces: - raise AcsError(REPLAY_DETECTED, "nonce was already seen in this session") - session.seen_nonces.add(nonce) - - # A session answers only the key and the agent that opened it. - # Both are answered as a signed DENY, not an error: an error would - # leave the Observed Agent to its failure posture, which by - # default proceeds. - if key_id != session.key_id: - return self._answer_denial( - rpc_id, session, request_id, key, key_id, method, - "key_not_bound", - "the request is signed with a key_id other than the one that opened this session", - ) - if params.get("metadata", {}).get("agent_id") != session.agent_id: - return self._answer_denial( - rpc_id, session, request_id, key, key_id, method, - "agent_id_not_bound", - "the request names an agent other than the agent that opened this session", - ) - if session.closed: - return self._answer_denial( - rpc_id, session, request_id, key, key_id, method, - "session_closed", - "the session has ended; no step enters after steps/sessionEnd", + # From here the request authenticated, and every failure is answered + # as a signed DENY: an error without a decision leaves the Observed + # Agent to its on_decision_failure posture, which by default proceeds. + entries_before: int | None = None + try: + # The method table decides the route before the session is read + # (the order the Go port in open PR #169 routes by): a method the + # standard does not define is METHOD_NOT_FOUND, one it defines and + # this Guardian never negotiates (agbom/*) is + # CAPABILITY_NOT_NEGOTIATED. + route = methods.route(method) + if route == methods.ROUTE_UNDEFINED: + raise AcsError(METHOD_NOT_FOUND, f"ACS v0.1 defines no method {method}", data={"method": method}) + if route == methods.ROUTE_UNSUPPORTED: + raise AcsError( + CAPABILITY_NOT_NEGOTIATED, f"this Guardian does not negotiate {method}", data={"method": method} ) - if method not in session.methods_evaluated: + + session = self.store.get(session_id) + if session is None: raise AcsError( CAPABILITY_NOT_NEGOTIATED, - f"method {method} was not negotiated for this session", + f"no negotiated session for {session_id}; handshake/hello is required first", data={"method": method}, ) - self._check_timestamp(params.get("timestamp"), session.skew_window_ms) - evaluation = self._evaluate(method, params, session, request_id, route) + # One session's steps are serialized: the replay check, the binding + # checks, the chain append, and the decision must be one atomic + # step, or two concurrent copies of a request both pass the check + # and both append. + with session.lock: + entries_before = len(session.chain.entries) + if isinstance(request_id, str): + if request_id in session.seen_request_ids: + raise AcsError(REPLAY_DETECTED, "request_id was already seen in this session") + session.seen_request_ids.add(request_id) + nonce = params.get("nonce") + if isinstance(nonce, str): + if nonce in session.seen_nonces: + raise AcsError(REPLAY_DETECTED, "nonce was already seen in this session") + session.seen_nonces.add(nonce) + + # A session answers only the agent that opened it. Answered as + # a signed DENY, not an error: an error would leave the + # Observed Agent to its failure posture, which by default + # proceeds. (The key_id binding is upstream, in handle(): a + # request naming another label never resolved a key, so it + # never reaches a signature check, let alone this one.) + if params.get("metadata", {}).get("agent_id") != session.agent_id: + return self._answer_denial( + rpc_id, session, request_id, key, method, + "agent_id_not_bound", + "the request names an agent other than the agent that opened this session", + ) + if session.closed: + return self._answer_denial( + rpc_id, session, request_id, key, method, + "session_closed", + "the session has ended; no step enters after steps/sessionEnd", + ) + if method not in session.methods_evaluated: + raise AcsError( + CAPABILITY_NOT_NEGOTIATED, + f"method {method} was not negotiated for this session", + data={"method": method}, + ) + # §3's forward-compat rule: the ServerHello named a version + # the client offered, and a step may not move off that major. + # The rule and the error data are the Go port's (SameMajor, + # supported_versions); this Guardian places the check after + # the method check and before the skew check, where the Go + # port checks the skew window first. + if not same_major(str(params.get("acs_version", "")), session.negotiated_version): + raise AcsError( + UNSUPPORTED_VERSION, + f"acs_version {params.get('acs_version')!r} does not share the negotiated major version", + data={"supported_versions": [session.negotiated_version]}, + ) + + self._check_timestamp(params.get("timestamp"), session.skew_window_ms) + evaluation = self._evaluate(method, params, session, request_id, route) + + # The chain entry is appended on arrival of an evaluated step, + # before the verdict is delivered: a denied step is still a + # step the session took (§8.6). The timestamp is the + # Guardian's write clock and entry_id is the request_id, the + # Go port's choices for the two fields ACS leaves undefined. + session.chain.append( + step_id=str(request_id), + step_type=method, + params=params, + timestamp=_now_iso(), + turn_id=_turn_id(params), + ) + if method == "steps/sessionEnd": + session.closed = True - # The chain entry is appended on arrival of an evaluated step, - # before the verdict is delivered: a denied step is still a step - # the session took (§8.6). - session.chain.append( - step_id=str(request_id), - step_type=method, - params=params, - timestamp=params.get("timestamp"), - ) + result = self._decision_result(session, request_id, evaluation) + + response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} + return self._sign(response, key, self.config.key_id) + except AcsError: + # Registry refusals (replay, skew, unnegotiated method/version) + # keep their codes: the agent branches on them. + raise + except Exception as error: # noqa: BLE001 - an authenticated step fails closed + print(f"guardian failed on a verified step: {error!r}", file=sys.stderr) + return self._fail_closed(rpc_id, method, params, session_id, request_id, key, entries_before) + + def _fail_closed( + self, + rpc_id: str | int | None, + method: str, + params: dict[str, Any], + session_id: str, + request_id: Any, + key: bytes | None, + entries_before: int | None, + ) -> dict[str, Any]: + """A signed DENY for a failure on the governed path, never an error. + + An error without a decision leaves the Observed Agent to its + ``on_decision_failure`` posture (default: proceed), so a failure after + the signature verified is answered like an engine failure: a signed + DENY carrying ``evaluation_failed``. The step's chain entry is + appended if the failure pre-empted it — the step did arrive (§8.6) — + and ``chain_hash`` is sent only when this step's entry is in the + chain, because the field names the head *after this step's entry*. + ``steps/sessionEnd`` closes the session either way. + """ + session = self.store.get(session_id) + if session is None or key is None: + # Nothing to bind a decision to; this route is a bug report, and + # the request may not have authenticated, so it stays unsigned. + return self._error(rpc_id, INTERNAL_ERROR, "guardian failed to handle the request") + with session.lock: + appended = entries_before is not None and len(session.chain.entries) > entries_before + if not appended: + try: + session.chain.append( + step_id=str(request_id), + step_type=method, + params=params, + timestamp=_now_iso(), + turn_id=_turn_id(params), + ) + appended = True + except Exception as error: # noqa: BLE001 - the denial goes out regardless + print(f"guardian could not append a failed step to the chain: {error!r}", file=sys.stderr) if method == "steps/sessionEnd": session.closed = True - - result = self._decision_result(session, request_id, evaluation) - + evaluation = _evaluation_failed(method) + result = self._decision_result(session, request_id, evaluation, include_chain_hash=appended) response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} return self._sign(response, key, self.config.key_id) @@ -381,7 +736,6 @@ def _answer_denial( session: Session, request_id: Any, key: bytes | None, - key_id: str | None, method: str, reason_code: str, reasoning: str, @@ -405,8 +759,7 @@ def _answer_denial( } # Signed under the deployment's key_id, like every other response: # key_id names the key a verifier resolves, and that is the one this - # Guardian holds. The session's key_id is a binding the request is - # checked against, not a second label for the same key. + # Guardian holds. Every session binds that same configured label. return self._sign(response, key, self.config.key_id) def _evaluate( @@ -442,8 +795,35 @@ def _evaluate( return _evaluation_failed(method) # A disposition the hook does not permit is not sent as one: the hook - # cannot carry it (hooks.md "Decision"). + # cannot carry it (hooks.md "Decision"). Only after that, a MODIFY the + # Observed Agent cannot apply is substituted too (§6.5), with its own + # reason code: at a hook that permits no MODIFY the substitution above + # already fired, and disposition_not_permitted is the accurate code + # there. evaluation = _substitute_permitted(method, evaluation) + if evaluation.decision == MODIFY and evaluation.client_cannot_apply_modify: + policy_codes = list(evaluation.reason_codes) + modifications = evaluation.modifications + evaluation = _modify_unsupported(method, evaluation) + # §9.2's precedent: the substitution is recorded as an audit + # event. postCompact's requirement is explicit: the event names + # the rewrite that could not be delivered, which is why the + # reasoning (it names the redaction paths / override keys / the + # replacement content) and the modifications themselves are part + # of the event. + event: dict[str, Any] = { + "event": "modify_unsupported", + "session_id": session.session_id, + "request_id": str(request_id), + "method": method, + "reasoning": evaluation.reasoning, + "reason_codes": ["modify_unsupported"], + } + if policy_codes: + event["policy_reason_codes"] = policy_codes + if isinstance(modifications, dict): + event["intended_modifications"] = modifications + self._audit(event) # A decision that cannot be expressed conformantly is not sent as one # (response-envelope.json requires reasoning on deny/modify/ask/defer, @@ -454,6 +834,13 @@ def _evaluate( return _evaluation_failed(method) return evaluation + def _audit(self, event: dict[str, Any]) -> None: + """Record a required audit event without letting the sink break governance.""" + try: + self.audit(event) + except Exception as error: # noqa: BLE001 - a failed sink is reported, not raised + print(f"the audit sink failed: {error!r}", file=sys.stderr) + @staticmethod def _decision_result( session: Session, request_id: Any, evaluation: Evaluation, *, include_chain_hash: bool = True @@ -485,31 +872,32 @@ def _decision_result( def _ping( self, + raw: dict[str, Any], rpc_id: str | int | None, params: dict[str, Any], key: bytes | None, - key_id: str | None, ) -> dict[str, Any]: - payload = params.get("payload") if isinstance(params.get("payload"), dict) else {} - result = { - "type": "final", - "acs_version": str(params.get("acs_version", ACS_VERSION)), - "request_id": params.get("request_id"), - "decision": ALLOW, - "payload": { - "status": "ok", - "echo": payload.get("echo"), - "server_timestamp": _now_iso(), - }, - } - response = {"jsonrpc": "2.0", "id": rpc_id, "result": result} - # Ping never requires a signature; if one authenticated, answering - # signed costs nothing and keeps the harness's verification uniform. - session_id = (params.get("metadata") or {}).get("session_id") if isinstance(params.get("metadata"), dict) else None - if key is not None and isinstance(session_id, str): + response = _ping_result(rpc_id, params) + # Ping never requires a signature, and its answer is signed only when + # the ping itself verified (the Go port's rule). Signing it on the + # strength of metadata.session_id alone would make the endpoint a + # signing oracle: anyone able to name a session could collect a + # Guardian-signed ALLOW under that session's key, which an attacker on + # the network path could substitute for a real signed DENY. + if key is not None and self._signature_ok(raw, params, key): return self._sign(response, key, self.config.key_id) return response + def _signed_response_any_depth(self, response: dict[str, Any], key: bytes) -> dict[str, Any]: + """Sign a response whose content may be too deep for the request thread.""" + try: + return self._sign(response, key, self.config.key_id) + except RecursionError: + ok, signed = _in_deep_thread(lambda: self._sign(response, key, self.config.key_id)) + if not ok: + raise signed + return signed + # -- helpers ----------------------------------------------------------- @staticmethod @@ -586,10 +974,161 @@ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +def _ping_result(rpc_id: str | int | None, params: dict[str, Any]) -> dict[str, Any]: + """The liveness answer (§13), before any signing decision. + + Split from ``_ping`` so the over-deep path can build and sign the answer + for a request whose signature it has already verified. + """ + payload = params.get("payload") if isinstance(params.get("payload"), dict) else {} + acs_version = params.get("acs_version") + result = { + "type": "final", + "acs_version": acs_version if isinstance(acs_version, str) else ACS_VERSION, + "request_id": params.get("request_id"), + "decision": ALLOW, + "payload": { + "status": "ok", + "echo": payload.get("echo"), + "server_timestamp": _now_iso(), + }, + } + return {"jsonrpc": "2.0", "id": rpc_id, "result": result} + + +def _turn_id(params: dict[str, Any]) -> str | None: + """The envelope's ``metadata.turn_id``, when the framework set one. + + turnStart requires the framework to propagate it onto every per-step + ContextEntry; the Go port carries it into the entry under the envelope + field's name, since context-entry.json names no member for it. + """ + metadata = params.get("metadata") + turn = metadata.get("turn_id") if isinstance(metadata, dict) else None + return turn if isinstance(turn, str) else None + + +def _exceeds_depth(value: Any, limit: int) -> bool: + """Whether ``value`` nests past ``limit``, walked iteratively. + + Iterative on purpose: a recursive walk would itself hit the recursion + limit it is meant to guard against, and the answer would depend on the + thread's stack depth and the interpreter build. The count is structural + (one per object/array level), so the same body always takes the same + branch. + """ + stack: list[tuple[Any, int]] = [(value, 1)] + while stack: + node, depth = stack.pop() + if isinstance(node, dict) or isinstance(node, list): + if depth > limit: + return True + children = node.values() if isinstance(node, dict) else node + stack.extend((child, depth + 1) for child in children) + return False + + +def _in_deep_thread(work: Callable[[], Any]) -> tuple[bool, Any]: + """Run ``work`` on a thread sized for any body the JSON parser accepts. + + The C parser accepts roughly ten thousand nesting levels; JCS + canonicalization is pure-Python recursion that the default limit rejects + around a thousand. Anything deeper than MAX_JSON_DEPTH canonicalizes + here instead, where the recursion limit and the thread's stack cover the + parser's maximum. Serialized by a module lock: the recursion limit is + process-global, so two overlapping calls could restore it under each + other — leaving a verified body to fail verification, the fail-open this + mechanism exists to prevent — and the lock also caps live deep threads at + one. Callers check a live session before entering (see ``_over_deep``), + so a spam of deep bodies can still queue here, but each holder's work is + bounded by one request body. Threads other requests create during the + window inherit the larger default stack — address space, not resident + memory. Returns ``(True, value)`` or ``(False, error)``. + """ + with _DEEP_LOCK: + outcome: dict[str, Any] = {} + + def run() -> None: + previous_limit = sys.getrecursionlimit() + sys.setrecursionlimit(DEEP_RECURSION_LIMIT) + try: + outcome["value"] = work() + except Exception as error: # noqa: BLE001 - reported to the caller + outcome["error"] = error + finally: + sys.setrecursionlimit(previous_limit) + + try: + previous_stack = threading.stack_size(DEEP_THREAD_STACK_BYTES) + except (ValueError, RuntimeError): + # A platform that refuses the stack size still gets the raised + # recursion limit, which is what pure-Python recursion needs. + previous_stack = None + try: + worker = threading.Thread(target=run, name="acs-deep-canonicalization", daemon=True) + worker.start() + worker.join() + finally: + if previous_stack is not None: + threading.stack_size(previous_stack) + if "error" in outcome: + return False, outcome["error"] + return True, outcome.get("value") + + +def _bytes_nesting_exceeds(body: bytes, limit: int) -> bool: + """Whether raw JSON text nests past ``limit``, walked iteratively. + + Counted on the bytes before parsing, so the bound does not depend on the + interpreter's parser limits: a body this deep could never be + canonicalized on the enlarged thread, and it is refused at the parse + boundary (where no signature could have been checked yet either). String + contents are skipped — a brace inside a string is not nesting — and UTF-8 + continuation bytes never collide with the ASCII structural characters. + """ + depth = 0 + in_string = False + escaped = False + for byte in body: + if in_string: + if escaped: + escaped = False + elif byte == 0x5C: # backslash + escaped = True + elif byte == 0x22: # double quote + in_string = False + continue + if byte == 0x22: + in_string = True + elif byte in (0x7B, 0x5B): # { [ + depth += 1 + if depth > limit: + return True + elif byte in (0x7D, 0x5D) and depth: # } ] + depth -= 1 + return False + + def _reject_non_finite(token: str) -> Any: raise ValueError(f"non-finite JSON number is not permitted: {token}") +def _reject_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Refuse duplicate object members: two parsers must not read one body differently. + + JSON's grammar permits duplicates and most parsers keep the last, so a + signed request containing ``{"a": 1, "a": 2}`` verifies against one + reading and executes another. The Go port refuses the body too; raising + ValueError lands it on the parse-error path (-32700). + """ + members: dict[str, Any] = {} + for key, value in pairs: + if key in members: + raise ValueError(f"duplicate object member: {key!r}") + members[key] = value + return members + + def _best_effort_id(raw: Any) -> str | int | None: """The JSON-RPC id for an error response, when one can be determined (§ response-envelope).""" if isinstance(raw, dict): @@ -677,6 +1216,43 @@ def _substitute_permitted(method: str, evaluation: Evaluation) -> Evaluation: ) +def _modify_unsupported(method: str, evaluation: Evaluation) -> Evaluation: + """§6.5: a MODIFY the Observed Agent cannot apply is substituted, never sent. + + DENY with ``reason_codes: ["modify_unsupported"]`` and a ``reasoning`` + that names the intended modification, except at a hook that permits no + DENY (``postCompact``), where the substitution is ALLOW: the compaction + has already happened, so a denial would change nothing and the audit + event is what makes the un-delivered rewrite visible. + """ + targets: list[str] = [] + modifications = evaluation.modifications if isinstance(evaluation.modifications, dict) else {} + redactions = modifications.get("redactions") + if isinstance(redactions, list): + for redaction in redactions: + if isinstance(redaction, dict) and isinstance(redaction.get("path"), str): + targets.append(f"redact {redaction['path']}") + overrides = modifications.get("parameter_overrides") + if isinstance(overrides, dict): + for argument in overrides: + targets.append(f"override {argument}") + if modifications.get("modified_content") is not None: + targets.append("the replacement content (modified_content)") + intended = ", ".join(targets) if targets else "the intended rewrite" + detail = ( + f"the Observed Agent cannot apply MODIFY; the intended modification ({intended}) " + "was not delivered, and the substitution is recorded as modify_unsupported" + ) + reasoning = f"{evaluation.reasoning}. {detail}" if evaluation.reasoning else detail + # §6.5 names the reason_codes value exactly: ["modify_unsupported"]. The + # engine's own codes are not on the wire, but the audit event keeps them + # (the audit event is where the deployment's record lives). + codes = ["modify_unsupported"] + if DENY in methods.permitted_dispositions(method): + return Evaluation(decision=DENY, reasoning=reasoning, reason_codes=codes) + return Evaluation(decision=ALLOW, reasoning=reasoning, reason_codes=codes) + + def _evaluation_failed(method: str) -> Evaluation: return _substitute_permitted( method, @@ -751,19 +1327,45 @@ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's own naming ) return body = self.rfile.read(declared) + if _bytes_nesting_exceeds(body, DEEP_PARSE_LIMIT): + # Refused before parsing: nested past what the enlarged thread + # can canonicalize, so no one could verify it here anyway, and + # the interpreter's own parser limit should not be the thing that + # decides. + self._respond_json({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": "Parse error"}}) + return try: # parse_constant rejects the literal NaN/Infinity tokens JSON.parse # accepts; numeric overflow (1e400, integers past 2^53) is caught # by the JCS canonicalizability check in Guardian.handle, which - # runs before anything tries to sign the envelope. - raw = json.loads(body.decode("utf-8"), parse_constant=_reject_non_finite) + # runs before anything tries to sign the envelope; a duplicate + # member is refused so two readers cannot disagree on the body's + # meaning (it lands on the parse-error path, like the Go port's). + raw = json.loads( + body.decode("utf-8"), + parse_constant=_reject_non_finite, + object_pairs_hook=_reject_duplicate_members, + ) except (json.JSONDecodeError, UnicodeDecodeError, ValueError, RecursionError): - # RecursionError: deeply nested JSON (~1000 levels, a few KB) is - # under the body cap but past the parser's limit; it must be a - # parse error, not a dropped connection. + # RecursionError: JSON nested past the C parser's own limit (about + # ten thousand levels, a few tens of KB) is under the body cap but + # past what this server can read; it must be a parse error, not a + # dropped connection. self._respond_json({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": "Parse error"}}) return - self._respond_json(self.guardian.handle(raw)) + try: + response = self.guardian.handle(raw) + except Exception as error: # noqa: BLE001 - an escaped exception must still answer + # handle() catches its own failures; this is a last resort so a + # bug cannot drop the connection (which the Observed Agent reads + # as no decision at all, and its posture may then proceed). + print(f"guardian escaped its own error handling: {error!r}", file=sys.stderr) + response = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": INTERNAL_ERROR, "message": "guardian failed to handle the request"}, + } + self._respond_json(response) def _respond_json(self, payload: dict[str, Any]) -> None: body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") diff --git a/reference-implementations/agt-python/src/acs_guardian/session.py b/reference-implementations/agt-python/src/acs_guardian/session.py index 78229583..e38a37a5 100644 --- a/reference-implementations/agt-python/src/acs_guardian/session.py +++ b/reference-implementations/agt-python/src/acs_guardian/session.py @@ -25,7 +25,6 @@ class Session: session_id: str agent_id: str - key_id: str negotiated_version: str methods_evaluated: tuple[str, ...] selected_transport: str diff --git a/reference-implementations/agt-python/tests/conftest.py b/reference-implementations/agt-python/tests/conftest.py index 919b78ff..d1de754b 100644 --- a/reference-implementations/agt-python/tests/conftest.py +++ b/reference-implementations/agt-python/tests/conftest.py @@ -8,13 +8,13 @@ from acs_guardian.server import Guardian, GuardianConfig, serve -from helpers import SECRET +from helpers import KEY_ID, SECRET @pytest.fixture() def guardian() -> Guardian: # port 0: the HTTP fixture below binds a free port rather than a fixed one. - return Guardian(GuardianConfig(secret=SECRET, port=0)) + return Guardian(GuardianConfig(secret=SECRET, key_id=KEY_ID, port=0)) @pytest.fixture() diff --git a/reference-implementations/agt-python/tests/test_chain.py b/reference-implementations/agt-python/tests/test_chain.py index df001dd5..bd12c310 100644 --- a/reference-implementations/agt-python/tests/test_chain.py +++ b/reference-implementations/agt-python/tests/test_chain.py @@ -40,6 +40,23 @@ def test_request_hash_commits_to_the_params_object() -> None: assert entry.request_hash == hashlib.sha256(canonical_bytes(params)).hexdigest() +def test_entry_id_is_the_step_id() -> None: + """The Go port's choice, so two implementations agree on the hashed content.""" + chain = AuditChain() + entry = chain.append(step_id="req-1", step_type="steps/toolCallRequest") + assert entry.entry_id == "req-1" + assert entry.to_dict()["entry_id"] == "req-1" + + +def test_turn_id_is_carried_into_the_hashed_content() -> None: + with_turn = AuditChain().append(step_id="s-1", step_type="steps/toolCallRequest", turn_id="t-1") + without = AuditChain().append(step_id="s-1", step_type="steps/toolCallRequest") + assert with_turn.to_dict()["turn_id"] == "t-1" + assert "turn_id" not in without.to_dict() + assert with_turn.entry_hash != without.entry_hash + assert verify_chain([with_turn.to_dict()]) + + def test_tampering_with_a_stored_entry_breaks_verification() -> None: chain = AuditChain() chain.append(step_id="s-1", step_type="steps/sessionStart") diff --git a/reference-implementations/agt-python/tests/test_handshake.py b/reference-implementations/agt-python/tests/test_handshake.py index f7e1aaa6..7783c2d1 100644 --- a/reference-implementations/agt-python/tests/test_handshake.py +++ b/reference-implementations/agt-python/tests/test_handshake.py @@ -46,8 +46,28 @@ def test_no_common_version_is_unsupported_version() -> None: assert caught.value.data == {"supported_versions": ["0.1.0"]} -def test_same_major_version_negotiates_this_guardians_minor() -> None: - assert negotiate(_hello(acs_versions_supported=["0.7.3"])).negotiated_version == "0.1.0" +def test_a_same_major_version_negotiates_an_offered_version() -> None: + """§3 forward compat: the answer names a version the client offered (the Go rule).""" + assert negotiate(_hello(acs_versions_supported=["0.7.3"])).negotiated_version == "0.7.3" + + +def test_the_highest_offered_version_sharing_the_major_wins() -> None: + negotiated = negotiate(_hello(acs_versions_supported=["0.2.0", "0.9.0", "0.3.0"])).negotiated_version + assert negotiated == "0.9.0" + + +def test_a_malformed_version_shares_no_major() -> None: + with pytest.raises(AcsError) as caught: + negotiate(_hello(acs_versions_supported=["0.1", "zero.1.0"])) + assert caught.value.code == UNSUPPORTED_VERSION + + +def test_only_ascii_digit_parts_are_versions() -> None: + """int() would accept signs, whitespace, underscores, non-ASCII digits and very long strings.""" + for malformed in ("+0.1.0", " 0.1.0", "-0.1.0", "0_1.0.0", "\u0661.0.0", "1" * 5000 + ".0.0"): + with pytest.raises(AcsError) as caught: + negotiate(_hello(acs_versions_supported=[malformed])) + assert caught.value.code == UNSUPPORTED_VERSION def test_version_mismatch_wins_over_every_other_refusal() -> None: diff --git a/reference-implementations/agt-python/tests/test_main.py b/reference-implementations/agt-python/tests/test_main.py new file mode 100644 index 00000000..e52fdae0 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_main.py @@ -0,0 +1,46 @@ +"""The CLI's configuration rules, where #118's key_id requirement lands.""" + +from __future__ import annotations + +import argparse + +import pytest + +from acs_guardian import main as cli +from helpers import KEY_ID, SECRET + + +def _args(tmp_path, *, key_id=None): + secret_file = tmp_path / "secret" + secret_file.write_bytes(SECRET) + return argparse.Namespace( + secret_file=str(secret_file), + key_id=key_id, + posture=None, + timeout_ms=5000, + skew_window_ms=300_000, + require_provenance=False, + spec_root=None, + host=None, + port=None, + ) + + +def test_serve_requires_a_key_id(monkeypatch, tmp_path) -> None: + """#118: key_id comes from config, with no default.""" + monkeypatch.delenv("ACS_GUARDIAN_KEY_ID", raising=False) + with pytest.raises(SystemExit) as caught: + cli._build_config(_args(tmp_path)) + assert "key_id" in str(caught.value) + + +def test_the_configured_key_id_reaches_the_config(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("ACS_GUARDIAN_KEY_ID", raising=False) + config = cli._build_config(_args(tmp_path, key_id="2026-10")) + assert config.key_id == "2026-10" + + +def test_the_environment_supplies_the_key_id(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("ACS_GUARDIAN_KEY_ID", KEY_ID) + config = cli._build_config(_args(tmp_path)) + assert config.key_id == KEY_ID diff --git a/reference-implementations/agt-python/tests/test_server.py b/reference-implementations/agt-python/tests/test_server.py index a7645f96..833f060e 100644 --- a/reference-implementations/agt-python/tests/test_server.py +++ b/reference-implementations/agt-python/tests/test_server.py @@ -10,6 +10,7 @@ from helpers import ( AGENT_ID, + KEY_ID, SECRET, envelope, expect_decision, @@ -23,11 +24,19 @@ ) from acs_guardian.canonical import canonical_bytes, response_signing_input +from acs_guardian.chain import AuditChain from acs_guardian.crypto import derive_session_key, sign from acs_guardian.engine import ASK, DEFER, MODIFY, Evaluation from acs_guardian.errors import INVALID_PARAMS, AcsError from acs_guardian.schemas import HOOK_METHODS -from acs_guardian.server import Guardian, GuardianConfig +from acs_guardian.server import ( + DEEP_PARSE_LIMIT, + MAX_JSON_DEPTH, + Guardian, + GuardianConfig, + _in_deep_thread, + _modify_unsupported, +) def _session(server, methods=("steps/toolCallRequest", "steps/toolCallResult", "system/ping")) -> str: @@ -125,12 +134,38 @@ def test_ping_is_always_allowed_and_does_not_touch_the_chain(server) -> None: assert result["payload"]["status"] == "ok" assert result["payload"]["echo"] == "hi" assert "chain_hash" not in result + # A signed ping's answer is signed too (the Go port's rule). + assert result["signature"]["key_id"] == KEY_ID + +def test_an_unsigned_ping_is_answered_but_not_signed(server) -> None: + """Answering an unsigned ping signed would make the endpoint a signing oracle. -def test_ping_needs_no_signature(server) -> None: + Anyone able to name a session could collect a Guardian-signed ALLOW under + that session's key, which an attacker on the network path could + substitute for a real signed DENY. + """ session_id = _session(server) response = http_post(server.url, envelope("system/ping", session_id, {})) assert response["result"]["decision"] == "allow" + assert "signature" not in response["result"] + + +def test_a_ping_with_a_bad_signature_is_answered_but_not_signed(server) -> None: + session_id = _session(server) + request = sign_request(envelope("system/ping", session_id, {"echo": "hi"}), session_id) + request["params"]["signature"]["value"] = "AAAA" + response = http_post(server.url, request) + assert response["result"]["decision"] == "allow" + assert "signature" not in response["result"] + + +def test_a_ping_with_an_unknown_key_id_is_answered_but_not_signed(server) -> None: + session_id = _session(server) + request = sign_request(envelope("system/ping", session_id, {}), session_id, key_id="some-other-key") + response = http_post(server.url, request) + assert response["result"]["decision"] == "allow" + assert "signature" not in response["result"] def test_a_stale_handshake_is_outside_the_skew_window(server) -> None: @@ -222,8 +257,8 @@ def test_deeply_nested_json_is_a_parse_error_not_a_dropped_connection(server) -> import json import urllib.request - # ~20 KB, far under the body cap, and far past the parser's ~1000-level - # recursion limit. + # ~20 KB, far under the body cap, and past the C parser's ~10,000-level + # recursion limit on the pinned interpreter. body = b"[" * 10_000 + b"]" * 10_000 request = urllib.request.Request( server.url, data=body, headers={"content-type": "application/json"}, method="POST" @@ -436,7 +471,7 @@ def evaluate(self, request) -> Evaluation: # noqa: ANN001 - the Protocol's shap ], ) def test_all_five_dispositions_produce_schema_valid_decisions(evaluation: Evaluation) -> None: - guardian = Guardian(GuardianConfig(secret=SECRET, port=0, engine=_StubEngine(evaluation))) + guardian = Guardian(GuardianConfig(secret=SECRET, key_id=KEY_ID, port=0, engine=_StubEngine(evaluation))) session_id = str(uuid.uuid4()) hello = sign_request( envelope( @@ -474,7 +509,7 @@ def test_all_five_dispositions_produce_schema_valid_decisions(evaluation: Evalua def test_an_incomplete_engine_decision_fails_closed() -> None: """A modify without modifications cannot be sent as one; it becomes a deny.""" guardian = Guardian( - GuardianConfig(secret=SECRET, port=0, engine=_StubEngine(Evaluation(decision=MODIFY, reasoning="no payload"))) + GuardianConfig(secret=SECRET, key_id=KEY_ID, port=0, engine=_StubEngine(Evaluation(decision=MODIFY, reasoning="no payload"))) ) session_id = str(uuid.uuid4()) hello = sign_request( @@ -518,8 +553,8 @@ def _kebab(method: str) -> str: return "".join(out) -def _guardian_handshake(engine, methods=("steps/toolCallRequest",), *, key_id: str | None = None) -> tuple[Guardian, str]: - guardian = Guardian(GuardianConfig(secret=SECRET, port=0, engine=engine)) +def _guardian_handshake(engine, methods=("steps/toolCallRequest",), **config) -> tuple[Guardian, str]: + guardian = Guardian(GuardianConfig(secret=SECRET, key_id=KEY_ID, port=0, engine=engine, **config)) session_id = str(uuid.uuid4()) hello = sign_request( envelope( @@ -534,18 +569,14 @@ def _guardian_handshake(engine, methods=("steps/toolCallRequest",), *, key_id: s }, ), session_id, - **({"key_id": key_id} if key_id is not None else {}), ) response = guardian.handle(hello) - if key_id is not None: - # The response label is the deployment's, not the client-chosen - # binding the session was opened with. - assert response["result"]["signature"]["key_id"] == "conformance" + verify_response(response, session_id, hello["id"]) return guardian, session_id def _guardian_wrapped(engine) -> tuple[Guardian, str]: - guardian = Guardian(GuardianConfig(secret=SECRET, port=0, engine=engine)) + guardian = Guardian(GuardianConfig(secret=SECRET, key_id=KEY_ID, port=0, engine=engine)) session_id = str(uuid.uuid4()) hello = sign_request( envelope( @@ -572,7 +603,6 @@ def _tool_call( *, method: str = "steps/toolCallRequest", payload=None, - key_id: str | None = None, ): request = sign_request( envelope( @@ -583,7 +613,6 @@ def _tool_call( else {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, ), session_id, - **({"key_id": key_id} if key_id is not None else {}), ) return request, guardian.handle(request) @@ -772,20 +801,9 @@ def test_a_step_after_session_end_is_denied_and_a_replay_is_still_a_replay() -> expect_error(response, -32005) -def test_a_wrong_key_id_is_a_signed_denial_not_an_error() -> None: - # The session is opened with a client-chosen key_id, so the session's - # key_id differs from the deployment's ("conformance"): the response label - # must be the deployment's, and the binding must still be the client's. - guardian, session_id = _guardian_handshake( - _StubEngine(Evaluation(decision="allow")), key_id="client-chosen" - ) - # A step first, so the chain is not empty: the denial still carries no - # chain_hash, because it wrote no entry — the previous step's head would - # be a false claim about this step, not a harmless omission. - ok, response = _tool_call(guardian, session_id, key_id="client-chosen") - verify_response(response, session_id, ok["id"]) - assert response["result"]["chain_hash"] - +def test_a_request_naming_an_unknown_key_id_is_refused_unsigned() -> None: + """#118: a verifier MUST reject an unknown key_id, never fall back to its secret.""" + guardian, session_id = _guardian_handshake(_StubEngine(Evaluation(decision="allow"))) request = sign_request( envelope( "steps/toolCallRequest", @@ -796,33 +814,40 @@ def test_a_wrong_key_id_is_a_signed_denial_not_an_error() -> None: key_id="some-other-key", ) response = guardian.handle(request) - # Signed under the deployment's key_id: key_id names the key a verifier - # resolves, and that is the one this Guardian holds. - verify_response(response, session_id, request["id"]) - assert response["result"]["signature"]["key_id"] == "conformance" - assert response["result"]["decision"] == "deny" - assert response["result"]["reason_codes"] == ["key_not_bound"] - # The step never reached the chain, so no chain_hash is sent (a null - # would fail response-envelope.json). - assert "chain_hash" not in response["result"] - assert guardian.schemas.validate_response(response) is None - - # The id was recorded on arrival, so the same request again is a replay. - response = guardian.handle(request) - verify_response(response, session_id, request["id"]) - expect_error(response, -32005) + expect_error(response, -32004) + # The request established no key under this label, so the answer is unsigned. + assert "signature" not in response["error"] -def test_ping_is_signed_under_the_deployments_key_id() -> None: - guardian, session_id = _guardian_handshake( - _StubEngine(Evaluation(decision="allow")), methods=("system/ping",), key_id="client-chosen" - ) - request = sign_request( - envelope("system/ping", session_id, {"echo": "hi"}), session_id, key_id="client-chosen" +def test_a_handshake_naming_an_unknown_key_id_is_refused_unsigned() -> None: + guardian = Guardian(GuardianConfig(secret=SECRET, key_id=KEY_ID, port=0, engine=_StubEngine(Evaluation(decision="allow")))) + session_id = str(uuid.uuid4()) + hello = sign_request( + envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + }, + ), + session_id, + key_id="client-chosen", ) - response = guardian.handle(request) - verify_response(response, session_id, request["id"]) - assert response["result"]["signature"]["key_id"] == "conformance" + response = guardian.handle(hello) + expect_error(response, -32004) + assert "signature" not in response["error"] + assert guardian.store.get(session_id) is None + + +def test_guardian_configuration_requires_a_key_id() -> None: + with pytest.raises(TypeError): + GuardianConfig(secret=SECRET, port=0) # type: ignore[call-arg] + with pytest.raises(ValueError, match="key_id"): + Guardian(GuardianConfig(secret=SECRET, key_id="", port=0)) def test_a_wrong_agent_id_is_a_signed_denial_not_an_error() -> None: @@ -864,3 +889,641 @@ def test_every_hook_payload_schema_loads_and_is_the_right_schema(guardian: Guard guardian.schemas.validate_payload(method, {}) except AcsError as error: assert error.code == INVALID_PARAMS + + +# -- fail-closed depth, version, §6.5, and Go alignment (review fixes) ------ + + +def _nested(value, levels): + for _ in range(levels): + value = {"a": value} + return value + + +def test_a_body_within_the_depth_limit_still_evaluates(server) -> None: + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "ls"}, "deep": {"value": _nested("leaf", MAX_JSON_DEPTH - 20)}}, + "raw_command": "ls", + }, + ) + response = signed_post(server.url, request, session_id) + assert response["result"]["decision"] == "allow" + + +def test_a_body_past_the_depth_limit_is_a_signed_denial(server) -> None: + """Fail closed where failing open would be a run the Guardian never saw. + + Before this guard, a signed request nested a few hundred levels deep hit + RecursionError on the signing path and came back as an unsigned -32603, + which the Observed Agent's default posture turns into a proceed. + """ + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "rm -rf /"}, "deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}, + "raw_command": "rm -rf /", + }, + ) + response = signed_post(server.url, request, session_id) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + # The verified step is recorded like any refusal: the entry is appended + # and the published head is this step's. + session = server.guardian.store.get(session_id) + assert response["result"]["chain_hash"] == session.chain.head + + # Resending the same verified body is a replay: a signed -32005, and no + # second entry (a repeated body must not grow the chain without bound). + entries = len(session.chain.entries) + replay = http_post(server.url, request) + verify_response(replay, session_id, request["id"]) + expect_error(replay, -32005) + assert len(session.chain.entries) == entries + + +def test_a_verified_step_deeper_than_the_default_stack_is_still_decided(server) -> None: + """The parser accepts ~10,000 levels; JCS dies ~1,000 on the default limit. + + In between, a verified step must still get a signed decision — the retry + canonicalizes on an enlarged thread — not an unsigned error the agent's + posture turns into a proceed. + """ + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "rm -rf /"}, "deep": {"value": _nested("leaf", 3000)}}, + "raw_command": "rm -rf /", + }, + ) + ok, _ = _in_deep_thread(lambda: sign_request(request, session_id)) + assert ok + response = http_post(server.url, request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + session = server.guardian.store.get(session_id) + assert response["result"]["chain_hash"] == session.chain.head + + +def test_a_verified_over_deep_postcompact_failure_substitutes_to_allow(server) -> None: + """postCompact permits no DENY, so a verified over-deep failure is its ALLOW.""" + session_id = _session(server, methods=("steps/postCompact",)) + request = envelope( + "steps/postCompact", + session_id, + { + "summary": {"value": "compacted", "provenance": {"provenance_id": "p1", "origin": "agent_generated"}}, + "entries_compacted": ["s-1"], + "pre_compact_chain_hash": "0" * 64, + "post_compact_chain_hash": "0" * 64, + "deep": _nested("leaf", MAX_JSON_DEPTH + 5), + }, + ) + response = signed_post(server.url, request, session_id) + assert response["result"]["decision"] == "allow" + assert response["result"]["reason_codes"] == ["evaluation_failed", "disposition_not_permitted"] + session = server.guardian.store.get(session_id) + assert response["result"]["chain_hash"] == session.chain.head + + +def test_an_over_deep_body_that_does_not_verify_gets_no_signed_answer(server) -> None: + """Nothing on this route may sign without verifying: key_id is not a secret. + + At postCompact the failure substitution turns a DENY into an ALLOW, so an + unverified signing path here would mint a Guardian-signed ALLOW for an + attacker-chosen request_id — the strongest form of the old ping oracle. + """ + session_id = _session(server) + payload = { + "summary": {"value": "compacted", "provenance": {"provenance_id": "p1", "origin": "agent_generated"}}, + "entries_compacted": ["s-1"], + "pre_compact_chain_hash": "0" * 64, + "post_compact_chain_hash": "0" * 64, + "deep": _nested("leaf", MAX_JSON_DEPTH + 5), + } + unsigned = envelope("steps/postCompact", session_id, payload) + response = http_post(server.url, unsigned) + expect_error(response, -32600) + assert "signature" not in response["error"] + + bad = sign_request(envelope("steps/postCompact", session_id, payload), session_id) + bad["params"]["signature"]["value"] = "AAAA" + response = http_post(server.url, bad) + expect_error(response, -32600) + assert "signature" not in response["error"] + + +def test_an_over_deep_step_gets_the_normal_route_and_binding_checks(server) -> None: + """Over-deep is a depth guard, not a bypass of the dispatch ladder.""" + session_id = _session(server, methods=("steps/toolCallRequest", "system/ping")) + session = server.guardian.store.get(session_id) + + # A ping is answered without touching the chain, however deep it is. + ping = envelope("system/ping", session_id, {"echo": "x", "deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}) + response = signed_post(server.url, ping, session_id) + assert response["result"]["decision"] == "allow" + assert "chain_hash" not in response["result"] + assert len(session.chain.entries) == 0 + + # An undefined method is its own refusal, signed, nothing appended. + bogus = envelope("steps/noSuchHook", session_id, {"deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}) + response = signed_post(server.url, bogus, session_id) + expect_error(response, -32601) + assert len(session.chain.entries) == 0 + + # A request naming another agent is a signed denial, nothing appended. + other = envelope( + "steps/toolCallRequest", + session_id, + {"deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}, + agent_id="someone-else", + ) + response = signed_post(server.url, other, session_id) + assert response["result"]["reason_codes"] == ["agent_id_not_bound"] + assert "chain_hash" not in response["result"] + assert len(session.chain.entries) == 0 + + +def test_an_over_deep_step_after_session_end_is_a_closed_denial(server) -> None: + """The closed check runs before the negotiated-method check, like _step. + + The late step names a method this session never negotiated; a closed + session must get its signed denial, not a -32003 error the posture could + turn into a proceed. + """ + session_id = _session(server, methods=("steps/toolCallRequest", "steps/sessionEnd")) + signed_post(server.url, envelope("steps/sessionEnd", session_id, {"reason": "cancelled"}), session_id) + session = server.guardian.store.get(session_id) + entries = len(session.chain.entries) + late = envelope("steps/memoryStore", session_id, {"deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}) + response = signed_post(server.url, late, session_id) + assert response["result"]["reason_codes"] == ["session_closed"] + assert "chain_hash" not in response["result"] + assert len(session.chain.entries) == entries + + +def test_a_deep_verified_ping_is_answered_not_failed(server) -> None: + """A ping past the default recursion limit must get the ping answer. + + Re-verifying and re-signing on the request thread would overflow, turning + a ping into an evaluation_failed denial — and, in a narrow depth band, + writing a chain entry, when pings never touch the chain. + """ + session_id = _session(server, methods=("steps/toolCallRequest", "system/ping")) + session = server.guardian.store.get(session_id) + + ping = envelope("system/ping", session_id, {"deep": {"value": _nested("leaf", 3000)}}) + ok, _ = _in_deep_thread(lambda: sign_request(ping, session_id)) + assert ok + response = http_post(server.url, ping) + verify_response(response, session_id, ping["id"]) + assert response["result"]["decision"] == "allow" + assert "chain_hash" not in response["result"] + # A ping is not a step: no replay rejection, and still nothing appended. + again = http_post(server.url, ping) + verify_response(again, session_id, ping["id"]) + assert again["result"]["decision"] == "allow" + assert len(session.chain.entries) == 0 + + # And a deep echo: signing the answer must also go through the enlarged + # thread rather than fail the ping. + echo_ping = envelope("system/ping", session_id, {"echo": {"value": _nested("leaf", 3000)}}) + ok, _ = _in_deep_thread(lambda: sign_request(echo_ping, session_id)) + assert ok + echoed = http_post(server.url, echo_ping) + ok, _ = _in_deep_thread(lambda: verify_response(echoed, session_id, echo_ping["id"])) + assert ok + assert echoed["result"]["decision"] == "allow" + assert len(session.chain.entries) == 0 + + +def test_an_over_deep_ping_with_a_nested_acs_version_still_answers(server) -> None: + """The ping echoes its version only when it is a string; it must not str() deep objects.""" + session_id = _session(server, methods=("steps/toolCallRequest", "system/ping")) + ping = envelope("system/ping", session_id, {"deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}) + ping["params"]["acs_version"] = {"nested": {"value": _nested("leaf", 5)}} + signed = sign_request(ping, session_id) + response = http_post(server.url, signed) + verify_response(response, session_id, ping["id"]) + assert response["result"]["decision"] == "allow" + assert response["result"]["acs_version"] == "0.1.0" + + +def test_a_failed_over_deep_ping_is_an_unsigned_internal_error(monkeypatch, server) -> None: + """A ping has no decision to fail closed with: an internal failure is reported, not faked.""" + session_id = _session(server, methods=("steps/toolCallRequest", "system/ping")) + session = server.guardian.store.get(session_id) + ping = envelope("system/ping", session_id, {"deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}) + signed = sign_request(ping, session_id) + + def explode(*args, **kwargs): + raise RuntimeError("ping signing exploded") + + monkeypatch.setattr(Guardian, "_signed_response_any_depth", explode) + response = http_post(server.url, signed) + expect_error(response, -32603) + assert "signature" not in response["error"] + assert len(session.chain.entries) == 0 + + +def test_an_over_deep_handshake_consumes_its_request_id(server) -> None: + """The normal handshake records the id/nonce before refusing; the deep one must too.""" + session_id = _session(server) + shared_id = str(uuid.uuid4()) + shared_nonce = str(uuid.uuid4()) + hello = envelope( + "handshake/hello", + session_id, + { + "acs_versions_supported": ["0.1.0"], + "methods_implemented": ["steps/toolCallRequest"], + "transports_supported": ["http"], + "provenance_producer": "none", + "profiles_supported": ["acs-core"], + "deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}, + }, + request_id=shared_id, + ) + hello["params"]["nonce"] = shared_nonce + signed = sign_request(hello, session_id) + response = http_post(server.url, signed) + verify_response(response, session_id, hello["id"]) + expect_error(response, -32000) + + # A replay of the handshake is a replay... + again = http_post(server.url, signed) + verify_response(again, session_id, hello["id"]) + expect_error(again, -32005) + + # ...and the id and nonce are consumed: a later normal step reusing + # either is a replay. + step = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + request_id=shared_id, + ) + replay = http_post(server.url, sign_request(step, session_id)) + verify_response(replay, session_id, step["id"]) + expect_error(replay, -32005) + + nonce_only = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ) + nonce_only["params"]["nonce"] = shared_nonce + replay = http_post(server.url, sign_request(nonce_only, session_id)) + verify_response(replay, session_id, nonce_only["id"]) + expect_error(replay, -32005) + + +def test_the_parse_boundary_scanner_counts_structure_not_string_contents() -> None: + from acs_guardian.server import _bytes_nesting_exceeds + + assert _bytes_nesting_exceeds(b"[" * (DEEP_PARSE_LIMIT + 1), DEEP_PARSE_LIMIT) + assert not _bytes_nesting_exceeds(b"[" * DEEP_PARSE_LIMIT, DEEP_PARSE_LIMIT) + # Braces and brackets inside a string are not structure (a naive counter + # would see 21 levels here). + assert not _bytes_nesting_exceeds(b'["[[[[[[[[[[{{{{{{{{{{"]', 1) + # An escaped quote does not close the string early (the braces stay inside). + assert not _bytes_nesting_exceeds(b'["a\\"{{{{{{{{{{"]', 1) + # A real quote after an escaped backslash does close it; the depth after counts. + assert _bytes_nesting_exceeds(b'["a\\\\"[[[', 2) + + +def test_a_body_past_the_parse_boundary_is_refused_by_the_scanner(server) -> None: + """10,001 structural levels come back a parse error from whichever layer refuses.""" + import json + import urllib.request + + n = DEEP_PARSE_LIMIT + 1 + body = b'{"a":' * n + b"1" + b"}" * n + request = urllib.request.Request(server.url, data=body, headers={"content-type": "application/json"}, method="POST") + with urllib.request.urlopen(request) as response: + payload = json.loads(response.read().decode()) + expect_error(payload, -32700) + + +def test_an_over_deep_body_without_a_string_request_id_is_a_signed_error(server) -> None: + """A body with no id must not reach the chain (its entry would not dedupe).""" + session_id = _session(server) + request = envelope("steps/toolCallRequest", session_id, {"deep": {"value": _nested("leaf", MAX_JSON_DEPTH + 5)}}) + del request["params"]["request_id"] + signed = sign_request(request, session_id) + response = http_post(server.url, signed) + verify_response(response, session_id, request["id"]) + expect_error(response, -32602) + session = server.guardian.store.get(session_id) + assert len(session.chain.entries) == 0 + + # Repeats stay refusals, not chain growth. + again = http_post(server.url, signed) + expect_error(again, -32602) + assert len(session.chain.entries) == 0 + + +def test_concurrent_deep_canonicalization_keeps_the_limits_intact() -> None: + """Two overlapping deep calls must not restore the global limits under each other. + + The recursion limit is process-global; without serialization, one call + restoring it mid-recursion makes a verified body fail verification — the + fail-open the deep thread exists to prevent. + """ + import sys + import threading + + original_limit = sys.getrecursionlimit() + original_stack = threading.stack_size() + deep = _nested("leaf", 3000) + outcomes: list[bool] = [] + lock = threading.Lock() + + def run() -> None: + ok, _ = _in_deep_thread(lambda: canonical_bytes(deep)) + with lock: + outcomes.append(ok) + + workers = [threading.Thread(target=run) for _ in range(4)] + for worker in workers: + worker.start() + for worker in workers: + worker.join() + assert outcomes == [True, True, True, True] + assert sys.getrecursionlimit() == original_limit + assert threading.stack_size() == original_stack + + +@pytest.mark.parametrize("levels", [500, 600, 985]) +def test_the_reviewers_regression_requests_are_signed_denials(server, levels) -> None: + """The depths from the review; 500 broke on the old code and 985 dropped the connection.""" + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "rm -rf /"}, "nested": {"value": _nested("x", levels)}}, + "raw_command": "rm -rf /", + }, + ) + ok, _ = _in_deep_thread(lambda: sign_request(request, session_id)) + assert ok + response = http_post(server.url, request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + + +def test_a_verified_step_near_the_parse_boundary_is_still_decided(server) -> None: + """The enlarged thread must cover the whole range the parser accepts.""" + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + { + "tool": {"name": "Bash"}, + "arguments": {"command": {"value": "rm -rf /"}, "nested": {"value": _nested("x", 9000)}}, + "raw_command": "rm -rf /", + }, + ) + ok, _ = _in_deep_thread(lambda: sign_request(request, session_id)) + assert ok + response = http_post(server.url, request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + session = server.guardian.store.get(session_id) + assert response["result"]["chain_hash"] == session.chain.head + + +def test_an_over_deep_request_without_a_negotiated_session_is_a_bad_request(server) -> None: + request = envelope("steps/toolCallRequest", str(uuid.uuid4()), {"deep": _nested("leaf", MAX_JSON_DEPTH + 5)}) + response = http_post(server.url, request) + expect_error(response, -32600) + assert "signature" not in response["error"] + + +def test_a_step_may_not_move_off_the_negotiated_major_version(server) -> None: + """§3/§4: a step's acs_version must share the session's major; else -32001.""" + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ) + request["params"]["acs_version"] = "7.3.0" + response = signed_post(server.url, request, session_id) + expect_error(response, -32001) + assert response["error"]["data"]["supported_versions"] == ["0.1.0"] + + +def test_a_step_on_an_offered_major_still_evaluates(server) -> None: + session_id = _session(server) + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ) + request["params"]["acs_version"] = "0.9.0" + response = signed_post(server.url, request, session_id) + assert response["result"]["decision"] == "allow" + + +def test_a_modify_the_agent_cannot_apply_is_substituted_with_a_denial() -> None: + evaluation = Evaluation( + decision=MODIFY, + reasoning="redact the command", + modifications={"redactions": [{"path": "/arguments/command"}]}, + client_cannot_apply_modify=True, + ) + guardian, session_id = _guardian_handshake(_StubEngine(evaluation)) + request, response = _tool_call(guardian, session_id) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + # §6.5 pins the reason_codes value exactly. + assert response["result"]["reason_codes"] == ["modify_unsupported"] + assert "/arguments/command" in response["result"]["reasoning"] + assert guardian.schemas.validate_response(response) is None + + +def test_a_modify_the_agent_cannot_apply_at_postcompact_is_an_allow() -> None: + events: list[dict] = [] + evaluation = Evaluation( + decision=MODIFY, + reasoning="rewrite the summary", + modifications={"modified_content": "rewritten"}, + client_cannot_apply_modify=True, + ) + guardian, session_id = _guardian_handshake( + _StubEngine(evaluation), methods=("steps/postCompact",), audit=events.append + ) + request, response = _tool_call( + guardian, + session_id, + method="steps/postCompact", + payload={ + "summary": {"value": "compacted", "provenance": {"provenance_id": "p1", "origin": "agent_generated"}}, + "entries_compacted": ["s-1"], + "pre_compact_chain_hash": "0" * 64, + "post_compact_chain_hash": "0" * 64, + }, + ) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "allow" + assert response["result"]["reason_codes"] == ["modify_unsupported"] + # postCompact's audit event must name the rewrite it could not deliver. + assert [event["event"] for event in events] == ["modify_unsupported"] + assert events[0]["intended_modifications"] == {"modified_content": "rewritten"} + assert "modified_content" in events[0]["reasoning"] + + +def test_a_modify_capable_agent_still_gets_modifications() -> None: + evaluation = Evaluation( + decision=MODIFY, + reasoning="redact the command", + modifications={"redactions": [{"path": "/arguments/command"}]}, + ) + guardian, session_id = _guardian_handshake(_StubEngine(evaluation)) + request, response = _tool_call(guardian, session_id) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "modify" + + +def test_the_modify_substitution_is_recorded_with_the_audit_sink() -> None: + events: list[dict] = [] + evaluation = Evaluation( + decision=MODIFY, + reasoning="redact the command", + modifications={"redactions": [{"path": "/arguments/command"}]}, + client_cannot_apply_modify=True, + ) + guardian, session_id = _guardian_handshake(_StubEngine(evaluation), audit=events.append) + _tool_call(guardian, session_id) + assert [event["event"] for event in events] == ["modify_unsupported"] + assert events[0]["session_id"] == session_id + assert events[0]["method"] == "steps/toolCallRequest" + assert events[0]["reason_codes"] == ["modify_unsupported"] + assert "/arguments/command" in events[0]["reasoning"] + assert events[0]["intended_modifications"] == {"redactions": [{"path": "/arguments/command"}]} + + +def test_a_modify_at_a_hook_without_modify_keeps_its_own_reason_code() -> None: + """The permitted-dispositions substitution runs first: its code is the accurate one. + + sessionStart permits ALLOW/DENY only, so the MODIFY is substituted with + disposition_not_permitted before §6.5 could ever see it — and no + modify_unsupported event is recorded, because nothing tried to deliver a + modification. + """ + events: list[dict] = [] + evaluation = Evaluation( + decision=MODIFY, + reasoning="stub", + modifications={"modified_content": "x"}, + client_cannot_apply_modify=True, + ) + guardian, session_id = _guardian_handshake( + _StubEngine(evaluation), methods=("steps/sessionStart",), audit=events.append + ) + request, response = _tool_call(guardian, session_id, method="steps/sessionStart", payload={}) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["disposition_not_permitted"] + assert events == [] + + +def test_a_post_verification_failure_is_a_signed_denial(monkeypatch) -> None: + """A failure after the signature verified must not become an error the agent may proceed on.""" + guardian, session_id = _guardian_handshake(_StubEngine(Evaluation(decision="allow"))) + + def explode(*args, **kwargs): + raise RuntimeError("chain append exploded") + + monkeypatch.setattr(AuditChain, "append", explode) + request = sign_request( + envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + ) + response = guardian.handle(request) + verify_response(response, session_id, request["id"]) + assert response["result"]["decision"] == "deny" + assert response["result"]["reason_codes"] == ["evaluation_failed"] + assert "chain_hash" not in response["result"] + + +def test_a_duplicate_member_is_a_parse_error(server) -> None: + """Two parsers must not disagree about one signed body (the Go port refuses it too).""" + import json + import urllib.request + + body = b'{"jsonrpc":"2.0","method":"system/ping","id":"x","id":"y","params":{}}' + request = urllib.request.Request(server.url, data=body, headers={"content-type": "application/json"}, method="POST") + with urllib.request.urlopen(request) as response: + payload = json.loads(response.read().decode()) + expect_error(payload, -32700) + + +def test_a_step_entry_carries_the_go_port_choices(server) -> None: + """entry_id = request_id, metadata.turn_id carried, timestamp is the write clock.""" + session_id = _session(server) + request_timestamp = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat().replace("+00:00", "Z") + request = envelope( + "steps/toolCallRequest", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + timestamp=request_timestamp, + ) + request["params"]["metadata"]["turn_id"] = "t-1" + before = datetime.now(timezone.utc) + signed_post(server.url, request, session_id) + after = datetime.now(timezone.utc) + session = server.guardian.store.get(session_id) + entry = session.chain.entries[-1].to_dict() + assert entry["entry_id"] == request["params"]["request_id"] + assert entry["step_id"] == request["params"]["request_id"] + assert entry["turn_id"] == "t-1" + # The entry's timestamp is when the entry was written (context-entry.json + # says exactly that; the Go port does the same), not the request's stale + # timestamp — proven by the write happening between the two clocks. + written = datetime.fromisoformat(entry["timestamp"].replace("Z", "+00:00")) + assert before <= written <= after + + +def test_modify_unsupported_names_the_intended_modification() -> None: + substituted = _modify_unsupported( + "steps/toolCallRequest", + Evaluation( + decision=MODIFY, + reasoning="redact", + modifications={"redactions": [{"path": "/arguments/token"}], "parameter_overrides": {"command": "ls"}}, + ), + ) + assert substituted.decision == "deny" + assert substituted.reason_codes == ["modify_unsupported"] + assert "/arguments/token" in substituted.reasoning + assert "command" in substituted.reasoning + + +def test_modify_unsupported_at_postcompact_is_an_allow() -> None: + substituted = _modify_unsupported( + "steps/postCompact", + Evaluation(decision=MODIFY, reasoning="rewrite", modifications={"modified_content": "x"}), + ) + assert substituted.decision == "allow" + assert substituted.reason_codes == ["modify_unsupported"]