diff --git a/references/google-adk/.gitignore b/references/google-adk/.gitignore new file mode 100644 index 0000000..af674dc --- /dev/null +++ b/references/google-adk/.gitignore @@ -0,0 +1,4 @@ +.venv/ +.local/ +__pycache__/ +.pytest_cache/ 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/google-adk/README.md b/references/google-adk/README.md new file mode 100644 index 0000000..f921288 --- /dev/null +++ b/references/google-adk/README.md @@ -0,0 +1,246 @@ +# 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 + +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 +./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` +- `mcp==1.29.0` +- `ratify-protocol==1.0.0a16` +- `pytest==8.4.1` + +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). + +## 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 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 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, + 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 `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. 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 + +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 | +|---|---|---| +| 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 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 | + +## 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, run the published-package gate above first. It +creates the `.venv` used below. Then run: + +```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 +``` + +`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 +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 +``` + +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 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 | Baseline in-process composition | +| 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 | + +## 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 + 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. +- 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 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 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, + 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/__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..9a1211a --- /dev/null +++ b/references/google-adk/adk_app/agent.py @@ -0,0 +1,25 @@ +"""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 +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.", + 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, + receiver_url=os.environ["RATIFY_MCP_RECEIVER_URL"], + transport_token=transport_token, + )], +) diff --git a/references/google-adk/authority_reference/__init__.py b/references/google-adk/authority_reference/__init__.py new file mode 100644 index 0000000..220da2b --- /dev/null +++ b/references/google-adk/authority_reference/__init__.py @@ -0,0 +1,17 @@ +"""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 + +__all__ = [ + "AuthorityFixture", + "InfrastructureReceiver", + "OperationRequest", + "build_adk_agent", + "build_mcp_toolset", + "build_provision_tool", + "issue_authority", + "RatifyMcpToolset", +] 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..de11633 --- /dev/null +++ b/references/google-adk/authority_reference/adk_agent.py @@ -0,0 +1,57 @@ +"""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.models.base_llm import BaseLlm +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 | BaseLlm = "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/adk_mcp.py b/references/google-adk/authority_reference/adk_mcp.py new file mode 100644 index 0000000..2a73547 --- /dev/null +++ b/references/google-adk/authority_reference/adk_mcp.py @@ -0,0 +1,166 @@ +"""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 inspect +import json +from typing import Any + +from google.adk.tools.mcp_tool.mcp_session_manager import ( + StreamableHTTPConnectionParams, +) +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, + 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: + 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 + ) + 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 super()._run_async_impl( + args={**args, "presentation": encode_proof_bundle(bundle)}, + tool_context=tool_context, + credential=credential, + ) + 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, + 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, 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={"X-Ratify-Transport-Token": 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": _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": _safe_error(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": _safe_error(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: + try: + parsed = json.loads(text) + except json.JSONDecodeError: + 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/authority.py b/references/google-adk/authority_reference/authority.py new file mode 100644 index 0000000..4712c64 --- /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 = "com.ratifyprotocol.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/deployment_config.py b/references/google-adk/authority_reference/deployment_config.py new file mode 100644 index 0000000..989e29c --- /dev/null +++ b/references/google-adk/authority_reference/deployment_config.py @@ -0,0 +1,69 @@ +"""Explicit receiver/public and presenter/private deployment configuration.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import secrets + +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: + transport_token = secrets.token_urlsafe(32) + 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, + }) + 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), + "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], + "transport_token": transport_token, + }) + _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) + + +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"]], + ) + + +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 new file mode 100644 index 0000000..321c521 --- /dev/null +++ b/references/google-adk/authority_reference/mcp_server.py @@ -0,0 +1,137 @@ +"""Receiver-operated Streamable HTTP MCP service.""" + +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 ratify_protocol import HybridPublicKey, base64_standard_decode, base64_standard_encode +import uvicorn + +from .receiver import InfrastructureReceiver, OperationRequest + + +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"], + trusted_root_public_key=HybridPublicKey( + ed25519=base64_standard_decode(config["root_ed25519"]), + ml_dsa_65=base64_standard_decode(config["root_ml_dsa_65"]), + ), + ) + return receiver, config["trusted_agent_id"], config["transport_token"] + + +class TransportTokenBoundary: + """Authenticate the reference transport without consuming Authorization.""" + + 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, + host: str, + port: int, +) -> FastMCP: + server = FastMCP( + "ratify-adk-authority-receiver", + host=host, + port=port, + stateless_http=False, + log_level="ERROR", + ) + + @server.tool() + 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.""" + 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), + "expires_at": grant.expires_at, + } + + @server.tool() + async 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 await asyncio.to_thread( + receiver.execute, + OperationRequest(request_id, region, instance_type, count), presentation + ) + + return server + + +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() + 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) + 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", + ) + + +if __name__ == "__main__": + main() diff --git a/references/google-adk/authority_reference/receiver.py b/references/google-adk/authority_reference/receiver.py new file mode 100644 index 0000000..7f09763 --- /dev/null +++ b/references/google-adk/authority_reference/receiver.py @@ -0,0 +1,259 @@ +"""Independent receiver-side challenge and verification boundary.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import re +import threading +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 + expires_at: int + + +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._max_pending = 128 + self._state_lock = threading.Lock() + 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") + 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", + 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), + ) + ) + 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( + 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)) + + 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: + 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) + + with self._state_lock: + self._pending.pop(request.request_id, None) + 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/bootstrap_live.py b/references/google-adk/bootstrap_live.py new file mode 100644 index 0000000..52e2c6c --- /dev/null +++ b/references/google-adk/bootstrap_live.py @@ -0,0 +1,20 @@ +#!/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) +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/demo.py b/references/google-adk/demo.py new file mode 100644 index 0000000..80c336e --- /dev/null +++ b/references/google-adk/demo.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""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 load_transport_token, write_configs + + +async def run() -> None: + authority = issue_authority() + 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: + 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", + transport_token=load_transport_token(str(presenter_config)), + ) + 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__": + asyncio.run(run()) diff --git a/references/google-adk/evidence/reference-evidence.md b/references/google-adk/evidence/reference-evidence.md new file mode 100644 index 0000000..a779d49 --- /dev/null +++ b/references/google-adk/evidence/reference-evidence.md @@ -0,0 +1,85 @@ +# 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` | +| 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 | `b934bca56ea62573af6b5ffe9b8b9224138ee405a5efd3f99f157c21fef5a3b9` | + +## 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 native ADK MCP demo. + +## Recorded result + +```text +pins: google-adk==2.6.3 mcp==1.29.0 ratify-protocol==1.0.0a16 +............................... [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 +GOOGLE ADK HTTP MCP AUTHORITY REFERENCE PASSED +``` + +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 + +- 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. +- 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 + 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. +- 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 + 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 + 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 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. +- 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 + targets, not results. diff --git a/references/google-adk/requirements.txt b/references/google-adk/requirements.txt new file mode 100644 index 0000000..cbb4c56 --- /dev/null +++ b/references/google-adk/requirements.txt @@ -0,0 +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 new file mode 100644 index 0000000..f5bb92f --- /dev/null +++ b/references/google-adk/tests/test_reference.py @@ -0,0 +1,720 @@ +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +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 +from google.adk.models.llm_response import LlmResponse +from google.adk.runners import InMemoryRunner +from google.genai import types +from ratify_protocol import ( + base64_standard_decode, + base64_standard_encode, + 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 +from authority_reference.deployment_config import write_configs + + +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 + + +@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" + 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)) + 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", transport_token + finally: + process.terminate() + process.wait(timeout=5) + + +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 + + +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 []) + ) + + +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, 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() + 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() + receiver_context.__exit__(None, None, None) + + asyncio.run(exercise()) + + +def test_real_adk_runner_executes_native_mcp_toolset(): + async def exercise(): + _, authority, _ = setup_reference() + receiver_context = running_http_receiver(authority) + 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"), + 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() + receiver_context.__exit__(None, None, None) + + asyncio.run(exercise()) + + +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, 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(headers={ + "X-Ratify-Transport-Token": token + }) + 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, + ) + 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_request = {**original, "request_id": "mcp-replay"} + replay_proof = await presentation_for(replay_request) + first = _result_object(await session.call_tool( + "provision_cloud_node", + arguments={**replay_request, "presentation": replay_proof}, + )) + await presentation_for(replay_request) + replay = _result_object(await session.call_tool( + "provision_cloud_node", + arguments={**replay_request, "presentation": replay_proof}, + )) + assert first["decision"] == "allow" + assert replay["decision"] == "deny" + 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_agent(): + async def exercise(): + _, accepted, _ = setup_reference() + attacker = issue_authority(now=int(time.time()) - 1) + receiver_context = running_http_receiver(accepted) + 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( + 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()) + + +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={ + "X-Ratify-Transport-Token": 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 + + +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()) + + +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 diff --git a/references/registry/google-adk.md b/references/registry/google-adk.md new file mode 100644 index 0000000..dbc4546 --- /dev/null +++ b/references/registry/google-adk.md @@ -0,0 +1,9 @@ +# 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` +- **Production backlog:** [`../google-adk/PRODUCTION_GAPS.md`](../google-adk/PRODUCTION_GAPS.md) +- **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..518ee46 --- /dev/null +++ b/scripts/google-adk-reference-check.sh @@ -0,0 +1,39 @@ +#!/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", + "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 mcp==1.29.0 ratify-protocol==1.0.0a16") +PY + +PYTHONPATH="$DEMO" "$VENV/bin/pytest" -q "$DEMO/tests" +PYTHONPATH="$DEMO" "$VENV/bin/python" "$DEMO/demo.py"