diff --git a/contracts/external-trust-signal-provider.schema.json b/contracts/external-trust-signal-provider.schema.json new file mode 100644 index 0000000..0c896fc --- /dev/null +++ b/contracts/external-trust-signal-provider.schema.json @@ -0,0 +1,346 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:srcos:agent-machine:schema:external-trust-signal-provider:v0.1.0", + "title": "ExternalTrustSignalProvider", + "description": "Secret-free adapter request/response contract for optional non-authoritative external identity, reputation, certificate-tier, counterparty, and registry lookup signals used as Agent Registry verifier inputs.", + "type": "object", + "additionalProperties": false, + "required": [ + "specVersion", + "id", + "kind", + "request", + "response", + "receiptSafety", + "observedAt" + ], + "$defs": { + "signalType": { + "type": "string", + "enum": [ + "agent-identity", + "cert-tier", + "reputation-score", + "counterparty-check", + "registry-lookup", + "other" + ] + }, + "sha256Digest": { + "type": [ + "string", + "null" + ], + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "freshness": { + "type": "object", + "additionalProperties": false, + "required": [ + "maxAgeSeconds", + "observedAgeSeconds", + "fresh" + ], + "properties": { + "maxAgeSeconds": { + "type": "integer", + "minimum": 0 + }, + "observedAgeSeconds": { + "type": "integer", + "minimum": 0 + }, + "fresh": { + "type": "boolean" + } + } + }, + "signature": { + "type": "object", + "additionalProperties": false, + "required": [ + "required", + "observed", + "signatureRef", + "signerRef" + ], + "properties": { + "required": { + "type": "boolean" + }, + "observed": { + "type": "boolean" + }, + "signatureRef": { + "type": [ + "string", + "null" + ] + }, + "signerRef": { + "type": [ + "string", + "null" + ] + } + } + }, + "externalTrustSignal": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerRef", + "signalType", + "signalRef", + "signalDigest", + "verifiedAt", + "freshness", + "signature", + "authority", + "failureReason" + ], + "properties": { + "providerRef": { + "type": "string" + }, + "signalType": { + "$ref": "#/$defs/signalType" + }, + "signalRef": { + "type": "string" + }, + "signalDigest": { + "$ref": "#/$defs/sha256Digest" + }, + "verifiedAt": { + "type": "string" + }, + "freshness": { + "$ref": "#/$defs/freshness" + }, + "signature": { + "$ref": "#/$defs/signature" + }, + "authority": { + "type": "string", + "const": "non-authoritative-verifier-input" + }, + "failureReason": { + "type": [ + "string", + "null" + ] + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "properties": { + "specVersion": { + "type": "string", + "const": "0.1.0" + }, + "id": { + "type": "string", + "pattern": "^urn:srcos:agent-machine:external-trust-signal-provider:[a-z0-9][a-z0-9-]*$" + }, + "kind": { + "type": "string", + "const": "ExternalTrustSignalProvider" + }, + "request": { + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "providerRef", + "agentPodId", + "requestedAgentIdentityRef", + "sessionRef", + "workroomRef", + "topicRef", + "requestedSignalTypes", + "verificationFreshnessSeconds", + "requestedExpiresAt", + "signatureRequired" + ], + "properties": { + "requestId": { + "type": "string" + }, + "providerRef": { + "type": "string" + }, + "agentPodId": { + "type": "string", + "pattern": "^urn:srcos:agent-machine:agent-pod:[a-z0-9][a-z0-9-]*$" + }, + "requestedAgentIdentityRef": { + "type": "string" + }, + "sessionRef": { + "type": "string" + }, + "workroomRef": { + "type": [ + "string", + "null" + ] + }, + "topicRef": { + "type": [ + "string", + "null" + ] + }, + "requestedSignalTypes": { + "type": "array", + "items": { + "$ref": "#/$defs/signalType" + }, + "minItems": 1, + "uniqueItems": true + }, + "verificationFreshnessSeconds": { + "type": "integer", + "minimum": 0 + }, + "requestedExpiresAt": { + "type": [ + "string", + "null" + ] + }, + "signatureRequired": { + "type": "boolean" + } + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "usableForGrantResolution", + "providerRef", + "authority", + "verifiedAt", + "freshness", + "signals", + "failureReason" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "available", + "unavailable", + "stale", + "malformed", + "unsigned", + "denied", + "error" + ] + }, + "usableForGrantResolution": { + "type": "boolean" + }, + "providerRef": { + "type": "string" + }, + "authority": { + "type": "string", + "const": "non-authoritative-verifier-input" + }, + "verifiedAt": { + "type": [ + "string", + "null" + ] + }, + "freshness": { + "$ref": "#/$defs/freshness" + }, + "signals": { + "type": "array", + "items": { + "$ref": "#/$defs/externalTrustSignal" + } + }, + "failureReason": { + "type": [ + "string", + "null" + ] + } + } + }, + "receiptSafety": { + "type": "object", + "additionalProperties": false, + "required": [ + "includeRawContent", + "rawPromptContentIncluded", + "rawKvCacheContentIncluded", + "secretValuesIncluded", + "privateMemoryIncluded", + "apiKeysIncluded", + "walletPrivateKeysIncluded", + "rawCredentialsIncluded", + "rawUserDataIncluded" + ], + "properties": { + "includeRawContent": { + "type": "boolean", + "const": false + }, + "rawPromptContentIncluded": { + "type": "boolean", + "const": false + }, + "rawKvCacheContentIncluded": { + "type": "boolean", + "const": false + }, + "secretValuesIncluded": { + "type": "boolean", + "const": false + }, + "privateMemoryIncluded": { + "type": "boolean", + "const": false + }, + "apiKeysIncluded": { + "type": "boolean", + "const": false + }, + "walletPrivateKeysIncluded": { + "type": "boolean", + "const": false + }, + "rawCredentialsIncluded": { + "type": "boolean", + "const": false + }, + "rawUserDataIncluded": { + "type": "boolean", + "const": false + } + } + }, + "observedAt": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/docs/architecture/agent-registry-grants.md b/docs/architecture/agent-registry-grants.md index 9a7219f..c860a20 100644 --- a/docs/architecture/agent-registry-grants.md +++ b/docs/architecture/agent-registry-grants.md @@ -10,7 +10,7 @@ This grant is a local SourceOS control-plane artifact. It may consume external i AgentPod -> Policy Fabric admission -> Agent Registry grant request - -> optional external verifier inputs + -> optional ExternalTrustSignalProvider verifier inputs -> local grant resolution -> ActivationDecision -> runtime placement or fail-closed @@ -56,9 +56,11 @@ Allowed scope must be no broader than the requested scope. Denied scope is expli ## External trust signals -External systems can be useful for agent identity verification, reputation, counterparty checks, and certificate-tier claims. They are not the Agent Registry. +External systems can be useful for agent identity verification, reputation, counterparty checks, registry lookup, and certificate-tier claims. They are not the Agent Registry. -When used, external trust signals must be recorded under `grant.externalTrustSignals` with: +`ExternalTrustSignalProvider` artifacts represent those adapter results. A usable result can be considered by the local Agent Registry grant resolver only when it is fresh, signed when signatures are required, scoped to the requested provider and signal types, and marked with `authority: non-authoritative-verifier-input`. + +When used inside an `AgentRegistryGrant`, external trust signals must be recorded under `grant.externalTrustSignals` with: - the provider reference; - the signal type; @@ -67,7 +69,7 @@ When used, external trust signals must be recorded under `grant.externalTrustSig - verification time; - `authority: non-authoritative-verifier-input`. -This keeps PCH/ERC-8004-style identity, reputation, and certificate-tier checks pluggable without making any external gateway the SourceOS root of trust. +This keeps PCH/ERC-8004-style identity, reputation, registry lookup, and certificate-tier checks pluggable without making any external gateway the SourceOS root of trust. ## Fail-closed rules @@ -79,10 +81,11 @@ Activation fails closed when: - the requested provider is not present in allowed provider scope; - required activation tools are absent from allowed tool scope; - the grant is missing a revocation hook; -- the grant payload includes secrets, raw prompts, raw KV-cache contents, or private memory contents. +- required external trust signals are unavailable, stale, malformed, unsigned when signatures are required, or authority-elevated; +- the grant or external trust payload includes secrets, raw prompts, raw KV-cache contents, private memory contents, API keys, private wallet keys, raw credentials, or raw user data. ## Relation to receipts -`DeploymentReceipt` proves deterministic derivation. `PolicyAdmission` proves policy admission. `AgentRegistryGrant` proves identity/session/tool/provider/storage authorization. `ActivationDecision` combines those inputs and either permits scoped activation or records fail-closed reasons. +`DeploymentReceipt` proves deterministic derivation. `PolicyAdmission` proves policy admission. `ExternalTrustSignalProvider` proves optional verifier-input posture. `AgentRegistryGrant` proves identity/session/tool/provider/storage authorization. `ActivationDecision` combines those inputs and either permits scoped activation or records fail-closed reasons. None of these artifacts should include raw prompt content, KV-cache contents, secret values, private memory, or raw user data. diff --git a/docs/architecture/external-trust-signal-providers.md b/docs/architecture/external-trust-signal-providers.md new file mode 100644 index 0000000..976a62b --- /dev/null +++ b/docs/architecture/external-trust-signal-providers.md @@ -0,0 +1,66 @@ +# External Trust Signal Providers + +`ExternalTrustSignalProvider` is the adapter contract for optional external identity, reputation, certificate-tier, counterparty, and registry lookup signals used by the local Agent Registry grant resolver. + +External trust signals are verifier inputs only. They are never authorization, never runtime placement permission, and never a replacement for local SourceOS grant resolution. + +## Boundary + +```text +AgentPod + -> local grant request + -> optional ExternalTrustSignalProvider adapter + -> local Agent Registry resolver + -> AgentRegistryGrant + -> ActivationDecision +``` + +The adapter can represent PCH/ERC-8004-style prior art: identity assertion, certificate tier, reputation score, counterparty check, and registry lookup. The adapter must not bind Agent Machine to PCH, ERC-8004, x402, Base, USDC, any hosted dashboard, any payment rail, or any external root of trust. + +## Request shape + +The request side records: + +- `providerRef`: external verifier provider or local mirror reference. +- `agentPodId`: AgentPod under evaluation. +- `requestedAgentIdentityRef`: non-human runtime participant identity. +- `sessionRef`: session boundary. +- `workroomRef` and `topicRef`: workspace context. +- `requestedSignalTypes`: identity, cert-tier, reputation, counterparty, registry lookup, or other. +- `verificationFreshnessSeconds`: maximum accepted age for the signal. +- `requestedExpiresAt`: requested validity window. +- `signatureRequired`: whether signatures are required for the signal to be usable. + +## Response shape + +The response side records: + +- `status`: `available`, `unavailable`, `stale`, `malformed`, `unsigned`, `denied`, or `error`. +- `usableForGrantResolution`: true only when the response may be considered by the local grant resolver. +- `authority`: fixed to `non-authoritative-verifier-input`. +- `freshness`: max age, observed age, and freshness result. +- `signals`: one or more typed signals. +- `failureReason`: required when the response is not usable. + +Each signal records provider reference, signal type, signal reference, optional digest, verification time, freshness, signature posture, authority, failure reason, and notes. + +## Semantic rules + +A usable adapter response must satisfy all of these conditions: + +- status is `available`; +- `usableForGrantResolution=true`; +- response authority is `non-authoritative-verifier-input`; +- response freshness is true; +- at least one signal is present; +- every signal uses the requested provider reference; +- every signal type was requested; +- every signal authority is `non-authoritative-verifier-input`; +- if signatures are required, every signal has an observed signature, signature reference, and signer reference; +- no raw prompt, KV-cache, secrets, private memory, API keys, private wallet keys, raw credentials, or raw user data appear in the payload. + +An unusable adapter response must be ignored by the local grant resolver or cause fail-closed behavior when local policy requires that signal. Stale, unavailable, malformed, unsigned, denied, or error responses cannot authorize activation. + +## Non-authority rule + +External trust signal providers can reduce or annotate local risk. They cannot widen requested scope, grant tools, grant storage, grant cache reuse, grant memory access, expose models, or activate an AgentPod. Only local `PolicyAdmission` plus local `AgentRegistryGrant` can lead to an `ActivationDecision` that allows activation. diff --git a/examples/external-trust-signal-provider.active.json b/examples/external-trust-signal-provider.active.json new file mode 100644 index 0000000..c1532b2 --- /dev/null +++ b/examples/external-trust-signal-provider.active.json @@ -0,0 +1,147 @@ +{ + "specVersion": "0.1.0", + "id": "urn:srcos:agent-machine:external-trust-signal-provider:local-pch-style-active", + "kind": "ExternalTrustSignalProvider", + "request": { + "requestId": "urn:srcos:agent-machine:external-trust-request:local-pch-style-active", + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "agentPodId": "urn:srcos:agent-machine:agent-pod:local-podman-llama-cpp", + "requestedAgentIdentityRef": "urn:srcos:agent:local-inference-provider", + "sessionRef": "urn:srcos:session:local-bootstrap", + "workroomRef": "urn:srcos:workroom:local-default", + "topicRef": "urn:srcos:topic:agent-machine", + "requestedSignalTypes": [ + "agent-identity", + "cert-tier", + "reputation-score", + "registry-lookup" + ], + "verificationFreshnessSeconds": 3600, + "requestedExpiresAt": "2026-05-04T13:50:00Z", + "signatureRequired": true + }, + "response": { + "status": "available", + "usableForGrantResolution": true, + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "authority": "non-authoritative-verifier-input", + "verifiedAt": "2026-05-04T12:50:10Z", + "freshness": { + "maxAgeSeconds": 3600, + "observedAgeSeconds": 10, + "fresh": true + }, + "signals": [ + { + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "signalType": "agent-identity", + "signalRef": "urn:srcos:external-trust-signal:local-agent-identity", + "signalDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "verifiedAt": "2026-05-04T12:50:10Z", + "freshness": { + "maxAgeSeconds": 3600, + "observedAgeSeconds": 10, + "fresh": true + }, + "signature": { + "required": true, + "observed": true, + "signatureRef": "urn:srcos:signature:local-agent-identity", + "signerRef": "urn:srcos:signer:local-trust-mirror" + }, + "authority": "non-authoritative-verifier-input", + "failureReason": null, + "notes": [ + "Identity signal is adapter input only; local Agent Registry remains authoritative." + ] + }, + { + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "signalType": "cert-tier", + "signalRef": "urn:srcos:external-trust-signal:local-agent-cert-tier-bronze", + "signalDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "verifiedAt": "2026-05-04T12:50:10Z", + "freshness": { + "maxAgeSeconds": 3600, + "observedAgeSeconds": 10, + "fresh": true + }, + "signature": { + "required": true, + "observed": true, + "signatureRef": "urn:srcos:signature:local-agent-cert-tier", + "signerRef": "urn:srcos:signer:local-trust-mirror" + }, + "authority": "non-authoritative-verifier-input", + "failureReason": null, + "notes": [ + "Cert-tier signal cannot authorize activation by itself." + ] + }, + { + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "signalType": "reputation-score", + "signalRef": "urn:srcos:external-trust-signal:local-agent-reputation-score", + "signalDigest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "verifiedAt": "2026-05-04T12:50:10Z", + "freshness": { + "maxAgeSeconds": 3600, + "observedAgeSeconds": 10, + "fresh": true + }, + "signature": { + "required": true, + "observed": true, + "signatureRef": "urn:srcos:signature:local-agent-reputation-score", + "signerRef": "urn:srcos:signer:local-trust-mirror" + }, + "authority": "non-authoritative-verifier-input", + "failureReason": null, + "notes": [ + "Reputation score can inform risk but cannot widen requested scope." + ] + }, + { + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "signalType": "registry-lookup", + "signalRef": "urn:srcos:external-trust-signal:local-agent-registry-lookup", + "signalDigest": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "verifiedAt": "2026-05-04T12:50:10Z", + "freshness": { + "maxAgeSeconds": 3600, + "observedAgeSeconds": 10, + "fresh": true + }, + "signature": { + "required": true, + "observed": true, + "signatureRef": "urn:srcos:signature:local-agent-registry-lookup", + "signerRef": "urn:srcos:signer:local-trust-mirror" + }, + "authority": "non-authoritative-verifier-input", + "failureReason": null, + "notes": [ + "Registry lookup is evidence for local grant resolution, not authorization." + ] + } + ], + "failureReason": null + }, + "receiptSafety": { + "includeRawContent": false, + "rawPromptContentIncluded": false, + "rawKvCacheContentIncluded": false, + "secretValuesIncluded": false, + "privateMemoryIncluded": false, + "apiKeysIncluded": false, + "walletPrivateKeysIncluded": false, + "rawCredentialsIncluded": false, + "rawUserDataIncluded": false + }, + "observedAt": "2026-05-04T12:50:10Z", + "labels": { + "sourceos.external-trust.prototype": "true", + "sourceos.external-trust.usable": "true", + "sourceos.external-trust.authority": "non-authoritative" + } +} diff --git a/examples/external-trust-signal-provider.stale.json b/examples/external-trust-signal-provider.stale.json new file mode 100644 index 0000000..f674f15 --- /dev/null +++ b/examples/external-trust-signal-provider.stale.json @@ -0,0 +1,78 @@ +{ + "specVersion": "0.1.0", + "id": "urn:srcos:agent-machine:external-trust-signal-provider:local-pch-style-stale", + "kind": "ExternalTrustSignalProvider", + "request": { + "requestId": "urn:srcos:agent-machine:external-trust-request:local-pch-style-stale", + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "agentPodId": "urn:srcos:agent-machine:agent-pod:local-podman-llama-cpp", + "requestedAgentIdentityRef": "urn:srcos:agent:local-inference-provider", + "sessionRef": "urn:srcos:session:local-bootstrap", + "workroomRef": "urn:srcos:workroom:local-default", + "topicRef": "urn:srcos:topic:agent-machine", + "requestedSignalTypes": [ + "agent-identity", + "cert-tier", + "reputation-score", + "registry-lookup" + ], + "verificationFreshnessSeconds": 300, + "requestedExpiresAt": "2026-05-04T13:50:00Z", + "signatureRequired": true + }, + "response": { + "status": "stale", + "usableForGrantResolution": false, + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "authority": "non-authoritative-verifier-input", + "verifiedAt": "2026-05-04T11:40:00Z", + "freshness": { + "maxAgeSeconds": 300, + "observedAgeSeconds": 4210, + "fresh": false + }, + "signals": [ + { + "providerRef": "urn:srcos:external-trust-provider:pch-style-local-mirror", + "signalType": "agent-identity", + "signalRef": "urn:srcos:external-trust-signal:local-agent-identity", + "signalDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "verifiedAt": "2026-05-04T11:40:00Z", + "freshness": { + "maxAgeSeconds": 300, + "observedAgeSeconds": 4210, + "fresh": false + }, + "signature": { + "required": true, + "observed": true, + "signatureRef": "urn:srcos:signature:local-agent-identity", + "signerRef": "urn:srcos:signer:local-trust-mirror" + }, + "authority": "non-authoritative-verifier-input", + "failureReason": "stale", + "notes": [ + "Stale external signal is not usable for grant resolution." + ] + } + ], + "failureReason": "External trust signal cache is stale; local grant resolver must ignore it or fail closed." + }, + "receiptSafety": { + "includeRawContent": false, + "rawPromptContentIncluded": false, + "rawKvCacheContentIncluded": false, + "secretValuesIncluded": false, + "privateMemoryIncluded": false, + "apiKeysIncluded": false, + "walletPrivateKeysIncluded": false, + "rawCredentialsIncluded": false, + "rawUserDataIncluded": false + }, + "observedAt": "2026-05-04T12:50:10Z", + "labels": { + "sourceos.external-trust.prototype": "true", + "sourceos.external-trust.usable": "false", + "sourceos.external-trust.failure": "stale" + } +} diff --git a/scripts/validate-governance.py b/scripts/validate-governance.py index ca2c9e4..1475522 100644 --- a/scripts/validate-governance.py +++ b/scripts/validate-governance.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate PolicyAdmission and AgentRegistryGrant semantic consistency.""" +"""Validate governance semantic consistency.""" from __future__ import annotations @@ -12,6 +12,10 @@ sys.path.insert(0, str(SRC_ROOT)) from agent_machine.contracts import load_json # noqa: E402 +from agent_machine.external_trust import ( # noqa: E402 + external_trust_signal_usable, + validate_external_trust_signal_provider_semantics, +) from agent_machine.governance import ( # noqa: E402 assert_activation_fails_closed, assert_activation_ready, @@ -33,6 +37,11 @@ "active_activation": REPO_ROOT / "examples" / "agent-registry-grant.active-activation.json", } +EXTERNAL_TRUST_EXAMPLES = { + "usable": REPO_ROOT / "examples" / "external-trust-signal-provider.active.json", + "stale": REPO_ROOT / "examples" / "external-trust-signal-provider.stale.json", +} + def validate_policy_examples() -> dict[str, dict]: values = {} @@ -54,6 +63,22 @@ def validate_grant_examples() -> dict[str, dict]: return values +def validate_external_trust_examples() -> dict[str, dict]: + values = {} + for name, path in EXTERNAL_TRUST_EXAMPLES.items(): + value = load_json(path) + validate_external_trust_signal_provider_semantics(value, str(path.relative_to(REPO_ROOT))) + values[name] = value + print(f"VALID external trust semantics {path.relative_to(REPO_ROOT)}") + + if not external_trust_signal_usable(values["usable"]): + raise AssertionError("usable external trust example must be usable for local grant resolution") + if external_trust_signal_usable(values["stale"]): + raise AssertionError("stale external trust example must not be usable for local grant resolution") + print("VALID external trust usable/stale matrix") + return values + + def validate_activation_matrix(policies: dict[str, dict], grants: dict[str, dict]) -> None: fail_closed_cases = [ ("missing", "missing"), @@ -85,6 +110,7 @@ def validate_activation_matrix(policies: dict[str, dict], grants: dict[str, dict def main() -> int: policies = validate_policy_examples() grants = validate_grant_examples() + validate_external_trust_examples() validate_activation_matrix(policies, grants) return 0 diff --git a/scripts/validate-package.py b/scripts/validate-package.py index b980768..972ea85 100644 --- a/scripts/validate-package.py +++ b/scripts/validate-package.py @@ -17,7 +17,9 @@ def main() -> int: import agent_machine.activation import agent_machine.cli import agent_machine.evidence + import agent_machine.external_trust import agent_machine.governance + import agent_machine.release_bundle import agent_machine.supply_chain import agent_machine.renderers.k8s import agent_machine.renderers.plan @@ -37,7 +39,9 @@ def main() -> int: "AgentPod", "AgentPlaneRuntimeEvidence", "AgentRegistryGrant", + "ExternalTrustSignalProvider", "PolicyAdmission", + "ReleaseEvidenceBundle", "StorageReceipt", } missing = sorted(required_kinds - set(mapping)) @@ -49,6 +53,10 @@ def main() -> int: raise AssertionError("stable_text_digest must return sha256: prefixed digest") if not agent_machine.supply_chain.is_sha256_digest("sha256:" + "a" * 64): raise AssertionError("supply_chain.is_sha256_digest rejected valid digest") + if agent_machine.release_bundle.DEFAULT_REPOSITORY != "SourceOS-Linux/agent-machine": + raise AssertionError("unexpected release_bundle default repository") + if agent_machine.external_trust.AUTHORITY != "non-authoritative-verifier-input": + raise AssertionError("unexpected external trust authority") if str(default_model_cache_path()) != "/var/lib/agent-machine/models": raise AssertionError("unexpected default model cache path") if str(default_evidence_path()) != "/var/lib/agent-machine/evidence": diff --git a/src/agent_machine/contracts.py b/src/agent_machine/contracts.py index 9c60d59..02673aa 100644 --- a/src/agent_machine/contracts.py +++ b/src/agent_machine/contracts.py @@ -53,8 +53,10 @@ def schema_by_kind(root: Path | None = None) -> dict[str, Path]: "AgentRegistryGrant": base / "agent-registry-grant.schema.json", "CacheTier": base / "cache-tier.schema.json", "DeploymentReceipt": base / "deployment-receipt.schema.json", + "ExternalTrustSignalProvider": base / "external-trust-signal-provider.schema.json", "InferenceProvider": base / "inference-provider.schema.json", "PolicyAdmission": base / "policy-admission.schema.json", + "ReleaseEvidenceBundle": base / "release-evidence-bundle.schema.json", "StorageReceipt": base / "storage-receipt.schema.json", } diff --git a/src/agent_machine/external_trust.py b/src/agent_machine/external_trust.py new file mode 100644 index 0000000..4ac3fd5 --- /dev/null +++ b/src/agent_machine/external_trust.py @@ -0,0 +1,227 @@ +"""External trust signal adapter validation. + +ExternalTrustSignalProvider artifacts are optional verifier inputs for Agent +Registry grant resolution. They are never authorization and must never become +the SourceOS root of trust. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +from agent_machine.contracts import load_json, schema_by_kind, validate_instance + +AUTHORITY = "non-authoritative-verifier-input" +USABLE_STATUS = "available" +UNUSABLE_STATUSES = {"unavailable", "stale", "malformed", "unsigned", "denied", "error"} +SIGNAL_TYPES = { + "agent-identity", + "cert-tier", + "reputation-score", + "counterparty-check", + "registry-lookup", + "other", +} +EXTRA_SAFETY_FLAGS = [ + "apiKeysIncluded", + "walletPrivateKeysIncluded", + "rawCredentialsIncluded", + "rawUserDataIncluded", +] +BASE_SAFETY_FLAGS = [ + "includeRawContent", + "rawPromptContentIncluded", + "rawKvCacheContentIncluded", + "secretValuesIncluded", + "privateMemoryIncluded", +] + + +def validate_external_trust_signal_provider_schema(path: Path, root: Path | None = None) -> dict[str, Any]: + validate_instance(path, schema_by_kind(root)["ExternalTrustSignalProvider"]) + value = load_json(path) + if not isinstance(value, dict): + raise AssertionError(f"{path}: ExternalTrustSignalProvider root must be an object") + return value + + +def validate_external_trust_signal_provider_semantics(provider: dict[str, Any], source: str = "") -> None: + request = _require_object(provider.get("request"), f"{source}: request") + response = _require_object(provider.get("response"), f"{source}: response") + safety = _require_object(provider.get("receiptSafety"), f"{source}: receiptSafety") + + provider_ref = request.get("providerRef") + if response.get("providerRef") != provider_ref: + raise AssertionError(f"{source}: response.providerRef must match request.providerRef") + + requested_signal_types = request.get("requestedSignalTypes") + if not isinstance(requested_signal_types, list) or not requested_signal_types: + raise AssertionError(f"{source}: request.requestedSignalTypes must be a non-empty list") + if len(requested_signal_types) != len(set(requested_signal_types)): + raise AssertionError(f"{source}: request.requestedSignalTypes must not contain duplicates") + if not set(requested_signal_types).issubset(SIGNAL_TYPES): + raise AssertionError(f"{source}: request.requestedSignalTypes contains unsupported values") + + freshness_window = request.get("verificationFreshnessSeconds") + if not isinstance(freshness_window, int) or freshness_window < 0: + raise AssertionError(f"{source}: request.verificationFreshnessSeconds must be a non-negative integer") + + status = response.get("status") + usable = response.get("usableForGrantResolution") + if status == USABLE_STATUS: + if usable is not True: + raise AssertionError(f"{source}: available response requires usableForGrantResolution=true") + if response.get("failureReason") is not None: + raise AssertionError(f"{source}: available response must not carry failureReason") + elif status in UNUSABLE_STATUSES: + if usable is not False: + raise AssertionError(f"{source}: response.status={status} requires usableForGrantResolution=false") + if not response.get("failureReason"): + raise AssertionError(f"{source}: response.status={status} requires failureReason") + else: + raise AssertionError(f"{source}: unsupported external trust status {status!r}") + + if response.get("authority") != AUTHORITY: + raise AssertionError(f"{source}: response.authority must be {AUTHORITY}") + + response_freshness = _require_object(response.get("freshness"), f"{source}: response.freshness") + _assert_freshness(response_freshness, f"{source}: response.freshness") + if usable is True and response_freshness.get("fresh") is not True: + raise AssertionError(f"{source}: usable external trust response must be fresh") + if status == "stale" and response_freshness.get("fresh") is not False: + raise AssertionError(f"{source}: stale response requires freshness.fresh=false") + + signals = response.get("signals") + if not isinstance(signals, list): + raise AssertionError(f"{source}: response.signals must be a list") + if usable is True and not signals: + raise AssertionError(f"{source}: usable external trust response requires at least one signal") + + signal_types_seen: list[str] = [] + for index, signal in enumerate(signals): + signal_source = f"{source}: response.signals[{index}]" + _assert_signal_payload( + signal, + signal_source, + expected_provider_ref=str(provider_ref), + requested_signal_types=set(requested_signal_types), + signature_required=bool(request.get("signatureRequired")), + usable_response=bool(usable), + ) + signal_types_seen.append(str(signal.get("signalType"))) + + if len(signal_types_seen) != len(set(signal_types_seen)): + raise AssertionError(f"{source}: response.signals must not contain duplicate signalType entries") + + _assert_safety_flags(safety, source) + + +def external_trust_signal_usable(provider: dict[str, Any]) -> bool: + """Return true only when an adapter result can be used as local verifier input. + + This result is still not authorization. It can only be considered by a local + Agent Registry grant resolver. + """ + response = provider.get("response", {}) + return ( + response.get("status") == USABLE_STATUS + and response.get("usableForGrantResolution") is True + and response.get("authority") == AUTHORITY + and response.get("freshness", {}).get("fresh") is True + ) + + +def _require_object(value: Any, source: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise AssertionError(f"{source} must be an object") + return value + + +def _assert_signal_payload( + signal: Any, + source: str, + *, + expected_provider_ref: str, + requested_signal_types: set[str], + signature_required: bool, + usable_response: bool, +) -> None: + signal_doc = _require_object(signal, source) + if signal_doc.get("providerRef") != expected_provider_ref: + raise AssertionError(f"{source}.providerRef must match request.providerRef") + signal_type = signal_doc.get("signalType") + if signal_type not in requested_signal_types: + raise AssertionError(f"{source}.signalType must be one of the requested signal types") + if signal_doc.get("authority") != AUTHORITY: + raise AssertionError(f"{source}.authority must be {AUTHORITY}") + if usable_response and signal_doc.get("failureReason") is not None: + raise AssertionError(f"{source}.failureReason must be null for usable responses") + + freshness = _require_object(signal_doc.get("freshness"), f"{source}.freshness") + _assert_freshness(freshness, f"{source}.freshness") + if usable_response and freshness.get("fresh") is not True: + raise AssertionError(f"{source}: usable response cannot include stale signal") + + signature = _require_object(signal_doc.get("signature"), f"{source}.signature") + if signature_required: + if signature.get("required") is not True: + raise AssertionError(f"{source}.signature.required must be true when request.signatureRequired=true") + if signature.get("observed") is not True: + raise AssertionError(f"{source}.signature.observed must be true when signatures are required") + if not signature.get("signatureRef"): + raise AssertionError(f"{source}.signature.signatureRef is required when signatures are required") + if not signature.get("signerRef"): + raise AssertionError(f"{source}.signature.signerRef is required when signatures are required") + + +def _assert_freshness(freshness: dict[str, Any], source: str) -> None: + max_age = freshness.get("maxAgeSeconds") + observed_age = freshness.get("observedAgeSeconds") + fresh = freshness.get("fresh") + if not isinstance(max_age, int) or max_age < 0: + raise AssertionError(f"{source}.maxAgeSeconds must be a non-negative integer") + if not isinstance(observed_age, int) or observed_age < 0: + raise AssertionError(f"{source}.observedAgeSeconds must be a non-negative integer") + if not isinstance(fresh, bool): + raise AssertionError(f"{source}.fresh must be a boolean") + if observed_age > max_age and fresh is True: + raise AssertionError(f"{source}.fresh cannot be true when observedAgeSeconds exceeds maxAgeSeconds") + if observed_age <= max_age and fresh is False: + raise AssertionError(f"{source}.fresh cannot be false when observedAgeSeconds is within maxAgeSeconds") + + +def _assert_safety_flags(safety: dict[str, Any], source: str) -> None: + for key in BASE_SAFETY_FLAGS + EXTRA_SAFETY_FLAGS: + if safety.get(key) is not False: + raise AssertionError(f"{source}: receiptSafety.{key} must be false") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate an ExternalTrustSignalProvider artifact") + parser.add_argument("external_trust_json", type=Path) + parser.add_argument("--expect", choices=["usable", "unusable"], required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + provider = validate_external_trust_signal_provider_schema(args.external_trust_json) + validate_external_trust_signal_provider_semantics(provider, str(args.external_trust_json)) + usable = external_trust_signal_usable(provider) + if args.expect == "usable" and not usable: + raise AssertionError(f"{args.external_trust_json}: expected usable external trust signal") + if args.expect == "unusable" and usable: + raise AssertionError(f"{args.external_trust_json}: expected unusable external trust signal") + print(f"VALID external trust {args.expect} {args.external_trust_json}") + 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