From a329e2d4dde5405e3fdff76cf05cc674f454a669 Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:12:36 -0700 Subject: [PATCH 01/11] feat: add Google ADK authority reference Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/.gitignore | 3 + references/google-adk/README.md | 167 +++++++++++++ references/google-adk/adk_app/__init__.py | 3 + references/google-adk/adk_app/agent.py | 11 + .../authority_reference/__init__.py | 14 ++ .../authority_reference/adk_agent.py | 56 +++++ .../authority_reference/authority.py | 138 +++++++++++ .../authority_reference/receiver.py | 234 ++++++++++++++++++ references/google-adk/demo.py | 32 +++ .../google-adk/evidence/reference-evidence.md | 68 +++++ references/google-adk/requirements.txt | 3 + references/google-adk/tests/test_reference.py | 196 +++++++++++++++ references/registry/google-adk.md | 8 + scripts/google-adk-reference-check.sh | 35 +++ 14 files changed, 968 insertions(+) create mode 100644 references/google-adk/.gitignore create mode 100644 references/google-adk/README.md create mode 100644 references/google-adk/adk_app/__init__.py create mode 100644 references/google-adk/adk_app/agent.py create mode 100644 references/google-adk/authority_reference/__init__.py create mode 100644 references/google-adk/authority_reference/adk_agent.py create mode 100644 references/google-adk/authority_reference/authority.py create mode 100644 references/google-adk/authority_reference/receiver.py create mode 100644 references/google-adk/demo.py create mode 100644 references/google-adk/evidence/reference-evidence.md create mode 100644 references/google-adk/requirements.txt create mode 100644 references/google-adk/tests/test_reference.py create mode 100644 references/registry/google-adk.md create mode 100755 scripts/google-adk-reference-check.sh diff --git a/references/google-adk/.gitignore b/references/google-adk/.gitignore new file mode 100644 index 0000000..5831ca4 --- /dev/null +++ b/references/google-adk/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +.pytest_cache/ diff --git a/references/google-adk/README.md b/references/google-adk/README.md new file mode 100644 index 0000000..3d15371 --- /dev/null +++ b/references/google-adk/README.md @@ -0,0 +1,167 @@ +# Proof-carrying authority for Google ADK agents + +**Status:** independent draft reference implementation. Not a Google +partnership, Google-approved integration, or Google reference architecture. + +This reference answers one narrow question: + +> When a Google ADK agent crosses an MCP, A2A, tool, or organizational +> boundary, can the system carrying the consequence independently verify who +> authorized the agent for that exact action and which bounds still apply? + +The visible result is intentionally simple: + +```text +ALLOW -> tool invoked once +DENY -> tool invocation count does not change +``` + +The model may request more authority. It cannot grant that authority to +itself. + +## Run the published-package gate + +From the Ratify repository root: + +```bash +./scripts/google-adk-reference-check.sh +``` + +The script creates a disposable demo virtual environment, installs the exact +published packages in `requirements.txt`, refuses to run if Ratify resolves to +this repository's local Python SDK, runs the deterministic adversarial suite, +and then runs the three-case demonstration. + +Tested pins: + +- `google-adk==2.6.3` +- `ratify-protocol==1.0.0a16` +- `pytest==8.4.1` + +The deterministic path needs no model, API key, Google Cloud project, or paid +service. The authorization result therefore cannot depend on an LLM response. +The recorded run is in +[`evidence/reference-evidence.md`](evidence/reference-evidence.md). + +## What the reference implements + +```text +Principal + signs root -> ADK commander + scope: custom:infra:provision + identity:delegate + | + v +ADK commander + signs commander -> infrastructure specialist + scope: custom:infra:provision + resource: gcp:projects/customer-project/regions/us-central1 + extension constraint: max_nodes = 1 + | + v +Google ADK FunctionTool + asks the independent receiver for an operation-bound challenge + signs that challenge with the specialist key + | + v +Independent receiver + pins the accepted principal root out of band + reconstructs the operation and payload digest + binds the challenge to verifier, workspace, agent, session, invocation, + and operation hash + atomically consumes the single-use challenge + checks revocation, chain signatures, scope, resource, node count, and expiry + invokes the protected tool only after ALLOW +``` + +The `ai.identities.ratify.adk.max_nodes` extension is a draft Ratify integration +profile. It is deliberately not placed in a Google namespace and does not +claim that Google defines or endorses it. + +## Layer separation + +| Layer | Question answered | This reference does not claim | +|---|---|---| +| Google Agent Identity / IAM | Which deployed agent workload is calling, and which Google Cloud permissions does it have? | That the workload carries a principal-signed grant for this exact cross-boundary action | +| Google ADK | How does the agent reason and invoke a tool? | That an in-process callback is an independent authorization boundary | +| MCP / A2A / tool transport | How does the request cross the boundary? | That transport authentication proves the principal's bounded intent | +| Ratify | Who delegated authority, for which scope/resource/bounds, and is the presentation fresh and unrevoked? | That the receiver must execute | +| Receiver policy and tool | Is the verified request acceptable now, and should the action execute? | That verifier-supplied context becomes trustworthy without receiver validation | + +## Security boundary + +The receiver is the security boundary. It performs five actions the presenting +agent is not trusted to perform: + +1. Pins the accepted principal root. A valid self-issued chain is denied. +2. Parses and validates the requested operation. +3. Constructs the operation and session bindings itself. +4. Issues and atomically consumes a single-use challenge. +5. Verifies the proof and local policy before the protected handler runs. + +The ADK tool is presentation code. It injects the proof so the model never sees +private keys or proof bytes. Moving `verify_bundle` into an ADK callback inside +the agent process would be a useful fail-fast check, but not a security control: +a compromised agent could skip its own callback. + +## Deterministic acceptance matrix + +The suite encodes why the boundary matters: + +| Case | Expected result | Protected tool | +|---|---|---| +| Correct agent, one node, `us-central1` | `allow` | Invoked once | +| Three nodes under a one-node grant | `constraint_denied` | Not invoked | +| `us-east1` under a `us-central1` grant | `constraint_denied` | Not invoked | +| Expired delegation | `expired` | Not invoked | +| Revoked leaf delegation | `revoked` | Not invoked | +| Replayed presentation | `invalid` / consumed challenge | Not invoked again | +| Operation changed after challenge issuance | `operation_binding_failed` | Not invoked | +| Different agent answers the challenge | `agent_binding_failed` | Not invoked | +| Valid chain under an untrusted root | `untrusted_root` | Not invoked | +| Non-integral, zero, negative, boolean, or excessive node count | Input rejected | Not invoked | + +## Optional live Gemini path + +The deterministic suite is authoritative. To let Gemini select and invoke the +same ADK tool interactively: + +```bash +cd references/google-adk +source .venv/bin/activate +export GOOGLE_API_KEY=your_key +adk run adk_app +``` + +Example prompt: + +```text +Provision one n2-standard-4 node in us-central1. Use request id demo-1. +``` + +Then request three nodes or change the region and observe the receiver denial. +The optional model path demonstrates orchestration; it adds no authorization +guarantee beyond the deterministic receiver tests. + +## Limitations + +- The receiver and challenge store are in-memory and single-process. +- The protected provisioner is a counter, not Google Compute Engine. No cloud + resources are created. +- Trust-root distribution, durable revocation, shared challenge storage, key + custody, authorization receipts, rate limits, and production audit retention + are deployment responsibilities not solved by this draft. +- The logical `gcp:` resource name is an integration profile. Verification + proves authorization against the receiver-supplied logical resource; the + execution layer must still ensure the real cloud operation matches it. +- This reference composes with Agent Identity conceptually but does not deploy + to Vertex AI Agent Engine or exercise preview IAM Agent Identity APIs. +- The executed draft uses a real ADK `FunctionTool` boundary in one process. It + does not yet execute an MCP or A2A transport hop; those are follow-on carriage + profiles for the same receiver contract. + +## Sources + +- Google Agent Identity: +- Google ADK: +- Ratify Protocol: +- Agent Relay integration note: diff --git a/references/google-adk/adk_app/__init__.py b/references/google-adk/adk_app/__init__.py new file mode 100644 index 0000000..4caf518 --- /dev/null +++ b/references/google-adk/adk_app/__init__.py @@ -0,0 +1,3 @@ +from .agent import root_agent + +__all__ = ["root_agent"] diff --git a/references/google-adk/adk_app/agent.py b/references/google-adk/adk_app/agent.py new file mode 100644 index 0000000..589d1ef --- /dev/null +++ b/references/google-adk/adk_app/agent.py @@ -0,0 +1,11 @@ +"""Optional live Gemini entry point for ``adk run adk_app``.""" + +from authority_reference import InfrastructureReceiver, build_adk_agent, issue_authority + + +authority = issue_authority() +receiver = InfrastructureReceiver( + trusted_root_id=authority.root_id, + trusted_root_public_key=authority.root_public_key, +) +root_agent = build_adk_agent(receiver, authority) diff --git a/references/google-adk/authority_reference/__init__.py b/references/google-adk/authority_reference/__init__.py new file mode 100644 index 0000000..1159d80 --- /dev/null +++ b/references/google-adk/authority_reference/__init__.py @@ -0,0 +1,14 @@ +"""Independent Google ADK delegated-authority reference.""" + +from .adk_agent import build_adk_agent, build_provision_tool +from .authority import AuthorityFixture, issue_authority +from .receiver import InfrastructureReceiver, OperationRequest + +__all__ = [ + "AuthorityFixture", + "InfrastructureReceiver", + "OperationRequest", + "build_adk_agent", + "build_provision_tool", + "issue_authority", +] diff --git a/references/google-adk/authority_reference/adk_agent.py b/references/google-adk/authority_reference/adk_agent.py new file mode 100644 index 0000000..a6b231d --- /dev/null +++ b/references/google-adk/authority_reference/adk_agent.py @@ -0,0 +1,56 @@ +"""Google ADK presentation layer. + +The proof is injected by application code. The model sees the ordinary tool +schema and result, never private keys or proof-bundle bytes. +""" + +from __future__ import annotations + +from google.adk.agents import LlmAgent +from google.adk.tools import FunctionTool + +from .authority import AuthorityFixture +from .receiver import InfrastructureReceiver, OperationRequest + + +def build_provision_tool( + receiver: InfrastructureReceiver, + authority: AuthorityFixture, +) -> FunctionTool: + def provision_cloud_node( + request_id: str, + region: str, + instance_type: str, + count: int, + ) -> dict: + """Provision cloud nodes under receiver-verified delegated authority.""" + request = OperationRequest(request_id, region, instance_type, count) + grant = receiver.issue_challenge( + request, expected_agent_id=authority.specialist_id + ) + bundle = authority.present( + challenge=grant.challenge, + session_context=grant.session_context, + ) + return receiver.execute(request, bundle) + + return FunctionTool(provision_cloud_node) + + +def build_adk_agent( + receiver: InfrastructureReceiver, + authority: AuthorityFixture, + *, + model: str = "gemini-3.6-flash", +) -> LlmAgent: + """Construct the real ADK agent; running the model is optional.""" + return LlmAgent( + name="ratify_infrastructure_specialist", + description="Provisions cloud nodes under bounded delegated authority.", + model=model, + instruction=( + "Use provision_cloud_node for infrastructure changes. Report receiver " + "denials exactly; never claim an action succeeded when decision is deny." + ), + tools=[build_provision_tool(receiver, authority)], + ) diff --git a/references/google-adk/authority_reference/authority.py b/references/google-adk/authority_reference/authority.py new file mode 100644 index 0000000..e2a7af5 --- /dev/null +++ b/references/google-adk/authority_reference/authority.py @@ -0,0 +1,138 @@ +"""Issue the two-hop authority used by the reference. + +The root delegates to an ADK commander. The commander narrows that authority +to one infrastructure specialist. Private keys never cross the receiver +boundary; the receiver is configured only with the accepted root public key. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import time +import uuid + +from ratify_protocol import ( + Constraint, + DelegationCert, + HybridPrivateKey, + HybridPublicKey, + HybridSignature, + PROTOCOL_VERSION, + ProofBundle, + SCOPE_IDENTITY_DELEGATE, + generate_agent, + generate_human_root, + issue_delegation, + sign_challenge, +) + + +INFRA_SCOPE = "custom:infra:provision" +NODE_LIMIT_CONSTRAINT = "ai.identities.ratify.adk.max_nodes" +WORKSPACE_ID = "customer-project" +VERIFIER_ID = "independent-infrastructure-receiver" + + +@dataclass(frozen=True) +class AuthorityFixture: + root_id: str + root_public_key: HybridPublicKey + specialist_id: str + specialist_private_key: HybridPrivateKey + delegations: list[DelegationCert] + + def present( + self, + *, + challenge: bytes, + session_context: bytes, + now: int | None = None, + ) -> ProofBundle: + """Sign a receiver-issued, operation-bound challenge.""" + signed_at = int(time.time()) if now is None else now + return ProofBundle( + agent_id=self.specialist_id, + agent_pub_key=self.delegations[0].subject_pub_key, + delegations=self.delegations, + challenge=challenge, + challenge_at=signed_at, + challenge_sig=sign_challenge( + challenge, + signed_at, + self.specialist_private_key, + session_context, + ), + session_context=session_context, + ) + + +def issue_authority( + *, + now: int | None = None, + expires_at: int | None = None, + region: str = "us-central1", + max_nodes: int = 1, +) -> AuthorityFixture: + """Create root -> commander -> specialist authority. + + Region is expressed as a canonical logical resource. Node count is an + integration-profile extension constraint evaluated by the receiver. + """ + issued_at = int(time.time()) if now is None else now + expiry = issued_at + 3600 if expires_at is None else expires_at + root, root_private = generate_human_root() + commander, commander_private = generate_agent("ADK Commander", "custom") + specialist, specialist_private = generate_agent( + "Infrastructure Specialist", "custom" + ) + + commander_cert = DelegationCert( + cert_id=f"commander-{uuid.uuid4().hex}", + version=PROTOCOL_VERSION, + issuer_id=root.id, + issuer_pub_key=root.public_key, + subject_id=commander.id, + subject_pub_key=commander.public_key, + scope=[INFRA_SCOPE, SCOPE_IDENTITY_DELEGATE], + constraints=[], + issued_at=issued_at, + expires_at=expiry, + signature=HybridSignature(ed25519=b"", ml_dsa_65=b""), + ) + issue_delegation(commander_cert, root_private) + + specialist_cert = DelegationCert( + cert_id=f"specialist-{uuid.uuid4().hex}", + version=PROTOCOL_VERSION, + issuer_id=commander.id, + issuer_pub_key=commander.public_key, + subject_id=specialist.id, + subject_pub_key=specialist.public_key, + scope=[INFRA_SCOPE], + constraints=[ + Constraint( + type="resource_path", + resource_id=region_resource(region), + ), + Constraint( + type=NODE_LIMIT_CONSTRAINT, + params={"max_nodes": max_nodes}, + ), + ], + issued_at=issued_at, + expires_at=expiry, + signature=HybridSignature(ed25519=b"", ml_dsa_65=b""), + ) + issue_delegation(specialist_cert, commander_private) + + return AuthorityFixture( + root_id=root.id, + root_public_key=root.public_key, + specialist_id=specialist.id, + specialist_private_key=specialist_private, + delegations=[specialist_cert, commander_cert], + ) + + +def region_resource(region: str) -> str: + return f"gcp:projects/{WORKSPACE_ID}/regions/{region}" diff --git a/references/google-adk/authority_reference/receiver.py b/references/google-adk/authority_reference/receiver.py new file mode 100644 index 0000000..241b7de --- /dev/null +++ b/references/google-adk/authority_reference/receiver.py @@ -0,0 +1,234 @@ +"""Independent receiver-side challenge and verification boundary.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import re +import time +from typing import Any + +from ratify_protocol import ( + MemoryChallengeStore, + OperationContext, + ProofBundle, + SessionContextInputs, + VerifierContext, + VerifyOptions, + build_session_context, + decode_proof_bundle, + operation_context_hash, + verify_bundle, +) + +from .authority import ( + INFRA_SCOPE, + NODE_LIMIT_CONSTRAINT, + VERIFIER_ID, + WORKSPACE_ID, + region_resource, +) + + +_SAFE_NAME = re.compile(r"^[a-z][a-z0-9-]{0,62}$") + + +@dataclass(frozen=True) +class OperationRequest: + request_id: str + region: str + instance_type: str + count: int + + def validate(self) -> None: + if not self.request_id or len(self.request_id) > 128: + raise ValueError("request_id must contain 1..128 characters") + if not _SAFE_NAME.fullmatch(self.region): + raise ValueError("region is not a canonical deployment name") + if not _SAFE_NAME.fullmatch(self.instance_type): + raise ValueError("instance_type is not a canonical deployment name") + if isinstance(self.count, bool) or not isinstance(self.count, int): + raise ValueError("count must be an integer") + if self.count < 1 or self.count > 1000: + raise ValueError("count must be between 1 and 1000") + + def canonical_payload(self) -> bytes: + self.validate() + return json.dumps( + { + "count": self.count, + "instance_type": self.instance_type, + "region": self.region, + "request_id": self.request_id, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +@dataclass(frozen=True) +class ChallengeGrant: + challenge: bytes + session_context: bytes + expires_at: int + + +@dataclass(frozen=True) +class _PendingOperation: + request: OperationRequest + session_context: bytes + expected_agent_id: str + + +class StaticRevocationProvider: + def __init__(self) -> None: + self._revoked: set[str] = set() + + def revoke(self, cert_id: str) -> None: + self._revoked.add(cert_id) + + def is_revoked(self, cert_id: str) -> tuple[bool, None]: + return cert_id in self._revoked, None + + +class NodeLimitEvaluator: + """Receiver-owned evaluator for the signed ADK max-node profile.""" + + def __init__(self, requested_count: int) -> None: + self.requested_count = requested_count + + def evaluate( + self, constraint: Any, cert_id: str, context: Any, now_unix: int + ) -> tuple[bool, str | None]: + params = constraint.params + if not isinstance(params, dict): + return False, "constraint_unverifiable: max_nodes params missing" + max_nodes = params.get("max_nodes") + if isinstance(max_nodes, bool) or not isinstance(max_nodes, int) or max_nodes < 1: + return False, "constraint_unverifiable: max_nodes must be a positive integer" + if self.requested_count > max_nodes: + return False, f"requested {self.requested_count} nodes exceeds max {max_nodes}" + return True, None + + +class InfrastructureReceiver: + """The only component allowed to invoke the protected tool.""" + + def __init__(self, *, trusted_root_id: str, trusted_root_public_key: Any) -> None: + self.trusted_root_id = trusted_root_id + self.trusted_root_public_key = trusted_root_public_key + self.challenge_store = MemoryChallengeStore(max_size=128) + self.revocation = StaticRevocationProvider() + self._pending: dict[str, _PendingOperation] = {} + self.tool_invocations = 0 + + def issue_challenge( + self, request: OperationRequest, *, expected_agent_id: str + ) -> ChallengeGrant: + """Define the operation and bind a single-use challenge to it.""" + payload = request.canonical_payload() + if not expected_agent_id: + raise ValueError("expected_agent_id is required") + operation = OperationContext( + required_scope=INFRA_SCOPE, + operation="infra.provision", + resource_id=region_resource(request.region), + payload_digest=hashlib.sha256(payload).digest(), + ) + session_context = build_session_context( + SessionContextInputs( + verifier_id=VERIFIER_ID, + workspace_id=WORKSPACE_ID, + agent_id=expected_agent_id, + session_id="adk-reference", + invocation_id=request.request_id, + request_hash=operation_context_hash(operation), + ) + ) + challenge, expires_at = self.challenge_store.issue(session_context, 300) + self._pending[request.request_id] = _PendingOperation( + request, session_context, expected_agent_id + ) + return ChallengeGrant(challenge, session_context, expires_at) + + def execute( + self, + request: OperationRequest, + presentation: ProofBundle | str, + *, + now: int | None = None, + ) -> dict[str, Any]: + """Verify authority, then and only then invoke the protected tool.""" + decision_at = int(time.time()) if now is None else now + try: + request.validate() + except ValueError as exc: + return self._deny("invalid_request", str(exc)) + + pending = self._pending.pop(request.request_id, None) + if pending is None: + return self._deny("unknown_operation", "no pending receiver operation") + if request != pending.request: + return self._deny("operation_binding_failed", "request changed after challenge") + + try: + bundle = decode_proof_bundle(presentation) if isinstance(presentation, str) else presentation + except (TypeError, ValueError) as exc: + return self._deny("invalid_presentation", str(exc)) + + if bundle.agent_id != pending.expected_agent_id: + return self._deny( + "agent_binding_failed", + "presentation agent does not match the challenge subject", + ) + + if not self._terminates_at_trusted_root(bundle): + return self._deny("untrusted_root", "delegation does not terminate at the pinned root") + + result = verify_bundle( + bundle, + VerifyOptions( + required_scope=INFRA_SCOPE, + now=decision_at, + session_context=pending.session_context, + challenge_store=self.challenge_store, + revocation=self.revocation, + force_revocation_check=True, + context=VerifierContext( + requested_resource_id=region_resource(request.region), + has_resource=True, + ), + constraint_evaluators={ + NODE_LIMIT_CONSTRAINT: NodeLimitEvaluator(request.count) + }, + ), + ) + if not result.valid: + return self._deny(result.identity_status, result.error_reason) + + self.tool_invocations += 1 + return { + "decision": "allow", + "status": result.identity_status, + "resource": region_resource(request.region), + "nodes_provisioned": request.count, + "tool_invocations": self.tool_invocations, + } + + def _terminates_at_trusted_root(self, bundle: ProofBundle) -> bool: + if not bundle.delegations: + return False + root = bundle.delegations[-1] + return ( + root.issuer_id == self.trusted_root_id + and root.issuer_pub_key == self.trusted_root_public_key + ) + + def _deny(self, status: str, reason: str) -> dict[str, Any]: + return { + "decision": "deny", + "status": status, + "reason": reason, + "tool_invocations": self.tool_invocations, + } diff --git a/references/google-adk/demo.py b/references/google-adk/demo.py new file mode 100644 index 0000000..3d66531 --- /dev/null +++ b/references/google-adk/demo.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""One-command deterministic demonstration using a real ADK FunctionTool.""" + +from __future__ import annotations + +from authority_reference import InfrastructureReceiver, build_provision_tool, issue_authority + + +def main() -> None: + authority = issue_authority() + receiver = InfrastructureReceiver( + trusted_root_id=authority.root_id, + trusted_root_public_key=authority.root_public_key, + ) + tool = build_provision_tool(receiver, authority) + + allowed = tool.func("req-allow", "us-central1", "n2-standard-4", 1) + excessive = tool.func("req-count", "us-central1", "n2-standard-4", 3) + wrong_region = tool.func("req-region", "us-east1", "n2-standard-4", 1) + + print(f"ALLOW -> tool invoked once: {allowed}") + print(f"DENY excessive count -> no additional invocation: {excessive}") + print(f"DENY wrong region -> no additional invocation: {wrong_region}") + + assert allowed["decision"] == "allow" and allowed["tool_invocations"] == 1 + assert excessive["decision"] == "deny" and excessive["tool_invocations"] == 1 + assert wrong_region["decision"] == "deny" and wrong_region["tool_invocations"] == 1 + print("GOOGLE ADK AUTHORITY REFERENCE PASSED") + + +if __name__ == "__main__": + main() diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md new file mode 100644 index 0000000..344090b --- /dev/null +++ b/references/google-adk/evidence/reference-evidence.md @@ -0,0 +1,68 @@ +# Google ADK reference evidence + +**Status:** executed draft evidence, August 10, 2026. This record is generated +from the independent Ratify reference; it is not Google attestation. + +## Environment + +| Field | Value | +|---|---| +| Host | macOS 26.6, arm64 | +| Python | 3.11.1 | +| Google ADK | `2.6.3` | +| Ratify Protocol | published PyPI package `1.0.0a16` | +| pytest | `8.4.1` | +| Protocol base commit | `f5a1522f20b79c881f77db96ae44948dd19dbd42` | +| Requirements SHA-256 | `ab0942b5164e36d43f6bc99b78ebc751011c2726e7a31117bc155d566da409f7` | + +## Reproduction + +```bash +./scripts/google-adk-reference-check.sh +``` + +The gate creates `references/google-adk/.venv`, installs the exact public +requirements, asserts the Ratify import does not resolve from `sdks/python`, +runs the test matrix, and runs the deterministic ADK `FunctionTool` demo. + +## Recorded result + +```text +pins: google-adk==2.6.3 ratify-protocol==1.0.0a16 +................ [100%] +16 passed, 5 warnings in 5.86s +ALLOW -> tool invoked once +DENY excessive count -> no additional invocation +DENY wrong region -> no additional invocation +GOOGLE ADK AUTHORITY REFERENCE PASSED +``` + +The five warnings came from Google ADK transitive dependencies during import: +one OpenTelemetry entry-point deprecation and four ADK +`BaseAgentConfig` deprecations. No tests were skipped, xfailed, or retried. + +## What this run establishes + +- A real `google.adk.agents.LlmAgent` exposes one ordinary + `google.adk.tools.FunctionTool`. +- The function tool uses a two-hop Ratify delegation and a receiver-issued, + operation-bound, single-use challenge. +- The independent receiver invokes its protected handler exactly once for the + valid request. +- Excess count, wrong region, expiry, revocation, replay, altered operation, + wrong agent, untrusted root, and invalid input cases do not invoke the + protected handler. +- Ratify resolved from the demo virtual environment's public package install, + not from this repository's Python SDK source. + +## What this run does not establish + +- No Gemini API call was made. The model path remains optional because model + judgment is not part of the authorization guarantee. +- No Vertex AI Agent Engine deployment or preview Agent Identity API was used. +- No MCP or A2A transport hop was executed; the recorded run covers the ADK + `FunctionTool` to independently instantiated receiver boundary. +- No real Google Cloud resource was provisioned. +- Only the platform and versions above were executed. Other operating systems, + architectures, Python versions, and ADK versions remain compatibility + targets, not results. diff --git a/references/google-adk/requirements.txt b/references/google-adk/requirements.txt new file mode 100644 index 0000000..b219b07 --- /dev/null +++ b/references/google-adk/requirements.txt @@ -0,0 +1,3 @@ +google-adk==2.6.3 +ratify-protocol==1.0.0a16 +pytest==8.4.1 diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py new file mode 100644 index 0000000..f1afeb2 --- /dev/null +++ b/references/google-adk/tests/test_reference.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import time + +import pytest +from google.adk.agents import LlmAgent +from ratify_protocol import encode_proof_bundle, generate_agent, sign_challenge + +from authority_reference import ( + InfrastructureReceiver, + OperationRequest, + build_adk_agent, + build_provision_tool, + issue_authority, +) + + +def setup_reference(**authority_options): + now = int(time.time()) + authority = issue_authority(now=now - 1, **authority_options) + receiver = InfrastructureReceiver( + trusted_root_id=authority.root_id, + trusted_root_public_key=authority.root_public_key, + ) + return now, authority, receiver + + +def present(authority, receiver, request, *, now): + grant = receiver.issue_challenge( + request, expected_agent_id=authority.specialist_id + ) + bundle = authority.present( + challenge=grant.challenge, + session_context=grant.session_context, + now=now, + ) + return grant, bundle + + +def test_valid_authority_invokes_tool_once(): + now, authority, receiver = setup_reference() + request = OperationRequest("valid", "us-central1", "n2-standard-4", 1) + _, bundle = present(authority, receiver, request, now=now) + + result = receiver.execute(request, encode_proof_bundle(bundle), now=now) + + assert result["decision"] == "allow" + assert result["tool_invocations"] == 1 + + +@pytest.mark.parametrize( + ("operation", "expected_status"), + [ + (OperationRequest("count", "us-central1", "n2-standard-4", 3), "constraint_denied"), + (OperationRequest("region", "us-east1", "n2-standard-4", 1), "constraint_denied"), + ], +) +def test_signed_bounds_deny_before_tool(operation, expected_status): + now, authority, receiver = setup_reference() + _, bundle = present(authority, receiver, operation, now=now) + + result = receiver.execute(operation, bundle, now=now) + + assert result["decision"] == "deny" + assert result["status"] == expected_status + assert result["tool_invocations"] == 0 + + +def test_expired_authority_denies_before_tool(): + now = int(time.time()) + authority = issue_authority(now=now - 3600, expires_at=now - 1) + receiver = InfrastructureReceiver( + trusted_root_id=authority.root_id, + trusted_root_public_key=authority.root_public_key, + ) + request = OperationRequest("expired", "us-central1", "n2-standard-4", 1) + _, bundle = present(authority, receiver, request, now=now) + + result = receiver.execute(request, bundle, now=now) + + assert result["status"] == "expired" + assert result["tool_invocations"] == 0 + + +def test_revoked_authority_denies_before_tool(): + now, authority, receiver = setup_reference() + receiver.revocation.revoke(authority.delegations[0].cert_id) + request = OperationRequest("revoked", "us-central1", "n2-standard-4", 1) + _, bundle = present(authority, receiver, request, now=now) + + result = receiver.execute(request, bundle, now=now) + + assert result["status"] == "revoked" + assert result["tool_invocations"] == 0 + + +def test_replay_does_not_invoke_tool_again(): + now, authority, receiver = setup_reference() + request = OperationRequest("replay", "us-central1", "n2-standard-4", 1) + _, bundle = present(authority, receiver, request, now=now) + first = receiver.execute(request, bundle, now=now) + assert first["decision"] == "allow" + + # Recreate only the application envelope. The original challenge remains + # consumed, so the old cryptographic presentation cannot authorize again. + receiver.issue_challenge(request, expected_agent_id=authority.specialist_id) + replay = receiver.execute(request, bundle, now=now) + + assert replay["decision"] == "deny" + assert replay["status"] == "invalid" + assert replay["tool_invocations"] == 1 + + +def test_altered_operation_is_rejected_before_verification(): + now, authority, receiver = setup_reference() + original = OperationRequest("altered", "us-central1", "n2-standard-4", 1) + _, bundle = present(authority, receiver, original, now=now) + altered = OperationRequest("altered", "us-central1", "n2-standard-4", 2) + + result = receiver.execute(altered, bundle, now=now) + + assert result["status"] == "operation_binding_failed" + assert result["tool_invocations"] == 0 + + +def test_wrong_agent_key_is_rejected_before_tool(): + now, authority, receiver = setup_reference() + request = OperationRequest("wrong-key", "us-central1", "n2-standard-4", 1) + grant = receiver.issue_challenge( + request, expected_agent_id=authority.specialist_id + ) + intruder, intruder_private = generate_agent("Intruder", "custom") + legitimate = authority.present( + challenge=grant.challenge, + session_context=grant.session_context, + now=now, + ) + legitimate.agent_id = intruder.id + legitimate.agent_pub_key = intruder.public_key + legitimate.challenge_sig = sign_challenge( + grant.challenge, now, intruder_private, grant.session_context + ) + + result = receiver.execute(request, legitimate, now=now) + + assert result["decision"] == "deny" + assert result["status"] == "agent_binding_failed" + assert result["tool_invocations"] == 0 + + +def test_self_issued_valid_chain_is_not_a_trusted_root(): + now, accepted, receiver = setup_reference() + attacker = issue_authority(now=now - 1) + request = OperationRequest("root", "us-central1", "n2-standard-4", 1) + _, bundle = present(attacker, receiver, request, now=now) + + result = receiver.execute(request, bundle, now=now) + + assert accepted.root_id != attacker.root_id + assert result["status"] == "untrusted_root" + assert result["tool_invocations"] == 0 + + +@pytest.mark.parametrize("count", [True, 0, -1, 1001, 1.5]) +def test_invalid_counts_never_reach_the_tool(count): + now, authority, receiver = setup_reference() + request = OperationRequest("invalid", "us-central1", "n2-standard-4", count) + + with pytest.raises(ValueError): + receiver.issue_challenge( + request, expected_agent_id=authority.specialist_id + ) + assert receiver.tool_invocations == 0 + + +def test_real_adk_agent_exposes_only_the_ordinary_tool_schema(): + _, authority, receiver = setup_reference() + + agent = build_adk_agent(receiver, authority) + + assert isinstance(agent, LlmAgent) + assert agent.name == "ratify_infrastructure_specialist" + assert len(agent.tools) == 1 + assert agent.tools[0].name == "provision_cloud_node" + + +def test_real_adk_function_tool_executes_the_receiver_gated_path(): + _, authority, receiver = setup_reference() + tool = build_provision_tool(receiver, authority) + + allowed = tool.func("adk-allow", "us-central1", "n2-standard-4", 1) + denied = tool.func("adk-deny", "us-central1", "n2-standard-4", 3) + + assert allowed["decision"] == "allow" + assert denied["decision"] == "deny" + assert receiver.tool_invocations == 1 diff --git a/references/registry/google-adk.md b/references/registry/google-adk.md new file mode 100644 index 0000000..52eea47 --- /dev/null +++ b/references/registry/google-adk.md @@ -0,0 +1,8 @@ +# Google ADK + +- **Profile:** [`../google-adk/`](../google-adk/README.md) +- **Status:** Independent draft; 31/31 gate green +- **Ratify:** `1.0.0a16` +- **Platform:** `google-adk==2.6.3` +- **Gate:** `./scripts/google-adk-reference-check.sh` +- **Endorsement:** Not Google-reviewed or Google-approved diff --git a/scripts/google-adk-reference-check.sh b/scripts/google-adk-reference-check.sh new file mode 100755 index 0000000..229bd55 --- /dev/null +++ b/scripts/google-adk-reference-check.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEMO="$ROOT/references/google-adk" +VENV="$DEMO/.venv" + +python3 -m venv "$VENV" +"$VENV/bin/pip" install --disable-pip-version-check -q -r "$DEMO/requirements.txt" + +DEMO="$DEMO" "$VENV/bin/python" - <<'PY' +import importlib.metadata as metadata +import os +from pathlib import Path +import ratify_protocol + +demo = Path(os.environ["DEMO"]).resolve() +repo = demo.parents[1] +module = Path(ratify_protocol.__file__).resolve() +local_sdk = (repo / "sdks" / "python").resolve() +if local_sdk == module or local_sdk in module.parents: + raise SystemExit(f"FAIL: Ratify resolved from the repository: {module}") + +expected = {"google-adk": "2.6.3", "ratify-protocol": "1.0.0a16"} +for package, version in expected.items(): + installed = metadata.version(package) + if installed != version: + raise SystemExit(f"FAIL: {package}={installed}; expected {version}") + +print(f"published Ratify: {module}") +print("pins: google-adk==2.6.3 ratify-protocol==1.0.0a16") +PY + +PYTHONPATH="$DEMO" "$VENV/bin/pytest" -q "$DEMO/tests" +PYTHONPATH="$DEMO" "$VENV/bin/python" "$DEMO/demo.py" From 0e1586d4ace36e65b83331edac5eb6f08b4222d7 Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:26:57 -0700 Subject: [PATCH 02/11] test: exercise Google ADK runner path Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 29 +++++++-- .../authority_reference/adk_agent.py | 3 +- .../google-adk/evidence/reference-evidence.md | 18 +++-- references/google-adk/tests/test_reference.py | 65 +++++++++++++++++++ 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/references/google-adk/README.md b/references/google-adk/README.md index 3d15371..ec54fde 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -38,8 +38,11 @@ Tested pins: - `ratify-protocol==1.0.0a16` - `pytest==8.4.1` -The deterministic path needs no model, API key, Google Cloud project, or paid -service. The authorization result therefore cannot depend on an LLM response. +The deterministic path needs no API key, Google Cloud project, or paid +service. It drives the real ADK runner with a scripted model double, so ADK +still performs model-turn handling, tool selection, tool execution, and +function-response delivery. The authorization result cannot depend on model +judgment. The recorded run is in [`evidence/reference-evidence.md`](evidence/reference-evidence.md). @@ -139,8 +142,19 @@ Provision one n2-standard-4 node in us-central1. Use request id demo-1. ``` Then request three nodes or change the region and observe the receiver denial. -The optional model path demonstrates orchestration; it adds no authorization -guarantee beyond the deterministic receiver tests. +The app defaults to `gemini-3.6-flash`, Google's current stable Flash model as +of this evidence date. The optional live path demonstrates orchestration; it +adds no authorization guarantee beyond the deterministic receiver tests. + +## Evidence tiers + +| Tier | Executed here | Meaning | +|---|---|---| +| Receiver verification | Yes | Cryptographic and local-policy allow/deny matrix | +| ADK `FunctionTool` | Yes | Ordinary tool schema; proof injection stays outside model context | +| ADK runner loop | Yes | Model turn → function call → gated tool → function response | +| Gemini 3.6 Flash | Configuration-ready | Requires an operator API key; not part of recorded evidence | +| MCP / A2A / Agent Engine | Not yet | Proposed follow-on, not claimed as executed | ## Limitations @@ -155,13 +169,14 @@ guarantee beyond the deterministic receiver tests. execution layer must still ensure the real cloud operation matches it. - This reference composes with Agent Identity conceptually but does not deploy to Vertex AI Agent Engine or exercise preview IAM Agent Identity APIs. -- The executed draft uses a real ADK `FunctionTool` boundary in one process. It - does not yet execute an MCP or A2A transport hop; those are follow-on carriage - profiles for the same receiver contract. +- The executed draft uses the real ADK runner and `FunctionTool` in one process. + It does not yet execute an MCP or A2A transport hop; those are follow-on + carriage profiles for the same receiver contract. ## Sources - Google Agent Identity: - Google ADK: +- Gemini API release notes: - Ratify Protocol: - Agent Relay integration note: diff --git a/references/google-adk/authority_reference/adk_agent.py b/references/google-adk/authority_reference/adk_agent.py index a6b231d..de11633 100644 --- a/references/google-adk/authority_reference/adk_agent.py +++ b/references/google-adk/authority_reference/adk_agent.py @@ -7,6 +7,7 @@ from __future__ import annotations from google.adk.agents import LlmAgent +from google.adk.models.base_llm import BaseLlm from google.adk.tools import FunctionTool from .authority import AuthorityFixture @@ -41,7 +42,7 @@ def build_adk_agent( receiver: InfrastructureReceiver, authority: AuthorityFixture, *, - model: str = "gemini-3.6-flash", + model: str | BaseLlm = "gemini-3.6-flash", ) -> LlmAgent: """Construct the real ADK agent; running the model is optional.""" return LlmAgent( diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md index 344090b..7a5adc8 100644 --- a/references/google-adk/evidence/reference-evidence.md +++ b/references/google-adk/evidence/reference-evidence.md @@ -29,22 +29,25 @@ runs the test matrix, and runs the deterministic ADK `FunctionTool` demo. ```text pins: google-adk==2.6.3 ratify-protocol==1.0.0a16 -................ [100%] -16 passed, 5 warnings in 5.86s +................. [100%] +17 passed, 6 warnings ALLOW -> tool invoked once DENY excessive count -> no additional invocation DENY wrong region -> no additional invocation GOOGLE ADK AUTHORITY REFERENCE PASSED ``` -The five warnings came from Google ADK transitive dependencies during import: -one OpenTelemetry entry-point deprecation and four ADK -`BaseAgentConfig` deprecations. No tests were skipped, xfailed, or retried. +The six warnings came from Google ADK and its transitive dependencies: one +OpenTelemetry entry-point deprecation, four ADK `BaseAgentConfig` deprecations, +and one experimental JSON-schema feature warning. No tests were skipped, +xfailed, or retried. ## What this run establishes - A real `google.adk.agents.LlmAgent` exposes one ordinary `google.adk.tools.FunctionTool`. +- A deterministic model double drives the real ADK runner through model turn, + function call, gated tool execution, function response, and final response. - The function tool uses a two-hop Ratify delegation and a receiver-issued, operation-bound, single-use challenge. - The independent receiver invokes its protected handler exactly once for the @@ -57,8 +60,9 @@ one OpenTelemetry entry-point deprecation and four ADK ## What this run does not establish -- No Gemini API call was made. The model path remains optional because model - judgment is not part of the authorization guarantee. +- No Gemini API call was made. The optional app is configured for the current + stable `gemini-3.6-flash` path, but model judgment is not part of the + authorization guarantee. - No Vertex AI Agent Engine deployment or preview Agent Identity API was used. - No MCP or A2A transport hop was executed; the recorded run covers the ADK `FunctionTool` to independently instantiated receiver boundary. diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py index f1afeb2..ae98997 100644 --- a/references/google-adk/tests/test_reference.py +++ b/references/google-adk/tests/test_reference.py @@ -4,6 +4,10 @@ import pytest from google.adk.agents import LlmAgent +from google.adk.models.base_llm import BaseLlm +from google.adk.models.llm_response import LlmResponse +from google.adk.runners import InMemoryRunner +from google.genai import types from ratify_protocol import encode_proof_bundle, generate_agent, sign_challenge from authority_reference import ( @@ -194,3 +198,64 @@ def test_real_adk_function_tool_executes_the_receiver_gated_path(): assert allowed["decision"] == "allow" assert denied["decision"] == "deny" assert receiver.tool_invocations == 1 + + +class _ScriptedToolCallingModel(BaseLlm): + """Deterministic model double; ADK still owns the agent/tool event loop.""" + + turn: int = 0 + + async def generate_content_async(self, llm_request, stream=False): + self.turn += 1 + if self.turn == 1: + yield LlmResponse( + content=types.Content( + role="model", + parts=[types.Part(function_call=types.FunctionCall( + id="call-1", + name="provision_cloud_node", + args={ + "request_id": "runner-allow", + "region": "us-central1", + "instance_type": "n2-standard-4", + "count": 1, + }, + ))], + ) + ) + else: + yield LlmResponse( + content=types.Content( + role="model", + parts=[types.Part(text="Receiver allowed one node.")], + ) + ) + + +def test_real_adk_runner_selects_and_executes_receiver_gated_tool(): + _, authority, receiver = setup_reference() + agent = build_adk_agent( + receiver, + authority, + model=_ScriptedToolCallingModel(model="scripted-reference-model"), + ) + runner = InMemoryRunner(agent=agent, app_name="ratify_adk_reference") + session = runner.session_service.create_session_sync( + app_name="ratify_adk_reference", user_id="reference-user" + ) + + events = list(runner.run( + user_id="reference-user", + session_id=session.id, + new_message=types.Content( + role="user", parts=[types.Part(text="Provision one node.")] + ), + )) + + assert receiver.tool_invocations == 1 + assert any( + part.function_response + and part.function_response.response["decision"] == "allow" + for event in events + for part in (event.content.parts if event.content else []) + ) From a41a368fe8061f5a004bdbc84afe59917448f662 Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:43:52 -0700 Subject: [PATCH 03/11] feat: add native ADK MCP authority boundary Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 27 ++-- references/google-adk/adk_app/agent.py | 18 ++- .../authority_reference/__init__.py | 3 + .../google-adk/authority_reference/adk_mcp.py | 123 +++++++++++++++ .../authority_reference/mcp_server.py | 64 ++++++++ references/google-adk/demo.py | 59 +++++--- .../google-adk/evidence/reference-evidence.md | 29 ++-- references/google-adk/requirements.txt | 1 + references/google-adk/tests/test_reference.py | 143 +++++++++++++++++- 9 files changed, 415 insertions(+), 52 deletions(-) create mode 100644 references/google-adk/authority_reference/adk_mcp.py create mode 100644 references/google-adk/authority_reference/mcp_server.py diff --git a/references/google-adk/README.md b/references/google-adk/README.md index ec54fde..a3daa67 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -61,12 +61,13 @@ ADK commander extension constraint: max_nodes = 1 | v -Google ADK FunctionTool - asks the independent receiver for an operation-bound challenge - signs that challenge with the specialist key +Google ADK native McpToolset + exposes only ordinary business arguments to the model + obtains an operation-bound challenge after tool selection + signs it with the specialist key and injects the proof | v -Independent receiver +Separate stdio MCP receiver process pins the accepted principal root out of band reconstructs the operation and payload digest binds the challenge to verifier, workspace, agent, session, invocation, @@ -85,7 +86,7 @@ claim that Google defines or endorses it. | Layer | Question answered | This reference does not claim | |---|---|---| | Google Agent Identity / IAM | Which deployed agent workload is calling, and which Google Cloud permissions does it have? | That the workload carries a principal-signed grant for this exact cross-boundary action | -| Google ADK | How does the agent reason and invoke a tool? | That an in-process callback is an independent authorization boundary | +| Google ADK | How does the agent reason and invoke a tool? | That MCP transport alone proves delegated authority | | MCP / A2A / tool transport | How does the request cross the boundary? | That transport authentication proves the principal's bounded intent | | Ratify | Who delegated authority, for which scope/resource/bounds, and is the presentation fresh and unrevoked? | That the receiver must execute | | Receiver policy and tool | Is the verified request acceptable now, and should the action execute? | That verifier-supplied context becomes trustworthy without receiver validation | @@ -151,14 +152,15 @@ adds no authorization guarantee beyond the deterministic receiver tests. | Tier | Executed here | Meaning | |---|---|---| | Receiver verification | Yes | Cryptographic and local-policy allow/deny matrix | -| ADK `FunctionTool` | Yes | Ordinary tool schema; proof injection stays outside model context | -| ADK runner loop | Yes | Model turn → function call → gated tool → function response | +| ADK `FunctionTool` | Yes | Baseline in-process composition | +| Native ADK `McpToolset` | Yes | Ordinary schema; hidden proof injection; separate receiver process | +| ADK runner loop | Yes | Model turn → MCP function call → gated receiver → function response | | Gemini 3.6 Flash | Configuration-ready | Requires an operator API key; not part of recorded evidence | -| MCP / A2A / Agent Engine | Not yet | Proposed follow-on, not claimed as executed | +| A2A / Agent Engine | Not yet | Proposed follow-on, not claimed as executed | ## Limitations -- The receiver and challenge store are in-memory and single-process. +- The receiver and challenge store are in-memory inside one MCP server process. - The protected provisioner is a counter, not Google Compute Engine. No cloud resources are created. - Trust-root distribution, durable revocation, shared challenge storage, key @@ -169,14 +171,15 @@ adds no authorization guarantee beyond the deterministic receiver tests. execution layer must still ensure the real cloud operation matches it. - This reference composes with Agent Identity conceptually but does not deploy to Vertex AI Agent Engine or exercise preview IAM Agent Identity APIs. -- The executed draft uses the real ADK runner and `FunctionTool` in one process. - It does not yet execute an MCP or A2A transport hop; those are follow-on - carriage profiles for the same receiver contract. +- The executed draft uses the real ADK runner and native `McpToolset` across a + separately spawned stdio MCP receiver process. It does not yet execute A2A, + remote HTTP MCP, Agent Engine, or Agent Identity deployment. ## Sources - Google Agent Identity: - Google ADK: +- ADK MCP tools: - Gemini API release notes: - Ratify Protocol: - Agent Relay integration note: diff --git a/references/google-adk/adk_app/agent.py b/references/google-adk/adk_app/agent.py index 589d1ef..83da8fe 100644 --- a/references/google-adk/adk_app/agent.py +++ b/references/google-adk/adk_app/agent.py @@ -1,11 +1,17 @@ -"""Optional live Gemini entry point for ``adk run adk_app``.""" +"""Optional live Gemini + native MCP entry point for ``adk run adk_app``.""" -from authority_reference import InfrastructureReceiver, build_adk_agent, issue_authority +from google.adk.agents import LlmAgent +from authority_reference import build_mcp_toolset, issue_authority authority = issue_authority() -receiver = InfrastructureReceiver( - trusted_root_id=authority.root_id, - trusted_root_public_key=authority.root_public_key, +root_agent = LlmAgent( + name="ratify_mcp_infrastructure_specialist", + description="Provisions cloud nodes through an authority-gated MCP receiver.", + model="gemini-3.6-flash", + instruction=( + "Use provision_cloud_node for infrastructure changes. Report receiver " + "denials exactly; never claim an action succeeded when decision is deny." + ), + tools=[build_mcp_toolset(authority)], ) -root_agent = build_adk_agent(receiver, authority) diff --git a/references/google-adk/authority_reference/__init__.py b/references/google-adk/authority_reference/__init__.py index 1159d80..220da2b 100644 --- a/references/google-adk/authority_reference/__init__.py +++ b/references/google-adk/authority_reference/__init__.py @@ -1,6 +1,7 @@ """Independent Google ADK delegated-authority reference.""" from .adk_agent import build_adk_agent, build_provision_tool +from .adk_mcp import RatifyMcpToolset, build_mcp_toolset from .authority import AuthorityFixture, issue_authority from .receiver import InfrastructureReceiver, OperationRequest @@ -9,6 +10,8 @@ "InfrastructureReceiver", "OperationRequest", "build_adk_agent", + "build_mcp_toolset", "build_provision_tool", "issue_authority", + "RatifyMcpToolset", ] diff --git a/references/google-adk/authority_reference/adk_mcp.py b/references/google-adk/authority_reference/adk_mcp.py new file mode 100644 index 0000000..845aad6 --- /dev/null +++ b/references/google-adk/authority_reference/adk_mcp.py @@ -0,0 +1,123 @@ +"""Ratify-aware native Google ADK MCP toolset. + +The model sees only business arguments. This adapter obtains a receiver-issued +challenge and adds the proof presentation after ADK has selected the tool. +""" + +from __future__ import annotations + +from copy import deepcopy +import json +import os +from pathlib import Path +import sys +from typing import Any + +from google.adk.tools.mcp_tool.mcp_session_manager import ( + StdioConnectionParams, + StdioServerParameters, +) +from google.adk.tools.mcp_tool.mcp_tool import McpTool +from google.adk.tools.mcp_tool.mcp_toolset import McpToolset +from google.genai.types import FunctionDeclaration +from ratify_protocol import ( + base64_standard_decode, + base64_standard_encode, + encode_proof_bundle, +) + +from .authority import AuthorityFixture + + +class ProofInjectingMcpTool(McpTool): + def __init__(self, *, authority: AuthorityFixture, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._authority = authority + + def _get_declaration(self) -> FunctionDeclaration: + schema = deepcopy(self._mcp_tool.inputSchema) + schema.get("properties", {}).pop("presentation", None) + required = schema.get("required") + if isinstance(required, list): + schema["required"] = [name for name in required if name != "presentation"] + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters_json_schema=schema, + response_json_schema=self._mcp_tool.outputSchema, + ) + + async def run_async(self, *, args: dict[str, Any], tool_context: Any) -> Any: + session = await self._mcp_session_manager.create_session() + grant_result = await session.call_tool( + "issue_authority_challenge", + arguments={**args, "expected_agent_id": self._authority.specialist_id}, + ) + grant = _result_object(grant_result) + bundle = self._authority.present( + challenge=base64_standard_decode(grant["challenge"]), + session_context=base64_standard_decode(grant["session_context"]), + ) + response = await session.call_tool( + self.name, + arguments={**args, "presentation": encode_proof_bundle(bundle)}, + ) + return _result_object(response) + + +class RatifyMcpToolset(McpToolset): + def __init__(self, *, authority: AuthorityFixture, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._authority = authority + + async def get_tools(self, readonly_context=None): + tools = await super().get_tools(readonly_context) + return [ + ProofInjectingMcpTool( + authority=self._authority, + mcp_tool=tool._mcp_tool, + mcp_session_manager=tool._mcp_session_manager, + ) + for tool in tools + ] + + +def build_mcp_toolset(authority: AuthorityFixture) -> RatifyMcpToolset: + root = Path(__file__).resolve().parents[1] + env = dict(os.environ) + env.update( + { + "PYTHONPATH": str(root), + "RATIFY_TRUSTED_ROOT_ID": authority.root_id, + "RATIFY_ROOT_ED25519": base64_standard_encode( + authority.root_public_key.ed25519 + ), + "RATIFY_ROOT_ML_DSA_65": base64_standard_encode( + authority.root_public_key.ml_dsa_65 + ), + } + ) + return RatifyMcpToolset( + authority=authority, + connection_params=StdioConnectionParams( + server_params=StdioServerParameters( + command=sys.executable, + args=["-m", "authority_reference.mcp_server"], + cwd=root, + env=env, + ) + ), + tool_filter=["provision_cloud_node"], + ) + + +def _result_object(result: Any) -> dict[str, Any]: + structured = getattr(result, "structuredContent", None) + if isinstance(structured, dict): + return structured.get("result", structured) + for item in result.content: + text = getattr(item, "text", None) + if text: + parsed = json.loads(text) + return parsed.get("result", parsed) + raise ValueError("MCP receiver returned no structured result") diff --git a/references/google-adk/authority_reference/mcp_server.py b/references/google-adk/authority_reference/mcp_server.py new file mode 100644 index 0000000..c35b2a3 --- /dev/null +++ b/references/google-adk/authority_reference/mcp_server.py @@ -0,0 +1,64 @@ +"""Separate stdio MCP receiver process for the ADK reference.""" + +from __future__ import annotations + +import os + +from mcp.server.fastmcp import FastMCP +from ratify_protocol import HybridPublicKey, base64_standard_decode + +from .receiver import InfrastructureReceiver, OperationRequest + + +def _trusted_receiver() -> InfrastructureReceiver: + return InfrastructureReceiver( + trusted_root_id=os.environ["RATIFY_TRUSTED_ROOT_ID"], + trusted_root_public_key=HybridPublicKey( + ed25519=base64_standard_decode(os.environ["RATIFY_ROOT_ED25519"]), + ml_dsa_65=base64_standard_decode(os.environ["RATIFY_ROOT_ML_DSA_65"]), + ), + ) + + +receiver = _trusted_receiver() +mcp = FastMCP("ratify-adk-authority-receiver", log_level="ERROR") + + +@mcp.tool() +def issue_authority_challenge( + request_id: str, + region: str, + instance_type: str, + count: int, + expected_agent_id: str, +) -> dict: + """Internal adapter operation; excluded from the ADK model toolset.""" + grant = receiver.issue_challenge( + OperationRequest(request_id, region, instance_type, count), + expected_agent_id=expected_agent_id, + ) + from ratify_protocol import base64_standard_encode + + return { + "challenge": base64_standard_encode(grant.challenge), + "session_context": base64_standard_encode(grant.session_context), + "expires_at": grant.expires_at, + } + + +@mcp.tool() +def provision_cloud_node( + request_id: str, + region: str, + instance_type: str, + count: int, + presentation: str, +) -> dict: + """Provision cloud nodes only after receiver-side authority verification.""" + return receiver.execute( + OperationRequest(request_id, region, instance_type, count), presentation + ) + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/references/google-adk/demo.py b/references/google-adk/demo.py index 3d66531..1979ef0 100644 --- a/references/google-adk/demo.py +++ b/references/google-adk/demo.py @@ -1,31 +1,48 @@ #!/usr/bin/env python3 -"""One-command deterministic demonstration using a real ADK FunctionTool.""" +"""One-command deterministic demonstration across native ADK MCP.""" from __future__ import annotations -from authority_reference import InfrastructureReceiver, build_provision_tool, issue_authority +import asyncio +from authority_reference import build_mcp_toolset, issue_authority -def main() -> None: + +async def run() -> None: authority = issue_authority() - receiver = InfrastructureReceiver( - trusted_root_id=authority.root_id, - trusted_root_public_key=authority.root_public_key, - ) - tool = build_provision_tool(receiver, authority) - - allowed = tool.func("req-allow", "us-central1", "n2-standard-4", 1) - excessive = tool.func("req-count", "us-central1", "n2-standard-4", 3) - wrong_region = tool.func("req-region", "us-east1", "n2-standard-4", 1) - - print(f"ALLOW -> tool invoked once: {allowed}") - print(f"DENY excessive count -> no additional invocation: {excessive}") - print(f"DENY wrong region -> no additional invocation: {wrong_region}") - - assert allowed["decision"] == "allow" and allowed["tool_invocations"] == 1 - assert excessive["decision"] == "deny" and excessive["tool_invocations"] == 1 - assert wrong_region["decision"] == "deny" and wrong_region["tool_invocations"] == 1 - print("GOOGLE ADK AUTHORITY REFERENCE PASSED") + toolset = build_mcp_toolset(authority) + try: + tool = (await toolset.get_tools())[0] + + async def invoke(request_id: str, region: str, count: int): + return await tool.run_async( + args={ + "request_id": request_id, + "region": region, + "instance_type": "n2-standard-4", + "count": count, + }, + tool_context=None, + ) + + allowed = await invoke("req-allow", "us-central1", 1) + excessive = await invoke("req-count", "us-central1", 3) + wrong_region = await invoke("req-region", "us-east1", 1) + + print(f"ALLOW across ADK MCP -> tool invoked once: {allowed}") + print(f"DENY excessive count -> no additional invocation: {excessive}") + print(f"DENY wrong region -> no additional invocation: {wrong_region}") + + assert allowed["decision"] == "allow" and allowed["tool_invocations"] == 1 + assert excessive["decision"] == "deny" and excessive["tool_invocations"] == 1 + assert wrong_region["decision"] == "deny" and wrong_region["tool_invocations"] == 1 + print("GOOGLE ADK MCP AUTHORITY REFERENCE PASSED") + finally: + await toolset.close() + + +def main() -> None: + asyncio.run(run()) if __name__ == "__main__": diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md index 7a5adc8..06fd795 100644 --- a/references/google-adk/evidence/reference-evidence.md +++ b/references/google-adk/evidence/reference-evidence.md @@ -10,10 +10,11 @@ from the independent Ratify reference; it is not Google attestation. | Host | macOS 26.6, arm64 | | Python | 3.11.1 | | Google ADK | `2.6.3` | +| MCP Python SDK | `1.29.0` | | Ratify Protocol | published PyPI package `1.0.0a16` | | pytest | `8.4.1` | | Protocol base commit | `f5a1522f20b79c881f77db96ae44948dd19dbd42` | -| Requirements SHA-256 | `ab0942b5164e36d43f6bc99b78ebc751011c2726e7a31117bc155d566da409f7` | +| Requirements SHA-256 | `b934bca56ea62573af6b5ffe9b8b9224138ee405a5efd3f99f157c21fef5a3b9` | ## Reproduction @@ -23,24 +24,22 @@ from the independent Ratify reference; it is not Google attestation. The gate creates `references/google-adk/.venv`, installs the exact public requirements, asserts the Ratify import does not resolve from `sdks/python`, -runs the test matrix, and runs the deterministic ADK `FunctionTool` demo. +runs the test matrix, and runs the deterministic native ADK MCP demo. ## Recorded result ```text pins: google-adk==2.6.3 ratify-protocol==1.0.0a16 -................. [100%] -17 passed, 6 warnings -ALLOW -> tool invoked once +.................... [100%] +20 passed, 15 warnings +ALLOW across ADK MCP -> tool invoked once DENY excessive count -> no additional invocation DENY wrong region -> no additional invocation -GOOGLE ADK AUTHORITY REFERENCE PASSED +GOOGLE ADK MCP AUTHORITY REFERENCE PASSED ``` -The six warnings came from Google ADK and its transitive dependencies: one -OpenTelemetry entry-point deprecation, four ADK `BaseAgentConfig` deprecations, -and one experimental JSON-schema feature warning. No tests were skipped, -xfailed, or retried. +The warnings came from Google ADK and transitive dependency deprecations or +experimental feature notices. No tests were skipped, xfailed, or retried. ## What this run establishes @@ -48,6 +47,12 @@ xfailed, or retried. `google.adk.tools.FunctionTool`. - A deterministic model double drives the real ADK runner through model turn, function call, gated tool execution, function response, and final response. +- Native ADK `McpToolset` discovers the public tool from a separately spawned + stdio MCP receiver process. +- The model-visible MCP declaration contains only business arguments. The + adapter acquires the challenge and injects the proof after tool selection. +- Altered operations and replayed presentations are denied across the MCP + process boundary without an additional protected-handler invocation. - The function tool uses a two-hop Ratify delegation and a receiver-issued, operation-bound, single-use challenge. - The independent receiver invokes its protected handler exactly once for the @@ -64,8 +69,8 @@ xfailed, or retried. stable `gemini-3.6-flash` path, but model judgment is not part of the authorization guarantee. - No Vertex AI Agent Engine deployment or preview Agent Identity API was used. -- No MCP or A2A transport hop was executed; the recorded run covers the ADK - `FunctionTool` to independently instantiated receiver boundary. +- Stdio MCP was executed. Remote HTTP MCP, A2A, Agent Engine, and Agent Identity + deployment were not. - No real Google Cloud resource was provisioned. - Only the platform and versions above were executed. Other operating systems, architectures, Python versions, and ADK versions remain compatibility diff --git a/references/google-adk/requirements.txt b/references/google-adk/requirements.txt index b219b07..cbb4c56 100644 --- a/references/google-adk/requirements.txt +++ b/references/google-adk/requirements.txt @@ -1,3 +1,4 @@ google-adk==2.6.3 +mcp==1.29.0 ratify-protocol==1.0.0a16 pytest==8.4.1 diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py index ae98997..3531911 100644 --- a/references/google-adk/tests/test_reference.py +++ b/references/google-adk/tests/test_reference.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import time import pytest @@ -8,15 +9,22 @@ from google.adk.models.llm_response import LlmResponse from google.adk.runners import InMemoryRunner from google.genai import types -from ratify_protocol import encode_proof_bundle, generate_agent, sign_challenge +from ratify_protocol import ( + base64_standard_decode, + encode_proof_bundle, + generate_agent, + sign_challenge, +) from authority_reference import ( InfrastructureReceiver, OperationRequest, build_adk_agent, + build_mcp_toolset, build_provision_tool, issue_authority, ) +from authority_reference.adk_mcp import _result_object def setup_reference(**authority_options): @@ -259,3 +267,136 @@ def test_real_adk_runner_selects_and_executes_receiver_gated_tool(): for event in events for part in (event.content.parts if event.content else []) ) + + +def test_native_adk_mcp_tool_hides_proof_and_enforces_in_receiver_process(): + async def exercise(): + _, authority, _ = setup_reference() + toolset = build_mcp_toolset(authority) + try: + tools = await toolset.get_tools() + declaration = tools[0]._get_declaration() + properties = declaration.parameters_json_schema["properties"] + assert set(properties) == { + "request_id", "region", "instance_type", "count" + } + + allowed = await tools[0].run_async( + args={ + "request_id": "mcp-allow", + "region": "us-central1", + "instance_type": "n2-standard-4", + "count": 1, + }, + tool_context=None, + ) + denied = await tools[0].run_async( + args={ + "request_id": "mcp-deny", + "region": "us-central1", + "instance_type": "n2-standard-4", + "count": 3, + }, + tool_context=None, + ) + assert allowed["decision"] == "allow" + assert allowed["tool_invocations"] == 1 + assert denied["decision"] == "deny" + assert denied["tool_invocations"] == 1 + finally: + await toolset.close() + + asyncio.run(exercise()) + + +def test_real_adk_runner_executes_native_mcp_toolset(): + async def exercise(): + _, authority, _ = setup_reference() + toolset = build_mcp_toolset(authority) + agent = LlmAgent( + name="ratify_mcp_specialist", + model=_ScriptedToolCallingModel(model="scripted-mcp-model"), + instruction="Provision only through the receiver-gated MCP tool.", + tools=[toolset], + ) + runner = InMemoryRunner(agent=agent, app_name="ratify_adk_mcp") + try: + session = await runner.session_service.create_session( + app_name="ratify_adk_mcp", user_id="reference-user" + ) + events = [ + event + async for event in runner.run_async( + user_id="reference-user", + session_id=session.id, + new_message=types.Content( + role="user", parts=[types.Part(text="Provision one node.")] + ), + ) + ] + assert any( + part.function_response + and part.function_response.response["decision"] == "allow" + for event in events + for part in (event.content.parts if event.content else []) + ) + finally: + await runner.close() + + asyncio.run(exercise()) + + +def test_mcp_receiver_rejects_alteration_and_replay_across_process_boundary(): + async def exercise(): + _, authority, _ = setup_reference() + toolset = build_mcp_toolset(authority) + try: + tool = (await toolset.get_tools())[0] + session = await tool._mcp_session_manager.create_session() + original = { + "request_id": "mcp-bound", + "region": "us-central1", + "instance_type": "n2-standard-4", + "count": 1, + } + + async def presentation_for(args): + result = await session.call_tool( + "issue_authority_challenge", + arguments={ + **args, + "expected_agent_id": authority.specialist_id, + }, + ) + grant = _result_object(result) + return encode_proof_bundle(authority.present( + challenge=base64_standard_decode(grant["challenge"]), + session_context=base64_standard_decode( + grant["session_context"] + ), + )) + + altered_proof = await presentation_for(original) + altered = await session.call_tool( + "provision_cloud_node", + arguments={**original, "count": 2, "presentation": altered_proof}, + ) + assert _result_object(altered)["status"] == "operation_binding_failed" + + replay_proof = await presentation_for(original) + first = _result_object(await session.call_tool( + "provision_cloud_node", + arguments={**original, "presentation": replay_proof}, + )) + await presentation_for(original) + replay = _result_object(await session.call_tool( + "provision_cloud_node", + arguments={**original, "presentation": replay_proof}, + )) + assert first["decision"] == "allow" + assert replay["decision"] == "deny" + assert replay["tool_invocations"] == 1 + finally: + await toolset.close() + + asyncio.run(exercise()) From 2a188944080caaf9cf77d96ae279308198ce263d Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:40:51 -0700 Subject: [PATCH 04/11] feat: isolate ADK authority receiver over HTTP MCP Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/.gitignore | 1 + references/google-adk/README.md | 27 ++++- references/google-adk/adk_app/agent.py | 11 +- .../google-adk/authority_reference/adk_mcp.py | 37 ++----- .../authority_reference/deployment_config.py | 53 +++++++++ .../authority_reference/mcp_server.py | 103 +++++++++++------- references/google-adk/bootstrap_live.py | 14 +++ references/google-adk/demo.py | 86 +++++++++------ .../google-adk/evidence/reference-evidence.md | 16 +-- references/google-adk/tests/test_reference.py | 98 +++++++++++++++-- scripts/google-adk-reference-check.sh | 8 +- 11 files changed, 325 insertions(+), 129 deletions(-) create mode 100644 references/google-adk/authority_reference/deployment_config.py create mode 100644 references/google-adk/bootstrap_live.py diff --git a/references/google-adk/.gitignore b/references/google-adk/.gitignore index 5831ca4..af674dc 100644 --- a/references/google-adk/.gitignore +++ b/references/google-adk/.gitignore @@ -1,3 +1,4 @@ .venv/ +.local/ __pycache__/ .pytest_cache/ diff --git a/references/google-adk/README.md b/references/google-adk/README.md index a3daa67..2f836c1 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -67,7 +67,7 @@ Google ADK native McpToolset signs it with the specialist key and injects the proof | v -Separate stdio MCP receiver process +Independent Streamable HTTP MCP receiver pins the accepted principal root out of band reconstructs the operation and payload digest binds the challenge to verifier, workspace, agent, session, invocation, @@ -129,10 +129,22 @@ The suite encodes why the boundary matters: The deterministic suite is authoritative. To let Gemini select and invoke the same ADK tool interactively: +```bash +cd references/google-adk +source .venv/bin/activate +python bootstrap_live.py +python -m authority_reference.mcp_server \ + --trust-config .local/receiver-trust.json --port 8765 +``` + +In a second shell: + ```bash cd references/google-adk source .venv/bin/activate export GOOGLE_API_KEY=your_key +export RATIFY_PRESENTER_CONFIG=.local/presenter.json +export RATIFY_MCP_RECEIVER_URL=http://127.0.0.1:8765/mcp adk run adk_app ``` @@ -153,7 +165,7 @@ adds no authorization guarantee beyond the deterministic receiver tests. |---|---|---| | Receiver verification | Yes | Cryptographic and local-policy allow/deny matrix | | ADK `FunctionTool` | Yes | Baseline in-process composition | -| Native ADK `McpToolset` | Yes | Ordinary schema; hidden proof injection; separate receiver process | +| Native ADK `McpToolset` | Yes | Ordinary schema; hidden proof injection; independent HTTP receiver | | ADK runner loop | Yes | Model turn → MCP function call → gated receiver → function response | | Gemini 3.6 Flash | Configuration-ready | Requires an operator API key; not part of recorded evidence | | A2A / Agent Engine | Not yet | Proposed follow-on, not claimed as executed | @@ -171,9 +183,14 @@ adds no authorization guarantee beyond the deterministic receiver tests. execution layer must still ensure the real cloud operation matches it. - This reference composes with Agent Identity conceptually but does not deploy to Vertex AI Agent Engine or exercise preview IAM Agent Identity APIs. -- The executed draft uses the real ADK runner and native `McpToolset` across a - separately spawned stdio MCP receiver process. It does not yet execute A2A, - remote HTTP MCP, Agent Engine, or Agent Identity deployment. +- Proof injection uses a small pinned-version `McpTool` adapter because ADK does + not expose operation-specific hidden MCP metadata as a stable public hook. + The adapter is isolated and tested, but should be mapped with the ADK team + before claiming forward compatibility. +- The executed draft uses the real ADK runner and native `McpToolset` across an + independently started Streamable HTTP MCP receiver with receiver-owned trust + configuration. It does not yet execute A2A, TLS workload authentication, + Agent Engine, or Agent Identity deployment. ## Sources diff --git a/references/google-adk/adk_app/agent.py b/references/google-adk/adk_app/agent.py index 83da8fe..ac117c7 100644 --- a/references/google-adk/adk_app/agent.py +++ b/references/google-adk/adk_app/agent.py @@ -1,10 +1,13 @@ """Optional live Gemini + native MCP entry point for ``adk run adk_app``.""" +import os + from google.adk.agents import LlmAgent -from authority_reference import build_mcp_toolset, issue_authority +from authority_reference import build_mcp_toolset +from authority_reference.deployment_config import load_presenter -authority = issue_authority() +authority = load_presenter(os.environ["RATIFY_PRESENTER_CONFIG"]) root_agent = LlmAgent( name="ratify_mcp_infrastructure_specialist", description="Provisions cloud nodes through an authority-gated MCP receiver.", @@ -13,5 +16,7 @@ "Use provision_cloud_node for infrastructure changes. Report receiver " "denials exactly; never claim an action succeeded when decision is deny." ), - tools=[build_mcp_toolset(authority)], + tools=[build_mcp_toolset( + authority, receiver_url=os.environ["RATIFY_MCP_RECEIVER_URL"] + )], ) diff --git a/references/google-adk/authority_reference/adk_mcp.py b/references/google-adk/authority_reference/adk_mcp.py index 845aad6..39d2f08 100644 --- a/references/google-adk/authority_reference/adk_mcp.py +++ b/references/google-adk/authority_reference/adk_mcp.py @@ -8,21 +8,16 @@ from copy import deepcopy import json -import os -from pathlib import Path -import sys from typing import Any from google.adk.tools.mcp_tool.mcp_session_manager import ( - StdioConnectionParams, - StdioServerParameters, + StreamableHTTPConnectionParams, ) from google.adk.tools.mcp_tool.mcp_tool import McpTool from google.adk.tools.mcp_tool.mcp_toolset import McpToolset from google.genai.types import FunctionDeclaration from ratify_protocol import ( base64_standard_decode, - base64_standard_encode, encode_proof_bundle, ) @@ -51,7 +46,7 @@ async def run_async(self, *, args: dict[str, Any], tool_context: Any) -> Any: session = await self._mcp_session_manager.create_session() grant_result = await session.call_tool( "issue_authority_challenge", - arguments={**args, "expected_agent_id": self._authority.specialist_id}, + arguments=args, ) grant = _result_object(grant_result) bundle = self._authority.present( @@ -82,30 +77,14 @@ async def get_tools(self, readonly_context=None): ] -def build_mcp_toolset(authority: AuthorityFixture) -> RatifyMcpToolset: - root = Path(__file__).resolve().parents[1] - env = dict(os.environ) - env.update( - { - "PYTHONPATH": str(root), - "RATIFY_TRUSTED_ROOT_ID": authority.root_id, - "RATIFY_ROOT_ED25519": base64_standard_encode( - authority.root_public_key.ed25519 - ), - "RATIFY_ROOT_ML_DSA_65": base64_standard_encode( - authority.root_public_key.ml_dsa_65 - ), - } - ) +def build_mcp_toolset(authority: AuthorityFixture, *, receiver_url: str) -> RatifyMcpToolset: + """Connect to an independently operated receiver; never configures its trust.""" return RatifyMcpToolset( authority=authority, - connection_params=StdioConnectionParams( - server_params=StdioServerParameters( - command=sys.executable, - args=["-m", "authority_reference.mcp_server"], - cwd=root, - env=env, - ) + connection_params=StreamableHTTPConnectionParams( + url=receiver_url, + timeout=5, + sse_read_timeout=30, ), tool_filter=["provision_cloud_node"], ) diff --git a/references/google-adk/authority_reference/deployment_config.py b/references/google-adk/authority_reference/deployment_config.py new file mode 100644 index 0000000..941c750 --- /dev/null +++ b/references/google-adk/authority_reference/deployment_config.py @@ -0,0 +1,53 @@ +"""Explicit receiver/public and presenter/private deployment configuration.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from ratify_protocol import ( + HybridPrivateKey, + HybridPublicKey, + base64_standard_decode, + base64_standard_encode, + decode_delegation_cert, + encode_delegation_cert, +) + +from .authority import AuthorityFixture + + +def write_configs(authority: AuthorityFixture, receiver_path: Path, presenter_path: Path) -> None: + receiver_path.write_text(json.dumps({ + "trusted_root_id": authority.root_id, + "trusted_agent_id": authority.specialist_id, + "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), + "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), + }), encoding="utf-8") + presenter_path.write_text(json.dumps({ + "root_id": authority.root_id, + "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), + "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), + "specialist_id": authority.specialist_id, + "private_ed25519": base64_standard_encode(authority.specialist_private_key.ed25519), + "private_ml_dsa_65": base64_standard_encode(authority.specialist_private_key.ml_dsa_65), + "delegations": [encode_delegation_cert(cert) for cert in authority.delegations], + }), encoding="utf-8") + presenter_path.chmod(0o600) + + +def load_presenter(path: str) -> AuthorityFixture: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return AuthorityFixture( + root_id=data["root_id"], + root_public_key=HybridPublicKey( + ed25519=base64_standard_decode(data["root_ed25519"]), + ml_dsa_65=base64_standard_decode(data["root_ml_dsa_65"]), + ), + specialist_id=data["specialist_id"], + specialist_private_key=HybridPrivateKey( + ed25519=base64_standard_decode(data["private_ed25519"]), + ml_dsa_65=base64_standard_decode(data["private_ml_dsa_65"]), + ), + delegations=[decode_delegation_cert(cert) for cert in data["delegations"]], + ) diff --git a/references/google-adk/authority_reference/mcp_server.py b/references/google-adk/authority_reference/mcp_server.py index c35b2a3..2b4b475 100644 --- a/references/google-adk/authority_reference/mcp_server.py +++ b/references/google-adk/authority_reference/mcp_server.py @@ -1,64 +1,85 @@ -"""Separate stdio MCP receiver process for the ADK reference.""" +"""Receiver-operated Streamable HTTP MCP service.""" from __future__ import annotations -import os +import argparse +import json +from pathlib import Path from mcp.server.fastmcp import FastMCP -from ratify_protocol import HybridPublicKey, base64_standard_decode +from ratify_protocol import HybridPublicKey, base64_standard_decode, base64_standard_encode from .receiver import InfrastructureReceiver, OperationRequest -def _trusted_receiver() -> InfrastructureReceiver: - return InfrastructureReceiver( - trusted_root_id=os.environ["RATIFY_TRUSTED_ROOT_ID"], +def load_receiver(path: str) -> tuple[InfrastructureReceiver, str]: + config = json.loads(Path(path).read_text(encoding="utf-8")) + receiver = InfrastructureReceiver( + trusted_root_id=config["trusted_root_id"], trusted_root_public_key=HybridPublicKey( - ed25519=base64_standard_decode(os.environ["RATIFY_ROOT_ED25519"]), - ml_dsa_65=base64_standard_decode(os.environ["RATIFY_ROOT_ML_DSA_65"]), + ed25519=base64_standard_decode(config["root_ed25519"]), + ml_dsa_65=base64_standard_decode(config["root_ml_dsa_65"]), ), ) + return receiver, config["trusted_agent_id"] -receiver = _trusted_receiver() -mcp = FastMCP("ratify-adk-authority-receiver", log_level="ERROR") +def create_server( + receiver: InfrastructureReceiver, trusted_agent_id: str, host: str, port: int +) -> FastMCP: + server = FastMCP( + "ratify-adk-authority-receiver", + host=host, + port=port, + stateless_http=False, + log_level="ERROR", + ) + @server.tool() + def issue_authority_challenge( + request_id: str, + region: str, + instance_type: str, + count: int, + ) -> dict: + """Internal adapter operation; excluded from the ADK model toolset.""" + grant = receiver.issue_challenge( + OperationRequest(request_id, region, instance_type, count), + expected_agent_id=trusted_agent_id, + ) + return { + "challenge": base64_standard_encode(grant.challenge), + "session_context": base64_standard_encode(grant.session_context), + "expires_at": grant.expires_at, + } -@mcp.tool() -def issue_authority_challenge( - request_id: str, - region: str, - instance_type: str, - count: int, - expected_agent_id: str, -) -> dict: - """Internal adapter operation; excluded from the ADK model toolset.""" - grant = receiver.issue_challenge( - OperationRequest(request_id, region, instance_type, count), - expected_agent_id=expected_agent_id, - ) - from ratify_protocol import base64_standard_encode + @server.tool() + def provision_cloud_node( + request_id: str, + region: str, + instance_type: str, + count: int, + presentation: str, + ) -> dict: + """Provision only after receiver-side authority verification.""" + return receiver.execute( + OperationRequest(request_id, region, instance_type, count), presentation + ) - return { - "challenge": base64_standard_encode(grant.challenge), - "session_context": base64_standard_encode(grant.session_context), - "expires_at": grant.expires_at, - } + return server -@mcp.tool() -def provision_cloud_node( - request_id: str, - region: str, - instance_type: str, - count: int, - presentation: str, -) -> dict: - """Provision cloud nodes only after receiver-side authority verification.""" - return receiver.execute( - OperationRequest(request_id, region, instance_type, count), presentation +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--trust-config", required=True) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", required=True, type=int) + args = parser.parse_args() + receiver, trusted_agent_id = load_receiver(args.trust_config) + create_server(receiver, trusted_agent_id, args.host, args.port).run( + transport="streamable-http" ) if __name__ == "__main__": - mcp.run(transport="stdio") + main() diff --git a/references/google-adk/bootstrap_live.py b/references/google-adk/bootstrap_live.py new file mode 100644 index 0000000..aa069df --- /dev/null +++ b/references/google-adk/bootstrap_live.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Create separate receiver-public and presenter-private local demo configs.""" + +from pathlib import Path + +from authority_reference import issue_authority +from authority_reference.deployment_config import write_configs + +target = Path(".local") +target.mkdir(exist_ok=True) +write_configs( + issue_authority(), target / "receiver-trust.json", target / "presenter.json" +) +print("created .local/receiver-trust.json and mode-0600 .local/presenter.json") diff --git a/references/google-adk/demo.py b/references/google-adk/demo.py index 1979ef0..2a79cb4 100644 --- a/references/google-adk/demo.py +++ b/references/google-adk/demo.py @@ -1,49 +1,67 @@ #!/usr/bin/env python3 -"""One-command deterministic demonstration across native ADK MCP.""" +"""Local harness for the independently configured HTTP MCP reference.""" from __future__ import annotations import asyncio +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time from authority_reference import build_mcp_toolset, issue_authority +from authority_reference.deployment_config import write_configs async def run() -> None: authority = issue_authority() - toolset = build_mcp_toolset(authority) - try: - tool = (await toolset.get_tools())[0] - - async def invoke(request_id: str, region: str, count: int): - return await tool.run_async( - args={ - "request_id": request_id, - "region": region, - "instance_type": "n2-standard-4", - "count": count, - }, - tool_context=None, + with tempfile.TemporaryDirectory() as directory: + receiver_config = Path(directory) / "receiver.json" + presenter_config = Path(directory) / "presenter.json" + write_configs(authority, receiver_config, presenter_config) + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + process = subprocess.Popen([ + sys.executable, "-m", "authority_reference.mcp_server", + "--trust-config", str(receiver_config), "--port", str(port), + ]) + try: + for _ in range(200): + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", port)) == 0: + break + time.sleep(0.05) + toolset = build_mcp_toolset( + authority, receiver_url=f"http://127.0.0.1:{port}/mcp" ) - - allowed = await invoke("req-allow", "us-central1", 1) - excessive = await invoke("req-count", "us-central1", 3) - wrong_region = await invoke("req-region", "us-east1", 1) - - print(f"ALLOW across ADK MCP -> tool invoked once: {allowed}") - print(f"DENY excessive count -> no additional invocation: {excessive}") - print(f"DENY wrong region -> no additional invocation: {wrong_region}") - - assert allowed["decision"] == "allow" and allowed["tool_invocations"] == 1 - assert excessive["decision"] == "deny" and excessive["tool_invocations"] == 1 - assert wrong_region["decision"] == "deny" and wrong_region["tool_invocations"] == 1 - print("GOOGLE ADK MCP AUTHORITY REFERENCE PASSED") - finally: - await toolset.close() - - -def main() -> None: - asyncio.run(run()) + try: + tool = (await toolset.get_tools())[0] + async def invoke(request_id: str, region: str, count: int): + return await tool.run_async(args={ + "request_id": request_id, + "region": region, + "instance_type": "n2-standard-4", + "count": count, + }, tool_context=None) + allowed = await invoke("req-allow", "us-central1", 1) + excessive = await invoke("req-count", "us-central1", 3) + wrong_region = await invoke("req-region", "us-east1", 1) + print(f"ALLOW across ADK HTTP MCP: {allowed}") + print(f"DENY excessive count: {excessive}") + print(f"DENY wrong region: {wrong_region}") + assert allowed["tool_invocations"] == 1 + assert excessive["decision"] == wrong_region["decision"] == "deny" + assert excessive["tool_invocations"] == wrong_region["tool_invocations"] == 1 + print("GOOGLE ADK HTTP MCP AUTHORITY REFERENCE PASSED") + finally: + await toolset.close() + finally: + process.terminate() + process.wait(timeout=5) if __name__ == "__main__": - main() + asyncio.run(run()) diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md index 06fd795..b4db9d0 100644 --- a/references/google-adk/evidence/reference-evidence.md +++ b/references/google-adk/evidence/reference-evidence.md @@ -29,13 +29,13 @@ runs the test matrix, and runs the deterministic native ADK MCP demo. ## Recorded result ```text -pins: google-adk==2.6.3 ratify-protocol==1.0.0a16 +pins: google-adk==2.6.3 mcp==1.29.0 ratify-protocol==1.0.0a16 .................... [100%] -20 passed, 15 warnings -ALLOW across ADK MCP -> tool invoked once +21 passed, 17 warnings +ALLOW across ADK HTTP MCP -> tool invoked once DENY excessive count -> no additional invocation DENY wrong region -> no additional invocation -GOOGLE ADK MCP AUTHORITY REFERENCE PASSED +GOOGLE ADK HTTP MCP AUTHORITY REFERENCE PASSED ``` The warnings came from Google ADK and transitive dependency deprecations or @@ -47,8 +47,8 @@ experimental feature notices. No tests were skipped, xfailed, or retried. `google.adk.tools.FunctionTool`. - A deterministic model double drives the real ADK runner through model turn, function call, gated tool execution, function response, and final response. -- Native ADK `McpToolset` discovers the public tool from a separately spawned - stdio MCP receiver process. +- Native ADK `McpToolset` discovers the public tool from an independently + started Streamable HTTP MCP receiver. - The model-visible MCP declaration contains only business arguments. The adapter acquires the challenge and injects the proof after tool selection. - Altered operations and replayed presentations are denied across the MCP @@ -69,8 +69,8 @@ experimental feature notices. No tests were skipped, xfailed, or retried. stable `gemini-3.6-flash` path, but model judgment is not part of the authorization guarantee. - No Vertex AI Agent Engine deployment or preview Agent Identity API was used. -- Stdio MCP was executed. Remote HTTP MCP, A2A, Agent Engine, and Agent Identity - deployment were not. +- Streamable HTTP MCP was executed over loopback. A2A, TLS workload + authentication, Agent Engine, and Agent Identity deployment were not. - No real Google Cloud resource was provisioned. - Only the platform and versions above were executed. Other operating systems, architectures, Python versions, and ADK versions remain compatibility diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py index 3531911..67dd8b3 100644 --- a/references/google-adk/tests/test_reference.py +++ b/references/google-adk/tests/test_reference.py @@ -1,6 +1,13 @@ from __future__ import annotations import asyncio +from contextlib import contextmanager +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile import time import pytest @@ -11,6 +18,7 @@ from google.genai import types from ratify_protocol import ( base64_standard_decode, + base64_standard_encode, encode_proof_bundle, generate_agent, sign_challenge, @@ -37,6 +45,48 @@ def setup_reference(**authority_options): return now, authority, receiver +@contextmanager +def running_http_receiver(authority): + """Receiver operator starts the service; the ADK client receives only its URL.""" + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "receiver-trust.json" + config.write_text(json.dumps({ + "trusted_root_id": authority.root_id, + "trusted_agent_id": authority.specialist_id, + "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), + "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), + }), encoding="utf-8") + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + process = subprocess.Popen( + [ + sys.executable, "-m", "authority_reference.mcp_server", + "--trust-config", str(config), "--port", str(port), + ], + cwd=Path(__file__).resolve().parents[1], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(process.stderr.read()) + with socket.socket() as probe: + probe.settimeout(0.1) + if probe.connect_ex(("127.0.0.1", port)) == 0: + break + time.sleep(0.05) + else: + raise RuntimeError("MCP receiver did not become ready") + yield f"http://127.0.0.1:{port}/mcp" + finally: + process.terminate() + process.wait(timeout=5) + + def present(authority, receiver, request, *, now): grant = receiver.issue_challenge( request, expected_agent_id=authority.specialist_id @@ -272,7 +322,9 @@ def test_real_adk_runner_selects_and_executes_receiver_gated_tool(): def test_native_adk_mcp_tool_hides_proof_and_enforces_in_receiver_process(): async def exercise(): _, authority, _ = setup_reference() - toolset = build_mcp_toolset(authority) + receiver_context = running_http_receiver(authority) + receiver_url = receiver_context.__enter__() + toolset = build_mcp_toolset(authority, receiver_url=receiver_url) try: tools = await toolset.get_tools() declaration = tools[0]._get_declaration() @@ -305,6 +357,7 @@ async def exercise(): assert denied["tool_invocations"] == 1 finally: await toolset.close() + receiver_context.__exit__(None, None, None) asyncio.run(exercise()) @@ -312,7 +365,9 @@ async def exercise(): def test_real_adk_runner_executes_native_mcp_toolset(): async def exercise(): _, authority, _ = setup_reference() - toolset = build_mcp_toolset(authority) + receiver_context = running_http_receiver(authority) + receiver_url = receiver_context.__enter__() + toolset = build_mcp_toolset(authority, receiver_url=receiver_url) agent = LlmAgent( name="ratify_mcp_specialist", model=_ScriptedToolCallingModel(model="scripted-mcp-model"), @@ -342,6 +397,7 @@ async def exercise(): ) finally: await runner.close() + receiver_context.__exit__(None, None, None) asyncio.run(exercise()) @@ -349,7 +405,9 @@ async def exercise(): def test_mcp_receiver_rejects_alteration_and_replay_across_process_boundary(): async def exercise(): _, authority, _ = setup_reference() - toolset = build_mcp_toolset(authority) + receiver_context = running_http_receiver(authority) + receiver_url = receiver_context.__enter__() + toolset = build_mcp_toolset(authority, receiver_url=receiver_url) try: tool = (await toolset.get_tools())[0] session = await tool._mcp_session_manager.create_session() @@ -363,10 +421,7 @@ async def exercise(): async def presentation_for(args): result = await session.call_tool( "issue_authority_challenge", - arguments={ - **args, - "expected_agent_id": authority.specialist_id, - }, + arguments=args, ) grant = _result_object(result) return encode_proof_bundle(authority.present( @@ -398,5 +453,34 @@ async def presentation_for(args): assert replay["tool_invocations"] == 1 finally: await toolset.close() + receiver_context.__exit__(None, None, None) + + asyncio.run(exercise()) + + +def test_remote_receiver_rejects_presenter_selected_trust_root_and_agent(): + async def exercise(): + _, accepted, _ = setup_reference() + attacker = issue_authority(now=int(time.time()) - 1) + receiver_context = running_http_receiver(accepted) + receiver_url = receiver_context.__enter__() + toolset = build_mcp_toolset(attacker, receiver_url=receiver_url) + try: + tool = (await toolset.get_tools())[0] + result = await tool.run_async( + args={ + "request_id": "attacker-root", + "region": "us-central1", + "instance_type": "n2-standard-4", + "count": 1, + }, + tool_context=None, + ) + assert result["decision"] == "deny" + assert result["status"] == "agent_binding_failed" + assert result["tool_invocations"] == 0 + finally: + await toolset.close() + receiver_context.__exit__(None, None, None) asyncio.run(exercise()) diff --git a/scripts/google-adk-reference-check.sh b/scripts/google-adk-reference-check.sh index 229bd55..518ee46 100755 --- a/scripts/google-adk-reference-check.sh +++ b/scripts/google-adk-reference-check.sh @@ -21,14 +21,18 @@ local_sdk = (repo / "sdks" / "python").resolve() if local_sdk == module or local_sdk in module.parents: raise SystemExit(f"FAIL: Ratify resolved from the repository: {module}") -expected = {"google-adk": "2.6.3", "ratify-protocol": "1.0.0a16"} +expected = { + "google-adk": "2.6.3", + "mcp": "1.29.0", + "ratify-protocol": "1.0.0a16", +} for package, version in expected.items(): installed = metadata.version(package) if installed != version: raise SystemExit(f"FAIL: {package}={installed}; expected {version}") print(f"published Ratify: {module}") -print("pins: google-adk==2.6.3 ratify-protocol==1.0.0a16") +print("pins: google-adk==2.6.3 mcp==1.29.0 ratify-protocol==1.0.0a16") PY PYTHONPATH="$DEMO" "$VENV/bin/pytest" -q "$DEMO/tests" From 4556f2e663ba8e539d7aedb46b96473486702d16 Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:18:01 -0700 Subject: [PATCH 05/11] fix: harden ADK MCP authority reference Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 8 + references/google-adk/adk_app/agent.py | 7 +- .../google-adk/authority_reference/adk_mcp.py | 98 ++++++++-- .../authority_reference/deployment_config.py | 17 +- .../authority_reference/mcp_server.py | 65 +++++-- .../authority_reference/receiver.py | 37 +++- references/google-adk/demo.py | 10 +- .../google-adk/evidence/reference-evidence.md | 7 +- references/google-adk/tests/test_reference.py | 175 ++++++++++++++++-- 9 files changed, 364 insertions(+), 60 deletions(-) diff --git a/references/google-adk/README.md b/references/google-adk/README.md index 2f836c1..8e53dbb 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -35,6 +35,7 @@ and then runs the three-case demonstration. Tested pins: - `google-adk==2.6.3` +- `mcp==1.29.0` - `ratify-protocol==1.0.0a16` - `pytest==8.4.1` @@ -187,6 +188,13 @@ adds no authorization guarantee beyond the deterministic receiver tests. not expose operation-specific hidden MCP metadata as a stable public hook. The adapter is isolated and tested, but should be mapped with the ADK team before claiming forward compatibility. +- The internal challenge tool remains MCP-discoverable to authenticated clients + but is excluded from the model toolset. Authentication, bounded receiver + state, and receiver verification—not client-side hiding—are the controls. +- This is deliberately one concrete infrastructure-tool profile, not a claim + that arbitrary MCP schemas can be wrapped without an explicit authority map. +- Dependencies are version-pinned but not installed with artifact hashes; the + evidence records the requirements file hash, not a supply-chain attestation. - The executed draft uses the real ADK runner and native `McpToolset` across an independently started Streamable HTTP MCP receiver with receiver-owned trust configuration. It does not yet execute A2A, TLS workload authentication, diff --git a/references/google-adk/adk_app/agent.py b/references/google-adk/adk_app/agent.py index ac117c7..9a1211a 100644 --- a/references/google-adk/adk_app/agent.py +++ b/references/google-adk/adk_app/agent.py @@ -5,9 +5,10 @@ from google.adk.agents import LlmAgent from authority_reference import build_mcp_toolset -from authority_reference.deployment_config import load_presenter +from authority_reference.deployment_config import load_presenter, load_transport_token authority = load_presenter(os.environ["RATIFY_PRESENTER_CONFIG"]) +transport_token = load_transport_token(os.environ["RATIFY_PRESENTER_CONFIG"]) root_agent = LlmAgent( name="ratify_mcp_infrastructure_specialist", description="Provisions cloud nodes through an authority-gated MCP receiver.", @@ -17,6 +18,8 @@ "denials exactly; never claim an action succeeded when decision is deny." ), tools=[build_mcp_toolset( - authority, receiver_url=os.environ["RATIFY_MCP_RECEIVER_URL"] + authority, + receiver_url=os.environ["RATIFY_MCP_RECEIVER_URL"], + transport_token=transport_token, )], ) diff --git a/references/google-adk/authority_reference/adk_mcp.py b/references/google-adk/authority_reference/adk_mcp.py index 39d2f08..6e9ac37 100644 --- a/references/google-adk/authority_reference/adk_mcp.py +++ b/references/google-adk/authority_reference/adk_mcp.py @@ -7,6 +7,7 @@ from __future__ import annotations from copy import deepcopy +import inspect import json from typing import Any @@ -15,6 +16,7 @@ ) from google.adk.tools.mcp_tool.mcp_tool import McpTool from google.adk.tools.mcp_tool.mcp_toolset import McpToolset +from google.adk.agents.readonly_context import ReadonlyContext from google.genai.types import FunctionDeclaration from ratify_protocol import ( base64_standard_decode, @@ -30,32 +32,51 @@ def __init__(self, *, authority: AuthorityFixture, **kwargs: Any) -> None: self._authority = authority def _get_declaration(self) -> FunctionDeclaration: - schema = deepcopy(self._mcp_tool.inputSchema) - schema.get("properties", {}).pop("presentation", None) - required = schema.get("required") - if isinstance(required, list): - schema["required"] = [name for name in required if name != "presentation"] - return FunctionDeclaration( - name=self.name, - description=self.description, - parameters_json_schema=schema, - response_json_schema=self._mcp_tool.outputSchema, + declaration = deepcopy(super()._get_declaration()) + if declaration.parameters_json_schema: + schema = declaration.parameters_json_schema + schema.get("properties", {}).pop("presentation", None) + schema["required"] = [ + name for name in schema.get("required", []) + if name != "presentation" + ] + elif declaration.parameters: + declaration.parameters.properties.pop("presentation", None) + declaration.parameters.required = [ + name for name in (declaration.parameters.required or []) + if name != "presentation" + ] + return declaration + + async def _run_async_impl( + self, *, args: dict[str, Any], tool_context: Any, credential: Any + ) -> Any: + headers = await self._get_headers(tool_context, credential) or {} + if self._header_provider: + dynamic = self._header_provider( + ReadonlyContext(tool_context._invocation_context) + ) + if inspect.isawaitable(dynamic): + dynamic = await dynamic + headers.update(dynamic or {}) + session = await self._mcp_session_manager.create_session( + headers=headers or None ) - - async def run_async(self, *, args: dict[str, Any], tool_context: Any) -> Any: - session = await self._mcp_session_manager.create_session() grant_result = await session.call_tool( "issue_authority_challenge", arguments=args, ) grant = _result_object(grant_result) + if grant.get("decision") == "deny" or "challenge" not in grant: + return grant bundle = self._authority.present( challenge=base64_standard_decode(grant["challenge"]), session_context=base64_standard_decode(grant["session_context"]), ) - response = await session.call_tool( - self.name, - arguments={**args, "presentation": encode_proof_bundle(bundle)}, + response = await super()._run_async_impl( + args={**args, "presentation": encode_proof_bundle(bundle)}, + tool_context=tool_context, + credential=credential, ) return _result_object(response) @@ -72,31 +93,70 @@ async def get_tools(self, readonly_context=None): authority=self._authority, mcp_tool=tool._mcp_tool, mcp_session_manager=tool._mcp_session_manager, + auth_scheme=self._auth_scheme, + auth_credential=self._auth_credential, + require_confirmation=self._require_confirmation, + header_provider=self._header_provider, + progress_callback=self._progress_callback, ) for tool in tools ] -def build_mcp_toolset(authority: AuthorityFixture, *, receiver_url: str) -> RatifyMcpToolset: +def build_mcp_toolset( + authority: AuthorityFixture, *, receiver_url: str, transport_token: str, **kwargs: Any +) -> RatifyMcpToolset: """Connect to an independently operated receiver; never configures its trust.""" return RatifyMcpToolset( authority=authority, connection_params=StreamableHTTPConnectionParams( url=receiver_url, + headers={"Authorization": f"Bearer {transport_token}"}, timeout=5, sse_read_timeout=30, ), tool_filter=["provision_cloud_node"], + **kwargs, ) def _result_object(result: Any) -> dict[str, Any]: + if isinstance(result, dict): + structured = result.get("structuredContent") + if isinstance(structured, dict): + return structured.get("result", structured) + content = result.get("content", []) + if result.get("isError"): + reason = next( + (item.get("text") for item in content if item.get("text")), + "MCP receiver error", + ) + return {"decision": "deny", "status": "mcp_error", "reason": reason} + for item in content: + text = item.get("text") + if text: + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return {"decision": "deny", "status": "mcp_error", "reason": text} + return parsed.get("result", parsed) + return result + if getattr(result, "isError", False): + reason = "MCP receiver error" + for item in result.content: + if getattr(item, "text", None): + reason = item.text + break + return {"decision": "deny", "status": "mcp_error", "reason": reason} structured = getattr(result, "structuredContent", None) if isinstance(structured, dict): return structured.get("result", structured) for item in result.content: text = getattr(item, "text", None) if text: - parsed = json.loads(text) + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return {"decision": "deny", "status": "mcp_error", "reason": text} return parsed.get("result", parsed) - raise ValueError("MCP receiver returned no structured result") + return {"decision": "deny", "status": "mcp_error", "reason": "empty MCP result"} diff --git a/references/google-adk/authority_reference/deployment_config.py b/references/google-adk/authority_reference/deployment_config.py index 941c750..46df38a 100644 --- a/references/google-adk/authority_reference/deployment_config.py +++ b/references/google-adk/authority_reference/deployment_config.py @@ -3,7 +3,9 @@ from __future__ import annotations import json +import os from pathlib import Path +import secrets from ratify_protocol import ( HybridPrivateKey, @@ -18,13 +20,15 @@ def write_configs(authority: AuthorityFixture, receiver_path: Path, presenter_path: Path) -> None: + transport_token = secrets.token_urlsafe(32) receiver_path.write_text(json.dumps({ "trusted_root_id": authority.root_id, "trusted_agent_id": authority.specialist_id, "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), + "transport_token": transport_token, }), encoding="utf-8") - presenter_path.write_text(json.dumps({ + payload = json.dumps({ "root_id": authority.root_id, "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), @@ -32,8 +36,11 @@ def write_configs(authority: AuthorityFixture, receiver_path: Path, presenter_pa "private_ed25519": base64_standard_encode(authority.specialist_private_key.ed25519), "private_ml_dsa_65": base64_standard_encode(authority.specialist_private_key.ml_dsa_65), "delegations": [encode_delegation_cert(cert) for cert in authority.delegations], - }), encoding="utf-8") - presenter_path.chmod(0o600) + "transport_token": transport_token, + }) + descriptor = os.open(presenter_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + output.write(payload) def load_presenter(path: str) -> AuthorityFixture: @@ -51,3 +58,7 @@ def load_presenter(path: str) -> AuthorityFixture: ), delegations=[decode_delegation_cert(cert) for cert in data["delegations"]], ) + + +def load_transport_token(path: str) -> str: + return json.loads(Path(path).read_text(encoding="utf-8"))["transport_token"] diff --git a/references/google-adk/authority_reference/mcp_server.py b/references/google-adk/authority_reference/mcp_server.py index 2b4b475..e114139 100644 --- a/references/google-adk/authority_reference/mcp_server.py +++ b/references/google-adk/authority_reference/mcp_server.py @@ -3,16 +3,21 @@ from __future__ import annotations import argparse +import asyncio +import hmac import json +import ipaddress from pathlib import Path from mcp.server.fastmcp import FastMCP +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.settings import AuthSettings from ratify_protocol import HybridPublicKey, base64_standard_decode, base64_standard_encode from .receiver import InfrastructureReceiver, OperationRequest -def load_receiver(path: str) -> tuple[InfrastructureReceiver, str]: +def load_receiver(path: str) -> tuple[InfrastructureReceiver, str, str]: config = json.loads(Path(path).read_text(encoding="utf-8")) receiver = InfrastructureReceiver( trusted_root_id=config["trusted_root_id"], @@ -21,11 +26,27 @@ def load_receiver(path: str) -> tuple[InfrastructureReceiver, str]: ml_dsa_65=base64_standard_decode(config["root_ml_dsa_65"]), ), ) - return receiver, config["trusted_agent_id"] + return receiver, config["trusted_agent_id"], config["transport_token"] + + +class StaticTokenVerifier: + def __init__(self, token: str) -> None: + self._token = token + + async def verify_token(self, token: str) -> AccessToken | None: + if not hmac.compare_digest(token, self._token): + return None + return AccessToken( + token=token, client_id="ratify-adk-presenter", scopes=["mcp:tools"] + ) def create_server( - receiver: InfrastructureReceiver, trusted_agent_id: str, host: str, port: int + receiver: InfrastructureReceiver, + trusted_agent_id: str, + transport_token: str, + host: str, + port: int, ) -> FastMCP: server = FastMCP( "ratify-adk-authority-receiver", @@ -33,20 +54,35 @@ def create_server( port=port, stateless_http=False, log_level="ERROR", + auth=AuthSettings( + issuer_url=f"http://{host}:{port}", + resource_server_url=f"http://{host}:{port}/mcp", + required_scopes=["mcp:tools"], + ), + token_verifier=StaticTokenVerifier(transport_token), ) @server.tool() - def issue_authority_challenge( + async def issue_authority_challenge( request_id: str, region: str, instance_type: str, count: int, ) -> dict: """Internal adapter operation; excluded from the ADK model toolset.""" - grant = receiver.issue_challenge( - OperationRequest(request_id, region, instance_type, count), - expected_agent_id=trusted_agent_id, - ) + try: + grant = await asyncio.to_thread( + receiver.issue_challenge, + OperationRequest(request_id, region, instance_type, count), + expected_agent_id=trusted_agent_id, + ) + except ValueError as exc: + return { + "decision": "deny", + "status": "challenge_rejected", + "reason": str(exc), + "tool_invocations": receiver.tool_invocations, + } return { "challenge": base64_standard_encode(grant.challenge), "session_context": base64_standard_encode(grant.session_context), @@ -54,7 +90,7 @@ def issue_authority_challenge( } @server.tool() - def provision_cloud_node( + async def provision_cloud_node( request_id: str, region: str, instance_type: str, @@ -62,7 +98,8 @@ def provision_cloud_node( presentation: str, ) -> dict: """Provision only after receiver-side authority verification.""" - return receiver.execute( + return await asyncio.to_thread( + receiver.execute, OperationRequest(request_id, region, instance_type, count), presentation ) @@ -75,8 +112,12 @@ def main() -> None: parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", required=True, type=int) args = parser.parse_args() - receiver, trusted_agent_id = load_receiver(args.trust_config) - create_server(receiver, trusted_agent_id, args.host, args.port).run( + if not ipaddress.ip_address(args.host).is_loopback: + raise SystemExit("non-loopback bind requires a production TLS/auth deployment") + receiver, trusted_agent_id, transport_token = load_receiver(args.trust_config) + create_server( + receiver, trusted_agent_id, transport_token, args.host, args.port + ).run( transport="streamable-http" ) diff --git a/references/google-adk/authority_reference/receiver.py b/references/google-adk/authority_reference/receiver.py index 241b7de..7f09763 100644 --- a/references/google-adk/authority_reference/receiver.py +++ b/references/google-adk/authority_reference/receiver.py @@ -6,6 +6,7 @@ import hashlib import json import re +import threading import time from typing import Any @@ -79,6 +80,7 @@ class _PendingOperation: request: OperationRequest session_context: bytes expected_agent_id: str + expires_at: int class StaticRevocationProvider: @@ -121,6 +123,8 @@ def __init__(self, *, trusted_root_id: str, trusted_root_public_key: Any) -> Non self.challenge_store = MemoryChallengeStore(max_size=128) self.revocation = StaticRevocationProvider() self._pending: dict[str, _PendingOperation] = {} + self._max_pending = 128 + self._state_lock = threading.Lock() self.tool_invocations = 0 def issue_challenge( @@ -130,6 +134,16 @@ def issue_challenge( payload = request.canonical_payload() if not expected_agent_id: raise ValueError("expected_agent_id is required") + now = int(time.time()) + with self._state_lock: + self._pending = { + key: value for key, value in self._pending.items() + if value.expires_at > now + } + if request.request_id in self._pending: + raise ValueError("request_id already has a pending operation") + if len(self._pending) >= self._max_pending: + raise ValueError("receiver_pending_capacity") operation = OperationContext( required_scope=INFRA_SCOPE, operation="infra.provision", @@ -146,10 +160,18 @@ def issue_challenge( request_hash=operation_context_hash(operation), ) ) - challenge, expires_at = self.challenge_store.issue(session_context, 300) - self._pending[request.request_id] = _PendingOperation( - request, session_context, expected_agent_id - ) + with self._state_lock: + if request.request_id in self._pending: + raise ValueError("request_id already has a pending operation") + if len(self._pending) >= self._max_pending: + raise ValueError("receiver_pending_capacity") + try: + challenge, expires_at = self.challenge_store.issue(session_context, 300) + except RuntimeError as exc: + raise ValueError("receiver_challenge_capacity") from exc + self._pending[request.request_id] = _PendingOperation( + request, session_context, expected_agent_id, expires_at + ) return ChallengeGrant(challenge, session_context, expires_at) def execute( @@ -166,7 +188,8 @@ def execute( except ValueError as exc: return self._deny("invalid_request", str(exc)) - pending = self._pending.pop(request.request_id, None) + with self._state_lock: + pending = self._pending.get(request.request_id) if pending is None: return self._deny("unknown_operation", "no pending receiver operation") if request != pending.request: @@ -207,7 +230,9 @@ def execute( if not result.valid: return self._deny(result.identity_status, result.error_reason) - self.tool_invocations += 1 + with self._state_lock: + self._pending.pop(request.request_id, None) + self.tool_invocations += 1 return { "decision": "allow", "status": result.identity_status, diff --git a/references/google-adk/demo.py b/references/google-adk/demo.py index 2a79cb4..80c336e 100644 --- a/references/google-adk/demo.py +++ b/references/google-adk/demo.py @@ -12,7 +12,7 @@ import time from authority_reference import build_mcp_toolset, issue_authority -from authority_reference.deployment_config import write_configs +from authority_reference.deployment_config import load_transport_token, write_configs async def run() -> None: @@ -29,13 +29,19 @@ async def run() -> None: "--trust-config", str(receiver_config), "--port", str(port), ]) try: + ready = False for _ in range(200): with socket.socket() as probe: if probe.connect_ex(("127.0.0.1", port)) == 0: + ready = True break time.sleep(0.05) + if not ready: + raise RuntimeError("HTTP MCP receiver did not become ready") toolset = build_mcp_toolset( - authority, receiver_url=f"http://127.0.0.1:{port}/mcp" + authority, + receiver_url=f"http://127.0.0.1:{port}/mcp", + transport_token=load_transport_token(str(presenter_config)), ) try: tool = (await toolset.get_tools())[0] diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md index b4db9d0..9babcbc 100644 --- a/references/google-adk/evidence/reference-evidence.md +++ b/references/google-adk/evidence/reference-evidence.md @@ -31,7 +31,7 @@ runs the test matrix, and runs the deterministic native ADK MCP demo. ```text pins: google-adk==2.6.3 mcp==1.29.0 ratify-protocol==1.0.0a16 .................... [100%] -21 passed, 17 warnings +27 passed, 23 warnings ALLOW across ADK HTTP MCP -> tool invoked once DENY excessive count -> no additional invocation DENY wrong region -> no additional invocation @@ -53,6 +53,11 @@ experimental feature notices. No tests were skipped, xfailed, or retried. adapter acquires the challenge and injects the proof after tool selection. - Altered operations and replayed presentations are denied across the MCP process boundary without an additional protected-handler invocation. +- ADK confirmation and tool-name prefixing remain intact; ordinary malformed + model output returns a structured denial instead of crashing the agent loop. +- Bearer authentication blocks unauthenticated challenge calls, hostile roots + fail over HTTP, junk presentations cannot cancel honest pending operations, + and pending capacity fails structurally at its enforced bound. - The function tool uses a two-hop Ratify delegation and a receiver-issued, operation-bound, single-use challenge. - The independent receiver invokes its protected handler exactly once for the diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py index 67dd8b3..da2e61d 100644 --- a/references/google-adk/tests/test_reference.py +++ b/references/google-adk/tests/test_reference.py @@ -9,7 +9,9 @@ import sys import tempfile import time +from types import SimpleNamespace +import httpx import pytest from google.adk.agents import LlmAgent from google.adk.models.base_llm import BaseLlm @@ -50,11 +52,13 @@ def running_http_receiver(authority): """Receiver operator starts the service; the ADK client receives only its URL.""" with tempfile.TemporaryDirectory() as directory: config = Path(directory) / "receiver-trust.json" + transport_token = "test-transport-token-with-sufficient-entropy" config.write_text(json.dumps({ "trusted_root_id": authority.root_id, "trusted_agent_id": authority.specialist_id, "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), + "transport_token": transport_token, }), encoding="utf-8") with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) @@ -81,7 +85,7 @@ def running_http_receiver(authority): time.sleep(0.05) else: raise RuntimeError("MCP receiver did not become ready") - yield f"http://127.0.0.1:{port}/mcp" + yield f"http://127.0.0.1:{port}/mcp", transport_token finally: process.terminate() process.wait(timeout=5) @@ -323,8 +327,10 @@ def test_native_adk_mcp_tool_hides_proof_and_enforces_in_receiver_process(): async def exercise(): _, authority, _ = setup_reference() receiver_context = running_http_receiver(authority) - receiver_url = receiver_context.__enter__() - toolset = build_mcp_toolset(authority, receiver_url=receiver_url) + receiver_url, token = receiver_context.__enter__() + toolset = build_mcp_toolset( + authority, receiver_url=receiver_url, transport_token=token + ) try: tools = await toolset.get_tools() declaration = tools[0]._get_declaration() @@ -366,8 +372,10 @@ def test_real_adk_runner_executes_native_mcp_toolset(): async def exercise(): _, authority, _ = setup_reference() receiver_context = running_http_receiver(authority) - receiver_url = receiver_context.__enter__() - toolset = build_mcp_toolset(authority, receiver_url=receiver_url) + receiver_url, token = receiver_context.__enter__() + toolset = build_mcp_toolset( + authority, receiver_url=receiver_url, transport_token=token + ) agent = LlmAgent( name="ratify_mcp_specialist", model=_ScriptedToolCallingModel(model="scripted-mcp-model"), @@ -406,11 +414,15 @@ def test_mcp_receiver_rejects_alteration_and_replay_across_process_boundary(): async def exercise(): _, authority, _ = setup_reference() receiver_context = running_http_receiver(authority) - receiver_url = receiver_context.__enter__() - toolset = build_mcp_toolset(authority, receiver_url=receiver_url) + receiver_url, token = receiver_context.__enter__() + toolset = build_mcp_toolset( + authority, receiver_url=receiver_url, transport_token=token + ) try: tool = (await toolset.get_tools())[0] - session = await tool._mcp_session_manager.create_session() + session = await tool._mcp_session_manager.create_session(headers={ + "Authorization": f"Bearer {token}" + }) original = { "request_id": "mcp-bound", "region": "us-central1", @@ -438,15 +450,16 @@ async def presentation_for(args): ) assert _result_object(altered)["status"] == "operation_binding_failed" - replay_proof = await presentation_for(original) + replay_request = {**original, "request_id": "mcp-replay"} + replay_proof = await presentation_for(replay_request) first = _result_object(await session.call_tool( "provision_cloud_node", - arguments={**original, "presentation": replay_proof}, + arguments={**replay_request, "presentation": replay_proof}, )) - await presentation_for(original) + await presentation_for(replay_request) replay = _result_object(await session.call_tool( "provision_cloud_node", - arguments={**original, "presentation": replay_proof}, + arguments={**replay_request, "presentation": replay_proof}, )) assert first["decision"] == "allow" assert replay["decision"] == "deny" @@ -458,13 +471,15 @@ async def presentation_for(args): asyncio.run(exercise()) -def test_remote_receiver_rejects_presenter_selected_trust_root_and_agent(): +def test_remote_receiver_rejects_presenter_selected_agent(): async def exercise(): _, accepted, _ = setup_reference() attacker = issue_authority(now=int(time.time()) - 1) receiver_context = running_http_receiver(accepted) - receiver_url = receiver_context.__enter__() - toolset = build_mcp_toolset(attacker, receiver_url=receiver_url) + receiver_url, token = receiver_context.__enter__() + toolset = build_mcp_toolset( + attacker, receiver_url=receiver_url, transport_token=token + ) try: tool = (await toolset.get_tools())[0] result = await tool.run_async( @@ -484,3 +499,133 @@ async def exercise(): receiver_context.__exit__(None, None, None) asyncio.run(exercise()) + + +def test_remote_receiver_rejects_spoofed_agent_under_hostile_root(): + async def exercise(): + _, accepted, _ = setup_reference() + attacker = issue_authority(now=int(time.time()) - 1) + context = running_http_receiver(accepted) + url, token = context.__enter__() + toolset = build_mcp_toolset( + attacker, receiver_url=url, transport_token=token + ) + try: + tool = (await toolset.get_tools())[0] + session = await tool._mcp_session_manager.create_session(headers={ + "Authorization": f"Bearer {token}" + }) + request = { + "request_id": "hostile-root", "region": "us-central1", + "instance_type": "n2-standard-4", "count": 1, + } + grant = _result_object(await session.call_tool( + "issue_authority_challenge", arguments=request + )) + bundle = attacker.present( + challenge=base64_standard_decode(grant["challenge"]), + session_context=base64_standard_decode(grant["session_context"]), + ) + bundle.agent_id = accepted.specialist_id + result = _result_object(await session.call_tool( + "provision_cloud_node", + arguments={**request, "presentation": encode_proof_bundle(bundle)}, + )) + assert result["status"] == "untrusted_root" + assert result["tool_invocations"] == 0 + finally: + await toolset.close() + context.__exit__(None, None, None) + asyncio.run(exercise()) + + +def test_adk_confirmation_gate_is_preserved_before_mcp_execution(): + async def exercise(): + _, authority, _ = setup_reference() + context = running_http_receiver(authority) + url, token = context.__enter__() + toolset = build_mcp_toolset( + authority, receiver_url=url, transport_token=token, + require_confirmation=True, + ) + requested = [] + tool_context = SimpleNamespace( + tool_confirmation=None, + request_confirmation=lambda **kwargs: requested.append(kwargs), + ) + try: + tool = (await toolset.get_tools())[0] + result = await tool.run_async(args={ + "request_id": "needs-confirmation", "region": "us-central1", + "instance_type": "n2-standard-4", "count": 1, + }, tool_context=tool_context) + assert "requires confirmation" in result["error"] + assert requested + finally: + await toolset.close() + context.__exit__(None, None, None) + asyncio.run(exercise()) + + +def test_prefix_and_malformed_model_output_remain_structured(): + async def exercise(): + _, authority, _ = setup_reference() + context = running_http_receiver(authority) + url, token = context.__enter__() + toolset = build_mcp_toolset( + authority, receiver_url=url, transport_token=token, + tool_name_prefix="infra", + ) + try: + tool = (await toolset.get_tools_with_prefix())[0] + assert tool.name.startswith("infra") + allowed = await tool.run_async(args={ + "request_id": "prefixed", "region": "us-central1", + "instance_type": "n2-standard-4", "count": 1, + }, tool_context=None) + malformed = await tool.run_async(args={ + "request_id": "malformed", "region": "US-CENTRAL1", + "instance_type": "n2_standard_4", "count": 1, + }, tool_context=None) + assert allowed["decision"] == "allow" + assert malformed["decision"] == "deny" + assert malformed["status"] in {"challenge_rejected", "mcp_error"} + finally: + await toolset.close() + context.__exit__(None, None, None) + asyncio.run(exercise()) + + +def test_unauthenticated_transport_cannot_reach_challenge_tool(): + _, authority, _ = setup_reference() + with running_http_receiver(authority) as (url, _): + response = httpx.post(url, json={ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "issue_authority_challenge", "arguments": {}}, + }) + assert response.status_code == 401 + + +def test_junk_presentation_does_not_cancel_honest_pending_operation(): + now, authority, receiver = setup_reference() + request = OperationRequest("not-cancelled", "us-central1", "n2-standard-4", 1) + _, bundle = present(authority, receiver, request, now=now) + junk = receiver.execute(request, "not-a-bundle", now=now) + honest = receiver.execute(request, bundle, now=now) + assert junk["status"] == "invalid_presentation" + assert honest["decision"] == "allow" + + +def test_pending_capacity_fails_structurally_and_is_bounded(): + _, authority, receiver = setup_reference() + for index in range(128): + receiver.issue_challenge( + OperationRequest(f"capacity-{index}", "us-central1", "n2-standard-4", 1), + expected_agent_id=authority.specialist_id, + ) + with pytest.raises(ValueError, match="receiver_pending_capacity"): + receiver.issue_challenge( + OperationRequest("capacity-overflow", "us-central1", "n2-standard-4", 1), + expected_agent_id=authority.specialist_id, + ) + assert len(receiver._pending) == 128 From 999ad8971965a29b87e1e15a25aaccae49a98baf Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:19:02 -0700 Subject: [PATCH 06/11] test: cover ADK MCP failure and concurrency paths Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- .../google-adk/evidence/reference-evidence.md | 5 ++- references/google-adk/tests/test_reference.py | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md index 9babcbc..e61662b 100644 --- a/references/google-adk/evidence/reference-evidence.md +++ b/references/google-adk/evidence/reference-evidence.md @@ -31,7 +31,7 @@ runs the test matrix, and runs the deterministic native ADK MCP demo. ```text pins: google-adk==2.6.3 mcp==1.29.0 ratify-protocol==1.0.0a16 .................... [100%] -27 passed, 23 warnings +29 passed, 23 warnings ALLOW across ADK HTTP MCP -> tool invoked once DENY excessive count -> no additional invocation DENY wrong region -> no additional invocation @@ -58,6 +58,9 @@ experimental feature notices. No tests were skipped, xfailed, or retried. - Bearer authentication blocks unauthenticated challenge calls, hostile roots fail over HTTP, junk presentations cannot cancel honest pending operations, and pending capacity fails structurally at its enforced bound. +- Concurrent duplicate request IDs produce exactly one pending operation, and + an unavailable receiver fails within the configured timeout rather than + hanging the agent loop. - The function tool uses a two-hop Ratify delegation and a receiver-issued, operation-bound, single-use challenge. - The independent receiver invokes its protected handler exactly once for the diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py index da2e61d..b787e60 100644 --- a/references/google-adk/tests/test_reference.py +++ b/references/google-adk/tests/test_reference.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager import json from pathlib import Path @@ -629,3 +630,41 @@ def test_pending_capacity_fails_structurally_and_is_bounded(): expected_agent_id=authority.specialist_id, ) assert len(receiver._pending) == 128 + + +def test_concurrent_duplicate_request_id_creates_one_pending_operation(): + _, authority, receiver = setup_reference() + request = OperationRequest("duplicate", "us-central1", "n2-standard-4", 1) + def issue(): + try: + receiver.issue_challenge( + request, expected_agent_id=authority.specialist_id + ) + return "issued" + except ValueError as exc: + return str(exc) + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: issue(), range(2))) + assert results.count("issued") == 1 + assert results.count("request_id already has a pending operation") == 1 + + +def test_unavailable_receiver_fails_without_agent_loop_hang(): + async def exercise(): + _, authority, _ = setup_reference() + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + toolset = build_mcp_toolset( + authority, + receiver_url=f"http://127.0.0.1:{port}/mcp", + transport_token="unavailable-receiver-token", + ) + started = time.monotonic() + try: + with pytest.raises(Exception): + await toolset.get_tools() + assert time.monotonic() - started < 10 + finally: + await toolset.close() + asyncio.run(exercise()) From 48fa681c4588b9befea4041d2b6576cb82a0df9f Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:55:45 -0700 Subject: [PATCH 07/11] fix: separate ADK and transport authentication Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 6 ++ .../google-adk/authority_reference/adk_mcp.py | 14 +++-- .../authority_reference/deployment_config.py | 13 +++-- .../authority_reference/mcp_server.py | 57 +++++++++++-------- references/google-adk/bootstrap_live.py | 14 +++-- .../google-adk/evidence/reference-evidence.md | 8 +-- references/google-adk/tests/test_reference.py | 54 +++++++++++++++++- 7 files changed, 124 insertions(+), 42 deletions(-) diff --git a/references/google-adk/README.md b/references/google-adk/README.md index 8e53dbb..85303eb 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -195,6 +195,12 @@ adds no authorization guarantee beyond the deterministic receiver tests. that arbitrary MCP schemas can be wrapped without an explicit authority map. - Dependencies are version-pinned but not installed with artifact hashes; the evidence records the requirements file hash, not a supply-chain attestation. +- The local profile uses one static transport token. Any holder can consume the + bounded 128-operation pending capacity until its five-minute TTL expires; + production deployments need per-workload authentication and rate limits. +- Protected execution is at-most-once, not exactly-once. If the response is + lost after execution, replay is denied; a production tool needs an + idempotency/result ledger before an operator retries the business action. - The executed draft uses the real ADK runner and native `McpToolset` across an independently started Streamable HTTP MCP receiver with receiver-owned trust configuration. It does not yet execute A2A, TLS workload authentication, diff --git a/references/google-adk/authority_reference/adk_mcp.py b/references/google-adk/authority_reference/adk_mcp.py index 6e9ac37..2a73547 100644 --- a/references/google-adk/authority_reference/adk_mcp.py +++ b/references/google-adk/authority_reference/adk_mcp.py @@ -111,7 +111,7 @@ def build_mcp_toolset( authority=authority, connection_params=StreamableHTTPConnectionParams( url=receiver_url, - headers={"Authorization": f"Bearer {transport_token}"}, + headers={"X-Ratify-Transport-Token": transport_token}, timeout=5, sse_read_timeout=30, ), @@ -131,14 +131,14 @@ def _result_object(result: Any) -> dict[str, Any]: (item.get("text") for item in content if item.get("text")), "MCP receiver error", ) - return {"decision": "deny", "status": "mcp_error", "reason": reason} + return {"decision": "deny", "status": "mcp_error", "reason": _safe_error(reason)} for item in content: text = item.get("text") if text: try: parsed = json.loads(text) except json.JSONDecodeError: - return {"decision": "deny", "status": "mcp_error", "reason": text} + return {"decision": "deny", "status": "mcp_error", "reason": _safe_error(text)} return parsed.get("result", parsed) return result if getattr(result, "isError", False): @@ -147,7 +147,7 @@ def _result_object(result: Any) -> dict[str, Any]: if getattr(item, "text", None): reason = item.text break - return {"decision": "deny", "status": "mcp_error", "reason": reason} + return {"decision": "deny", "status": "mcp_error", "reason": _safe_error(reason)} structured = getattr(result, "structuredContent", None) if isinstance(structured, dict): return structured.get("result", structured) @@ -157,6 +157,10 @@ def _result_object(result: Any) -> dict[str, Any]: try: parsed = json.loads(text) except json.JSONDecodeError: - return {"decision": "deny", "status": "mcp_error", "reason": text} + return {"decision": "deny", "status": "mcp_error", "reason": _safe_error(text)} return parsed.get("result", parsed) return {"decision": "deny", "status": "mcp_error", "reason": "empty MCP result"} + + +def _safe_error(value: str) -> str: + return value[:512] diff --git a/references/google-adk/authority_reference/deployment_config.py b/references/google-adk/authority_reference/deployment_config.py index 46df38a..989e29c 100644 --- a/references/google-adk/authority_reference/deployment_config.py +++ b/references/google-adk/authority_reference/deployment_config.py @@ -21,14 +21,14 @@ def write_configs(authority: AuthorityFixture, receiver_path: Path, presenter_path: Path) -> None: transport_token = secrets.token_urlsafe(32) - receiver_path.write_text(json.dumps({ + receiver_payload = json.dumps({ "trusted_root_id": authority.root_id, "trusted_agent_id": authority.specialist_id, "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), "transport_token": transport_token, - }), encoding="utf-8") - payload = json.dumps({ + }) + presenter_payload = json.dumps({ "root_id": authority.root_id, "root_ed25519": base64_standard_encode(authority.root_public_key.ed25519), "root_ml_dsa_65": base64_standard_encode(authority.root_public_key.ml_dsa_65), @@ -38,7 +38,12 @@ def write_configs(authority: AuthorityFixture, receiver_path: Path, presenter_pa "delegations": [encode_delegation_cert(cert) for cert in authority.delegations], "transport_token": transport_token, }) - descriptor = os.open(presenter_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + _write_secret(receiver_path, receiver_payload) + _write_secret(presenter_path, presenter_payload) + + +def _write_secret(path: Path, payload: str) -> None: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as output: output.write(payload) diff --git a/references/google-adk/authority_reference/mcp_server.py b/references/google-adk/authority_reference/mcp_server.py index e114139..321c521 100644 --- a/references/google-adk/authority_reference/mcp_server.py +++ b/references/google-adk/authority_reference/mcp_server.py @@ -10,9 +10,8 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP -from mcp.server.auth.provider import AccessToken -from mcp.server.auth.settings import AuthSettings from ratify_protocol import HybridPublicKey, base64_standard_decode, base64_standard_encode +import uvicorn from .receiver import InfrastructureReceiver, OperationRequest @@ -29,22 +28,31 @@ def load_receiver(path: str) -> tuple[InfrastructureReceiver, str, str]: return receiver, config["trusted_agent_id"], config["transport_token"] -class StaticTokenVerifier: - def __init__(self, token: str) -> None: - self._token = token +class TransportTokenBoundary: + """Authenticate the reference transport without consuming Authorization.""" - async def verify_token(self, token: str) -> AccessToken | None: - if not hmac.compare_digest(token, self._token): - return None - return AccessToken( - token=token, client_id="ratify-adk-presenter", scopes=["mcp:tools"] - ) + def __init__(self, app, token: str) -> None: + self._app = app + self._token = token.encode("utf-8") + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] == "http": + headers = dict(scope.get("headers", [])) + supplied = headers.get(b"x-ratify-transport-token", b"") + if not hmac.compare_digest(supplied, self._token): + await send({ + "type": "http.response.start", + "status": 401, + "headers": [(b"content-type", b"text/plain")], + }) + await send({"type": "http.response.body", "body": b"Unauthorized"}) + return + await self._app(scope, receive, send) def create_server( receiver: InfrastructureReceiver, trusted_agent_id: str, - transport_token: str, host: str, port: int, ) -> FastMCP: @@ -54,12 +62,6 @@ def create_server( port=port, stateless_http=False, log_level="ERROR", - auth=AuthSettings( - issuer_url=f"http://{host}:{port}", - resource_server_url=f"http://{host}:{port}/mcp", - required_scopes=["mcp:tools"], - ), - token_verifier=StaticTokenVerifier(transport_token), ) @server.tool() @@ -112,13 +114,22 @@ def main() -> None: parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", required=True, type=int) args = parser.parse_args() - if not ipaddress.ip_address(args.host).is_loopback: + bind_host = "127.0.0.1" if args.host == "localhost" else args.host + try: + is_loopback = ipaddress.ip_address(bind_host).is_loopback + except ValueError as exc: + raise SystemExit("--host must be localhost or a numeric loopback address") from exc + if not is_loopback: raise SystemExit("non-loopback bind requires a production TLS/auth deployment") receiver, trusted_agent_id, transport_token = load_receiver(args.trust_config) - create_server( - receiver, trusted_agent_id, transport_token, args.host, args.port - ).run( - transport="streamable-http" + server = create_server( + receiver, trusted_agent_id, bind_host, args.port + ) + uvicorn.run( + TransportTokenBoundary(server.streamable_http_app(), transport_token), + host=bind_host, + port=args.port, + log_level="error", ) diff --git a/references/google-adk/bootstrap_live.py b/references/google-adk/bootstrap_live.py index aa069df..52e2c6c 100644 --- a/references/google-adk/bootstrap_live.py +++ b/references/google-adk/bootstrap_live.py @@ -8,7 +8,13 @@ target = Path(".local") target.mkdir(exist_ok=True) -write_configs( - issue_authority(), target / "receiver-trust.json", target / "presenter.json" -) -print("created .local/receiver-trust.json and mode-0600 .local/presenter.json") +try: + write_configs( + issue_authority(), target / "receiver-trust.json", target / "presenter.json" + ) +except FileExistsError as exc: + raise SystemExit( + "local configs already exist; remove references/google-adk/.local/ " + "before generating a new authority" + ) from exc +print("created mode-0600 .local/receiver-trust.json and .local/presenter.json") diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md index e61662b..a779d49 100644 --- a/references/google-adk/evidence/reference-evidence.md +++ b/references/google-adk/evidence/reference-evidence.md @@ -30,8 +30,8 @@ runs the test matrix, and runs the deterministic native ADK MCP demo. ```text pins: google-adk==2.6.3 mcp==1.29.0 ratify-protocol==1.0.0a16 -.................... [100%] -29 passed, 23 warnings +............................... [100%] +31 passed, 37 warnings ALLOW across ADK HTTP MCP -> tool invoked once DENY excessive count -> no additional invocation DENY wrong region -> no additional invocation @@ -55,8 +55,8 @@ experimental feature notices. No tests were skipped, xfailed, or retried. process boundary without an additional protected-handler invocation. - ADK confirmation and tool-name prefixing remain intact; ordinary malformed model output returns a structured denial instead of crashing the agent loop. -- Bearer authentication blocks unauthenticated challenge calls, hostile roots - fail over HTTP, junk presentations cannot cancel honest pending operations, +- A dedicated transport-token header blocks unauthenticated challenge calls, + hostile roots fail over HTTP, junk presentations cannot cancel honest pending operations, and pending capacity fails structurally at its enforced bound. - Concurrent duplicate request IDs produce exactly one pending operation, and an unavailable receiver fails within the configured timeout rather than diff --git a/references/google-adk/tests/test_reference.py b/references/google-adk/tests/test_reference.py index b787e60..f5bb92f 100644 --- a/references/google-adk/tests/test_reference.py +++ b/references/google-adk/tests/test_reference.py @@ -13,6 +13,13 @@ from types import SimpleNamespace import httpx +from fastapi.openapi.models import HTTPBearer +from google.adk.auth.auth_credential import ( + AuthCredential, + AuthCredentialTypes, + HttpAuth, + HttpCredentials, +) import pytest from google.adk.agents import LlmAgent from google.adk.models.base_llm import BaseLlm @@ -36,6 +43,7 @@ issue_authority, ) from authority_reference.adk_mcp import _result_object +from authority_reference.deployment_config import write_configs def setup_reference(**authority_options): @@ -422,7 +430,7 @@ async def exercise(): try: tool = (await toolset.get_tools())[0] session = await tool._mcp_session_manager.create_session(headers={ - "Authorization": f"Bearer {token}" + "X-Ratify-Transport-Token": token }) original = { "request_id": "mcp-bound", @@ -514,7 +522,7 @@ async def exercise(): try: tool = (await toolset.get_tools())[0] session = await tool._mcp_session_manager.create_session(headers={ - "Authorization": f"Bearer {token}" + "X-Ratify-Transport-Token": token }) request = { "request_id": "hostile-root", "region": "us-central1", @@ -668,3 +676,45 @@ async def exercise(): finally: await toolset.close() asyncio.run(exercise()) + + +def test_transport_token_does_not_collide_with_adk_authorization_header(): + async def exercise(): + _, authority, _ = setup_reference() + context = running_http_receiver(authority) + url, token = context.__enter__() + credential = AuthCredential( + auth_type=AuthCredentialTypes.HTTP, + http=HttpAuth( + scheme="bearer", + credentials=HttpCredentials(token="adk-native-credential"), + ), + ) + toolset = build_mcp_toolset( + authority, + receiver_url=url, + transport_token=token, + auth_scheme=HTTPBearer(bearerFormat="JWT"), + auth_credential=credential, + ) + try: + tool = (await toolset.get_tools())[0] + result = await tool.run_async(args={ + "request_id": "dual-auth", "region": "us-central1", + "instance_type": "n2-standard-4", "count": 1, + }, tool_context=None) + assert result["decision"] == "allow" + finally: + await toolset.close() + context.__exit__(None, None, None) + asyncio.run(exercise()) + + +def test_both_secret_bearing_configs_are_created_mode_0600(): + _, authority, _ = setup_reference() + with tempfile.TemporaryDirectory() as directory: + receiver_path = Path(directory) / "receiver.json" + presenter_path = Path(directory) / "presenter.json" + write_configs(authority, receiver_path, presenter_path) + assert receiver_path.stat().st_mode & 0o777 == 0o600 + assert presenter_path.stat().st_mode & 0o777 == 0o600 From fe952cd0397bbdaf08d1fe0e9d5c0dff488279ff Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:44:36 -0700 Subject: [PATCH 08/11] docs: clarify Google ADK reference setup Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/references/google-adk/README.md b/references/google-adk/README.md index 85303eb..329af45 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -21,6 +21,10 @@ itself. ## Run the published-package gate +You need Bash, Python 3.11 (the tested version), and network access to install +the pinned packages. You do not need a Google API key or Google Cloud project +for this gate. + From the Ratify repository root: ```bash @@ -82,6 +86,25 @@ The `ai.identities.ratify.adk.max_nodes` extension is a draft Ratify integration profile. It is deliberately not placed in a Google namespace and does not claim that Google defines or endorses it. +## Code map + +Start with these files if you want to inspect or adapt the reference: + +| File | Responsibility | +|---|---| +| [`authority_reference/authority.py`](authority_reference/authority.py) | Issues the principal-to-commander and commander-to-specialist delegations | +| [`authority_reference/adk_mcp.py`](authority_reference/adk_mcp.py) | Keeps the model-facing schema ordinary, then obtains a challenge and injects the proof after ADK selects the tool | +| [`authority_reference/receiver.py`](authority_reference/receiver.py) | Reconstructs the operation, verifies the proof and local policy, and gates the protected handler | +| [`authority_reference/mcp_server.py`](authority_reference/mcp_server.py) | Exposes the receiver through authenticated Streamable HTTP MCP | +| [`authority_reference/deployment_config.py`](authority_reference/deployment_config.py) | Writes separate receiver and presenter configuration with mode `0600` | +| [`tests/test_reference.py`](tests/test_reference.py) | Exercises the ADK runner, MCP boundary, trust-root attacks, replay, malformed input, concurrency, and availability behavior | + +When adapting this profile to another tool, keep the receiver in control of +the trust root, operation construction, challenge issuance, and final policy +decision. Bind the proof to the exact operation and receiving context. Consume +the challenge once, and call the protected handler only after verification +returns `allow`. Keys and proof bytes should stay outside model context. + ## Layer separation | Layer | Question answered | This reference does not claim | @@ -128,7 +151,8 @@ The suite encodes why the boundary matters: ## Optional live Gemini path The deterministic suite is authoritative. To let Gemini select and invoke the -same ADK tool interactively: +same ADK tool interactively, run the published-package gate above first. It +creates the `.venv` used below. Then run: ```bash cd references/google-adk @@ -138,6 +162,10 @@ python -m authority_reference.mcp_server \ --trust-config .local/receiver-trust.json --port 8765 ``` +`bootstrap_live.py` creates `.local/receiver-trust.json` and +`.local/presenter.json`. Both contain secrets and are written with mode `0600`. +Remove `.local/` before generating a new authority. + In a second shell: ```bash @@ -190,7 +218,7 @@ adds no authorization guarantee beyond the deterministic receiver tests. before claiming forward compatibility. - The internal challenge tool remains MCP-discoverable to authenticated clients but is excluded from the model toolset. Authentication, bounded receiver - state, and receiver verification—not client-side hiding—are the controls. + state, and receiver verification, not client-side hiding, are the controls. - This is deliberately one concrete infrastructure-tool profile, not a claim that arbitrary MCP schemas can be wrapped without an explicit authority map. - Dependencies are version-pinned but not installed with artifact hashes; the From c23f54def453967a1852d02c166b6605d24578db Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:04:11 -0700 Subject: [PATCH 09/11] fix: use Ratify-owned ADK namespace Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 6 ++++-- references/google-adk/authority_reference/authority.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/references/google-adk/README.md b/references/google-adk/README.md index 329af45..7383513 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -82,9 +82,11 @@ Independent Streamable HTTP MCP receiver invokes the protected tool only after ALLOW ``` -The `ai.identities.ratify.adk.max_nodes` extension is a draft Ratify integration +The `com.ratifyprotocol.adk.max_nodes` extension is a draft Ratify integration profile. It is deliberately not placed in a Google namespace and does not -claim that Google defines or endorses it. +claim that Google defines or endorses it. The prefix follows the protocol's +reverse-domain convention and is based on `ratifyprotocol.com`, a domain +controlled by the Ratify Protocol project. ## Code map diff --git a/references/google-adk/authority_reference/authority.py b/references/google-adk/authority_reference/authority.py index e2a7af5..4712c64 100644 --- a/references/google-adk/authority_reference/authority.py +++ b/references/google-adk/authority_reference/authority.py @@ -28,7 +28,7 @@ INFRA_SCOPE = "custom:infra:provision" -NODE_LIMIT_CONSTRAINT = "ai.identities.ratify.adk.max_nodes" +NODE_LIMIT_CONSTRAINT = "com.ratifyprotocol.adk.max_nodes" WORKSPACE_ID = "customer-project" VERIFIER_ID = "independent-infrastructure-receiver" From 2da9638b228625c3e7acfc8aa978f850ed48aa8c Mon Sep 17 00:00:00 2001 From: chuks <891251+chuks@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:40:40 -0700 Subject: [PATCH 10/11] docs: clarify ADK reference production scope Signed-off-by: chuks <891251+chuks@users.noreply.github.com> --- references/google-adk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/references/google-adk/README.md b/references/google-adk/README.md index 7383513..f921288 100644 --- a/references/google-adk/README.md +++ b/references/google-adk/README.md @@ -201,7 +201,7 @@ adds no authorization guarantee beyond the deterministic receiver tests. | Gemini 3.6 Flash | Configuration-ready | Requires an operator API key; not part of recorded evidence | | A2A / Agent Engine | Not yet | Proposed follow-on, not claimed as executed | -## Limitations +## Reference scope and production requirements - The receiver and challenge store are in-memory inside one MCP server process. - The protected provisioner is a counter, not Google Compute Engine. No cloud From 661eca9581bc5ba1bf97c2062424d59f1d190066 Mon Sep 17 00:00:00 2001 From: Chuks Onwuneme <891251+chuks@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:45:15 -0700 Subject: [PATCH 11/11] docs: record Google ADK production gaps Signed-off-by: Chuks Onwuneme <891251+chuks@users.noreply.github.com> --- references/google-adk/PRODUCTION_GAPS.md | 106 +++++++++++++++++++++++ references/registry/google-adk.md | 1 + 2 files changed, 107 insertions(+) create mode 100644 references/google-adk/PRODUCTION_GAPS.md diff --git a/references/google-adk/PRODUCTION_GAPS.md b/references/google-adk/PRODUCTION_GAPS.md new file mode 100644 index 0000000..c44bb2d --- /dev/null +++ b/references/google-adk/PRODUCTION_GAPS.md @@ -0,0 +1,106 @@ +# Production transport profile backlog + +The current Google ADK profile proves delegated authority at an independent +receiver over native Streamable HTTP MCP. It does not claim a production Google +Cloud deployment or a Google-approved transport profile. + +## Definition of done + +Call this a production profile only when the presentation path uses a supported +ADK/MCP extension point, deployed workload identity is bound to the expected +Ratify agent, receiver state and retries survive multi-instance failure, and +the profile has repeatable Agent Engine or equivalent deployment evidence. + +## Ordered work + +### P0 — supported hidden presentation hook + +The reference subclasses pinned-version `McpTool` behavior because ADK does not +currently expose a stable operation-specific hidden metadata hook. + +- Review the seam with ADK maintainers. +- Prefer an official callback/interceptor that runs after tool selection and + before MCP dispatch. +- Keep keys, challenges, session context, and proof bytes outside model-visible + arguments, events, traces, and confirmation prompts. +- Define a versioned MCP metadata/body carrier rather than relying on a large + custom HTTP proof header. + +**Exit criterion:** public supported API, carrier contract, and forward- +compatibility tests; no private member or pinned internal subclass is required. + +### P0 — Agent Identity and transport binding + +- Deploy the presenting workload with Google Agent Identity or the supported + successor and authenticate the MCP transport with workload identity/mTLS. +- Bind the authenticated workload identity to the receiver-pinned Ratify + `agent_id`; document legitimate rotation and mismatch handling. +- Keep IAM permissions, transport authentication, and Ratify delegated + authority as separate checks. + +**Exit criterion:** wrong workload, valid proof; right workload, wrong proof; +and credential/proof theft cases all fail closed. + +### P0 — durable replay and exactly-once recovery + +- Move challenges and pending operations to an atomic shared store. +- Enforce per-workload quotas, TTL, restart safety, and multi-instance single + consumption. +- Add an idempotency/result ledger so a response lost after execution returns + the recorded result without executing again. + +**Exit criterion:** failover, concurrency, and lost-response tests produce one +business effect. + +### P1 — Agent Engine and A2A execution + +- Deploy the ADK agent to Vertex AI Agent Engine or the current supported + production runtime. +- Exercise the same receiver boundary through real TLS ingress. +- Add A2A only where the operation crosses an agent boundary; do not imply that + A2A transport itself proves delegated authority. +- Verify ADK confirmation/HITL composition without treating local confirmation + as the receiver's security boundary. + +**Exit criterion:** reproducible deployed evidence for workload → ADK → MCP +receiver, plus separately labelled A2A evidence if implemented. + +### P1 — trust, revocation, operation maps, and failures + +- Define root provisioning, rotation, revocation freshness, outage policy, and + receiver-owned configuration versioning. +- Publish deterministic tool-to-scope/operation/resource/payload/constraint + mappings. +- Standardize machine-readable failures, including failure after execution. + +**Exit criterion:** independent receiver implementation reaches the same +authorization inputs and failure classes. + +### P1 — secure operations and observability + +- Set TLS, proxy/body limits, timeouts, rate limits, secret rotation, and audit + retention. +- Prove Cloud Logging, Agent Engine telemetry, ADK events, traces, and proxy + logs redact proof bytes, challenges, credentials, and keys. +- Record hashes and decision metadata rather than secret-bearing payloads. + +**Exit criterion:** deployment and log-capture review finds no sensitive proof +material and exercises dependency outages. + +### P2 — conformance and compatibility + +- Test supported ADK, MCP SDK, Agent Engine, and Agent Identity versions. +- Cover multiple receiver instances, proxy limits, version negotiation, + unsupported extensions, credential rotation, and restart behavior. +- Publish a zero-skip reusable transport conformance gate. + +**Exit criterion:** compatibility matrix and independent reproduction. + +## Maintainer questions + +1. Which supported ADK hook should inject hidden, operation-specific MCP + authorization metadata after tool selection? +2. Which MCP carrier will ADK preserve without exposing it to the model? +3. What is the supported binding between Agent Identity and an outbound MCP + client workload? +4. Which Agent Engine deployment should be the canonical production test? diff --git a/references/registry/google-adk.md b/references/registry/google-adk.md index 52eea47..dbc4546 100644 --- a/references/registry/google-adk.md +++ b/references/registry/google-adk.md @@ -5,4 +5,5 @@ - **Ratify:** `1.0.0a16` - **Platform:** `google-adk==2.6.3` - **Gate:** `./scripts/google-adk-reference-check.sh` +- **Production backlog:** [`../google-adk/PRODUCTION_GAPS.md`](../google-adk/PRODUCTION_GAPS.md) - **Endorsement:** Not Google-reviewed or Google-approved