diff --git a/docs/architecture/agent-registry-grant-resolution.md b/docs/architecture/agent-registry-grant-resolution.md new file mode 100644 index 0000000..565a9bd --- /dev/null +++ b/docs/architecture/agent-registry-grant-resolution.md @@ -0,0 +1,144 @@ +# AgentRegistryGrant Resolution + +Agent Machine now has a local Agent Registry grant resolver for bootstrap and dry-run activation flows. This is a deterministic local-store resolver, not a production Agent Registry client. + +## Purpose + +Activation evaluation should not require callers to manually select an `AgentRegistryGrant` forever. The resolver lets Agent Machine scan explicit files or directories and select the matching grant by request shape. + +The resolver supports: + +- explicit grant files; +- local grant store directories; +- request matching by AgentPod ID, requested agent identity, session, optional AgentMachine, workroom, and topic; +- disambiguation by grant ID or expected grant status; +- fail-closed missing-grant stub generation; +- semantic validation through governance rules; +- activation evaluation using a locally resolved grant. + +## Current commands + +Resolve a grant from a local store by explicit grant ID: + +```bash +agent-machine registry resolve \ + examples/local-podman-llama-cpp.agent-pod.json \ + --grant-dir examples \ + --grant-id urn:srcos:agent-machine:agent-registry-grant:active-loopback-activation \ + --requested-agent-identity-ref urn:srcos:agent:local-inference-provider \ + --session-ref urn:srcos:session:local-bootstrap \ + --agent-machine-id urn:srcos:agent-machine:m2-asahi-local \ + --pretty +``` + +Resolve a grant by status and request shape: + +```bash +agent-machine registry resolve \ + examples/local-podman-llama-cpp.agent-pod.json \ + --grant-dir examples \ + --expected-status revoked \ + --requested-agent-identity-ref urn:srcos:agent:local-inference-provider \ + --session-ref urn:srcos:session:local-bootstrap \ + --agent-machine-id urn:srcos:agent-machine:m2-asahi-local \ + --workroom-ref urn:srcos:workroom:local-default \ + --topic-ref urn:srcos:topic:agent-machine \ + --pretty +``` + +Evaluate activation using a resolved registry grant: + +```bash +agent-machine activate evaluate \ + examples/local-podman-llama-cpp.agent-pod.json \ + examples/policy-admission.allowed-activation.json \ + --grant-dir examples \ + --grant-id urn:srcos:agent-machine:agent-registry-grant:active-loopback-activation \ + --deployment-receipt-id urn:srcos:agent-machine:deployment-receipt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --requested-agent-identity-ref urn:srcos:agent:local-inference-provider \ + --session-ref urn:srcos:session:local-bootstrap \ + --agent-machine-id urn:srcos:agent-machine:m2-asahi-local \ + --provider-id urn:srcos:agent-machine:inference-provider:asahi-llama-cpp \ + --storage-receipt-dir examples \ + --decided-at 2026-05-04T12:51:00Z \ + --decision-id urn:srcos:agent-machine:activation-decision:local-llama-cpp-allowed \ + --pretty +``` + +## Fail-closed behavior + +If no matching grant is found and missing stubs are allowed, the resolver emits a synthetic `AgentRegistryGrant` with: + +```text +grant.status = missing +grant.authorizationGranted = false +grant.revocationStatus = unavailable +``` + +That stub denies requested scopes and causes `ActivationDecision` to fail closed. + +If `--no-missing-stub` is provided and no grant matches, resolution fails. + +## Ambiguity behavior + +If multiple grants match the request, resolution fails unless the caller disambiguates with: + +```text +--grant-id +``` + +or: + +```text +--expected-status active|revoked|expired|denied|missing|unknown +``` + +This is deliberate. Silent selection among conflicting grants would be unsafe. + +## Scope behavior + +The resolver can construct a requested scope for generated missing stubs from CLI arguments: + +```text +--provider-id +--model-ref +--tool-ref +--storage-scope-ref +--evidence-scope-ref +``` + +For resolved real grants, schema and governance validation ensure `scope.allowed` does not exceed `request.requestedScope`. + +## Bootstrap boundary + +This resolver is not a production Agent Registry client. It does not: + +- call a remote Agent Registry endpoint; +- verify grant signatures; +- resolve revocations online; +- prove session freshness; +- bind identity to live proof-of-life; +- prove grant freshness beyond the contents of local artifacts. + +It is the bootstrap adapter shape that a real Agent Registry client can replace. + +## Validation + +Agent Registry resolver validation is part of: + +```bash +make validate-agent-registry +make validate +``` + +The validation path checks: + +- directory scanning; +- schema and semantic validation; +- ambiguity rejection; +- active activation grant resolution; +- revoked grant resolution; +- generated missing stubs; +- grant-id disambiguation; +- CLI registry resolution; +- activation evaluation using a resolved local grant. diff --git a/scripts/resolve-agent-registry-grant.py b/scripts/resolve-agent-registry-grant.py new file mode 100755 index 0000000..c5349d7 --- /dev/null +++ b/scripts/resolve-agent-registry-grant.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Resolve AgentRegistryGrant from local Agent Registry files/stores.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from agent_machine.agent_registry import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-agent-registry.py b/scripts/validate-agent-registry.py new file mode 100755 index 0000000..3f76e6b --- /dev/null +++ b/scripts/validate-agent-registry.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Validate local Agent Registry grant resolution behavior.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from agent_machine.agent_registry import ( # noqa: E402 + load_agent_registry_grants, + requested_scope_from_inputs, + resolve_agent_registry_grant, + validate_agent_registry_grant_payload, +) + +AGENTPOD_ID = "urn:srcos:agent-machine:agent-pod:local-podman-llama-cpp" +AGENT_MACHINE_ID = "urn:srcos:agent-machine:m2-asahi-local" +IDENTITY_REF = "urn:srcos:agent:local-inference-provider" +SESSION_REF = "urn:srcos:session:local-bootstrap" +WORKROOM_REF = "urn:srcos:workroom:local-default" +TOPIC_REF = "urn:srcos:topic:agent-machine" +PROVIDER_ID = "urn:srcos:agent-machine:inference-provider:asahi-llama-cpp" +ISSUED_AT = "2026-05-04T12:51:00Z" + + +def expect_status(grant: dict, expected: str, label: str) -> None: + observed = grant.get("grant", {}).get("status") + if observed != expected: + raise AssertionError(f"{label}: expected status={expected}, observed {observed}") + validate_agent_registry_grant_payload(grant, REPO_ROOT, source=label) + print(f"VALID registry resolve {label} status={expected}") + + +def expect_ambiguous(grants: list[dict]) -> None: + try: + resolve_agent_registry_grant( + grants=grants, + agentpod_id=AGENTPOD_ID, + requested_agent_identity_ref=IDENTITY_REF, + session_ref=SESSION_REF, + agent_machine_id=AGENT_MACHINE_ID, + workroom_ref=WORKROOM_REF, + topic_ref=TOPIC_REF, + allow_missing_stub=False, + root=REPO_ROOT, + ) + except AssertionError as exc: + if "ambiguous AgentRegistryGrant" not in str(exc): + raise + print("VALID registry resolve ambiguous grant requires disambiguation") + return + raise AssertionError("expected ambiguous AgentRegistryGrant resolution to fail") + + +def main() -> int: + grants = load_agent_registry_grants(directories=[REPO_ROOT / "examples"], root=REPO_ROOT) + if len(grants) < 4: + raise AssertionError("expected at least four AgentRegistryGrant examples") + + expect_ambiguous(grants) + + active_activation = resolve_agent_registry_grant( + grants=grants, + agentpod_id=AGENTPOD_ID, + requested_agent_identity_ref=IDENTITY_REF, + session_ref=SESSION_REF, + agent_machine_id=AGENT_MACHINE_ID, + workroom_ref=WORKROOM_REF, + topic_ref=TOPIC_REF, + grant_id="urn:srcos:agent-machine:agent-registry-grant:active-loopback-activation", + root=REPO_ROOT, + ) + expect_status(active_activation, "active", "active-activation") + + revoked = resolve_agent_registry_grant( + grants=grants, + agentpod_id=AGENTPOD_ID, + requested_agent_identity_ref=IDENTITY_REF, + session_ref=SESSION_REF, + agent_machine_id=AGENT_MACHINE_ID, + workroom_ref=WORKROOM_REF, + topic_ref=TOPIC_REF, + expected_status="revoked", + root=REPO_ROOT, + ) + expect_status(revoked, "revoked", "revoked") + + missing = resolve_agent_registry_grant( + grants=grants, + agentpod_id=AGENTPOD_ID, + requested_agent_identity_ref="urn:srcos:agent:unresolved-provider", + session_ref=SESSION_REF, + agent_machine_id=AGENT_MACHINE_ID, + workroom_ref=WORKROOM_REF, + topic_ref=TOPIC_REF, + allow_missing_stub=True, + requested_scope=requested_scope_from_inputs( + provider_id=PROVIDER_ID, + tool_refs=["urn:srcos:tool:start-provider", "urn:srcos:tool:mount-cache"], + ), + issued_at=ISSUED_AT, + root=REPO_ROOT, + ) + expect_status(missing, "missing", "generated-missing-stub") + + by_id = resolve_agent_registry_grant( + grants=grants, + agentpod_id=AGENTPOD_ID, + requested_agent_identity_ref=IDENTITY_REF, + session_ref=SESSION_REF, + grant_id="urn:srcos:agent-machine:agent-registry-grant:active-render-only", + root=REPO_ROOT, + ) + expect_status(by_id, "active", "grant-id") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AssertionError, RuntimeError) as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) from exc diff --git a/src/agent_machine/agent_registry.py b/src/agent_machine/agent_registry.py new file mode 100644 index 0000000..258965d --- /dev/null +++ b/src/agent_machine/agent_registry.py @@ -0,0 +1,344 @@ +"""Local Agent Registry grant resolver for Agent Machine. + +This module is a bootstrap stand-in for a real Agent Registry client. It resolves +secret-free AgentRegistryGrant artifacts from explicit files or local stores and +can produce a fail-closed missing-grant stub when no grant is present. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from agent_machine.contracts import load_json, schema_by_kind +from agent_machine.governance import GRANT_SCOPE_KEYS, validate_agent_registry_grant_semantics + +DEFAULT_ISSUED_AT = "1970-01-01T00:00:00Z" +EMPTY_SCOPE = { + "providerIds": [], + "modelRefs": [], + "toolRefs": [], + "cacheScopeRefs": [], + "memoryScopeRefs": [], + "storageScopeRefs": [], + "evidenceScopeRefs": [], +} + + +def validate_payload_against_kind(value: dict[str, Any], kind: str, root: Path | None = None) -> None: + schema_path = schema_by_kind(root)[kind] + schema_payload = load_json(schema_path) + try: + from jsonschema.validators import validator_for + except ImportError as exc: # pragma: no cover + raise RuntimeError( + "Missing dependency: jsonschema. Install with `python -m pip install -r requirements-dev.txt`." + ) from exc + validator_cls = validator_for(schema_payload) + validator_cls.check_schema(schema_payload) + validator = validator_cls(schema_payload) + errors = sorted(validator.iter_errors(value), key=lambda err: list(err.path)) + if errors: + rendered = [] + for err in errors: + location = "/".join(str(part) for part in err.path) or "" + rendered.append(f" - {location}: {err.message}") + raise AssertionError(f"{kind} failed schema validation:\n" + "\n".join(rendered)) + + +def validate_agent_registry_grant_payload(grant: dict[str, Any], root: Path | None = None, source: str = "") -> None: + validate_payload_against_kind(grant, "AgentRegistryGrant", root) + validate_agent_registry_grant_semantics(grant, source=source) + + +def iter_json_files(directory: Path) -> list[Path]: + if not directory.exists(): + raise AssertionError(f"grant store directory does not exist: {directory}") + if not directory.is_dir(): + raise AssertionError(f"grant store path is not a directory: {directory}") + return sorted(path for path in directory.rglob("*.json") if path.is_file()) + + +def load_agent_registry_grants( + *, + files: list[Path] | None = None, + directories: list[Path] | None = None, + root: Path | None = None, +) -> list[dict[str, Any]]: + """Load AgentRegistryGrant objects from files and/or local store directories.""" + grants: list[dict[str, Any]] = [] + seen_paths: set[Path] = set() + + for path in files or []: + resolved = path.resolve() + seen_paths.add(resolved) + value = load_json(path) + if not isinstance(value, dict): + raise AssertionError(f"{path}: agent registry grant file root must be an object") + if value.get("kind") != "AgentRegistryGrant": + raise AssertionError(f"{path}: expected kind=AgentRegistryGrant") + validate_agent_registry_grant_payload(value, root, source=str(path)) + grants.append(value) + + for directory in directories or []: + for path in iter_json_files(directory): + resolved = path.resolve() + if resolved in seen_paths: + continue + value = load_json(path) + if isinstance(value, dict) and value.get("kind") == "AgentRegistryGrant": + validate_agent_registry_grant_payload(value, root, source=str(path)) + grants.append(value) + seen_paths.add(resolved) + + grant_ids: dict[str, dict[str, Any]] = {} + for grant in grants: + grant_id = grant.get("id") + if not isinstance(grant_id, str): + raise AssertionError("AgentRegistryGrant loaded without string id") + if grant_id in grant_ids: + raise AssertionError(f"duplicate AgentRegistryGrant id loaded: {grant_id}") + grant_ids[grant_id] = grant + return grants + + +def request_matches( + grant: dict[str, Any], + *, + agentpod_id: str, + requested_agent_identity_ref: str, + session_ref: str, + agent_machine_id: str | None = None, + workroom_ref: str | None = None, + topic_ref: str | None = None, +) -> bool: + request = grant.get("request", {}) + if request.get("agentPodId") != agentpod_id: + return False + if request.get("requestedAgentIdentityRef") != requested_agent_identity_ref: + return False + if request.get("sessionRef") != session_ref: + return False + if agent_machine_id and request.get("agentMachineId") != agent_machine_id: + return False + if workroom_ref and request.get("workroomRef") != workroom_ref: + return False + if topic_ref and request.get("topicRef") != topic_ref: + return False + return True + + +def requested_scope_from_inputs( + *, + provider_id: str | None = None, + model_ref: str | None = None, + tool_refs: list[str] | None = None, + storage_scope_ref: str | None = None, + evidence_scope_ref: str | None = None, +) -> dict[str, list[str]]: + scope = {key: [] for key in GRANT_SCOPE_KEYS} + if provider_id: + scope["providerIds"].append(provider_id) + if model_ref: + scope["modelRefs"].append(model_ref) + for tool_ref in tool_refs or []: + if tool_ref not in scope["toolRefs"]: + scope["toolRefs"].append(tool_ref) + if storage_scope_ref: + scope["storageScopeRefs"].append(storage_scope_ref) + if evidence_scope_ref: + scope["evidenceScopeRefs"].append(evidence_scope_ref) + return scope + + +def missing_agent_registry_grant_stub( + *, + agentpod_id: str, + requested_agent_identity_ref: str, + session_ref: str, + issued_at: str, + agent_machine_id: str | None = None, + workroom_ref: str | None = None, + topic_ref: str | None = None, + requested_scope: dict[str, list[str]] | None = None, + requested_expires_at: str | None = None, +) -> dict[str, Any]: + suffix = agentpod_id.split(":")[-1] + scope = {key: list((requested_scope or EMPTY_SCOPE).get(key) or []) for key in GRANT_SCOPE_KEYS} + return { + "specVersion": "0.1.0", + "id": f"urn:srcos:agent-machine:agent-registry-grant:missing-{suffix}", + "kind": "AgentRegistryGrant", + "request": { + "requestId": f"urn:srcos:agent-machine:grant-request:missing-{suffix}", + "agentMachineId": agent_machine_id, + "agentPodId": agentpod_id, + "requestedAgentIdentityRef": requested_agent_identity_ref, + "sessionRef": session_ref, + "workroomRef": workroom_ref, + "topicRef": topic_ref, + "requestedScope": scope, + "requestedExpiresAt": requested_expires_at, + }, + "grant": { + "status": "missing", + "authorizationGranted": False, + "grantRef": None, + "grantDigest": None, + "reason": "No matching AgentRegistryGrant was resolved; activation must fail closed.", + "expiresAt": None, + "revocationStatus": "unavailable", + "revocationRef": None, + "revocationHookRef": None, + "externalTrustSignals": [], + }, + "scope": { + "allowed": {key: [] for key in GRANT_SCOPE_KEYS}, + "denied": scope, + }, + "receiptSafety": { + "includeRawContent": False, + "rawPromptContentIncluded": False, + "rawKvCacheContentIncluded": False, + "secretValuesIncluded": False, + "privateMemoryIncluded": False, + }, + "issuedAt": issued_at, + "labels": { + "sourceos.registry.resolver": "local-store", + "sourceos.activation.fail-closed": "true", + }, + } + + +def resolve_agent_registry_grant( + *, + grants: list[dict[str, Any]], + agentpod_id: str, + requested_agent_identity_ref: str, + session_ref: str, + agent_machine_id: str | None = None, + workroom_ref: str | None = None, + topic_ref: str | None = None, + grant_id: str | None = None, + expected_status: str | None = None, + allow_missing_stub: bool = True, + issued_at: str = DEFAULT_ISSUED_AT, + requested_scope: dict[str, list[str]] | None = None, + requested_expires_at: str | None = None, + root: Path | None = None, +) -> dict[str, Any]: + """Resolve one AgentRegistryGrant or return a fail-closed missing stub. + + Ambiguity is a hard failure. A caller may disambiguate by grant_id or expected_status. + """ + if grant_id: + matches = [grant for grant in grants if grant.get("id") == grant_id] + else: + matches = [ + grant + for grant in grants + if request_matches( + grant, + agentpod_id=agentpod_id, + requested_agent_identity_ref=requested_agent_identity_ref, + session_ref=session_ref, + agent_machine_id=agent_machine_id, + workroom_ref=workroom_ref, + topic_ref=topic_ref, + ) + ] + if expected_status: + matches = [grant for grant in matches if grant.get("grant", {}).get("status") == expected_status] + + if len(matches) == 1: + validate_agent_registry_grant_payload(matches[0], root, source=str(matches[0].get("id"))) + return matches[0] + if not matches: + if not allow_missing_stub: + raise AssertionError("no matching AgentRegistryGrant found") + stub = missing_agent_registry_grant_stub( + agentpod_id=agentpod_id, + requested_agent_identity_ref=requested_agent_identity_ref, + session_ref=session_ref, + agent_machine_id=agent_machine_id, + workroom_ref=workroom_ref, + topic_ref=topic_ref, + requested_scope=requested_scope, + requested_expires_at=requested_expires_at, + issued_at=issued_at, + ) + validate_agent_registry_grant_payload(stub, root, source="missing-grant-stub") + return stub + + ids = ", ".join(sorted(str(grant.get("id")) for grant in matches)) + raise AssertionError(f"ambiguous AgentRegistryGrant match; disambiguate with grant_id or expected_status: {ids}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Resolve AgentRegistryGrant from local Agent Registry files/stores") + parser.add_argument("agentpod_json", type=Path) + parser.add_argument("--grant-file", action="append", type=Path, default=[]) + parser.add_argument("--grant-dir", action="append", type=Path, default=[]) + parser.add_argument("--requested-agent-identity-ref", required=True) + parser.add_argument("--session-ref", required=True) + parser.add_argument("--agent-machine-id") + parser.add_argument("--workroom-ref") + parser.add_argument("--topic-ref") + parser.add_argument("--grant-id") + parser.add_argument("--expected-status", choices=["missing", "active", "expired", "revoked", "denied", "unknown"]) + parser.add_argument("--no-missing-stub", action="store_true") + parser.add_argument("--provider-id") + parser.add_argument("--model-ref") + parser.add_argument("--tool-ref", action="append", default=[]) + parser.add_argument("--storage-scope-ref") + parser.add_argument("--evidence-scope-ref") + parser.add_argument("--requested-expires-at") + parser.add_argument("--issued-at", default=DEFAULT_ISSUED_AT) + parser.add_argument("--pretty", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + agentpod = load_json(args.agentpod_json) + if not isinstance(agentpod, dict) or agentpod.get("kind") != "AgentPod": + raise AssertionError(f"{args.agentpod_json}: expected kind=AgentPod") + grants = load_agent_registry_grants(files=args.grant_file, directories=args.grant_dir) + grant = resolve_agent_registry_grant( + grants=grants, + agentpod_id=str(agentpod.get("id")), + requested_agent_identity_ref=args.requested_agent_identity_ref, + session_ref=args.session_ref, + agent_machine_id=args.agent_machine_id, + workroom_ref=args.workroom_ref, + topic_ref=args.topic_ref, + grant_id=args.grant_id, + expected_status=args.expected_status, + allow_missing_stub=not args.no_missing_stub, + requested_scope=requested_scope_from_inputs( + provider_id=args.provider_id, + model_ref=args.model_ref, + tool_refs=args.tool_ref, + storage_scope_ref=args.storage_scope_ref, + evidence_scope_ref=args.evidence_scope_ref, + ), + requested_expires_at=args.requested_expires_at, + issued_at=args.issued_at, + ) + if args.pretty: + print(json.dumps(grant, indent=2, sort_keys=True)) + else: + print(json.dumps(grant, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AssertionError, RuntimeError) as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) from exc