diff --git a/.github/workflows/reference-implementation-python.yml b/.github/workflows/reference-implementation-python.yml new file mode 100644 index 00000000..9be2fe06 --- /dev/null +++ b/.github/workflows/reference-implementation-python.yml @@ -0,0 +1,64 @@ +# 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 + 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 + + - name: Run the tests + run: uv run pytest -v 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/.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..b57fbd52 --- /dev/null +++ b/reference-implementations/agt-python/README.md @@ -0,0 +1,189 @@ +# 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` | +| 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` | +| Liveness, `system/ping` | Met | `server.py`, `tests/test_server.py` | +| 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; `agbom/*` is refused with `-32003` | +| Intent handling (IBAC) | Not claimed | Intent is optional and IBAC-conditional; `ask_details` passes through | + +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 + +```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 --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` (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 +`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. `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 \ +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 `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 + 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`. 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 + +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 + +- **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` 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/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..b14641af --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/__init__.py @@ -0,0 +1,10 @@ +"""Python reference Guardian implementation for OWASP ACS v0.1.0. + +An independent implementation of the ACS-Core wire contract +(docs/spec/conformance.md): JSON-RPC 2.0 envelopes, the capability +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 new file mode 100644 index 00000000..8c6d8dc8 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/canonical.py @@ -0,0 +1,62 @@ +"""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 port in open PR #169 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 + +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]: + """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): + unsigned[holder] = {key: value for key, value in container.items() if key != "signature"} + 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..4f51f00d --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/chain.py @@ -0,0 +1,142 @@ +"""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 +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 + turn_id: 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.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: + 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, + 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": 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) + appended = ChainEntry( + entry_id=entry["entry_id"], + 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, + ) + 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. + + 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 + 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..07a9722e --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/engine.py @@ -0,0 +1,133 @@ +"""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 + # §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): + """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 [] + + +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: + candidates = _tool_call_texts(request.method, request.payload) + if candidates is None: + return Evaluation(decision=ALLOW) + 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..22f231bd --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/errors.py @@ -0,0 +1,66 @@ +"""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``, ``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 +"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 port's rule in open PR #169). + """ + + 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..f657e3d8 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/handshake.py @@ -0,0 +1,226 @@ +"""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 . import mcp, methods +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") +# Every ACS-Core item in docs/spec/conformance.md is implemented (handshake, +# 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 +DEFAULT_SKEW_WINDOW_MS = 300_000 + +# 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") + + +@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 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 _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: + 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", + 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", + ) + + declared = declared_wrapped_protocols(client_hello) + client_methods = [m for m in client_hello.get("methods_implemented", []) if isinstance(m, str)] + 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( + 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..ef7be475 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/main.py @@ -0,0 +1,81 @@ +"""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 + + +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)" + ) + 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=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="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") + 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/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 new file mode 100644 index 00000000..e95fcbf3 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/schemas.py @@ -0,0 +1,175 @@ +"""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 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) + 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..e27b2569 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/server.py @@ -0,0 +1,1408 @@ +"""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 (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``, and JCS canonicalizability -> + ``-32600`` (a number outside the JCS domain cannot be signed by anyone). + 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 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, + §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 +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 re +import sys +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, 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, MODIFY, Evaluation, EvaluationRequest, PolicyEngine +from .errors import ( + CAPABILITY_NOT_NEGOTIATED, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + METHOD_NOT_FOUND, + PARSE_ERROR, + REPLAY_DETECTED, + SESSION_REFUSED, + SIGNATURE_INVALID, + TIMESTAMP_OUT_OF_WINDOW, + UNSUPPORTED_VERSION, + AcsError, +) +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 +class GuardianConfig: + """Deployment configuration. The secret is deployment-provided key material (§10).""" + + secret: bytes + # 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 + 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 + audit: AuditHook | 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") + 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: + 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") + 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, RecursionError) 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 + 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") + 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) + 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: + # 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 + # 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") + + 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( + self, + raw: dict[str, Any], + rpc_id: str | int | None, + params: Any, + key: bytes | 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) + # §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", + 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, 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) + 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) + 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", + data={"reason": "renegotiation_not_supported"}, + ) + + response = {"jsonrpc": "2.0", "id": rpc_id, "result": server_hello(terms)} + return self._sign(response, key, self.config.key_id) + + def _new_session( + self, + params: dict[str, Any], + session_id: str, + request_id: Any, + terms: Any, + nonce: Any = None, + ) -> Session: + return Session( + session_id=session_id, + agent_id=str((params.get("metadata") or {}).get("agent_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={nonce} if isinstance(nonce, str) else 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, + ) -> 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(raw, rpc_id, params, key) + + # 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} + ) + + 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: + 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 + + 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 + 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) + + def _answer_denial( + self, + rpc_id: str | int | None, + session: Session, + request_id: Any, + key: bytes | 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. Every session binds that same configured label. + return self._sign(response, key, self.config.key_id) + + def _evaluate( + self, + method: str, + 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=engine_payload, + 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(method) + + # A disposition the hook does not permit is not sent as one: the hook + # 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, + # 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 + + 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 + ) -> dict[str, Any]: + result: dict[str, Any] = { + "type": "final", + "acs_version": session.negotiated_version, + "request_id": request_id, + "decision": evaluation.decision, + } + # 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: + 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, + raw: dict[str, Any], + rpc_id: str | int | None, + params: dict[str, Any], + key: bytes | None, + ) -> dict[str, Any]: + 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 + 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, + ) -> 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, self.config.key_id) + return response + + +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): + rpc_id = raw.get("id") + if isinstance(rpc_id, (str, int)) and not isinstance(rpc_id, bool): + return rpc_id + return None + + +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 != ALLOW and not evaluation.reasoning: + return False + 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 + # 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 _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=ALLOW, + reasoning=f"{reasoning} The action has already happened, so the step stands.", + reason_codes=codes, + ) + + +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, + 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: + # 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") + 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. + 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) + 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; 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: 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 + 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") + 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) + + 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))) + if self.close_connection: + self.send_header("Connection", "close") + 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..e38a37a5 --- /dev/null +++ b/reference-implementations/agt-python/src/acs_guardian/session.py @@ -0,0 +1,76 @@ +"""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 + 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) + # 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 + # 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..d1de754b --- /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 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, key_id=KEY_ID, 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..bd12c310 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_chain.py @@ -0,0 +1,83 @@ +"""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_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") + 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) + + +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_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..7783c2d1 --- /dev/null +++ b/reference-implementations/agt-python/tests/test_handshake.py @@ -0,0 +1,122 @@ +"""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_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: + """§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_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: + # 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 == ("acs-core",) + + +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_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 new file mode 100644 index 00000000..833f060e --- /dev/null +++ b/reference-implementations/agt-python/tests/test_server.py @@ -0,0 +1,1529 @@ +"""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, + KEY_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.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 ( + DEEP_PARSE_LIMIT, + MAX_JSON_DEPTH, + Guardian, + GuardianConfig, + _in_deep_thread, + _modify_unsupported, +) + + +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 + # 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. + + 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: + """§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 _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 = _recv_all(sock).decode() + 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 = _recv_all(sock).decode() + _, _, 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 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" + ) + 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( + "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_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) + + +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, key_id=KEY_ID, 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, key_id=KEY_ID, 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 (the namespace is dropped).""" + 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) + + +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( + "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, + ) + response = guardian.handle(hello) + verify_response(response, session_id, hello["id"]) + return guardian, session_id + + +def _guardian_wrapped(engine) -> tuple[Guardian, str]: + guardian = Guardian(GuardianConfig(secret=SECRET, key_id=KEY_ID, 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, +): + 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, + ) + 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_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", + session_id, + {"tool": {"name": "Bash"}, "arguments": {"command": {"value": "ls"}}, "raw_command": "ls"}, + ), + session_id, + key_id="some-other-key", + ) + response = guardian.handle(request) + 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_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(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: + 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. + + 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 + + +# -- 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"] 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("") 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" }, +]