diff --git a/docs/capability-intent-schema.v0.1.json b/docs/capability-intent-schema.v0.1.json new file mode 100644 index 00000000..1036119c --- /dev/null +++ b/docs/capability-intent-schema.v0.1.json @@ -0,0 +1,580 @@ +{ + "$defs": { + "CapabilityIntentPolicyV1": { + "additionalProperties": false, + "properties": { + "capability_intent_schema_version": { + "const": "0.1", + "default": "0.1", + "title": "Capability Intent Schema Version", + "type": "string" + }, + "coverage": { + "$ref": "#/$defs/IntentCoverageV1" + }, + "experimental": { + "const": true, + "default": true, + "title": "Experimental", + "type": "boolean" + }, + "owner": { + "minLength": 1, + "title": "Owner", + "type": "string" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "rules": { + "items": { + "$ref": "#/$defs/IntentRuleV1" + }, + "title": "Rules", + "type": "array" + }, + "subject": { + "$ref": "#/$defs/ReconciliationSubjectV1" + } + }, + "required": [ + "subject", + "owner", + "reason" + ], + "title": "CapabilityIntentPolicyV1", + "type": "object" + }, + "FilesystemTargetV1": { + "additionalProperties": false, + "properties": { + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "filesystem", + "default": "filesystem", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "path_class": { + "enum": [ + "workspace", + "temporary", + "home", + "system", + "secret", + "external", + "unknown" + ], + "title": "Path Class", + "type": "string" + }, + "resource_class": { + "default": "file", + "title": "Resource Class", + "type": "string" + } + }, + "required": [ + "path_class" + ], + "title": "FilesystemTargetV1", + "type": "object" + }, + "IntentCoverageV1": { + "additionalProperties": false, + "properties": { + "filesystem": { + "default": "partial", + "enum": [ + "closed_world", + "partial" + ], + "title": "Filesystem", + "type": "string" + }, + "mcp": { + "default": "partial", + "enum": [ + "closed_world", + "partial" + ], + "title": "Mcp", + "type": "string" + }, + "network": { + "default": "partial", + "enum": [ + "closed_world", + "partial" + ], + "title": "Network", + "type": "string" + }, + "resource": { + "default": "partial", + "enum": [ + "closed_world", + "partial" + ], + "title": "Resource", + "type": "string" + }, + "tool": { + "default": "partial", + "enum": [ + "closed_world", + "partial" + ], + "title": "Tool", + "type": "string" + } + }, + "title": "IntentCoverageV1", + "type": "object" + }, + "IntentRuleV1": { + "additionalProperties": false, + "properties": { + "effect": { + "enum": [ + "allow", + "deny" + ], + "title": "Effect", + "type": "string" + }, + "expected_principal": { + "anyOf": [ + { + "$ref": "#/$defs/PrincipalBindingV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "title": "Id", + "type": "string" + }, + "target": { + "discriminator": { + "mapping": { + "filesystem": "#/$defs/FilesystemTargetV1", + "mcp": "#/$defs/McpTargetV1", + "network": "#/$defs/NetworkTargetV1", + "resource": "#/$defs/ResourceTargetV1", + "tool": "#/$defs/ToolTargetV1" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ToolTargetV1" + }, + { + "$ref": "#/$defs/McpTargetV1" + }, + { + "$ref": "#/$defs/NetworkTargetV1" + }, + { + "$ref": "#/$defs/FilesystemTargetV1" + }, + { + "$ref": "#/$defs/ResourceTargetV1" + } + ], + "title": "Target" + } + }, + "required": [ + "id", + "effect", + "target" + ], + "title": "IntentRuleV1", + "type": "object" + }, + "McpTargetV1": { + "additionalProperties": false, + "properties": { + "capability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capability Id" + }, + "kind": { + "const": "mcp", + "default": "mcp", + "title": "Kind", + "type": "string" + }, + "mcp_server": { + "title": "Mcp Server", + "type": "string" + }, + "operation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "mcp_server", + "tool_name" + ], + "title": "McpTargetV1", + "type": "object" + }, + "NetworkTargetV1": { + "additionalProperties": false, + "properties": { + "domain": { + "pattern": "^[^:/?#@*]+$", + "title": "Domain", + "type": "string" + }, + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "network", + "default": "network", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "port": { + "anyOf": [ + { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Port" + }, + "resource_class": { + "default": "url", + "title": "Resource Class", + "type": "string" + }, + "scheme": { + "anyOf": [ + { + "enum": [ + "http", + "https", + "other" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scheme" + } + }, + "required": [ + "domain" + ], + "title": "NetworkTargetV1", + "type": "object" + }, + "PrincipalBindingV1": { + "additionalProperties": false, + "properties": { + "auth_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Type" + }, + "authority_mode": { + "default": "unknown", + "enum": [ + "none", + "scoped", + "unscoped", + "ambient", + "unknown" + ], + "title": "Authority Mode", + "type": "string" + }, + "authority_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Source" + }, + "credential_mode": { + "title": "Credential Mode", + "type": "string" + }, + "kind": { + "enum": [ + "human_impersonation", + "delegated_agent", + "service_account", + "assumed_role", + "agent_principal", + "workload_identity", + "ambient", + "anonymous", + "unknown", + "other" + ], + "title": "Kind", + "type": "string" + }, + "principal_ref_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal Ref Sha256" + }, + "scopes": { + "items": { + "type": "string" + }, + "title": "Scopes", + "type": "array" + } + }, + "required": [ + "kind", + "credential_mode" + ], + "title": "PrincipalBindingV1", + "type": "object" + }, + "ReconciliationSubjectV1": { + "additionalProperties": false, + "properties": { + "agent_id": { + "title": "Agent Id", + "type": "string" + }, + "environment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Environment" + } + }, + "required": [ + "agent_id" + ], + "title": "ReconciliationSubjectV1", + "type": "object" + }, + "ResourceTargetV1": { + "additionalProperties": false, + "properties": { + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "resource", + "default": "resource", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "resource_class": { + "title": "Resource Class", + "type": "string" + } + }, + "required": [ + "resource_class" + ], + "title": "ResourceTargetV1", + "type": "object" + }, + "ToolTargetV1": { + "additionalProperties": false, + "properties": { + "capability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capability Id" + }, + "kind": { + "const": "tool", + "default": "tool", + "title": "Kind", + "type": "string" + }, + "operation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "tool_name" + ], + "title": "ToolTargetV1", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/capability-intent-schema.v0.1.json", + "$ref": "#/$defs/CapabilityIntentPolicyV1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Experimental JSON Schema for a reviewed, human-owned capability-intent sidecar. It is separate from untrusted observations and is not a release gate.", + "title": "Agents Shipgate Experimental Capability Intent v0.1" +} diff --git a/docs/capability-observation-schema.v0.1.json b/docs/capability-observation-schema.v0.1.json new file mode 100644 index 00000000..e336334a --- /dev/null +++ b/docs/capability-observation-schema.v0.1.json @@ -0,0 +1,755 @@ +{ + "$defs": { + "CapabilityObservationBundleV1": { + "additionalProperties": false, + "properties": { + "capability_observation_schema_version": { + "const": "0.1", + "default": "0.1", + "title": "Capability Observation Schema Version", + "type": "string" + }, + "evidence_trust": { + "const": "untrusted", + "default": "untrusted", + "title": "Evidence Trust", + "type": "string" + }, + "experimental": { + "const": true, + "default": true, + "title": "Experimental", + "type": "boolean" + }, + "grants": { + "items": { + "$ref": "#/$defs/GrantRecordV1" + }, + "title": "Grants", + "type": "array" + }, + "observations": { + "items": { + "$ref": "#/$defs/ObservationRecordV1" + }, + "title": "Observations", + "type": "array" + }, + "sources": { + "items": { + "$ref": "#/$defs/ObservationSourceV1" + }, + "title": "Sources", + "type": "array" + }, + "subject": { + "$ref": "#/$defs/ReconciliationSubjectV1" + } + }, + "required": [ + "subject" + ], + "title": "CapabilityObservationBundleV1", + "type": "object" + }, + "FilesystemTargetV1": { + "additionalProperties": false, + "properties": { + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "filesystem", + "default": "filesystem", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "path_class": { + "enum": [ + "workspace", + "temporary", + "home", + "system", + "secret", + "external", + "unknown" + ], + "title": "Path Class", + "type": "string" + }, + "resource_class": { + "default": "file", + "title": "Resource Class", + "type": "string" + } + }, + "required": [ + "path_class" + ], + "title": "FilesystemTargetV1", + "type": "object" + }, + "GrantRecordV1": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "principal": { + "$ref": "#/$defs/PrincipalBindingV1" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "target": { + "discriminator": { + "mapping": { + "filesystem": "#/$defs/FilesystemTargetV1", + "mcp": "#/$defs/McpTargetV1", + "network": "#/$defs/NetworkTargetV1", + "resource": "#/$defs/ResourceTargetV1", + "tool": "#/$defs/ToolTargetV1" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ToolTargetV1" + }, + { + "$ref": "#/$defs/McpTargetV1" + }, + { + "$ref": "#/$defs/NetworkTargetV1" + }, + { + "$ref": "#/$defs/FilesystemTargetV1" + }, + { + "$ref": "#/$defs/ResourceTargetV1" + } + ], + "title": "Target" + } + }, + "required": [ + "id", + "source_id", + "target", + "principal" + ], + "title": "GrantRecordV1", + "type": "object" + }, + "InventoryCoverageByKindV1": { + "additionalProperties": false, + "properties": { + "filesystem": { + "default": "unknown", + "enum": [ + "complete", + "partial", + "unknown" + ], + "title": "Filesystem", + "type": "string" + }, + "mcp": { + "default": "unknown", + "enum": [ + "complete", + "partial", + "unknown" + ], + "title": "Mcp", + "type": "string" + }, + "network": { + "default": "unknown", + "enum": [ + "complete", + "partial", + "unknown" + ], + "title": "Network", + "type": "string" + }, + "resource": { + "default": "unknown", + "enum": [ + "complete", + "partial", + "unknown" + ], + "title": "Resource", + "type": "string" + }, + "tool": { + "default": "unknown", + "enum": [ + "complete", + "partial", + "unknown" + ], + "title": "Tool", + "type": "string" + } + }, + "title": "InventoryCoverageByKindV1", + "type": "object" + }, + "McpTargetV1": { + "additionalProperties": false, + "properties": { + "capability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capability Id" + }, + "kind": { + "const": "mcp", + "default": "mcp", + "title": "Kind", + "type": "string" + }, + "mcp_server": { + "title": "Mcp Server", + "type": "string" + }, + "operation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "mcp_server", + "tool_name" + ], + "title": "McpTargetV1", + "type": "object" + }, + "NetworkTargetV1": { + "additionalProperties": false, + "properties": { + "domain": { + "pattern": "^[^:/?#@*]+$", + "title": "Domain", + "type": "string" + }, + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "network", + "default": "network", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "port": { + "anyOf": [ + { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Port" + }, + "resource_class": { + "default": "url", + "title": "Resource Class", + "type": "string" + }, + "scheme": { + "anyOf": [ + { + "enum": [ + "http", + "https", + "other" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scheme" + } + }, + "required": [ + "domain" + ], + "title": "NetworkTargetV1", + "type": "object" + }, + "ObservationRecordV1": { + "additionalProperties": false, + "properties": { + "correlation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Correlation Id" + }, + "id": { + "title": "Id", + "type": "string" + }, + "outcome": { + "enum": [ + "attempted", + "allowed", + "denied" + ], + "title": "Outcome", + "type": "string" + }, + "principal": { + "$ref": "#/$defs/PrincipalBindingV1" + }, + "sequence": { + "minimum": 0, + "title": "Sequence", + "type": "integer" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "target": { + "discriminator": { + "mapping": { + "filesystem": "#/$defs/FilesystemTargetV1", + "mcp": "#/$defs/McpTargetV1", + "network": "#/$defs/NetworkTargetV1", + "resource": "#/$defs/ResourceTargetV1", + "tool": "#/$defs/ToolTargetV1" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ToolTargetV1" + }, + { + "$ref": "#/$defs/McpTargetV1" + }, + { + "$ref": "#/$defs/NetworkTargetV1" + }, + { + "$ref": "#/$defs/FilesystemTargetV1" + }, + { + "$ref": "#/$defs/ResourceTargetV1" + } + ], + "title": "Target" + } + }, + "required": [ + "id", + "source_id", + "sequence", + "target", + "outcome", + "principal" + ], + "title": "ObservationRecordV1", + "type": "object" + }, + "ObservationSourceV1": { + "additionalProperties": false, + "properties": { + "behavioral_coverage": { + "const": "sampled", + "default": "sampled", + "title": "Behavioral Coverage", + "type": "string" + }, + "collection_mode": { + "enum": [ + "learning", + "enforced", + "evaluation" + ], + "title": "Collection Mode", + "type": "string" + }, + "grant_inventory_coverage": { + "$ref": "#/$defs/InventoryCoverageByKindV1" + }, + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "sandbox_policy", + "sandbox_run", + "eval_run" + ], + "title": "Kind", + "type": "string" + }, + "observation_collection_coverage": { + "default": "unknown", + "enum": [ + "complete_for_run", + "partial", + "unknown" + ], + "title": "Observation Collection Coverage", + "type": "string" + }, + "policy_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Policy Sha256" + }, + "producer_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Producer Version" + }, + "run_id": { + "title": "Run Id", + "type": "string" + }, + "run_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Run Sha256", + "type": "string" + }, + "vendor": { + "title": "Vendor", + "type": "string" + } + }, + "required": [ + "id", + "kind", + "vendor", + "collection_mode", + "run_id", + "run_sha256", + "policy_sha256" + ], + "title": "ObservationSourceV1", + "type": "object" + }, + "PrincipalBindingV1": { + "additionalProperties": false, + "properties": { + "auth_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Type" + }, + "authority_mode": { + "default": "unknown", + "enum": [ + "none", + "scoped", + "unscoped", + "ambient", + "unknown" + ], + "title": "Authority Mode", + "type": "string" + }, + "authority_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Source" + }, + "credential_mode": { + "title": "Credential Mode", + "type": "string" + }, + "kind": { + "enum": [ + "human_impersonation", + "delegated_agent", + "service_account", + "assumed_role", + "agent_principal", + "workload_identity", + "ambient", + "anonymous", + "unknown", + "other" + ], + "title": "Kind", + "type": "string" + }, + "principal_ref_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal Ref Sha256" + }, + "scopes": { + "items": { + "type": "string" + }, + "title": "Scopes", + "type": "array" + } + }, + "required": [ + "kind", + "credential_mode" + ], + "title": "PrincipalBindingV1", + "type": "object" + }, + "ReconciliationSubjectV1": { + "additionalProperties": false, + "properties": { + "agent_id": { + "title": "Agent Id", + "type": "string" + }, + "environment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Environment" + } + }, + "required": [ + "agent_id" + ], + "title": "ReconciliationSubjectV1", + "type": "object" + }, + "ResourceTargetV1": { + "additionalProperties": false, + "properties": { + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "resource", + "default": "resource", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "resource_class": { + "title": "Resource Class", + "type": "string" + } + }, + "required": [ + "resource_class" + ], + "title": "ResourceTargetV1", + "type": "object" + }, + "ToolTargetV1": { + "additionalProperties": false, + "properties": { + "capability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capability Id" + }, + "kind": { + "const": "tool", + "default": "tool", + "title": "Kind", + "type": "string" + }, + "operation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "tool_name" + ], + "title": "ToolTargetV1", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/capability-observation-schema.v0.1.json", + "$ref": "#/$defs/CapabilityObservationBundleV1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Experimental vendor-neutral JSON Schema for untrusted local sandbox grants and sampled eval observations. It performs no live collection and does not gate.", + "title": "Agents Shipgate Experimental Capability Observations v0.1" +} diff --git a/docs/capability-reconciliation-schema.v0.1.json b/docs/capability-reconciliation-schema.v0.1.json new file mode 100644 index 00000000..19adc629 --- /dev/null +++ b/docs/capability-reconciliation-schema.v0.1.json @@ -0,0 +1,1180 @@ +{ + "$defs": { + "AuthorityDeltaV1": { + "additionalProperties": false, + "properties": { + "after": { + "$ref": "#/$defs/PrincipalBindingV1" + }, + "before": { + "$ref": "#/$defs/PrincipalBindingV1" + } + }, + "required": [ + "before", + "after" + ], + "title": "AuthorityDeltaV1", + "type": "object" + }, + "CandidatePolicyChangeV1": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "remove_or_narrow_grant", + "investigate_runtime_route", + "investigate_denied_attempt", + "consider_minimal_grant", + "review_principal", + "add_or_correct_static_declaration", + "review_intent_or_declaration", + "review_intent_policy", + "inspect_enforcement_integrity" + ], + "title": "Action", + "type": "string" + }, + "actor": { + "const": "human", + "default": "human", + "title": "Actor", + "type": "string" + }, + "approval_state": { + "const": "unreviewed", + "default": "unreviewed", + "title": "Approval State", + "type": "string" + }, + "auto_apply": { + "const": false, + "default": false, + "title": "Auto Apply", + "type": "boolean" + }, + "cohort_id": { + "title": "Cohort Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "requires_human_review": { + "const": true, + "default": true, + "title": "Requires Human Review", + "type": "boolean" + }, + "safe_to_attempt": { + "const": false, + "default": false, + "title": "Safe To Attempt", + "type": "boolean" + }, + "target": { + "discriminator": { + "mapping": { + "filesystem": "#/$defs/FilesystemTargetV1", + "mcp": "#/$defs/McpTargetV1", + "network": "#/$defs/NetworkTargetV1", + "resource": "#/$defs/ResourceTargetV1", + "tool": "#/$defs/ToolTargetV1" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ToolTargetV1" + }, + { + "$ref": "#/$defs/McpTargetV1" + }, + { + "$ref": "#/$defs/NetworkTargetV1" + }, + { + "$ref": "#/$defs/FilesystemTargetV1" + }, + { + "$ref": "#/$defs/ResourceTargetV1" + } + ], + "title": "Target" + } + }, + "required": [ + "id", + "cohort_id", + "target", + "action", + "rationale" + ], + "title": "CandidatePolicyChangeV1", + "type": "object" + }, + "CapabilityReconciliationV1": { + "additionalProperties": false, + "properties": { + "candidate_policy_changes": { + "items": { + "$ref": "#/$defs/CandidatePolicyChangeV1" + }, + "title": "Candidate Policy Changes", + "type": "array" + }, + "capability_lock_semantic_hash": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Capability Lock Semantic Hash", + "type": "string" + }, + "capability_reconciliation_schema_version": { + "const": "0.1", + "default": "0.1", + "title": "Capability Reconciliation Schema Version", + "type": "string" + }, + "cohorts": { + "items": { + "$ref": "#/$defs/ReconciliationCohortV1" + }, + "title": "Cohorts", + "type": "array" + }, + "evidence_gaps": { + "items": { + "$ref": "#/$defs/ReconciliationEvidenceGapV1" + }, + "title": "Evidence Gaps", + "type": "array" + }, + "evidence_trust": { + "const": "untrusted", + "default": "untrusted", + "title": "Evidence Trust", + "type": "string" + }, + "experimental": { + "const": true, + "default": true, + "title": "Experimental", + "type": "boolean" + }, + "inputs": { + "items": { + "$ref": "#/$defs/ReconciliationInputBindingV1" + }, + "title": "Inputs", + "type": "array" + }, + "live_collection_performed": { + "const": false, + "default": false, + "title": "Live Collection Performed", + "type": "boolean" + }, + "local_imported_evidence_only": { + "const": true, + "default": true, + "title": "Local Imported Evidence Only", + "type": "boolean" + }, + "notes": { + "items": { + "type": "string" + }, + "title": "Notes", + "type": "array" + }, + "release_gate_effect": { + "const": false, + "default": false, + "title": "Release Gate Effect", + "type": "boolean" + }, + "results": { + "items": { + "$ref": "#/$defs/ReconciliationResultV1" + }, + "title": "Results", + "type": "array" + }, + "subject": { + "$ref": "#/$defs/ReconciliationSubjectV1" + }, + "summary": { + "$ref": "#/$defs/ReconciliationSummaryV1" + } + }, + "required": [ + "subject", + "capability_lock_semantic_hash", + "inputs", + "summary", + "cohorts", + "results", + "evidence_gaps", + "candidate_policy_changes" + ], + "title": "CapabilityReconciliationV1", + "type": "object" + }, + "FilesystemTargetV1": { + "additionalProperties": false, + "properties": { + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "filesystem", + "default": "filesystem", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "path_class": { + "enum": [ + "workspace", + "temporary", + "home", + "system", + "secret", + "external", + "unknown" + ], + "title": "Path Class", + "type": "string" + }, + "resource_class": { + "default": "file", + "title": "Resource Class", + "type": "string" + } + }, + "required": [ + "path_class" + ], + "title": "FilesystemTargetV1", + "type": "object" + }, + "McpTargetV1": { + "additionalProperties": false, + "properties": { + "capability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capability Id" + }, + "kind": { + "const": "mcp", + "default": "mcp", + "title": "Kind", + "type": "string" + }, + "mcp_server": { + "title": "Mcp Server", + "type": "string" + }, + "operation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "mcp_server", + "tool_name" + ], + "title": "McpTargetV1", + "type": "object" + }, + "NetworkTargetV1": { + "additionalProperties": false, + "properties": { + "domain": { + "pattern": "^[^:/?#@*]+$", + "title": "Domain", + "type": "string" + }, + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "network", + "default": "network", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "port": { + "anyOf": [ + { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Port" + }, + "resource_class": { + "default": "url", + "title": "Resource Class", + "type": "string" + }, + "scheme": { + "anyOf": [ + { + "enum": [ + "http", + "https", + "other" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scheme" + } + }, + "required": [ + "domain" + ], + "title": "NetworkTargetV1", + "type": "object" + }, + "PrincipalBindingV1": { + "additionalProperties": false, + "properties": { + "auth_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Auth Type" + }, + "authority_mode": { + "default": "unknown", + "enum": [ + "none", + "scoped", + "unscoped", + "ambient", + "unknown" + ], + "title": "Authority Mode", + "type": "string" + }, + "authority_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Source" + }, + "credential_mode": { + "title": "Credential Mode", + "type": "string" + }, + "kind": { + "enum": [ + "human_impersonation", + "delegated_agent", + "service_account", + "assumed_role", + "agent_principal", + "workload_identity", + "ambient", + "anonymous", + "unknown", + "other" + ], + "title": "Kind", + "type": "string" + }, + "principal_ref_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Principal Ref Sha256" + }, + "scopes": { + "items": { + "type": "string" + }, + "title": "Scopes", + "type": "array" + } + }, + "required": [ + "kind", + "credential_mode" + ], + "title": "PrincipalBindingV1", + "type": "object" + }, + "ReconciliationCohortCoverageV1": { + "additionalProperties": false, + "properties": { + "declared": { + "enum": [ + "complete", + "partial" + ], + "title": "Declared", + "type": "string" + }, + "granted": { + "additionalProperties": { + "enum": [ + "complete", + "partial", + "unknown" + ], + "type": "string" + }, + "propertyNames": { + "enum": [ + "tool", + "mcp", + "network", + "filesystem", + "resource" + ] + }, + "title": "Granted", + "type": "object" + }, + "intended": { + "additionalProperties": { + "enum": [ + "closed_world", + "partial" + ], + "type": "string" + }, + "propertyNames": { + "enum": [ + "tool", + "mcp", + "network", + "filesystem", + "resource" + ] + }, + "title": "Intended", + "type": "object" + }, + "observed": { + "const": "sampled", + "default": "sampled", + "title": "Observed", + "type": "string" + } + }, + "required": [ + "intended", + "declared", + "granted" + ], + "title": "ReconciliationCohortCoverageV1", + "type": "object" + }, + "ReconciliationCohortV1": { + "additionalProperties": false, + "properties": { + "coverage": { + "$ref": "#/$defs/ReconciliationCohortCoverageV1" + }, + "evidence_gap_count": { + "title": "Evidence Gap Count", + "type": "integer" + }, + "id": { + "title": "Id", + "type": "string" + }, + "policy_sha256": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Policy Sha256" + }, + "result_count": { + "title": "Result Count", + "type": "integer" + }, + "signal_counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Signal Counts", + "type": "object" + }, + "source_refs": { + "items": { + "type": "string" + }, + "title": "Source Refs", + "type": "array" + }, + "source_runs": { + "items": { + "$ref": "#/$defs/ReconciliationSourceRunV1" + }, + "title": "Source Runs", + "type": "array" + } + }, + "required": [ + "id", + "policy_sha256", + "source_refs", + "source_runs", + "coverage", + "result_count", + "evidence_gap_count" + ], + "title": "ReconciliationCohortV1", + "type": "object" + }, + "ReconciliationEvidenceGapV1": { + "additionalProperties": false, + "properties": { + "cohort_id": { + "title": "Cohort Id", + "type": "string" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "missing_policy_hash", + "policy_snapshot_mismatch", + "ambiguous_target", + "unmapped_target", + "incomplete_intent_inventory", + "incomplete_declared_inventory", + "incomplete_grant_inventory", + "conflicting_grants", + "unsupported_resource_comparison" + ], + "title": "Kind", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "target": { + "anyOf": [ + { + "discriminator": { + "mapping": { + "filesystem": "#/$defs/FilesystemTargetV1", + "mcp": "#/$defs/McpTargetV1", + "network": "#/$defs/NetworkTargetV1", + "resource": "#/$defs/ResourceTargetV1", + "tool": "#/$defs/ToolTargetV1" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ToolTargetV1" + }, + { + "$ref": "#/$defs/McpTargetV1" + }, + { + "$ref": "#/$defs/NetworkTargetV1" + }, + { + "$ref": "#/$defs/FilesystemTargetV1" + }, + { + "$ref": "#/$defs/ResourceTargetV1" + } + ] + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target" + } + }, + "required": [ + "id", + "cohort_id", + "kind", + "message" + ], + "title": "ReconciliationEvidenceGapV1", + "type": "object" + }, + "ReconciliationInputBindingV1": { + "additionalProperties": false, + "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "role": { + "enum": [ + "capability_lock", + "intent_policy", + "evidence", + "base_evidence" + ], + "title": "Role", + "type": "string" + }, + "schema_version": { + "title": "Schema Version", + "type": "string" + }, + "sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "role", + "path", + "sha256", + "schema_version" + ], + "title": "ReconciliationInputBindingV1", + "type": "object" + }, + "ReconciliationResultV1": { + "additionalProperties": false, + "properties": { + "authority_delta": { + "anyOf": [ + { + "$ref": "#/$defs/AuthorityDeltaV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "cohort_id": { + "title": "Cohort Id", + "type": "string" + }, + "disposition": { + "enum": [ + "informational", + "investigate", + "human_review" + ], + "title": "Disposition", + "type": "string" + }, + "evidence_refs": { + "items": { + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "membership": { + "$ref": "#/$defs/SetMembershipV1" + }, + "outcomes": { + "items": { + "enum": [ + "attempted", + "allowed", + "denied" + ], + "type": "string" + }, + "title": "Outcomes", + "type": "array" + }, + "primary_signal": { + "enum": [ + "excess_authority", + "policy_unclassified_grant", + "behavioral_drift", + "denied_attempt", + "declared_ungranted", + "granted_unobserved", + "allowed_ungranted", + "intended_undeclared", + "declared_unintended", + "principal_changed", + "principal_mismatch", + "aligned" + ], + "title": "Primary Signal", + "type": "string" + }, + "signals": { + "items": { + "enum": [ + "excess_authority", + "policy_unclassified_grant", + "behavioral_drift", + "denied_attempt", + "declared_ungranted", + "granted_unobserved", + "allowed_ungranted", + "intended_undeclared", + "declared_unintended", + "principal_changed", + "principal_mismatch", + "aligned" + ], + "type": "string" + }, + "title": "Signals", + "type": "array" + }, + "target": { + "discriminator": { + "mapping": { + "filesystem": "#/$defs/FilesystemTargetV1", + "mcp": "#/$defs/McpTargetV1", + "network": "#/$defs/NetworkTargetV1", + "resource": "#/$defs/ResourceTargetV1", + "tool": "#/$defs/ToolTargetV1" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ToolTargetV1" + }, + { + "$ref": "#/$defs/McpTargetV1" + }, + { + "$ref": "#/$defs/NetworkTargetV1" + }, + { + "$ref": "#/$defs/FilesystemTargetV1" + }, + { + "$ref": "#/$defs/ResourceTargetV1" + } + ], + "title": "Target" + } + }, + "required": [ + "id", + "cohort_id", + "target", + "membership", + "signals", + "primary_signal", + "disposition" + ], + "title": "ReconciliationResultV1", + "type": "object" + }, + "ReconciliationSourceRunV1": { + "additionalProperties": false, + "properties": { + "collection_mode": { + "enum": [ + "learning", + "enforced", + "evaluation" + ], + "title": "Collection Mode", + "type": "string" + }, + "kind": { + "enum": [ + "sandbox_policy", + "sandbox_run", + "eval_run" + ], + "title": "Kind", + "type": "string" + }, + "policy_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Policy Sha256" + }, + "run_id": { + "title": "Run Id", + "type": "string" + }, + "run_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Run Sha256", + "type": "string" + }, + "source_id": { + "title": "Source Id", + "type": "string" + } + }, + "required": [ + "source_id", + "kind", + "collection_mode", + "run_id", + "run_sha256", + "policy_sha256" + ], + "title": "ReconciliationSourceRunV1", + "type": "object" + }, + "ReconciliationSubjectV1": { + "additionalProperties": false, + "properties": { + "agent_id": { + "title": "Agent Id", + "type": "string" + }, + "environment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Environment" + } + }, + "required": [ + "agent_id" + ], + "title": "ReconciliationSubjectV1", + "type": "object" + }, + "ReconciliationSummaryV1": { + "additionalProperties": false, + "properties": { + "candidate_policy_change_count": { + "title": "Candidate Policy Change Count", + "type": "integer" + }, + "cohort_count": { + "title": "Cohort Count", + "type": "integer" + }, + "evidence_gap_count": { + "title": "Evidence Gap Count", + "type": "integer" + }, + "result_count": { + "title": "Result Count", + "type": "integer" + }, + "signal_counts": { + "additionalProperties": { + "type": "integer" + }, + "title": "Signal Counts", + "type": "object" + }, + "target_count": { + "title": "Target Count", + "type": "integer" + } + }, + "required": [ + "cohort_count", + "target_count", + "result_count", + "evidence_gap_count", + "candidate_policy_change_count" + ], + "title": "ReconciliationSummaryV1", + "type": "object" + }, + "ResourceTargetV1": { + "additionalProperties": false, + "properties": { + "granularity": { + "default": "class", + "enum": [ + "exact", + "class" + ], + "title": "Granularity", + "type": "string" + }, + "kind": { + "const": "resource", + "default": "resource", + "title": "Kind", + "type": "string" + }, + "locator_sha256": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locator Sha256" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "resource_class": { + "title": "Resource Class", + "type": "string" + } + }, + "required": [ + "resource_class" + ], + "title": "ResourceTargetV1", + "type": "object" + }, + "SetMembershipV1": { + "additionalProperties": false, + "properties": { + "declared": { + "enum": [ + "present", + "absent", + "unknown" + ], + "title": "Declared", + "type": "string" + }, + "granted": { + "enum": [ + "present", + "absent", + "unknown" + ], + "title": "Granted", + "type": "string" + }, + "intended": { + "enum": [ + "present", + "absent", + "unknown" + ], + "title": "Intended", + "type": "string" + }, + "observed": { + "enum": [ + "observed", + "not_observed" + ], + "title": "Observed", + "type": "string" + } + }, + "required": [ + "intended", + "declared", + "granted", + "observed" + ], + "title": "SetMembershipV1", + "type": "object" + }, + "ToolTargetV1": { + "additionalProperties": false, + "properties": { + "capability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Capability Id" + }, + "kind": { + "const": "tool", + "default": "tool", + "title": "Kind", + "type": "string" + }, + "operation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Operation" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "tool_name": { + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "tool_name" + ], + "title": "ToolTargetV1", + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/main/docs/capability-reconciliation-schema.v0.1.json", + "$ref": "#/$defs/CapabilityReconciliationV1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Experimental JSON Schema for deterministic four-set capability reconciliation. Candidate changes are human-only and release_decision.decision remains the sole gate.", + "title": "Agents Shipgate Experimental Capability Reconciliation v0.1" +} diff --git a/docs/engineering/capability-reconciliation.md b/docs/engineering/capability-reconciliation.md new file mode 100644 index 00000000..88cc83a4 --- /dev/null +++ b/docs/engineering/capability-reconciliation.md @@ -0,0 +1,226 @@ +# Capability Reconciliation Prototype + +Status: **experimental, local-only, non-gating**. + +This design reconciles four different claims about an agent capability: + +1. what reviewed policy intends; +2. what the static capability surface declares; +3. what a sandbox grants; and +4. what an eval or sandbox run attempted. + +The prototype consumes local artifacts and emits a deterministic audit artifact. +It does not run an agent, connect to a sandbox, call a tool, update an allowlist, +or affect `report.json.release_decision.decision`. + +## Why this is not an extension of trace learning + +Existing trace ingestion normalizes selected tool-call scalars and +`CapabilityRuntimeEvidence` links them to `CapabilityFactV1`. That block is +deliberately audit-only and excludes raw prompts, arguments, and outputs. It +does not represent a complete sandbox grant set, resource selectors, explicit +sandbox enforcement outcomes, or principal bindings. + +A learning run is especially unsuitable as policy authority. It can capture an +accidental or adversarial attempt, and it can miss rare legitimate paths. This +prototype therefore keeps reviewed intent and untrusted observations in +different artifacts and never promotes observations into an approved policy. + +## Crosswalk + +| Set | Current Shipgate substrate | Prototype source | Absence is meaningful when | +|---|---|---|---| +| Intended (`I`) | `action_surface.actions[]`, policies, prohibited actions, and prose lenses | Human-owned capability-intent sidecar | The target kind is `closed_world`, or an explicit deny selector matches | +| Declared (`D`) | Capability lock v0.3 and `CapabilityFactV1` | Existing exported capability lock | Static extraction is complete for tool/MCP identity | +| Granted (`G`) | No product-agent runtime grant inventory; host grants are a different boundary | Effective grants in the observation bundle | Coverage is `complete` for the same target kind and policy cohort | +| Observed (`O`) | Normalized trace evidence | Attempt/allow/deny rows in the observation bundle | Never; a missing row means only “not observed in supplied runs” | + +`CapabilityIdentity.resource` is derived from static permission/scope evidence. +It must not be equated heuristically with a runtime hostname or file class. +Consequently, v0.1 can definitively reconcile resource intent, grants, and +observations while static declaration membership for resource-only targets +remains `unknown`. + +## Trust model + +- The capability-intent sidecar is a reviewed, human-owned trust input. It + requires an owner and reason. +- Sandbox and eval artifacts always declare `evidence_trust: "untrusted"`. +- A source run hash and sandbox policy hash bind bytes/equality. They do not + authenticate the producer or prove correct collection. +- A sandbox grant snapshot supports negative conclusions only for target kinds + whose coverage is explicitly `complete`. +- Observation collection can be complete for one run, but behavioral coverage + is always `sampled`. +- Imported evidence may reveal risk. It never establishes safety, closes static + semantic evidence gaps, or narrows the declared capability envelope. +- Candidate changes are reviewer actions, never executable patches. + +## Experimental contracts + +### Reviewed intent + +`capability-intent-schema.v0.1.json` contains: + +- subject (`agent_id`, optional environment); +- human `owner` and `reason`; +- per-target-kind coverage (`closed_world` or `partial`); and +- exact allow/deny rules with optional expected principal binding. + +Conflicting overlapping rules are invalid. `closed_world` means that a target +not covered by an allow rule is not intended. `partial` means that unmatched +intent is unknown, not denied. + +### Untrusted observations + +`capability-observation-schema.v0.1.json` contains one or more sources and their +effective grants/observations. Each source records: + +- kind (`sandbox_policy`, `sandbox_run`, or `eval_run`); +- vendor and producer version; +- collection mode (`learning`, `enforced`, or `evaluation`); +- run ID and `sha256:` run hash; +- sandbox policy hash, or `null` when unavailable; +- grant inventory coverage by target kind; and +- observation collection coverage. + +Observation outcome means the sandbox enforcement result: + +- `attempted`: the enforcement result was not captured; +- `allowed`: the sandbox authorized the attempt; and +- `denied`: the sandbox rejected the attempt. + +Tool success, downstream API failure, and sandbox denial are different events. +Adapters must not infer `allowed` or `denied` from a success/error field. + +### Atomic target grammar + +Each row has exactly one target. Multi-resource activity is split into atomic +rows connected by an optional correlation ID. + +| Kind | Required identity | Safe resource detail | +|---|---|---| +| `tool` | tool name; optional capability ID/provider/operation | none | +| `mcp` | MCP server and tool name; optional capability ID/operation | none | +| `network` | normalized hostname-only domain | optional scheme/port, resource class, opaque exact-locator hash | +| `filesystem` | path class | resource class and optional opaque exact-locator hash | +| `resource` | vendor-neutral resource class | optional provider and opaque exact-locator hash | + +Resource selectors support only `class` and `exact` granularity. Arbitrary +wildcard, glob, regex, URL path, and raw file path semantics are out of scope +for v0.1. Class selectors contain exact selectors of the same normalized class. + +### Principal binding + +A binding contains principal kind, credential mode, optional auth type, +authority source/mode, scopes, and an optional opaque principal-reference hash. +Change detection compares the authority binding and excludes the opaque +instance reference so two equivalent user/eval instances do not create noise. + +Raw emails, usernames, role ARNs, credentials, tokens, prompts, arguments, +outputs, headers, query strings, fragments, and absolute file paths are not +valid fields in the observation contract. + +## Matching and cohorts + +Evidence is partitioned by `(agent, environment, policy_sha256)`. Sources with +no policy hash remain in separate unbound cohorts and cannot be unioned. + +Tool/MCP matching is deterministic: + +1. exact valid capability ID; +2. exact provider/operation/tool identity; +3. exact MCP server/tool identity; +4. unique tool-name fallback; and +5. otherwise ambiguous or unmapped evidence. + +There is no fuzzy matching. Resource comparison uses only the typed exact/class +grammar above. Different policy hashes are never merged into one effective +grant or observation set. + +Membership is three-valued: `present`, `absent`, or `unknown`. Incomplete +evidence suppresses only absence-based conclusions; positive observations and +grants remain visible. + +## Deterministic result rules + +| Signal | Condition | Required handling | +|---|---|---| +| `excess_authority` | `G` present and `I` authoritatively absent | Review removal or narrowing; never expand intent automatically | +| `policy_unclassified_grant` | `G` present and `I` unknown | Review intent coverage; do not call it excess | +| `behavioral_drift` | `O` present and `D` authoritatively absent | Investigate the route; do not add a declaration or grant automatically | +| `denied_attempt` | Any denied observation | Investigate; subtype is visible through four-set membership | +| `declared_ungranted` | `D` present and `G` absent under complete grant coverage | Treat as broken configuration only when `I` is also present | +| `granted_unobserved` | `G` present but no matching supplied observation | Informational only; never infer removal safety | +| `allowed_ungranted` | Allowed observation contradicts a complete grant snapshot | Review enforcement integrity | +| `intended_undeclared` | `I` present and `D` authoritatively absent | Review static declaration/configuration | +| `declared_unintended` | `D` present and `I` authoritatively absent | Review intent or remove the declaration | +| `principal_changed` | Current complete grant binding differs from an optional complete base snapshot | Authority delta with unknown direction; human review required | +| `principal_mismatch` | Current evidence differs from reviewed/static authority without historical proof | Human review; do not label it changed | + +One target can emit multiple orthogonal signals. The primary signal uses this +order: evidence integrity, principal delta, enforcement contradiction, excess +authority, undeclared behavior, missing grants, policy mismatch/denials, +unobserved grants, then alignment. + +Evidence gaps include missing policy hashes, ambiguous/unmapped targets, +incomplete intent/static/grant inventories, conflicting complete grant +snapshots, and resource comparisons the static capability standard cannot yet +prove. + +## Candidate policy changes + +Every candidate is hard-coded to: + +```json +{ + "actor": "human", + "approval_state": "unreviewed", + "auto_apply": false, + "safe_to_attempt": false, + "requires_human_review": true +} +``` + +An observation can propose investigation, never allowlisting. A +`consider_minimal_grant` proposal is emitted only when reviewed intent and a +static declaration independently exist and a complete grant snapshot proves +the grant is absent. A reviewer must supply the updated policy separately and +rerun reconciliation. + +## Prototype command + +Export the static capability lock, then invoke the developer-only script: + +```bash +agents-shipgate capability export \ + --config shipgate.yaml \ + --out agents-shipgate-reports/capabilities.lock.json + +python scripts/prototype_capability_reconciliation.py \ + --workspace . \ + --lock agents-shipgate-reports/capabilities.lock.json \ + --intent reconciliation/intended-policy.json \ + --evidence reconciliation/sandbox-grants.json \ + --evidence reconciliation/eval-run.json \ + --base-evidence reconciliation/previous-sandbox.json \ + --out agents-shipgate-reports/capability-reconciliation.json \ + --json +``` + +The script containment-checks and size-limits local inputs. Reconciliation +signals exit `0`; nonzero exits are reserved for flags, malformed/missing +inputs, or internal/I/O failures. + +## Adapter and promotion gate + +An AgentBeach adapter is explicitly out of scope. It may be proposed only +after at least two independently sanitized exports map into these contracts +without schema changes, definitive absence claims have no reviewer-confirmed +false positives, and no learning-mode observation produces an automatic grant +proposal. + +If the experiment graduates, expose the engine as the supporting +`agents-shipgate capability reconcile` command. If selected signals later +affect releases, convert them into ordinary findings that feed the existing +`release_decision.decision`. Never add a reconciliation-specific verdict. diff --git a/docs/examples/capability-intent.v0.1.example.json b/docs/examples/capability-intent.v0.1.example.json new file mode 100644 index 00000000..6eff8886 --- /dev/null +++ b/docs/examples/capability-intent.v0.1.example.json @@ -0,0 +1,53 @@ +{ + "capability_intent_schema_version": "0.1", + "experimental": true, + "subject": { + "agent_id": "agent:support", + "environment": "production" + }, + "owner": "platform-security", + "reason": "Reviewed production capability boundary for the reconciliation example.", + "coverage": { + "tool": "closed_world", + "mcp": "partial", + "network": "closed_world", + "filesystem": "closed_world", + "resource": "partial" + }, + "rules": [ + { + "id": "allow.cases.search", + "effect": "allow", + "target": { + "kind": "tool", + "capability_id": "cap_aaaaaaaaaaaa", + "tool_name": "cases.search", + "provider": "openapi", + "operation": "read" + }, + "expected_principal": { + "kind": "delegated_agent", + "credential_mode": "delegated", + "auth_type": "oauth2", + "authority_source": "openapi", + "authority_mode": "scoped", + "scopes": [ + "cases:read" + ], + "principal_ref_sha256": null + } + }, + { + "id": "deny.system.files", + "effect": "deny", + "target": { + "kind": "filesystem", + "path_class": "system", + "resource_class": "file", + "granularity": "class", + "locator_sha256": null + }, + "expected_principal": null + } + ] +} diff --git a/docs/examples/capability-observations-base.v0.1.example.json b/docs/examples/capability-observations-base.v0.1.example.json new file mode 100644 index 00000000..b02469ee --- /dev/null +++ b/docs/examples/capability-observations-base.v0.1.example.json @@ -0,0 +1,55 @@ +{ + "capability_observation_schema_version": "0.1", + "experimental": true, + "evidence_trust": "untrusted", + "subject": { + "agent_id": "agent:support", + "environment": "production" + }, + "sources": [ + { + "id": "sandbox-policy-base", + "kind": "sandbox_policy", + "vendor": "example-sandbox", + "producer_version": "2026.06", + "collection_mode": "enforced", + "run_id": "policy-export-base", + "run_sha256": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "policy_sha256": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "grant_inventory_coverage": { + "tool": "complete", + "mcp": "unknown", + "network": "complete", + "filesystem": "complete", + "resource": "unknown" + }, + "observation_collection_coverage": "unknown", + "behavioral_coverage": "sampled" + } + ], + "grants": [ + { + "id": "base-grant-cases-search", + "source_id": "sandbox-policy-base", + "target": { + "kind": "tool", + "capability_id": "cap_aaaaaaaaaaaa", + "tool_name": "cases.search", + "provider": "openapi", + "operation": "read" + }, + "principal": { + "kind": "service_account", + "credential_mode": "delegated", + "auth_type": "oauth2", + "authority_source": "openapi", + "authority_mode": "scoped", + "scopes": [ + "cases:read" + ], + "principal_ref_sha256": null + } + } + ], + "observations": [] +} diff --git a/docs/examples/capability-observations.v0.1.example.json b/docs/examples/capability-observations.v0.1.example.json new file mode 100644 index 00000000..41019a24 --- /dev/null +++ b/docs/examples/capability-observations.v0.1.example.json @@ -0,0 +1,149 @@ +{ + "capability_observation_schema_version": "0.1", + "experimental": true, + "evidence_trust": "untrusted", + "subject": { + "agent_id": "agent:support", + "environment": "production" + }, + "sources": [ + { + "id": "sandbox-policy-current", + "kind": "sandbox_policy", + "vendor": "example-sandbox", + "producer_version": "2026.07", + "collection_mode": "enforced", + "run_id": "policy-export-current", + "run_sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "policy_sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "grant_inventory_coverage": { + "tool": "complete", + "mcp": "unknown", + "network": "complete", + "filesystem": "complete", + "resource": "unknown" + }, + "observation_collection_coverage": "unknown", + "behavioral_coverage": "sampled" + }, + { + "id": "eval-run-current", + "kind": "eval_run", + "vendor": "example-eval", + "producer_version": "2026.07", + "collection_mode": "evaluation", + "run_id": "eval-current", + "run_sha256": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "policy_sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "grant_inventory_coverage": { + "tool": "unknown", + "mcp": "unknown", + "network": "unknown", + "filesystem": "unknown", + "resource": "unknown" + }, + "observation_collection_coverage": "complete_for_run", + "behavioral_coverage": "sampled" + } + ], + "grants": [ + { + "id": "grant-cases-search", + "source_id": "sandbox-policy-current", + "target": { + "kind": "tool", + "capability_id": "cap_aaaaaaaaaaaa", + "tool_name": "cases.search", + "provider": "openapi", + "operation": "read" + }, + "principal": { + "kind": "delegated_agent", + "credential_mode": "delegated", + "auth_type": "oauth2", + "authority_source": "openapi", + "authority_mode": "scoped", + "scopes": [ + "cases:read" + ], + "principal_ref_sha256": null + } + }, + { + "id": "grant-unreviewed-domain", + "source_id": "sandbox-policy-current", + "target": { + "kind": "network", + "domain": "admin.example.com", + "scheme": "https", + "port": null, + "resource_class": "admin_api", + "granularity": "class", + "locator_sha256": null + }, + "principal": { + "kind": "delegated_agent", + "credential_mode": "delegated", + "auth_type": "oauth2", + "authority_source": "openapi", + "authority_mode": "scoped", + "scopes": [ + "cases:read" + ], + "principal_ref_sha256": null + } + } + ], + "observations": [ + { + "id": "observe-cases-search", + "source_id": "eval-run-current", + "sequence": 0, + "correlation_id": "eval-case-1", + "target": { + "kind": "tool", + "capability_id": "cap_aaaaaaaaaaaa", + "tool_name": "cases.search", + "provider": "openapi", + "operation": "read" + }, + "outcome": "allowed", + "principal": { + "kind": "delegated_agent", + "credential_mode": "delegated", + "auth_type": "oauth2", + "authority_source": "openapi", + "authority_mode": "scoped", + "scopes": [ + "cases:read" + ], + "principal_ref_sha256": null + } + }, + { + "id": "observe-system-file", + "source_id": "eval-run-current", + "sequence": 1, + "correlation_id": "eval-case-2", + "target": { + "kind": "filesystem", + "path_class": "system", + "resource_class": "file", + "granularity": "class", + "locator_sha256": null + }, + "outcome": "denied", + "principal": { + "kind": "delegated_agent", + "credential_mode": "delegated", + "auth_type": "oauth2", + "authority_source": "openapi", + "authority_mode": "scoped", + "scopes": [ + "cases:read" + ], + "principal_ref_sha256": null + } + } + ] +} diff --git a/docs/examples/capability-reconciliation.v0.1.example.json b/docs/examples/capability-reconciliation.v0.1.example.json new file mode 100644 index 00000000..880f9763 --- /dev/null +++ b/docs/examples/capability-reconciliation.v0.1.example.json @@ -0,0 +1,336 @@ +{ + "candidate_policy_changes": [ + { + "action": "investigate_denied_attempt", + "actor": "human", + "approval_state": "unreviewed", + "auto_apply": false, + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "evidence_refs": [ + "intent:deny.system.files", + "observation:eval-run-current:observe-system-file" + ], + "id": "proposal_28ff0a34b6351fc495780eb4", + "rationale": "A sandbox denial is evidence to investigate, never an allowlist recommendation.", + "requires_human_review": true, + "safe_to_attempt": false, + "target": { + "granularity": "class", + "kind": "filesystem", + "locator_sha256": null, + "path_class": "system", + "resource_class": "file" + } + }, + { + "action": "remove_or_narrow_grant", + "actor": "human", + "approval_state": "unreviewed", + "auto_apply": false, + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "evidence_refs": [ + "grant:sandbox-policy-current:grant-unreviewed-domain" + ], + "id": "proposal_7108f453712b5416ad6d35eb", + "rationale": "Grant exceeds the reviewed intended policy; prefer least-authority removal or narrowing.", + "requires_human_review": true, + "safe_to_attempt": false, + "target": { + "domain": "admin.example.com", + "granularity": "class", + "kind": "network", + "locator_sha256": null, + "port": null, + "resource_class": "admin_api", + "scheme": "https" + } + }, + { + "action": "review_principal", + "actor": "human", + "approval_state": "unreviewed", + "auto_apply": false, + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "evidence_refs": [ + "capability:cap_aaaaaaaaaaaa", + "grant:sandbox-policy-current:grant-cases-search", + "intent:allow.cases.search", + "observation:eval-run-current:observe-cases-search" + ], + "id": "proposal_b9975b489bfdc74814fe74e0", + "rationale": "Authority binding changed or conflicts with reviewed expectations and requires human review.", + "requires_human_review": true, + "safe_to_attempt": false, + "target": { + "capability_id": "cap_aaaaaaaaaaaa", + "kind": "tool", + "operation": "read", + "provider": "openapi", + "tool_name": "cases.search" + } + } + ], + "capability_lock_semantic_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "capability_reconciliation_schema_version": "0.1", + "cohorts": [ + { + "coverage": { + "declared": "complete", + "granted": { + "filesystem": "complete", + "mcp": "unknown", + "network": "complete", + "resource": "unknown", + "tool": "complete" + }, + "intended": { + "filesystem": "closed_world", + "mcp": "partial", + "network": "closed_world", + "resource": "partial", + "tool": "closed_world" + }, + "observed": "sampled" + }, + "evidence_gap_count": 2, + "id": "cohort_8298e88dce61b333d6c06d20", + "policy_sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "result_count": 3, + "signal_counts": { + "denied_attempt": 1, + "excess_authority": 1, + "granted_unobserved": 1, + "principal_changed": 1 + }, + "source_refs": [ + "eval-run-current", + "sandbox-policy-current" + ], + "source_runs": [ + { + "collection_mode": "evaluation", + "kind": "eval_run", + "policy_sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "run_id": "eval-current", + "run_sha256": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "source_id": "eval-run-current" + }, + { + "collection_mode": "enforced", + "kind": "sandbox_policy", + "policy_sha256": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "run_id": "policy-export-current", + "run_sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "source_id": "sandbox-policy-current" + } + ] + } + ], + "evidence_gaps": [ + { + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "evidence_refs": [ + "intent:deny.system.files", + "observation:eval-run-current:observe-system-file" + ], + "id": "gap_42a2f3d559886fb35879ad80", + "kind": "unsupported_resource_comparison", + "message": "The static capability lock cannot prove absence for this target.", + "target": { + "granularity": "class", + "kind": "filesystem", + "locator_sha256": null, + "path_class": "system", + "resource_class": "file" + } + }, + { + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "evidence_refs": [ + "grant:sandbox-policy-current:grant-unreviewed-domain" + ], + "id": "gap_d7fd3700c90ed834176a4728", + "kind": "unsupported_resource_comparison", + "message": "The static capability lock cannot prove absence for this target.", + "target": { + "domain": "admin.example.com", + "granularity": "class", + "kind": "network", + "locator_sha256": null, + "port": null, + "resource_class": "admin_api", + "scheme": "https" + } + } + ], + "evidence_trust": "untrusted", + "experimental": true, + "inputs": [ + { + "path": "docs/examples/capability-observations-base.v0.1.example.json", + "role": "base_evidence", + "schema_version": "0.1", + "sha256": "sha256:39fd13adedde71e873cf721fd495a29248c4160937b4409b31ff38d9de5e66d2" + }, + { + "path": "docs/examples/capability-lock.v0.3.example.json", + "role": "capability_lock", + "schema_version": "0.3", + "sha256": "sha256:709600a967a289e4671016b950fae75ed70647624a1313d4be62024ac93c25da" + }, + { + "path": "docs/examples/capability-observations.v0.1.example.json", + "role": "evidence", + "schema_version": "0.1", + "sha256": "sha256:0a9bd7417c446eb1adf740c48f06f93f46f22f1ddc91d35ad6ddcf233a8f0d25" + }, + { + "path": "docs/examples/capability-intent.v0.1.example.json", + "role": "intent_policy", + "schema_version": "0.1", + "sha256": "sha256:0ec21cdc104a56017fee05e5a61db9b9c0999cc0568847bef7d3c9a94c7ec1de" + } + ], + "live_collection_performed": false, + "local_imported_evidence_only": true, + "notes": [ + "Observations are sampled, untrusted evidence and never establish runtime absence.", + "Candidate policy changes are non-executable proposals requiring human review.", + "This artifact does not affect report.json.release_decision.decision." + ], + "release_gate_effect": false, + "results": [ + { + "authority_delta": null, + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "disposition": "human_review", + "evidence_refs": [ + "grant:sandbox-policy-current:grant-unreviewed-domain" + ], + "id": "rec_c78f7135cfd9980f6aa78453", + "membership": { + "declared": "unknown", + "granted": "present", + "intended": "absent", + "observed": "not_observed" + }, + "outcomes": [], + "primary_signal": "excess_authority", + "signals": [ + "excess_authority", + "granted_unobserved" + ], + "target": { + "domain": "admin.example.com", + "granularity": "class", + "kind": "network", + "locator_sha256": null, + "port": null, + "resource_class": "admin_api", + "scheme": "https" + } + }, + { + "authority_delta": null, + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "disposition": "investigate", + "evidence_refs": [ + "intent:deny.system.files", + "observation:eval-run-current:observe-system-file" + ], + "id": "rec_cf9fc4e5d70b468f694ed055", + "membership": { + "declared": "unknown", + "granted": "absent", + "intended": "absent", + "observed": "observed" + }, + "outcomes": [ + "denied" + ], + "primary_signal": "denied_attempt", + "signals": [ + "denied_attempt" + ], + "target": { + "granularity": "class", + "kind": "filesystem", + "locator_sha256": null, + "path_class": "system", + "resource_class": "file" + } + }, + { + "authority_delta": { + "after": { + "auth_type": "oauth2", + "authority_mode": "scoped", + "authority_source": "openapi", + "credential_mode": "delegated", + "kind": "delegated_agent", + "principal_ref_sha256": null, + "scopes": [ + "cases:read" + ] + }, + "before": { + "auth_type": "oauth2", + "authority_mode": "scoped", + "authority_source": "openapi", + "credential_mode": "delegated", + "kind": "service_account", + "principal_ref_sha256": null, + "scopes": [ + "cases:read" + ] + } + }, + "cohort_id": "cohort_8298e88dce61b333d6c06d20", + "disposition": "human_review", + "evidence_refs": [ + "capability:cap_aaaaaaaaaaaa", + "grant:sandbox-policy-current:grant-cases-search", + "intent:allow.cases.search", + "observation:eval-run-current:observe-cases-search" + ], + "id": "rec_e3f6cf5c0c39d76ce1821365", + "membership": { + "declared": "present", + "granted": "present", + "intended": "present", + "observed": "observed" + }, + "outcomes": [ + "allowed" + ], + "primary_signal": "principal_changed", + "signals": [ + "principal_changed" + ], + "target": { + "capability_id": "cap_aaaaaaaaaaaa", + "kind": "tool", + "operation": "read", + "provider": "openapi", + "tool_name": "cases.search" + } + } + ], + "subject": { + "agent_id": "agent:support", + "environment": "production" + }, + "summary": { + "candidate_policy_change_count": 3, + "cohort_count": 1, + "evidence_gap_count": 2, + "result_count": 3, + "signal_counts": { + "denied_attempt": 1, + "excess_authority": 1, + "granted_unobserved": 1, + "principal_changed": 1 + }, + "target_count": 3 + } +} diff --git a/scripts/generate_schemas.py b/scripts/generate_schemas.py index b6fd569b..92f31609 100644 --- a/scripts/generate_schemas.py +++ b/scripts/generate_schemas.py @@ -49,6 +49,12 @@ - docs/capability-lock-diff-schema.v0.4.json (from agents_shipgate.schemas.capabilities. CapabilityLockDiffArtifactV1) +- docs/capability-intent-schema.v0.1.json +- docs/capability-observation-schema.v0.1.json +- docs/capability-reconciliation-schema.v0.1.json + (experimental local reconciliation contracts + from agents_shipgate.schemas. + capability_reconciliation) - docs/governance-benchmark-catalog-schema.v0.2.json (from agents_shipgate.schemas.governance_benchmark. GovernanceBenchmarkCatalogArtifactV1) @@ -1433,6 +1439,78 @@ def write_capability_lock_diff_schema( ) +def build_capability_intent_schema() -> tuple[Path, str]: + """Generate the experimental human-owned capability-intent schema.""" + + from agents_shipgate.schemas.capability_reconciliation import ( + CAPABILITY_INTENT_SCHEMA_VERSION, + CapabilityIntentPolicyArtifactV1, + ) + + minor = CAPABILITY_INTENT_SCHEMA_VERSION + schema = CapabilityIntentPolicyArtifactV1.model_json_schema() + schema["$id"] = ( + "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" + f"main/docs/capability-intent-schema.v{minor}.json" + ) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["title"] = f"Agents Shipgate Experimental Capability Intent v{minor}" + schema["description"] = ( + "Experimental JSON Schema for a reviewed, human-owned capability-intent " + "sidecar. It is separate from untrusted observations and is not a release gate." + ) + target = DOCS / f"capability-intent-schema.v{minor}.json" + return target, _canonical_json(schema) + + +def build_capability_observation_schema() -> tuple[Path, str]: + """Generate the experimental vendor-neutral observation schema.""" + + from agents_shipgate.schemas.capability_reconciliation import ( + CAPABILITY_OBSERVATION_SCHEMA_VERSION, + CapabilityObservationArtifactV1, + ) + + minor = CAPABILITY_OBSERVATION_SCHEMA_VERSION + schema = CapabilityObservationArtifactV1.model_json_schema() + schema["$id"] = ( + "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" + f"main/docs/capability-observation-schema.v{minor}.json" + ) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["title"] = f"Agents Shipgate Experimental Capability Observations v{minor}" + schema["description"] = ( + "Experimental vendor-neutral JSON Schema for untrusted local sandbox grants " + "and sampled eval observations. It performs no live collection and does not gate." + ) + target = DOCS / f"capability-observation-schema.v{minor}.json" + return target, _canonical_json(schema) + + +def build_capability_reconciliation_schema() -> tuple[Path, str]: + """Generate the experimental non-gating reconciliation-result schema.""" + + from agents_shipgate.schemas.capability_reconciliation import ( + CAPABILITY_RECONCILIATION_SCHEMA_VERSION, + CapabilityReconciliationArtifactV1, + ) + + minor = CAPABILITY_RECONCILIATION_SCHEMA_VERSION + schema = CapabilityReconciliationArtifactV1.model_json_schema() + schema["$id"] = ( + "https://raw.githubusercontent.com/ThreeMoonsLab/agents-shipgate/" + f"main/docs/capability-reconciliation-schema.v{minor}.json" + ) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["title"] = f"Agents Shipgate Experimental Capability Reconciliation v{minor}" + schema["description"] = ( + "Experimental JSON Schema for deterministic four-set capability reconciliation. " + "Candidate changes are human-only and release_decision.decision remains the sole gate." + ) + target = DOCS / f"capability-reconciliation-schema.v{minor}.json" + return target, _canonical_json(schema) + + def build_governance_benchmark_catalog_schema() -> tuple[Path, str]: """Generate the stable governance-benchmark catalog schema.""" @@ -1624,6 +1702,9 @@ def build_host_grants_inventory_schema() -> tuple[Path, str]: ("preflight", build_preflight_schema), ("capability_lock", build_capability_lock_schema), ("capability_lock_diff", build_capability_lock_diff_schema), + ("capability_intent", build_capability_intent_schema), + ("capability_observation", build_capability_observation_schema), + ("capability_reconciliation", build_capability_reconciliation_schema), ("attestation", build_attestation_schema), ("org_governance", build_org_governance_schema), ("org_evidence_bundle", build_org_evidence_bundle_schema), diff --git a/scripts/prototype_capability_reconciliation.py b/scripts/prototype_capability_reconciliation.py new file mode 100644 index 00000000..96ab23e0 --- /dev/null +++ b/scripts/prototype_capability_reconciliation.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Local, non-gating prototype for four-set capability reconciliation. + +This developer-only script reads explicit local artifacts. It never connects to +a sandbox, runs an agent, calls tools, or mutates an allowlist. +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +from pathlib import Path + +from pydantic import ValidationError + +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC = REPO_ROOT / "src" +sys.path.insert(0, str(SRC)) + +from agents_shipgate.core.capability_reconciliation import ( # noqa: E402 + load_capability_intent_policy, + load_capability_observation_bundle, + reconcile_capabilities, + render_capability_reconciliation_json, +) +from agents_shipgate.core.errors import InputParseError # noqa: E402 +from agents_shipgate.inputs.common import load_structured_file, resolve_input_path # noqa: E402 +from agents_shipgate.schemas.capabilities import ( # noqa: E402 + CAPABILITY_LOCK_SCHEMA_VERSION, + CapabilityLockFileV1, +) +from agents_shipgate.schemas.capability_reconciliation import ( # noqa: E402 + ReconciliationInputBindingV1, +) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="prototype_capability_reconciliation", + description=( + "Reconcile reviewed intent, a static capability lock, sandbox grants, " + "and sampled observations into a deterministic non-gating artifact." + ), + ) + parser.add_argument("--workspace", type=Path, default=Path(".")) + parser.add_argument("--lock", type=str, required=True) + parser.add_argument("--intent", type=str, required=True) + parser.add_argument("--evidence", type=str, action="append", required=True) + parser.add_argument("--base-evidence", type=str, action="append", default=[]) + parser.add_argument("--out", type=str, required=True) + parser.add_argument( + "--json", + action="store_true", + dest="json_output", + help="Also print the reconciliation artifact to stdout.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + workspace = args.workspace.resolve() + try: + lock_path = resolve_input_path(workspace, args.lock) + intent_path = resolve_input_path(workspace, args.intent) + evidence_paths = [resolve_input_path(workspace, value) for value in args.evidence] + base_paths = [ + resolve_input_path(workspace, value) for value in args.base_evidence + ] + out_path = resolve_input_path(workspace, args.out) + input_paths = [lock_path, intent_path, *evidence_paths, *base_paths] + for path in [*input_paths, out_path]: + if path.suffix.lower() != ".json": + raise InputParseError( + f"Capability reconciliation artifacts must use .json paths: {path}" + ) + if out_path in input_paths: + raise InputParseError("Output path must not overwrite an input artifact") + + lock = _load_current_lock(lock_path) + intent = load_capability_intent_policy(intent_path) + evidence = [load_capability_observation_bundle(path) for path in evidence_paths] + base_evidence = [load_capability_observation_bundle(path) for path in base_paths] + bindings = [ + _binding( + lock_path, + workspace, + role="capability_lock", + schema_version=lock.capability_lock_schema_version, + ), + _binding( + intent_path, + workspace, + role="intent_policy", + schema_version=intent.capability_intent_schema_version, + ), + ] + bindings.extend( + _binding( + path, + workspace, + role="evidence", + schema_version=bundle.capability_observation_schema_version, + ) + for path, bundle in zip(evidence_paths, evidence, strict=True) + ) + bindings.extend( + _binding( + path, + workspace, + role="base_evidence", + schema_version=bundle.capability_observation_schema_version, + ) + for path, bundle in zip(base_paths, base_evidence, strict=True) + ) + + artifact = reconcile_capabilities( + lock=lock, + intent=intent, + evidence_bundles=evidence, + base_evidence_bundles=base_evidence, + input_bindings=bindings, + ) + rendered = render_capability_reconciliation_json(artifact) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(rendered, encoding="utf-8") + except InputParseError as exc: + print(f"Input parsing error: {exc}", file=sys.stderr) + return 3 + except OSError as exc: + print(f"Input/output error: {exc}", file=sys.stderr) + return 4 + except Exception as exc: # pragma: no cover - defensive CLI boundary + print(f"Internal error: {exc}", file=sys.stderr) + return 4 + + if args.json_output: + print(rendered, end="") + else: + print(f"Wrote capability reconciliation artifact to {_display_path(out_path, workspace)}") + print( + "Targets: " + f"{artifact.summary.target_count} " + f"Evidence gaps: {artifact.summary.evidence_gap_count} " + f"Manual proposals: {artifact.summary.candidate_policy_change_count}" + ) + return 0 + + +def _load_current_lock(path: Path) -> CapabilityLockFileV1: + payload = load_structured_file(path) + if not isinstance(payload, dict): + raise InputParseError(f"Capability lock must be a JSON object: {path}") + version = payload.get("capability_lock_schema_version") + if version != CAPABILITY_LOCK_SCHEMA_VERSION: + raise InputParseError( + "Capability reconciliation requires capability lock " + f"v{CAPABILITY_LOCK_SCHEMA_VERSION}; found {version!r}. Re-export with exactly: " + f"`agents-shipgate capability export --config shipgate.yaml --out {path} " + "--no-report-copy`." + ) + try: + return CapabilityLockFileV1.model_validate(payload) + except ValidationError as exc: + raise InputParseError(f"Invalid capability lock file {path}: {exc}") from exc + + +def _binding( + path: Path, + workspace: Path, + *, + role: str, + schema_version: str, +) -> ReconciliationInputBindingV1: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return ReconciliationInputBindingV1( + role=role, + path=_display_path(path, workspace), + sha256=f"sha256:{digest}", + schema_version=schema_version, + ) + + +def _display_path(path: Path, workspace: Path) -> str: + return path.resolve().relative_to(workspace.resolve()).as_posix() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agents_shipgate/core/capability_reconciliation.py b/src/agents_shipgate/core/capability_reconciliation.py new file mode 100644 index 00000000..7e530f0e --- /dev/null +++ b/src/agents_shipgate/core/capability_reconciliation.py @@ -0,0 +1,1039 @@ +from __future__ import annotations + +import hashlib +import json +from collections import Counter, defaultdict +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal, cast + +from pydantic import ValidationError + +from agents_shipgate.core.errors import InputParseError +from agents_shipgate.inputs.common import load_structured_file +from agents_shipgate.schemas.capabilities import CapabilityFactV1, CapabilityLockFileV1 +from agents_shipgate.schemas.capability_reconciliation import ( + AuthorityDeltaV1, + CandidatePolicyChangeV1, + CapabilityIntentPolicyV1, + CapabilityObservationBundleV1, + CapabilityReconciliationV1, + CapabilityTargetV1, + CoverageMode, + EvidenceGapKind, + FilesystemTargetV1, + GrantRecordV1, + InventoryCoverage, + McpTargetV1, + MembershipState, + NetworkTargetV1, + ObservationRecordV1, + ObservationSourceV1, + PrincipalBindingV1, + ReconciliationCohortCoverageV1, + ReconciliationCohortV1, + ReconciliationEvidenceGapV1, + ReconciliationInputBindingV1, + ReconciliationResultV1, + ReconciliationSignal, + ReconciliationSourceRunV1, + ReconciliationSummaryV1, + ResourceTargetV1, + SetMembershipV1, + TargetKind, + ToolTargetV1, +) + +_PRIMARY_SIGNAL_ORDER: tuple[ReconciliationSignal, ...] = ( + "principal_changed", + "principal_mismatch", + "allowed_ungranted", + "excess_authority", + "behavioral_drift", + "intended_undeclared", + "declared_unintended", + "declared_ungranted", + "policy_unclassified_grant", + "denied_attempt", + "granted_unobserved", + "aligned", +) + +_SIGNAL_ORDER = {signal: index for index, signal in enumerate(_PRIMARY_SIGNAL_ORDER)} + + +@dataclass(frozen=True) +class _TargetMatch: + target: CapabilityTargetV1 + fact: CapabilityFactV1 | None + reason: Literal["capability_id", "composite", "tool_name", "ambiguous", "unmapped"] + + +@dataclass +class _CohortData: + partition_key: str + policy_sha256: str | None + sources: dict[str, ObservationSourceV1] = field(default_factory=dict) + grants: list[GrantRecordV1] = field(default_factory=list) + observations: list[ObservationRecordV1] = field(default_factory=list) + + +@dataclass(frozen=True) +class _PreparedRule: + id: str + effect: Literal["allow", "deny"] + target: CapabilityTargetV1 + expected_principal: PrincipalBindingV1 | None + + +class _DeclaredIndex: + def __init__(self, lock: CapabilityLockFileV1) -> None: + self.facts = tuple(lock.capabilities) + self.by_id = {fact.id: fact for fact in self.facts} + self.by_tool: dict[str, list[CapabilityFactV1]] = defaultdict(list) + for fact in self.facts: + self.by_tool[fact.identity.tool_name].append(fact) + for facts in self.by_tool.values(): + facts.sort(key=lambda fact: fact.id) + + def match(self, target: CapabilityTargetV1) -> _TargetMatch: + if target.kind not in {"tool", "mcp"}: + return _TargetMatch(target=target, fact=None, reason="unmapped") + + capability_id = target.capability_id + if capability_id is not None: + fact = self.by_id.get(capability_id) + if fact is None: + return _TargetMatch(target=target, fact=None, reason="unmapped") + return _TargetMatch( + target=_target_from_fact(fact), + fact=fact, + reason="capability_id", + ) + + candidates = list(self.by_tool.get(target.tool_name, ())) + composite = [fact for fact in candidates if _fact_matches_composite(fact, target)] + has_composite_selector = _has_composite_selector(target) + if has_composite_selector and len(composite) == 1: + fact = composite[0] + return _TargetMatch( + target=_target_from_fact(fact), + fact=fact, + reason="composite", + ) + if has_composite_selector and len(composite) > 1: + return _TargetMatch(target=target, fact=None, reason="ambiguous") + if has_composite_selector and not composite: + return _TargetMatch(target=target, fact=None, reason="unmapped") + if len(candidates) == 1: + fact = candidates[0] + return _TargetMatch( + target=_target_from_fact(fact), + fact=fact, + reason="tool_name", + ) + if len(candidates) > 1: + return _TargetMatch(target=target, fact=None, reason="ambiguous") + return _TargetMatch(target=target, fact=None, reason="unmapped") + + +def load_capability_intent_policy(path: Path) -> CapabilityIntentPolicyV1: + return _load_model(path, CapabilityIntentPolicyV1, label="capability intent policy") + + +def load_capability_observation_bundle(path: Path) -> CapabilityObservationBundleV1: + return _load_model(path, CapabilityObservationBundleV1, label="capability observation bundle") + + +def render_capability_reconciliation_json(artifact: CapabilityReconciliationV1) -> str: + return json.dumps(artifact.model_dump(mode="json"), indent=2, sort_keys=True) + "\n" + + +def reconcile_capabilities( + *, + lock: CapabilityLockFileV1, + intent: CapabilityIntentPolicyV1, + evidence_bundles: Iterable[CapabilityObservationBundleV1], + base_evidence_bundles: Iterable[CapabilityObservationBundleV1] = (), + input_bindings: Iterable[ReconciliationInputBindingV1] = (), +) -> CapabilityReconciliationV1: + """Reconcile policy, static facts, sandbox grants, and sampled observations. + + The function consumes only already-loaded local artifacts. It never executes + an agent, connects to a sandbox, calls a tool, or mutates policy. Results are + non-gating audit evidence and every candidate policy change is human-only. + """ + + evidence = tuple(evidence_bundles) + base_evidence = tuple(base_evidence_bundles) + subject = intent.subject + if lock.source.agent_id != subject.agent_id: + raise InputParseError( + "Capability lock agent_id does not match intent policy: " + f"{lock.source.agent_id!r} != {subject.agent_id!r}" + ) + if ( + subject.environment is not None + and lock.source.environment_target is not None + and lock.source.environment_target != subject.environment + ): + raise InputParseError( + "Capability lock environment does not match intent policy: " + f"{lock.source.environment_target!r} != {subject.environment!r}" + ) + _validate_bundle_subjects(subject.agent_id, subject.environment, evidence, role="evidence") + _validate_bundle_subjects( + subject.agent_id, + subject.environment, + base_evidence, + role="base evidence", + ) + + declared = _DeclaredIndex(lock) + prepared_rules, rule_match_gaps = _prepare_rules(intent, declared) + cohorts = _partition_evidence(evidence) + base_grants = _base_grants(base_evidence, declared) + declared_complete = ( + lock.source.source_warning_count == 0 + and lock.source.toolkit_bound_count == 0 + and lock.source.plugins_enabled + ) + + all_results: list[ReconciliationResultV1] = [] + all_gaps: list[ReconciliationEvidenceGapV1] = [] + all_proposals: list[CandidatePolicyChangeV1] = [] + cohort_rows: list[ReconciliationCohortV1] = [] + + for cohort in sorted(cohorts.values(), key=lambda row: row.partition_key): + cohort_id = _stable_id("cohort", cohort.partition_key) + gaps: dict[tuple[str, str, str], ReconciliationEvidenceGapV1] = {} + for gap_kind, target, message, refs in rule_match_gaps: + _add_gap(gaps, cohort_id, gap_kind, target, message, refs) + if cohort.policy_sha256 is None: + _add_gap( + gaps, + cohort_id, + "missing_policy_hash", + None, + "Source has no sandbox policy hash; it remains isolated and cannot support " + "cross-source absence claims.", + tuple(sorted(cohort.sources)), + ) + + grant_coverage = _aggregate_grant_coverage(cohort.sources.values()) + conflicting_grant_kinds = _add_conflicting_grant_gaps( + gaps, + cohort_id, + cohort, + declared, + ) + for kind in conflicting_grant_kinds: + grant_coverage[kind] = "unknown" + if cohort.policy_sha256 is None: + grant_coverage = { + kind: "unknown" + for kind in ("tool", "mcp", "network", "filesystem", "resource") + } + + prepared_grants = [ + (grant, declared.match(grant.target)) + for grant in sorted(cohort.grants, key=lambda row: (row.source_id, row.id)) + ] + prepared_observations = [ + (observation, declared.match(observation.target)) + for observation in sorted( + cohort.observations, + key=lambda row: (row.source_id, row.sequence, row.id), + ) + ] + _add_match_gaps( + gaps, + cohort_id, + prepared_grants, + prepared_observations, + ) + + universe: dict[str, CapabilityTargetV1] = {} + for fact in declared.facts: + target = _target_from_fact(fact) + universe[_target_key(target)] = target + for rule in prepared_rules: + universe[_target_key(rule.target)] = rule.target + for _row, match in [*prepared_grants, *prepared_observations]: + universe[_target_key(match.target)] = match.target + + cohort_results: list[ReconciliationResultV1] = [] + cohort_proposals: list[CandidatePolicyChangeV1] = [] + for target_key in sorted(universe): + target = universe[target_key] + fact_match = declared.match(target) + intended, intent_refs, expected_principals = _intent_membership( + target, + prepared_rules, + intent.coverage.for_kind(cast(TargetKind, target.kind)), + ) + declared_state = _declared_membership(target, fact_match, declared_complete) + matching_grants = [ + grant + for grant, match in prepared_grants + if _selector_contains(match.target, target) + ] + granted = _granted_membership( + target, + matching_grants, + grant_coverage[cast(TargetKind, target.kind)], + ) + matching_observations = [ + observation + for observation, match in prepared_observations + if _selector_contains(target, match.target) + ] + observed = "observed" if matching_observations else "not_observed" + outcomes = tuple( + sorted( + {observation.outcome for observation in matching_observations}, + key=("attempted", "allowed", "denied").index, + ) + ) + + evidence_refs = set(intent_refs) + if fact_match.fact is not None: + evidence_refs.add(f"capability:{fact_match.fact.id}") + evidence_refs.update( + f"grant:{grant.source_id}:{grant.id}" for grant in matching_grants + ) + evidence_refs.update( + f"observation:{row.source_id}:{row.id}" for row in matching_observations + ) + + principal_changed, authority_delta = _principal_change( + target, + matching_grants, + base_grants, + ) + principal_mismatch = _principal_mismatch( + matching_grants, + matching_observations, + expected_principals, + fact_match.fact, + ) + signals = _signals( + intended=intended, + declared=declared_state, + granted=granted, + observed=observed, + outcomes=outcomes, + principal_changed=principal_changed, + principal_mismatch=principal_mismatch, + ) + primary = min(signals, key=_SIGNAL_ORDER.__getitem__) + disposition = _disposition(signals, intended=intended) + result = ReconciliationResultV1( + id=_stable_id("rec", cohort_id, target_key), + cohort_id=cohort_id, + target=target, + membership=SetMembershipV1( + intended=intended, + declared=declared_state, + granted=granted, + observed=observed, + ), + outcomes=outcomes, + signals=tuple(sorted(signals, key=_SIGNAL_ORDER.__getitem__)), + primary_signal=primary, + disposition=disposition, + evidence_refs=tuple(sorted(evidence_refs)), + authority_delta=authority_delta, + ) + cohort_results.append(result) + cohort_proposals.extend(_proposals_for_result(result)) + + if intended == "unknown": + _add_gap( + gaps, + cohort_id, + "incomplete_intent_inventory", + target, + "Intent is partial for this target kind; absence is unknown.", + tuple(sorted(intent_refs)), + ) + if declared_state == "unknown": + gap_kind: EvidenceGapKind = ( + "unsupported_resource_comparison" + if target.kind in {"network", "filesystem", "resource"} + else "incomplete_declared_inventory" + ) + _add_gap( + gaps, + cohort_id, + gap_kind, + target, + "The static capability lock cannot prove absence for this target.", + tuple(sorted(evidence_refs)), + ) + if granted == "unknown": + _add_gap( + gaps, + cohort_id, + "incomplete_grant_inventory", + target, + "The sandbox grant inventory is not complete for this target kind.", + tuple(sorted(evidence_refs)), + ) + + cohort_results.sort(key=lambda row: (row.id, row.primary_signal)) + cohort_proposals = _dedupe_proposals(cohort_proposals) + gap_rows = sorted(gaps.values(), key=lambda row: row.id) + signal_counts = Counter( + signal for result in cohort_results for signal in result.signals + ) + cohort_rows.append( + ReconciliationCohortV1( + id=cohort_id, + policy_sha256=cohort.policy_sha256, + source_refs=tuple(sorted(cohort.sources)), + source_runs=tuple( + ReconciliationSourceRunV1( + source_id=source.id, + kind=source.kind, + collection_mode=source.collection_mode, + run_id=source.run_id, + run_sha256=source.run_sha256, + policy_sha256=source.policy_sha256, + ) + for source in sorted(cohort.sources.values(), key=lambda row: row.id) + ), + coverage=ReconciliationCohortCoverageV1( + intended={ + kind: intent.coverage.for_kind(kind) + for kind in ("tool", "mcp", "network", "filesystem", "resource") + }, + declared="complete" if declared_complete else "partial", + granted=grant_coverage, + ), + result_count=len(cohort_results), + evidence_gap_count=len(gap_rows), + signal_counts=dict(sorted(signal_counts.items())), + ) + ) + all_results.extend(cohort_results) + all_gaps.extend(gap_rows) + all_proposals.extend(cohort_proposals) + + all_results.sort(key=lambda row: (row.cohort_id, row.id)) + all_gaps.sort(key=lambda row: (row.cohort_id, row.id)) + all_proposals = _dedupe_proposals(all_proposals) + total_signal_counts = Counter(signal for row in all_results for signal in row.signals) + return CapabilityReconciliationV1( + subject=subject, + capability_lock_semantic_hash=( + f"sha256:{lock.hashes.semantic_capability_set_hash}" + if not lock.hashes.semantic_capability_set_hash.startswith("sha256:") + else lock.hashes.semantic_capability_set_hash + ), + inputs=tuple(sorted(input_bindings, key=lambda row: (row.role, row.path))), + summary=ReconciliationSummaryV1( + cohort_count=len(cohort_rows), + target_count=len(all_results), + result_count=len(all_results), + evidence_gap_count=len(all_gaps), + candidate_policy_change_count=len(all_proposals), + signal_counts=dict(sorted(total_signal_counts.items())), + ), + cohorts=tuple(sorted(cohort_rows, key=lambda row: row.id)), + results=tuple(all_results), + evidence_gaps=tuple(all_gaps), + candidate_policy_changes=tuple(all_proposals), + notes=( + "Observations are sampled, untrusted evidence and never establish runtime absence.", + "Candidate policy changes are non-executable proposals requiring human review.", + "This artifact does not affect report.json.release_decision.decision.", + ), + ) + + +def _load_model(path: Path, model_type, *, label: str): + try: + return model_type.model_validate(load_structured_file(path)) + except ValidationError as exc: + raise InputParseError(f"Invalid {label} {path}: {exc}") from exc + + +def _validate_bundle_subjects( + agent_id: str, + environment: str | None, + bundles: Iterable[CapabilityObservationBundleV1], + *, + role: str, +) -> None: + for bundle in bundles: + if bundle.subject.agent_id != agent_id: + raise InputParseError( + f"{role.title()} agent_id does not match intent policy: " + f"{bundle.subject.agent_id!r} != {agent_id!r}" + ) + if environment is not None and bundle.subject.environment not in {None, environment}: + raise InputParseError( + f"{role.title()} environment does not match intent policy: " + f"{bundle.subject.environment!r} != {environment!r}" + ) + + +def _prepare_rules( + intent: CapabilityIntentPolicyV1, + declared: _DeclaredIndex, +) -> tuple[ + tuple[_PreparedRule, ...], + tuple[tuple[EvidenceGapKind, CapabilityTargetV1 | None, str, tuple[str, ...]], ...], +]: + rows: list[_PreparedRule] = [] + gaps: list[tuple[EvidenceGapKind, CapabilityTargetV1 | None, str, tuple[str, ...]]] = [] + for rule in sorted(intent.rules, key=lambda row: row.id): + match = declared.match(rule.target) + rows.append( + _PreparedRule( + id=rule.id, + effect=rule.effect, + target=match.target, + expected_principal=rule.expected_principal, + ) + ) + if rule.target.kind in {"tool", "mcp"} and match.reason in {"ambiguous", "unmapped"}: + kind: EvidenceGapKind = ( + "ambiguous_target" if match.reason == "ambiguous" else "unmapped_target" + ) + gaps.append( + ( + kind, + rule.target, + f"Intent rule {rule.id} could not be matched to one static capability.", + (f"intent:{rule.id}",), + ) + ) + return tuple(rows), tuple(gaps) + + +def _partition_evidence( + bundles: Iterable[CapabilityObservationBundleV1], +) -> dict[str, _CohortData]: + cohorts: dict[str, _CohortData] = {} + global_source_ids: set[str] = set() + for bundle in bundles: + sources = {source.id: source for source in bundle.sources} + for source in bundle.sources: + if source.id in global_source_ids: + raise InputParseError( + f"Evidence source id {source.id!r} is duplicated across bundles" + ) + global_source_ids.add(source.id) + partition = source.policy_sha256 or f"unbound:{source.id}" + cohort = cohorts.setdefault( + partition, + _CohortData(partition_key=partition, policy_sha256=source.policy_sha256), + ) + cohort.sources[source.id] = source + for grant in bundle.grants: + source = sources[grant.source_id] + partition = source.policy_sha256 or f"unbound:{source.id}" + cohorts[partition].grants.append(grant) + for observation in bundle.observations: + source = sources[observation.source_id] + partition = source.policy_sha256 or f"unbound:{source.id}" + cohorts[partition].observations.append(observation) + return cohorts + + +def _base_grants( + bundles: Iterable[CapabilityObservationBundleV1], + declared: _DeclaredIndex, +) -> tuple[tuple[GrantRecordV1, ObservationSourceV1, CapabilityTargetV1], ...]: + rows: list[tuple[GrantRecordV1, ObservationSourceV1, CapabilityTargetV1]] = [] + seen_source_ids: set[str] = set() + for bundle in bundles: + sources = {source.id: source for source in bundle.sources} + overlap = seen_source_ids & sources.keys() + if overlap: + duplicate = sorted(overlap)[0] + raise InputParseError( + f"Base evidence source id {duplicate!r} is duplicated across bundles" + ) + seen_source_ids.update(sources) + for grant in bundle.grants: + source = sources[grant.source_id] + kind = cast(TargetKind, grant.target.kind) + if source.grant_inventory_coverage.for_kind(kind) != "complete": + continue + rows.append((grant, source, declared.match(grant.target).target)) + return tuple(rows) + + +def _aggregate_grant_coverage( + sources: Iterable[ObservationSourceV1], +) -> dict[TargetKind, InventoryCoverage]: + result: dict[TargetKind, InventoryCoverage] = {} + for kind in ("tool", "mcp", "network", "filesystem", "resource"): + values = [source.grant_inventory_coverage.for_kind(kind) for source in sources] + if "complete" in values: + result[kind] = "complete" + elif "partial" in values: + result[kind] = "partial" + else: + result[kind] = "unknown" + return result + + +def _add_conflicting_grant_gaps( + gaps: dict[tuple[str, str, str], ReconciliationEvidenceGapV1], + cohort_id: str, + cohort: _CohortData, + declared: _DeclaredIndex, +) -> set[TargetKind]: + conflicting: set[TargetKind] = set() + for kind in ("tool", "mcp", "network", "filesystem", "resource"): + complete_sources = [ + source + for source in cohort.sources.values() + if source.grant_inventory_coverage.for_kind(kind) == "complete" + ] + if len(complete_sources) < 2: + continue + snapshots: dict[str, frozenset[str]] = {} + for source in complete_sources: + targets = frozenset( + _target_key(declared.match(grant.target).target) + + "|" + + _principal_key(grant.principal) + for grant in cohort.grants + if grant.source_id == source.id and grant.target.kind == kind + ) + snapshots[source.id] = targets + if len(set(snapshots.values())) > 1: + conflicting.add(kind) + _add_gap( + gaps, + cohort_id, + "conflicting_grants", + None, + f"Complete grant snapshots disagree for target kind {kind}.", + tuple(sorted(snapshots)), + ) + return conflicting + + +def _add_match_gaps( + gaps: dict[tuple[str, str, str], ReconciliationEvidenceGapV1], + cohort_id: str, + grants: Iterable[tuple[GrantRecordV1, _TargetMatch]], + observations: Iterable[tuple[ObservationRecordV1, _TargetMatch]], +) -> None: + for prefix, rows in (("grant", grants), ("observation", observations)): + for row, match in rows: + if row.target.kind not in {"tool", "mcp"}: + continue + if match.reason not in {"ambiguous", "unmapped"}: + continue + kind: EvidenceGapKind = ( + "ambiguous_target" if match.reason == "ambiguous" else "unmapped_target" + ) + _add_gap( + gaps, + cohort_id, + kind, + row.target, + f"{prefix.title()} row {row.id} could not be matched to one static capability.", + (f"{prefix}:{row.source_id}:{row.id}",), + ) + + +def _intent_membership( + target: CapabilityTargetV1, + rules: Iterable[_PreparedRule], + coverage: CoverageMode, +) -> tuple[MembershipState, tuple[str, ...], tuple[PrincipalBindingV1, ...]]: + matching = [rule for rule in rules if _selector_contains(rule.target, target)] + refs = tuple(sorted(f"intent:{rule.id}" for rule in matching)) + principals = tuple( + rule.expected_principal + for rule in matching + if rule.effect == "allow" and rule.expected_principal is not None + ) + if any(rule.effect == "deny" for rule in matching): + return "absent", refs, principals + if any(rule.effect == "allow" for rule in matching): + return "present", refs, principals + if coverage == "closed_world": + return "absent", refs, principals + return "unknown", refs, principals + + +def _declared_membership( + target: CapabilityTargetV1, + match: _TargetMatch, + declared_complete: bool, +) -> MembershipState: + if target.kind in {"network", "filesystem", "resource"}: + return "unknown" + if match.fact is not None: + return "present" + if match.reason == "ambiguous": + return "unknown" + return "absent" if declared_complete else "unknown" + + +def _granted_membership( + target: CapabilityTargetV1, + matching_grants: list[GrantRecordV1], + coverage: InventoryCoverage, +) -> MembershipState: + if matching_grants: + return "present" + return "absent" if coverage == "complete" else "unknown" + + +def _signals( + *, + intended: MembershipState, + declared: MembershipState, + granted: MembershipState, + observed: Literal["observed", "not_observed"], + outcomes: tuple[str, ...], + principal_changed: bool, + principal_mismatch: bool, +) -> set[ReconciliationSignal]: + signals: set[ReconciliationSignal] = set() + if granted == "present" and intended == "absent": + signals.add("excess_authority") + elif granted == "present" and intended == "unknown": + signals.add("policy_unclassified_grant") + if observed == "observed" and declared == "absent": + signals.add("behavioral_drift") + if "denied" in outcomes: + signals.add("denied_attempt") + if declared == "present" and granted == "absent": + signals.add("declared_ungranted") + if granted == "present" and observed == "not_observed": + signals.add("granted_unobserved") + if "allowed" in outcomes and granted == "absent": + signals.add("allowed_ungranted") + if intended == "present" and declared == "absent": + signals.add("intended_undeclared") + if declared == "present" and intended == "absent": + signals.add("declared_unintended") + if principal_changed: + signals.add("principal_changed") + if principal_mismatch: + signals.add("principal_mismatch") + if not signals: + signals.add("aligned") + return signals + + +def _disposition( + signals: set[ReconciliationSignal], + *, + intended: MembershipState, +) -> Literal["informational", "investigate", "human_review"]: + human_review = { + "principal_changed", + "principal_mismatch", + "allowed_ungranted", + "excess_authority", + "intended_undeclared", + "declared_unintended", + } + if signals & human_review or ( + "declared_ungranted" in signals and intended == "present" + ): + return "human_review" + if signals & {"behavioral_drift", "denied_attempt", "policy_unclassified_grant"}: + return "investigate" + return "informational" + + +def _principal_change( + target: CapabilityTargetV1, + current_grants: list[GrantRecordV1], + base_grants: tuple[tuple[GrantRecordV1, ObservationSourceV1, CapabilityTargetV1], ...], +) -> tuple[bool, AuthorityDeltaV1 | None]: + current_bindings = {_principal_key(grant.principal): grant.principal for grant in current_grants} + previous_bindings = { + _principal_key(grant.principal): grant.principal + for grant, _source, base_target in base_grants + if _selector_contains(base_target, target) + } + if len(current_bindings) != 1 or len(previous_bindings) != 1: + return False, None + current_key, current = next(iter(current_bindings.items())) + previous_key, previous = next(iter(previous_bindings.items())) + if current_key == previous_key: + return False, None + return True, AuthorityDeltaV1(before=previous, after=current) + + +def _principal_mismatch( + grants: list[GrantRecordV1], + observations: list[ObservationRecordV1], + expected: tuple[PrincipalBindingV1, ...], + fact: CapabilityFactV1 | None, +) -> bool: + grant_principals = [grant.principal for grant in grants] + observed_principals = [row.principal for row in observations] + if expected and any( + not any(_principal_compatible(principal, expectation) for expectation in expected) + for principal in [*grant_principals, *observed_principals] + ): + return True + if grant_principals and observed_principals: + grant_keys = {_principal_key(principal) for principal in grant_principals} + if any(_principal_key(principal) not in grant_keys for principal in observed_principals): + return True + if fact is not None: + for principal in grant_principals: + if ( + fact.authority.credential_mode is not None + and principal.credential_mode != fact.authority.credential_mode + ): + return True + if fact.authority.auth_type is not None and principal.auth_type != fact.authority.auth_type: + return True + if fact.authority.source is not None and principal.authority_source != fact.authority.source: + return True + if fact.authority.scopes and principal.scopes != fact.authority.scopes: + return True + return False + + +def _principal_compatible(actual: PrincipalBindingV1, expected: PrincipalBindingV1) -> bool: + if expected.kind != "unknown" and actual.kind != expected.kind: + return False + if expected.credential_mode != "unknown" and actual.credential_mode != expected.credential_mode: + return False + for field_name in ("auth_type", "authority_source"): + expected_value = getattr(expected, field_name) + if expected_value is not None and getattr(actual, field_name) != expected_value: + return False + if expected.authority_mode != "unknown" and actual.authority_mode != expected.authority_mode: + return False + if expected.scopes and actual.scopes != expected.scopes: + return False + return True + + +def _principal_key(principal: PrincipalBindingV1) -> str: + payload = principal.model_dump(mode="json", exclude={"principal_ref_sha256"}) + return _canonical_json(payload) + + +def _proposals_for_result(result: ReconciliationResultV1) -> list[CandidatePolicyChangeV1]: + actions: list[tuple[str, str]] = [] + signals = set(result.signals) + if "excess_authority" in signals: + actions.append( + ( + "remove_or_narrow_grant", + "Grant exceeds the reviewed intended policy; prefer least-authority removal or narrowing.", + ) + ) + if "behavioral_drift" in signals: + actions.append( + ( + "investigate_runtime_route", + "Observed behavior has no complete static declaration; investigate before changing policy.", + ) + ) + if "denied_attempt" in signals: + actions.append( + ( + "investigate_denied_attempt", + "A sandbox denial is evidence to investigate, never an allowlist recommendation.", + ) + ) + if ( + "declared_ungranted" in signals + and result.membership.intended == "present" + and result.membership.granted == "absent" + ): + actions.append( + ( + "consider_minimal_grant", + "The target is independently intended and declared but absent from a complete grant snapshot.", + ) + ) + if signals & {"principal_changed", "principal_mismatch"}: + actions.append( + ( + "review_principal", + "Authority binding changed or conflicts with reviewed expectations and requires human review.", + ) + ) + if "intended_undeclared" in signals: + actions.append( + ( + "add_or_correct_static_declaration", + "Reviewed intent is not represented by the complete static capability surface.", + ) + ) + if "declared_unintended" in signals: + actions.append( + ( + "review_intent_or_declaration", + "The static capability declaration falls outside reviewed intent.", + ) + ) + if "policy_unclassified_grant" in signals: + actions.append( + ( + "review_intent_policy", + "The grant cannot be classified because intended-policy coverage is partial.", + ) + ) + if "allowed_ungranted" in signals: + actions.append( + ( + "inspect_enforcement_integrity", + "An allowed event contradicts a complete effective grant snapshot.", + ) + ) + return [ + CandidatePolicyChangeV1( + id=_stable_id("proposal", result.cohort_id, result.id, action), + cohort_id=result.cohort_id, + target=result.target, + action=cast(str, action), + rationale=rationale, + evidence_refs=result.evidence_refs, + ) + for action, rationale in actions + ] + + +def _dedupe_proposals( + proposals: Iterable[CandidatePolicyChangeV1], +) -> list[CandidatePolicyChangeV1]: + by_key = {(row.cohort_id, _target_key(row.target), row.action): row for row in proposals} + return sorted(by_key.values(), key=lambda row: row.id) + + +def _target_from_fact(fact: CapabilityFactV1) -> CapabilityTargetV1: + if fact.evidence.source_type == "mcp" and fact.evidence.source_id: + return McpTargetV1( + capability_id=fact.id, + mcp_server=fact.evidence.source_id, + tool_name=fact.identity.tool_name, + operation=fact.identity.operation, + ) + return ToolTargetV1( + capability_id=fact.id, + tool_name=fact.identity.tool_name, + provider=fact.identity.provider, + operation=fact.identity.operation, + ) + + +def _fact_matches_composite(fact: CapabilityFactV1, target: CapabilityTargetV1) -> bool: + if target.kind == "mcp": + if fact.evidence.source_type != "mcp": + return False + if fact.evidence.source_id != target.mcp_server: + return False + return target.operation is None or fact.identity.operation == target.operation + if target.kind == "tool": + if target.provider is not None and fact.identity.provider != target.provider: + return False + return target.operation is None or fact.identity.operation == target.operation + return False + + +def _has_composite_selector(target: CapabilityTargetV1) -> bool: + if target.kind == "mcp": + return True + if target.kind == "tool": + return target.provider is not None or target.operation is not None + return False + + +def _selector_contains(selector: CapabilityTargetV1, candidate: CapabilityTargetV1) -> bool: + if selector.kind != candidate.kind: + return False + if selector.kind in {"tool", "mcp"}: + return _target_key(selector) == _target_key(candidate) + if selector.kind == "network": + if ( + selector.domain != candidate.domain + or selector.scheme != candidate.scheme + or selector.port != candidate.port + or selector.resource_class != candidate.resource_class + ): + return False + return _granularity_contains(selector.granularity, selector.locator_sha256, candidate) + if selector.kind == "filesystem": + if ( + selector.path_class != candidate.path_class + or selector.resource_class != candidate.resource_class + ): + return False + return _granularity_contains(selector.granularity, selector.locator_sha256, candidate) + if selector.kind == "resource": + if ( + selector.resource_class != candidate.resource_class + or selector.provider != candidate.provider + ): + return False + return _granularity_contains(selector.granularity, selector.locator_sha256, candidate) + return False + + +def _granularity_contains( + selector_granularity: str, + selector_locator: str | None, + candidate: NetworkTargetV1 | FilesystemTargetV1 | ResourceTargetV1, +) -> bool: + if selector_granularity == "class": + return True + return candidate.granularity == "exact" and selector_locator == candidate.locator_sha256 + + +def _target_key(target: CapabilityTargetV1) -> str: + if target.kind in {"tool", "mcp"} and target.capability_id is not None: + return f"declared:{target.capability_id}" + return _canonical_json(target.model_dump(mode="json", exclude_none=True)) + + +def _canonical_json(payload: object) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _stable_id(prefix: str, *parts: str) -> str: + digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:24] + return f"{prefix}_{digest}" + + +def _add_gap( + gaps: dict[tuple[str, str, str], ReconciliationEvidenceGapV1], + cohort_id: str, + kind: EvidenceGapKind, + target: CapabilityTargetV1 | None, + message: str, + refs: tuple[str, ...], +) -> None: + target_key = _target_key(target) if target is not None else "" + key = (kind, target_key, message) + if key in gaps: + return + gaps[key] = ReconciliationEvidenceGapV1( + id=_stable_id("gap", cohort_id, kind, target_key, message), + cohort_id=cohort_id, + kind=kind, + target=target, + message=message, + evidence_refs=tuple(sorted(refs)), + ) + + +__all__ = [ + "load_capability_intent_policy", + "load_capability_observation_bundle", + "reconcile_capabilities", + "render_capability_reconciliation_json", +] diff --git a/src/agents_shipgate/schemas/capability_reconciliation.py b/src/agents_shipgate/schemas/capability_reconciliation.py new file mode 100644 index 00000000..c9619c1c --- /dev/null +++ b/src/agents_shipgate/schemas/capability_reconciliation.py @@ -0,0 +1,749 @@ +from __future__ import annotations + +import re +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator + +CAPABILITY_INTENT_SCHEMA_VERSION = "0.1" +CAPABILITY_OBSERVATION_SCHEMA_VERSION = "0.1" +CAPABILITY_RECONCILIATION_SCHEMA_VERSION = "0.1" + +_HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_SAFE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$") +_RESOURCE_CLASS_RE = re.compile(r"^[a-z][a-z0-9._:-]{0,127}$") +Sha256Value = Annotated[str, Field(pattern=_HASH_RE.pattern)] + +TargetKind = Literal["tool", "mcp", "network", "filesystem", "resource"] +CoverageMode = Literal["closed_world", "partial"] +InventoryCoverage = Literal["complete", "partial", "unknown"] +PrincipalKind = Literal[ + "human_impersonation", + "delegated_agent", + "service_account", + "assumed_role", + "agent_principal", + "workload_identity", + "ambient", + "anonymous", + "unknown", + "other", +] + + +def _require_sha256(value: str) -> str: + if not _HASH_RE.fullmatch(value): + raise ValueError("must use algorithm-prefixed lowercase sha256:<64 hex> form") + return value + + +def _normalize_safe_token(value: str) -> str: + normalized = value.strip() + if not _SAFE_TOKEN_RE.fullmatch(normalized): + raise ValueError("must be a non-secret identifier using letters, numbers, . _ : or -") + return normalized + + +def _normalize_resource_class(value: str) -> str: + normalized = value.strip().lower() + if not _RESOURCE_CLASS_RE.fullmatch(normalized): + raise ValueError("must be a lowercase resource-class slug") + return normalized + + +class ReconciliationSubjectV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + agent_id: str + environment: str | None = None + + @field_validator("agent_id") + @classmethod + def validate_agent_id(cls, value: str) -> str: + return _normalize_safe_token(value) + + @field_validator("environment") + @classmethod + def validate_environment(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + +class ToolTargetV1(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["tool"] = "tool" + capability_id: str | None = None + tool_name: str + provider: str | None = None + operation: str | None = None + + @field_validator("capability_id", "tool_name", "provider", "operation") + @classmethod + def validate_tokens(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + +class McpTargetV1(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["mcp"] = "mcp" + capability_id: str | None = None + mcp_server: str + tool_name: str + operation: str | None = None + + @field_validator("capability_id", "mcp_server", "tool_name", "operation") + @classmethod + def validate_tokens(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + +class NetworkTargetV1(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["network"] = "network" + domain: str = Field(pattern=r"^[^:/?#@*]+$") + scheme: Literal["http", "https", "other"] | None = None + port: int | None = Field(default=None, ge=1, le=65535) + resource_class: str = "url" + granularity: Literal["exact", "class"] = "class" + locator_sha256: Sha256Value | None = None + + @field_validator("domain") + @classmethod + def normalize_domain(cls, value: str) -> str: + raw = value.strip().rstrip(".") + if any(token in raw for token in ("://", "/", "?", "#", "@", "*")): + raise ValueError("domain must be hostname-only without URL, userinfo, or wildcard data") + try: + normalized = raw.encode("idna").decode("ascii").lower() + except UnicodeError as exc: + raise ValueError("domain is not a valid IDNA hostname") from exc + if not normalized or len(normalized) > 253: + raise ValueError("domain must be a non-empty hostname") + labels = normalized.split(".") + if any( + not label + or len(label) > 63 + or label.startswith("-") + or label.endswith("-") + or not re.fullmatch(r"[a-z0-9-]+", label) + for label in labels + ): + raise ValueError("domain is not a valid hostname") + return normalized + + @field_validator("resource_class") + @classmethod + def validate_resource_class(cls, value: str) -> str: + return _normalize_resource_class(value) + + @field_validator("locator_sha256") + @classmethod + def validate_locator_hash(cls, value: str | None) -> str | None: + return _require_sha256(value) if value is not None else None + + @model_validator(mode="after") + def validate_granularity(self) -> NetworkTargetV1: + if self.granularity == "exact" and self.locator_sha256 is None: + raise ValueError("exact network targets require locator_sha256") + if self.granularity == "class" and self.locator_sha256 is not None: + raise ValueError("class network targets must not carry locator_sha256") + return self + + +class FilesystemTargetV1(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["filesystem"] = "filesystem" + path_class: Literal[ + "workspace", + "temporary", + "home", + "system", + "secret", + "external", + "unknown", + ] + resource_class: str = "file" + granularity: Literal["exact", "class"] = "class" + locator_sha256: Sha256Value | None = None + + @field_validator("resource_class") + @classmethod + def validate_resource_class(cls, value: str) -> str: + return _normalize_resource_class(value) + + @field_validator("locator_sha256") + @classmethod + def validate_locator_hash(cls, value: str | None) -> str | None: + return _require_sha256(value) if value is not None else None + + @model_validator(mode="after") + def validate_granularity(self) -> FilesystemTargetV1: + if self.granularity == "exact" and self.locator_sha256 is None: + raise ValueError("exact filesystem targets require locator_sha256") + if self.granularity == "class" and self.locator_sha256 is not None: + raise ValueError("class filesystem targets must not carry locator_sha256") + return self + + +class ResourceTargetV1(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["resource"] = "resource" + resource_class: str + provider: str | None = None + granularity: Literal["exact", "class"] = "class" + locator_sha256: Sha256Value | None = None + + @field_validator("resource_class") + @classmethod + def validate_resource_class(cls, value: str) -> str: + return _normalize_resource_class(value) + + @field_validator("provider") + @classmethod + def validate_provider(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + @field_validator("locator_sha256") + @classmethod + def validate_locator_hash(cls, value: str | None) -> str | None: + return _require_sha256(value) if value is not None else None + + @model_validator(mode="after") + def validate_granularity(self) -> ResourceTargetV1: + if self.granularity == "exact" and self.locator_sha256 is None: + raise ValueError("exact resource targets require locator_sha256") + if self.granularity == "class" and self.locator_sha256 is not None: + raise ValueError("class resource targets must not carry locator_sha256") + return self + + +CapabilityTargetV1 = Annotated[ + ToolTargetV1 | McpTargetV1 | NetworkTargetV1 | FilesystemTargetV1 | ResourceTargetV1, + Field(discriminator="kind"), +] + + +def _policy_selector_contains( + selector: CapabilityTargetV1, + candidate: CapabilityTargetV1, +) -> bool: + if selector.kind != candidate.kind: + return False + if selector.kind == "tool": + if ( + selector.capability_id is not None + and candidate.capability_id is not None + and selector.capability_id == candidate.capability_id + ): + return True + return ( + selector.tool_name == candidate.tool_name + and selector.provider == candidate.provider + and selector.operation == candidate.operation + ) + if selector.kind == "mcp": + if ( + selector.capability_id is not None + and candidate.capability_id is not None + and selector.capability_id == candidate.capability_id + ): + return True + return ( + selector.mcp_server == candidate.mcp_server + and selector.tool_name == candidate.tool_name + and selector.operation == candidate.operation + ) + if selector.kind == "network": + same_class = ( + selector.domain == candidate.domain + and selector.scheme == candidate.scheme + and selector.port == candidate.port + and selector.resource_class == candidate.resource_class + ) + elif selector.kind == "filesystem": + same_class = ( + selector.path_class == candidate.path_class + and selector.resource_class == candidate.resource_class + ) + else: + same_class = ( + selector.resource_class == candidate.resource_class + and selector.provider == candidate.provider + ) + if not same_class: + return False + if selector.granularity == "class": + return True + return ( + candidate.granularity == "exact" + and selector.locator_sha256 == candidate.locator_sha256 + ) + + +class PrincipalBindingV1(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: PrincipalKind + credential_mode: str + auth_type: str | None = None + authority_source: str | None = None + authority_mode: Literal["none", "scoped", "unscoped", "ambient", "unknown"] = "unknown" + scopes: tuple[str, ...] = Field(default_factory=tuple) + principal_ref_sha256: Sha256Value | None = None + + @field_validator("credential_mode", "auth_type", "authority_source") + @classmethod + def validate_tokens(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + @field_validator("scopes") + @classmethod + def normalize_scopes(cls, value: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(sorted({_normalize_safe_token(scope) for scope in value})) + return normalized + + @field_validator("principal_ref_sha256") + @classmethod + def validate_principal_hash(cls, value: str | None) -> str | None: + return _require_sha256(value) if value is not None else None + + +class IntentCoverageV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + tool: CoverageMode = "partial" + mcp: CoverageMode = "partial" + network: CoverageMode = "partial" + filesystem: CoverageMode = "partial" + resource: CoverageMode = "partial" + + def for_kind(self, kind: TargetKind) -> CoverageMode: + return getattr(self, kind) + + +class IntentRuleV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + effect: Literal["allow", "deny"] + target: CapabilityTargetV1 + expected_principal: PrincipalBindingV1 | None = None + + @field_validator("id") + @classmethod + def validate_id(cls, value: str) -> str: + return _normalize_safe_token(value) + + +class CapabilityIntentPolicyV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + capability_intent_schema_version: Literal["0.1"] = CAPABILITY_INTENT_SCHEMA_VERSION + experimental: Literal[True] = True + subject: ReconciliationSubjectV1 + owner: str = Field(min_length=1) + reason: str = Field(min_length=1) + coverage: IntentCoverageV1 = Field(default_factory=IntentCoverageV1) + rules: list[IntentRuleV1] = Field(default_factory=list) + + @field_validator("owner", "reason") + @classmethod + def strip_human_fields(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("must be non-empty") + return normalized + + @model_validator(mode="after") + def validate_unique_rules(self) -> CapabilityIntentPolicyV1: + seen_ids: set[str] = set() + seen_targets: dict[str, tuple[str, str]] = {} + for rule in self.rules: + if rule.id in seen_ids: + raise ValueError(f"duplicate intent rule id: {rule.id}") + seen_ids.add(rule.id) + target_key = rule.target.model_dump_json(exclude_none=True) + previous = seen_targets.get(target_key) + if previous is not None: + previous_id, previous_effect = previous + if previous_effect != rule.effect: + raise ValueError( + "conflicting overlapping intent rules: " + f"{previous_id} ({previous_effect}) and {rule.id} ({rule.effect})" + ) + raise ValueError( + "duplicate or conflicting intent target: " + f"{previous_id} and {rule.id}" + ) + seen_targets[target_key] = (rule.id, rule.effect) + for index, left in enumerate(self.rules): + for right in self.rules[index + 1 :]: + if left.effect == right.effect: + continue + if _policy_selector_contains( + left.target, right.target + ) or _policy_selector_contains(right.target, left.target): + raise ValueError( + "conflicting overlapping intent rules: " + f"{left.id} ({left.effect}) and {right.id} ({right.effect})" + ) + return self + + +class InventoryCoverageByKindV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + tool: InventoryCoverage = "unknown" + mcp: InventoryCoverage = "unknown" + network: InventoryCoverage = "unknown" + filesystem: InventoryCoverage = "unknown" + resource: InventoryCoverage = "unknown" + + def for_kind(self, kind: TargetKind) -> InventoryCoverage: + return getattr(self, kind) + + +class ObservationSourceV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + kind: Literal["sandbox_policy", "sandbox_run", "eval_run"] + vendor: str + producer_version: str | None = None + collection_mode: Literal["learning", "enforced", "evaluation"] + run_id: str + run_sha256: Sha256Value + policy_sha256: Sha256Value | None + grant_inventory_coverage: InventoryCoverageByKindV1 = Field( + default_factory=InventoryCoverageByKindV1 + ) + observation_collection_coverage: Literal[ + "complete_for_run", "partial", "unknown" + ] = "unknown" + behavioral_coverage: Literal["sampled"] = "sampled" + + @field_validator("id", "vendor", "producer_version", "run_id") + @classmethod + def validate_tokens(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + @field_validator("run_sha256") + @classmethod + def validate_run_hash(cls, value: str) -> str: + return _require_sha256(value) + + @field_validator("policy_sha256") + @classmethod + def validate_policy_hash(cls, value: str | None) -> str | None: + return _require_sha256(value) if value is not None else None + + +class GrantRecordV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + source_id: str + target: CapabilityTargetV1 + principal: PrincipalBindingV1 + + @field_validator("id", "source_id") + @classmethod + def validate_ids(cls, value: str) -> str: + return _normalize_safe_token(value) + + +class ObservationRecordV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + source_id: str + sequence: int = Field(ge=0) + correlation_id: str | None = None + target: CapabilityTargetV1 + outcome: Literal["attempted", "allowed", "denied"] + principal: PrincipalBindingV1 + + @field_validator("id", "source_id", "correlation_id") + @classmethod + def validate_ids(cls, value: str | None) -> str | None: + return _normalize_safe_token(value) if value is not None else None + + +class CapabilityObservationBundleV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + capability_observation_schema_version: Literal["0.1"] = ( + CAPABILITY_OBSERVATION_SCHEMA_VERSION + ) + experimental: Literal[True] = True + evidence_trust: Literal["untrusted"] = "untrusted" + subject: ReconciliationSubjectV1 + sources: list[ObservationSourceV1] = Field(default_factory=list) + grants: list[GrantRecordV1] = Field(default_factory=list) + observations: list[ObservationRecordV1] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_references(self) -> CapabilityObservationBundleV1: + source_ids = [source.id for source in self.sources] + if len(source_ids) != len(set(source_ids)): + raise ValueError("observation source ids must be unique") + known_sources = set(source_ids) + row_ids: set[str] = set() + for row in [*self.grants, *self.observations]: + if row.id in row_ids: + raise ValueError(f"duplicate grant/observation id: {row.id}") + row_ids.add(row.id) + if row.source_id not in known_sources: + raise ValueError( + f"row {row.id} references unknown source_id {row.source_id}" + ) + return self + + +MembershipState = Literal["present", "absent", "unknown"] +ObservationMembership = Literal["observed", "not_observed"] +ReconciliationSignal = Literal[ + "excess_authority", + "policy_unclassified_grant", + "behavioral_drift", + "denied_attempt", + "declared_ungranted", + "granted_unobserved", + "allowed_ungranted", + "intended_undeclared", + "declared_unintended", + "principal_changed", + "principal_mismatch", + "aligned", +] +EvidenceGapKind = Literal[ + "missing_policy_hash", + "policy_snapshot_mismatch", + "ambiguous_target", + "unmapped_target", + "incomplete_intent_inventory", + "incomplete_declared_inventory", + "incomplete_grant_inventory", + "conflicting_grants", + "unsupported_resource_comparison", +] + + +class ReconciliationInputBindingV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + role: Literal["capability_lock", "intent_policy", "evidence", "base_evidence"] + path: str + sha256: Sha256Value + schema_version: str + + @field_validator("sha256") + @classmethod + def validate_hash(cls, value: str) -> str: + return _require_sha256(value) + + +class SetMembershipV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + intended: MembershipState + declared: MembershipState + granted: MembershipState + observed: ObservationMembership + + +class AuthorityDeltaV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + before: PrincipalBindingV1 + after: PrincipalBindingV1 + + +class ReconciliationResultV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + cohort_id: str + target: CapabilityTargetV1 + membership: SetMembershipV1 + outcomes: tuple[Literal["attempted", "allowed", "denied"], ...] = Field( + default_factory=tuple + ) + signals: tuple[ReconciliationSignal, ...] + primary_signal: ReconciliationSignal + disposition: Literal["informational", "investigate", "human_review"] + evidence_refs: tuple[str, ...] = Field(default_factory=tuple) + authority_delta: AuthorityDeltaV1 | None = None + + +class ReconciliationEvidenceGapV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + cohort_id: str + kind: EvidenceGapKind + target: CapabilityTargetV1 | None = None + message: str + evidence_refs: tuple[str, ...] = Field(default_factory=tuple) + + +class CandidatePolicyChangeV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + cohort_id: str + target: CapabilityTargetV1 + action: Literal[ + "remove_or_narrow_grant", + "investigate_runtime_route", + "investigate_denied_attempt", + "consider_minimal_grant", + "review_principal", + "add_or_correct_static_declaration", + "review_intent_or_declaration", + "review_intent_policy", + "inspect_enforcement_integrity", + ] + rationale: str + evidence_refs: tuple[str, ...] = Field(default_factory=tuple) + actor: Literal["human"] = "human" + approval_state: Literal["unreviewed"] = "unreviewed" + auto_apply: Literal[False] = False + safe_to_attempt: Literal[False] = False + requires_human_review: Literal[True] = True + + +class ReconciliationCohortCoverageV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + intended: dict[TargetKind, CoverageMode] + declared: Literal["complete", "partial"] + granted: dict[TargetKind, InventoryCoverage] + observed: Literal["sampled"] = "sampled" + + +class ReconciliationSourceRunV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + source_id: str + kind: Literal["sandbox_policy", "sandbox_run", "eval_run"] + collection_mode: Literal["learning", "enforced", "evaluation"] + run_id: str + run_sha256: Sha256Value + policy_sha256: Sha256Value | None + + @field_validator("run_sha256") + @classmethod + def validate_run_hash(cls, value: str) -> str: + return _require_sha256(value) + + @field_validator("policy_sha256") + @classmethod + def validate_policy_hash(cls, value: str | None) -> str | None: + return _require_sha256(value) if value is not None else None + + +class ReconciliationCohortV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + policy_sha256: str | None + source_refs: tuple[str, ...] + source_runs: tuple[ReconciliationSourceRunV1, ...] + coverage: ReconciliationCohortCoverageV1 + result_count: int + evidence_gap_count: int + signal_counts: dict[str, int] = Field(default_factory=dict) + + +class ReconciliationSummaryV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + cohort_count: int + target_count: int + result_count: int + evidence_gap_count: int + candidate_policy_change_count: int + signal_counts: dict[str, int] = Field(default_factory=dict) + + +class CapabilityReconciliationV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + capability_reconciliation_schema_version: Literal["0.1"] = ( + CAPABILITY_RECONCILIATION_SCHEMA_VERSION + ) + experimental: Literal[True] = True + local_imported_evidence_only: Literal[True] = True + live_collection_performed: Literal[False] = False + release_gate_effect: Literal[False] = False + evidence_trust: Literal["untrusted"] = "untrusted" + subject: ReconciliationSubjectV1 + capability_lock_semantic_hash: Sha256Value + inputs: tuple[ReconciliationInputBindingV1, ...] + summary: ReconciliationSummaryV1 + cohorts: tuple[ReconciliationCohortV1, ...] + results: tuple[ReconciliationResultV1, ...] + evidence_gaps: tuple[ReconciliationEvidenceGapV1, ...] + candidate_policy_changes: tuple[CandidatePolicyChangeV1, ...] + notes: tuple[str, ...] = Field(default_factory=tuple) + + @field_validator("capability_lock_semantic_hash") + @classmethod + def validate_semantic_hash(cls, value: str) -> str: + return _require_sha256(value) + + +class CapabilityIntentPolicyArtifactV1(RootModel[CapabilityIntentPolicyV1]): + root: CapabilityIntentPolicyV1 + + +class CapabilityObservationArtifactV1(RootModel[CapabilityObservationBundleV1]): + root: CapabilityObservationBundleV1 + + +class CapabilityReconciliationArtifactV1(RootModel[CapabilityReconciliationV1]): + root: CapabilityReconciliationV1 + + +__all__ = [ + "CAPABILITY_INTENT_SCHEMA_VERSION", + "CAPABILITY_OBSERVATION_SCHEMA_VERSION", + "CAPABILITY_RECONCILIATION_SCHEMA_VERSION", + "AuthorityDeltaV1", + "CandidatePolicyChangeV1", + "CapabilityIntentPolicyArtifactV1", + "CapabilityIntentPolicyV1", + "CapabilityObservationArtifactV1", + "CapabilityObservationBundleV1", + "CapabilityReconciliationArtifactV1", + "CapabilityReconciliationV1", + "CapabilityTargetV1", + "EvidenceGapKind", + "FilesystemTargetV1", + "GrantRecordV1", + "IntentCoverageV1", + "IntentRuleV1", + "InventoryCoverageByKindV1", + "McpTargetV1", + "NetworkTargetV1", + "ObservationRecordV1", + "ObservationSourceV1", + "PrincipalBindingV1", + "ReconciliationCohortCoverageV1", + "ReconciliationCohortV1", + "ReconciliationEvidenceGapV1", + "ReconciliationInputBindingV1", + "ReconciliationResultV1", + "ReconciliationSignal", + "ReconciliationSourceRunV1", + "ReconciliationSubjectV1", + "ReconciliationSummaryV1", + "ResourceTargetV1", + "SetMembershipV1", + "ToolTargetV1", +] diff --git a/tests/test_capability_reconciliation.py b/tests/test_capability_reconciliation.py new file mode 100644 index 00000000..2ff78da2 --- /dev/null +++ b/tests/test_capability_reconciliation.py @@ -0,0 +1,746 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import jsonschema +import pytest +from pydantic import ValidationError + +from agents_shipgate.core.capability_lock import build_capability_lock, render_capability_lock_json +from agents_shipgate.core.capability_reconciliation import ( + load_capability_intent_policy, + load_capability_observation_bundle, + reconcile_capabilities, + render_capability_reconciliation_json, +) +from agents_shipgate.core.domain import Agent, AuthInfo, Tool +from agents_shipgate.schemas.capabilities import CapabilityLockFileV1 +from agents_shipgate.schemas.capability_reconciliation import ( + CapabilityIntentPolicyV1, + CapabilityObservationBundleV1, + NetworkTargetV1, + PrincipalBindingV1, + ReconciliationInputBindingV1, +) +from agents_shipgate.schemas.manifest import AgentsShipgateManifest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SHA_A = "sha256:" + "a" * 64 +SHA_B = "sha256:" + "b" * 64 +SHA_C = "sha256:" + "c" * 64 + + +def _manifest() -> AgentsShipgateManifest: + return AgentsShipgateManifest.model_validate( + { + "version": "0.1", + "project": {"name": "reconciliation"}, + "agent": { + "name": "support-agent", + "declared_purpose": ["Support account workflows."], + }, + "environment": {"target": "local"}, + "tool_sources": [ + {"id": "support_api", "type": "openapi", "path": "support.json"} + ], + } + ) + + +def _tool( + name: str, + *, + provider: str = "support_api", + operation: str | None = None, + credential_mode: str = "delegated", +) -> Tool: + return Tool( + id=f"tool:{provider}:{name}", + name=name, + description=f"{name} test tool", + source_type="openapi", + source_id=provider, + source_ref="support.json", + operation=operation or name, + auth=AuthInfo( + type="oauth", + credential_mode=credential_mode, + source="manifest", + scopes=["cases:read"], + ), + extraction_confidence="high", + ) + + +def _lock( + tools: list[Tool] | None = None, + *, + source_warning_count: int = 0, + plugins_enabled: bool = True, +) -> CapabilityLockFileV1: + return build_capability_lock( + _manifest(), + agent=Agent(id="agent:reconcile", name="support-agent"), + tools=tools or [_tool("cases.search")], + config_path=Path("shipgate.yaml"), + manifest_dir=Path("."), + cli_version="test", + source_count=1, + source_warning_count=source_warning_count, + plugins_enabled=plugins_enabled, + ) + + +def _principal( + *, + kind: str = "service_account", + credential_mode: str = "delegated", +) -> PrincipalBindingV1: + return PrincipalBindingV1( + kind=kind, + credential_mode=credential_mode, + auth_type="oauth", + authority_source="manifest", + authority_mode="scoped", + scopes=("cases:read",), + ) + + +def _intent( + lock: CapabilityLockFileV1, + *, + coverage: str = "closed_world", + rules: list[dict[str, object]] | None = None, +) -> CapabilityIntentPolicyV1: + fact = lock.capabilities[0] + default_rules: list[dict[str, object]] = [ + { + "id": "allow.cases.search", + "effect": "allow", + "target": { + "kind": "tool", + "capability_id": fact.id, + "tool_name": fact.identity.tool_name, + }, + "expected_principal": _principal().model_dump(mode="json"), + } + ] + return CapabilityIntentPolicyV1.model_validate( + { + "subject": {"agent_id": "agent:reconcile", "environment": "local"}, + "owner": "platform-security", + "reason": "Reviewed reconciliation fixture.", + "coverage": {"tool": coverage}, + "rules": default_rules if rules is None else rules, + } + ) + + +def _source( + *, + source_id: str = "current", + policy_sha256: str | None = SHA_B, + tool_coverage: str = "complete", + network_coverage: str = "unknown", + collection_mode: str = "evaluation", +) -> dict[str, object]: + return { + "id": source_id, + "kind": "eval_run", + "vendor": "fixture", + "collection_mode": collection_mode, + "run_id": source_id, + "run_sha256": SHA_A, + "policy_sha256": policy_sha256, + "grant_inventory_coverage": { + "tool": tool_coverage, + "network": network_coverage, + }, + "observation_collection_coverage": "complete_for_run", + } + + +def _bundle( + *, + sources: list[dict[str, object]] | None = None, + grants: list[dict[str, object]] | None = None, + observations: list[dict[str, object]] | None = None, +) -> CapabilityObservationBundleV1: + return CapabilityObservationBundleV1.model_validate( + { + "subject": {"agent_id": "agent:reconcile", "environment": "local"}, + "sources": sources or [_source()], + "grants": grants or [], + "observations": observations or [], + } + ) + + +def _tool_target(name: str = "cases.search") -> dict[str, object]: + return {"kind": "tool", "tool_name": name} + + +def _grant( + target: dict[str, object] | None = None, + *, + row_id: str = "grant-1", + source_id: str = "current", + principal: PrincipalBindingV1 | None = None, +) -> dict[str, object]: + return { + "id": row_id, + "source_id": source_id, + "target": target or _tool_target(), + "principal": (principal or _principal()).model_dump(mode="json"), + } + + +def _observation( + target: dict[str, object] | None = None, + *, + row_id: str = "observation-1", + source_id: str = "current", + outcome: str = "allowed", + sequence: int = 0, + principal: PrincipalBindingV1 | None = None, +) -> dict[str, object]: + return { + "id": row_id, + "source_id": source_id, + "sequence": sequence, + "target": target or _tool_target(), + "outcome": outcome, + "principal": (principal or _principal()).model_dump(mode="json"), + } + + +def _reconcile( + lock: CapabilityLockFileV1, + intent: CapabilityIntentPolicyV1, + bundle: CapabilityObservationBundleV1, + *, + base: CapabilityObservationBundleV1 | None = None, +): + return reconcile_capabilities( + lock=lock, + intent=intent, + evidence_bundles=[bundle], + base_evidence_bundles=[] if base is None else [base], + ) + + +def _result_for_tool(artifact, tool_name: str): + return next( + row + for row in artifact.results + if row.target.kind in {"tool", "mcp"} and row.target.tool_name == tool_name + ) + + +def test_strict_schemas_reject_raw_resource_data_invalid_hashes_and_conflicts() -> None: + with pytest.raises(ValidationError, match="pattern"): + NetworkTargetV1(domain="https://example.com/path?token=secret") + + with pytest.raises(ValidationError, match="sha256"): + NetworkTargetV1( + domain="example.com", + granularity="exact", + locator_sha256="/Users/alice/secret.txt", + ) + + lock = _lock() + fact = lock.capabilities[0] + with pytest.raises(ValidationError, match="conflicting overlapping intent rules"): + _intent( + lock, + rules=[ + { + "id": "allow.search", + "effect": "allow", + "target": { + "kind": "tool", + "capability_id": fact.id, + "tool_name": "cases.search", + }, + }, + { + "id": "deny.search", + "effect": "deny", + "target": { + "kind": "tool", + "capability_id": fact.id, + "tool_name": "cases.search", + }, + }, + ], + ) + + +def test_aligned_result_is_deterministic_and_schema_valid() -> None: + lock = _lock() + intent = _intent(lock) + bundle = _bundle( + grants=[_grant()], + observations=[_observation()], + ) + + first = _reconcile(lock, intent, bundle) + reversed_bundle = _bundle( + grants=list(reversed(bundle.model_dump(mode="json")["grants"])), + observations=list(reversed(bundle.model_dump(mode="json")["observations"])), + ) + second = _reconcile(lock, intent, reversed_bundle) + + assert render_capability_reconciliation_json(first) == ( + render_capability_reconciliation_json(second) + ) + assert first.results[0].signals == ("aligned",) + assert first.release_gate_effect is False + schema = json.loads( + (REPO_ROOT / "docs/capability-reconciliation-schema.v0.1.json").read_text( + encoding="utf-8" + ) + ) + jsonschema.validate(first.model_dump(mode="json"), schema) + + +def test_excess_authority_is_distinct_from_unclassified_grant() -> None: + lock = _lock() + closed = _intent(lock, rules=[]) + partial = _intent(lock, coverage="partial", rules=[]) + bundle = _bundle(grants=[_grant()], observations=[]) + + excess = _result_for_tool(_reconcile(lock, closed, bundle), "cases.search") + unclassified = _result_for_tool(_reconcile(lock, partial, bundle), "cases.search") + + assert "excess_authority" in excess.signals + assert "declared_unintended" in excess.signals + assert "policy_unclassified_grant" in unclassified.signals + assert "excess_authority" not in unclassified.signals + + +def test_denied_unknown_tool_is_behavioral_drift_but_never_allowlisted() -> None: + lock = _lock() + intent = _intent(lock) + bundle = _bundle( + observations=[ + _observation( + _tool_target("shell.execute"), + outcome="denied", + ) + ] + ) + + artifact = _reconcile(lock, intent, bundle) + result = _result_for_tool(artifact, "shell.execute") + + assert {"behavioral_drift", "denied_attempt"} <= set(result.signals) + actions = { + proposal.action + for proposal in artifact.candidate_policy_changes + if proposal.target.kind == "tool" and proposal.target.tool_name == "shell.execute" + } + assert "investigate_runtime_route" in actions + assert "investigate_denied_attempt" in actions + assert "consider_minimal_grant" not in actions + assert all(not proposal.auto_apply for proposal in artifact.candidate_policy_changes) + + +def test_declared_ungranted_is_broken_only_when_independently_intended() -> None: + lock = _lock() + bundle = _bundle() + + intended_artifact = _reconcile(lock, _intent(lock), bundle) + intended = _result_for_tool(intended_artifact, "cases.search") + partial_artifact = _reconcile(lock, _intent(lock, coverage="partial", rules=[]), bundle) + partial = _result_for_tool(partial_artifact, "cases.search") + + assert "declared_ungranted" in intended.signals + assert intended.disposition == "human_review" + assert any( + proposal.action == "consider_minimal_grant" + for proposal in intended_artifact.candidate_policy_changes + ) + assert "declared_ungranted" in partial.signals + assert partial.disposition == "informational" + assert all( + proposal.action != "consider_minimal_grant" + for proposal in partial_artifact.candidate_policy_changes + ) + + +def test_granted_unobserved_is_informational_and_has_no_removal_proposal() -> None: + lock = _lock() + artifact = _reconcile(lock, _intent(lock), _bundle(grants=[_grant()])) + result = _result_for_tool(artifact, "cases.search") + + assert result.signals == ("granted_unobserved",) + assert result.disposition == "informational" + assert not artifact.candidate_policy_changes + + +def test_allowed_without_complete_grant_is_enforcement_contradiction() -> None: + lock = _lock() + bundle = _bundle( + observations=[_observation(outcome="allowed")], + ) + artifact = _reconcile(lock, _intent(lock), bundle) + result = _result_for_tool(artifact, "cases.search") + + assert "allowed_ungranted" in result.signals + assert "declared_ungranted" in result.signals + assert any( + proposal.action == "inspect_enforcement_integrity" + for proposal in artifact.candidate_policy_changes + ) + + +def test_incomplete_evidence_suppresses_absence_based_conclusions() -> None: + lock = _lock(source_warning_count=1) + intent = _intent(lock, coverage="partial", rules=[]) + bundle = _bundle( + sources=[_source(tool_coverage="partial")], + observations=[_observation(_tool_target("unknown.tool"), outcome="allowed")], + ) + + artifact = _reconcile(lock, intent, bundle) + result = _result_for_tool(artifact, "unknown.tool") + + assert result.membership.intended == "unknown" + assert result.membership.declared == "unknown" + assert result.membership.granted == "unknown" + assert "behavioral_drift" not in result.signals + assert "allowed_ungranted" not in result.signals + gap_kinds = {gap.kind for gap in artifact.evidence_gaps} + assert { + "incomplete_intent_inventory", + "incomplete_declared_inventory", + "incomplete_grant_inventory", + } <= gap_kinds + + +def test_missing_policy_hash_isolated_and_cannot_prove_missing_grants() -> None: + lock = _lock() + bundle = _bundle( + sources=[_source(policy_sha256=None, tool_coverage="complete")], + observations=[_observation()], + ) + + artifact = _reconcile(lock, _intent(lock), bundle) + result = _result_for_tool(artifact, "cases.search") + + assert result.membership.granted == "unknown" + assert "allowed_ungranted" not in result.signals + assert "missing_policy_hash" in {gap.kind for gap in artifact.evidence_gaps} + + +def test_ambiguous_tool_name_is_not_guessed_or_called_behavioral_drift() -> None: + lock = _lock( + [ + _tool("shared.search", provider="one", operation="search-one"), + _tool("shared.search", provider="two", operation="search-two"), + ] + ) + intent = _intent(lock, rules=[]) + bundle = _bundle( + observations=[_observation(_tool_target("shared.search"), outcome="attempted")] + ) + + artifact = _reconcile(lock, intent, bundle) + raw = next( + row + for row in artifact.results + if row.target.kind == "tool" + and row.target.tool_name == "shared.search" + and row.target.capability_id is None + ) + + assert raw.membership.declared == "unknown" + assert "behavioral_drift" not in raw.signals + assert "ambiguous_target" in {gap.kind for gap in artifact.evidence_gaps} + + +def test_resource_intent_and_grants_reconcile_without_claiming_static_declaration() -> None: + lock = _lock() + network_target = { + "kind": "network", + "domain": "API.EXAMPLE.COM.", + "scheme": "https", + "resource_class": "customer_api", + "granularity": "class", + } + intent = _intent( + lock, + rules=[ + { + "id": "deny.customer-api", + "effect": "deny", + "target": network_target, + } + ], + ) + bundle = _bundle( + sources=[_source(network_coverage="complete")], + grants=[_grant(network_target)], + ) + + artifact = _reconcile(lock, intent, bundle) + result = next(row for row in artifact.results if row.target.kind == "network") + + assert result.target.domain == "api.example.com" + assert result.membership.intended == "absent" + assert result.membership.granted == "present" + assert result.membership.declared == "unknown" + assert "excess_authority" in result.signals + assert "unsupported_resource_comparison" in { + gap.kind for gap in artifact.evidence_gaps + } + + +def test_principal_change_requires_complete_base_snapshot() -> None: + lock = _lock() + current_principal = _principal(kind="agent_principal") + previous_principal = _principal(kind="service_account") + intent = _intent( + lock, + rules=[ + { + "id": "allow.cases.search", + "effect": "allow", + "target": _tool_target(), + "expected_principal": current_principal.model_dump(mode="json"), + } + ], + ) + current = _bundle(grants=[_grant(principal=current_principal)]) + base = _bundle( + sources=[_source(source_id="base", policy_sha256=SHA_C)], + grants=[ + _grant( + row_id="base-grant", + source_id="base", + principal=previous_principal, + ) + ], + ) + + without_base = _result_for_tool(_reconcile(lock, intent, current), "cases.search") + with_base = _result_for_tool( + _reconcile(lock, intent, current, base=base), "cases.search" + ) + + assert "principal_changed" not in without_base.signals + assert "principal_changed" in with_base.signals + assert with_base.authority_delta is not None + assert with_base.authority_delta.before.kind == "service_account" + assert with_base.authority_delta.after.kind == "agent_principal" + + +def test_learning_mode_never_creates_executable_or_observation_only_grant() -> None: + lock = _lock() + bundle = _bundle( + sources=[_source(collection_mode="learning")], + observations=[ + _observation(_tool_target("unexpected.delete"), outcome="denied") + ], + ) + + artifact = _reconcile(lock, _intent(lock), bundle) + + assert all(proposal.actor == "human" for proposal in artifact.candidate_policy_changes) + assert all(proposal.requires_human_review for proposal in artifact.candidate_policy_changes) + assert all(not proposal.safe_to_attempt for proposal in artifact.candidate_policy_changes) + assert all(not proposal.auto_apply for proposal in artifact.candidate_policy_changes) + assert all( + proposal.action != "consider_minimal_grant" + for proposal in artifact.candidate_policy_changes + if proposal.target.kind == "tool" + and proposal.target.tool_name == "unexpected.delete" + ) + + +def test_input_contracts_validate_against_generated_schemas() -> None: + lock = _lock() + intent = _intent(lock) + bundle = _bundle(grants=[_grant()], observations=[_observation()]) + intent_schema = json.loads( + (REPO_ROOT / "docs/capability-intent-schema.v0.1.json").read_text( + encoding="utf-8" + ) + ) + observation_schema = json.loads( + (REPO_ROOT / "docs/capability-observation-schema.v0.1.json").read_text( + encoding="utf-8" + ) + ) + + jsonschema.validate(intent.model_dump(mode="json"), intent_schema) + jsonschema.validate(bundle.model_dump(mode="json"), observation_schema) + + +def test_documented_example_replays_byte_identically() -> None: + examples = REPO_ROOT / "docs/examples" + lock = CapabilityLockFileV1.model_validate( + json.loads((examples / "capability-lock.v0.3.example.json").read_text()) + ) + intent = load_capability_intent_policy( + examples / "capability-intent.v0.1.example.json" + ) + evidence = load_capability_observation_bundle( + examples / "capability-observations.v0.1.example.json" + ) + base = load_capability_observation_bundle( + examples / "capability-observations-base.v0.1.example.json" + ) + expected = json.loads( + (examples / "capability-reconciliation.v0.1.example.json").read_text() + ) + input_specs = [ + ("capability_lock", examples / "capability-lock.v0.3.example.json", "0.3"), + ("intent_policy", examples / "capability-intent.v0.1.example.json", "0.1"), + ("evidence", examples / "capability-observations.v0.1.example.json", "0.1"), + ( + "base_evidence", + examples / "capability-observations-base.v0.1.example.json", + "0.1", + ), + ] + bindings = [ + ReconciliationInputBindingV1( + role=role, + path=path.relative_to(REPO_ROOT).as_posix(), + sha256=f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}", + schema_version=version, + ) + for role, path, version in input_specs + ] + + artifact = reconcile_capabilities( + lock=lock, + intent=intent, + evidence_bundles=[evidence], + base_evidence_bundles=[base], + input_bindings=bindings, + ) + + assert artifact.model_dump(mode="json") == expected + assert all( + proposal.auto_apply is False + and proposal.safe_to_attempt is False + and proposal.requires_human_review is True + for proposal in artifact.candidate_policy_changes + ) + + +def test_prototype_script_writes_local_artifact_and_exits_zero_for_signals( + tmp_path: Path, +) -> None: + lock = _lock() + intent = _intent(lock, rules=[]) + bundle = _bundle(grants=[_grant()]) + lock_path = tmp_path / "capabilities.lock.json" + intent_path = tmp_path / "intent.json" + evidence_path = tmp_path / "evidence.json" + out_path = tmp_path / "result.json" + lock_path.write_text(render_capability_lock_json(lock), encoding="utf-8") + intent_path.write_text(intent.model_dump_json(indent=2) + "\n", encoding="utf-8") + evidence_path.write_text(bundle.model_dump_json(indent=2) + "\n", encoding="utf-8") + + completed = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts/prototype_capability_reconciliation.py"), + "--workspace", + str(tmp_path), + "--lock", + lock_path.name, + "--intent", + intent_path.name, + "--evidence", + evidence_path.name, + "--out", + out_path.name, + "--json", + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + stdout_payload = json.loads(completed.stdout) + file_payload = json.loads(out_path.read_text(encoding="utf-8")) + assert stdout_payload == file_payload + assert stdout_payload["release_gate_effect"] is False + assert stdout_payload["summary"]["signal_counts"]["excess_authority"] == 1 + assert all( + not proposal["auto_apply"] + for proposal in stdout_payload["candidate_policy_changes"] + ) + + +def test_prototype_script_rejects_outside_workspace_and_stale_lock(tmp_path: Path) -> None: + outside = tmp_path.parent / "outside-intent.json" + outside.write_text("{}", encoding="utf-8") + stale_lock = _lock().model_dump(mode="json") + stale_lock["capability_lock_schema_version"] = "0.2" + lock_path = tmp_path / "stale.lock.json" + evidence_path = tmp_path / "evidence.json" + lock_path.write_text(json.dumps(stale_lock), encoding="utf-8") + evidence_path.write_text(_bundle().model_dump_json(), encoding="utf-8") + + completed = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts/prototype_capability_reconciliation.py"), + "--workspace", + str(tmp_path), + "--lock", + lock_path.name, + "--intent", + "../outside-intent.json", + "--evidence", + evidence_path.name, + "--out", + "result.json", + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 3 + assert "outside manifest directory" in completed.stderr + + intent_path = tmp_path / "intent.json" + intent_path.write_text(_intent(_lock()).model_dump_json(), encoding="utf-8") + stale = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts/prototype_capability_reconciliation.py"), + "--workspace", + str(tmp_path), + "--lock", + lock_path.name, + "--intent", + intent_path.name, + "--evidence", + evidence_path.name, + "--out", + "result.json", + ], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + assert stale.returncode == 3 + assert "requires capability lock v0.3" in stale.stderr + assert "agents-shipgate capability export" in stale.stderr diff --git a/tests/test_schema_roundtrip.py b/tests/test_schema_roundtrip.py index d9d82359..2cb415b7 100644 --- a/tests/test_schema_roundtrip.py +++ b/tests/test_schema_roundtrip.py @@ -5,6 +5,7 @@ the current-minor ``packet-schema.v0.*.json``, and ``verifier-schema.v0.1.json``, ``verify-run-schema.v1.json``, ``agent-handoff-schema.v1.json``, ``codex-boundary-result-schema.v1.json``, +the three experimental ``capability-*-schema.v0.1.json`` reconciliation contracts, and ``agent-result-schema.v1.json``) MUST match what ``scripts/generate_schemas.py`` produces from the live Pydantic models. @@ -187,6 +188,9 @@ def test_builders_are_pure(generator): generator.build_org_evidence_bundle_schema, generator.build_registry_schema, generator.build_host_grants_inventory_schema, + generator.build_capability_intent_schema, + generator.build_capability_observation_schema, + generator.build_capability_reconciliation_schema, ): target_a, content_a = builder() target_b, content_b = builder()