diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc499c0..d9f0136 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: tests/test_bounded_scan.py::test_default_isolated_scan_supports_standalone_programmatic_call tests/test_public_api.py::test_public_scanners_support_unguarded_consumer_with_guarded_parity tests/test_content_guard.py::test_new_mode_applies_windows_git_filename_rejections + tests/cli/test_evidence_pack.py::test_audit_event_artifacts_preserve_nested_paths_for_duplicate_basenames tests/test_windows_file_boundaries.py - name: Verify Windows CLI report consumer contract shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d97f7..c35b4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Why: keep static guard releases auditable while the package is still alpha. ## Unreleased +- Isolated repository-controlled context-policy regular-expression matching + behind the existing bounded scanner worker and added fixed pattern-count and + pattern-length limits. Timeout and limit failures remain deterministic, + sanitized configuration errors; no raw pattern or context text is emitted. +- Content-bound optional `agent-policy` audit-event references with a + canonical-JSON, profile-bound, public-safe digest. Producers now require a + reviewed repo-local event file and explicit profile, while consumers require + the separately supplied event and reject missing, malformed, or replaced + content. The event body remains outside the fixed seven-file public bundle. - Defined a bounded demand-validation window through 2026-09-20 and froze feature releases pending an explicit maintainer decision after the 2026-09-21 review. Marketplace publication remains separately prohibited without diff --git a/README.md b/README.md index 6d63e27..fda5e7f 100644 --- a/README.md +++ b/README.md @@ -350,13 +350,15 @@ Add `--digest-policy .agent-guard/context-digest-policy.yaml` to the `report` command only after that policy is reviewed and committed. **Optional reviewed audit event.** To record a companion `agent-policy` audit -event, add the same -`--agent-policy-audit-event ` option to both the +event, add the same `--agent-policy-audit-event ` and +`--agent-policy-audit-event-profile ` options to both the `report` command and the standalone `evidence-pack manifest` command. Generate -both artifacts again after the producer has written the event and a maintainer -has reviewed it. The public bundle consumer requires the standalone manifest to -match the manifest embedded in the report. `agent-guard` records only the path -and does not validate the file's existence or content. Keep the event outside +both artifacts again after the producer has written the repo-local JSON event +and a maintainer has reviewed it. The manifest records only a sanitized +repository-relative path and a profile-bound canonical-content digest, not the +event body. If the standalone manifest is present, the public bundle consumer +requires it to match the manifest embedded in the report. It also requires the +event path and profile again to verify the binding. Keep the event outside `.agent-guard/evidence`: the seven-file public bundle allow-list rejects it. The following optional PR review command is available in `0.3.0`: @@ -603,7 +605,10 @@ MCP 2026-07-28 protocol/runtime/OAuth changes do not justify runtime execution o validation. No changelog item directly invalidates the current static committed-config labels, so this update does not change their taxonomy or code. With `--evidence-pack-manifest`, it embeds a public-safe artifact handoff manifest for pull request review. Add -`--agent-policy-audit-event ` to include a sanitized artifact reference to a companion `agent-policy` audit event without reading or embedding the event body. +`--agent-policy-audit-event ` and +`--agent-policy-audit-event-profile ` to bind a reviewed repo-local +companion `agent-policy` audit event without embedding its body. Consumers must +receive that event separately and verify it with the same expected profile. Read `recommended` as the reviewed static evidence baseline, not as the full pin-integrity profile. The recommended preset can emit digest and context-lock @@ -1006,7 +1011,7 @@ agent-guard context inventory --root --policy [--json] agent-guard context lock --root --policy [--check --digest-policy ] [--json] agent-guard mcp check --root [--policy ] [--json] agent-guard surface inventory --root --context-policy [--schema-version ] [--json] -agent-guard report --root --context-policy [--evidence-preset recommended] [--path-policy ] [--content-policy ] [--content-scan-dir ] [--api-policy ] [--mcp-config-check] [--mcp-policy ] [--digest-policy ] [--workflow-policy ] [--drift-check] [--drift-base-ref ] [--agent-policy-audit-event ] [--format ] [--output ] [--stderr-summary] +agent-guard report --root --context-policy [--evidence-preset recommended] [--path-policy ] [--content-policy ] [--content-scan-dir ] [--api-policy ] [--mcp-config-check] [--mcp-policy ] [--digest-policy ] [--workflow-policy ] [--drift-check] [--drift-base-ref ] [--agent-policy-audit-event --agent-policy-audit-event-profile ] [--format ] [--output ] [--stderr-summary] agent-guard render-report --root --input [--format ] [--output ] agent-guard path check --root --policy [--json] agent-guard digest check --root --policy [--json] diff --git a/docs/compatibility.md b/docs/compatibility.md index 7ee454e..24065e7 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -59,11 +59,12 @@ Public-safe is a bounded sanitization contract over declared controlled fields and controlled patterns. It is not a generic guarantee that an artifact contains no secrets or PII, and it does not replace dedicated secret scanners. -API, content, and path policies also have fail-closed resource ceilings. The -current implementation accepts policy files up to 256 KiB and at most 64 -policy-controlled regular expressions per scanner, bounds include lists and -filesystem walks, rejects repository-scoped include targets that resolve -outside the repository root, and limits each isolated matching run to five +API, content, context, and path policies also have fail-closed resource +ceilings. The current implementation accepts policy files up to 256 KiB and at +most 64 policy-controlled regular expressions per scanner, limits each pattern +to 4 KiB, bounds include lists and filesystem walks, rejects repository-scoped +include targets that resolve outside the repository root, and limits each +isolated matching run to five seconds after worker startup. Registered and preregistration content target walks also share a monotonic five-second enumeration deadline and charge directory entries plus pattern/path glob-state work against the fixed traversal @@ -78,6 +79,21 @@ with exit `2`; raw patterns, paths, or file contents are not included in the error. These ceilings are implementation safety limits, not evidence-schema fields or a promise of generic content/credential scanning. +Optional `agent-policy` audit-event entries are an additive v1 manifest field: +the existing required `path` and `role` fields retain their meaning, while new +producer output adds `content_binding`. The binding uses canonical JSON, an +explicit expected event profile, and a domain-separated SHA-256 digest encoded +as lowercase base32 with a controlled `b` prefix. This controlled digest is not +a raw hexadecimal hash and is the only hash-like value admitted on this field. +Current packaged consumers fail closed when an audit-event role lacks the +binding or when the separately supplied event does not match. Older v1 +consumers may ignore the optional field, so callers that require content +binding must use a consumer version that documents this contract. The event +profile identifies the caller-selected contract but does not itself validate +the event against an `agent-policy` JSON Schema; that remains producer-owned. +The event body remains outside the fixed public bundle. Replacing both a +trusted manifest and its event is outside this binding's threat model. + Workflow inputs use the same fail-closed approach. Workflow policies are capped at 256 KiB, individual policy strings at 4 KiB, and workflow files at 1 MiB. Distinct workflow input is capped in aggregate, while duplicate normalized paths diff --git a/docs/evidence-contracts.md b/docs/evidence-contracts.md index 31caf09..ea2591c 100644 --- a/docs/evidence-contracts.md +++ b/docs/evidence-contracts.md @@ -147,20 +147,33 @@ agent-guard report --root . \ --mcp-policy .agent-guard/mcp-policy.yaml \ --digest-policy .agent-guard/context-digest-policy.yaml \ --agent-policy-audit-event path/to/reviewed-policy-admission-event.json \ + --agent-policy-audit-event-profile agent-policy.audit_event.v1.1 \ --format json \ --output .agent-guard/evidence/agent-guard-report.json agent-guard evidence-pack manifest --root . \ --report .agent-guard/evidence/agent-guard-report.json \ --artifact .agent-guard/evidence/agent-guard-report.json \ --agent-policy-audit-event path/to/reviewed-policy-admission-event.json \ + --agent-policy-audit-event-profile agent-policy.audit_event.v1.1 \ --json +python examples/evidence_consumer.py \ + .agent-guard/evidence/agent-guard-report.json \ + --evidence-dir .agent-guard/evidence \ + --agent-policy-audit-event path/to/reviewed-policy-admission-event.json \ + --agent-policy-audit-event-profile agent-policy.audit_event.v1.1 ``` -The referenced event must already be produced and reviewed. Pass the identical -path to both commands: the public bundle consumer requires the standalone -manifest to match the manifest embedded in the report. `agent-guard` records -only the sanitized path and does not verify file existence or content. The -event itself is not part of the fixed seven-file public bundle. +The referenced event must already be produced, reviewed, and stored as a +repo-local regular JSON file. Pass the identical path and explicit expected +profile to both producers. The manifest records a sanitized repository-relative +path and a profile-bound digest. `agent-guard` reads and canonicalizes the +bounded event JSON locally to compute that binding, but never embeds the event +body. The consumer requires the event separately and fails closed when the +event is missing, malformed, supplied under a different expected profile, or +changed. The event itself is not part of the fixed seven-file public bundle. +The binding does not protect an attacker who can replace both the evidence +manifest and the event; use a signature, attestation, or immutable trusted +storage for that threat model. When CI uploads evidence, pin third-party actions to versions or commit SHAs according to the repository's normal supply-chain policy, and keep generated @@ -216,7 +229,9 @@ The JSON report is a compact statement of what `agent-guard` checked: evidence-pack expectations part of conformance. - Optional `evidence_pack_manifest` records the sanitized artifact manifest for reviewer handoff. Artifact roles are limited to `report` and - `agent-policy-audit-event`. + `agent-policy-audit-event`. Audit-event entries include a controlled binding + profile and public-safe canonical-content digest; consumers verify the event + supplied outside the public bundle. - `context_lock` records whether discovered context files are covered by digest policy, without emitting hash values. - Optional `path`, `content`, `api`, `digest`, and `workflow` sections summarize diff --git a/docs/evidence-samples/agent-guard-report.json b/docs/evidence-samples/agent-guard-report.json index 8374bb8..0bb63ed 100644 --- a/docs/evidence-samples/agent-guard-report.json +++ b/docs/evidence-samples/agent-guard-report.json @@ -1 +1 @@ -{"api": {"checked_count": 249, "finding_count": 0, "findings": [], "policy": {"path": "examples/architecture_policy.yaml"}, "status": "ok"}, "command": "report", "conformance": {"checked_count": 17, "finding_count": 0, "findings": [], "mcp_config_checked_count": 0, "profile": "recommended", "required_artifact_roles": [], "required_gates": ["context", "surface_inventory", "path", "content", "mcp_config", "workflow", "policy_spec_drift"], "required_policy_files": [".agent-guard/context-policy.yaml", ".agent-guard/path-policy.yaml", ".agent-guard/content-policy.yaml", ".agent-guard/mcp-policy.yaml", ".agent-guard/workflow-policy.yaml"], "required_report_sections": [], "required_surfaces": ["agent_context", "policy_file", "workflow_file", "workflow_reference"], "schema_version": "agent-guard.conformance.v1", "status": "ok"}, "content": {"checked_count": 40, "finding_count": 0, "findings": [], "mode": "registered", "policy": {"path": ".agent-guard/content-policy.yaml"}, "scan_dir": ".", "status": "ok"}, "context_lock": {"checked_count": 1, "covered": [{"check_id": "root_agents_md", "kind": "agents_md", "path": "AGENTS.md", "status": "covered"}], "covered_count": 1, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, "digest": {"checked_count": 6, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, "evidence_coverage": {"enabled_count": 10, "failing_count": 0, "gate_count": 10, "gates": [{"checked_count": 1, "finding_count": 0, "gate": "context", "policy": {"path": ".agent-guard/context-policy.yaml"}, "status": "ok"}, {"checked_count": 142, "finding_count": 0, "gate": "surface_inventory", "policy": {"path": ".agent-guard/context-policy.yaml"}, "status": "ok"}, {"checked_count": 737, "finding_count": 0, "gate": "path", "policy": {"path": ".agent-guard/path-policy.yaml"}, "status": "ok"}, {"checked_count": 40, "finding_count": 0, "gate": "content", "policy": {"path": ".agent-guard/content-policy.yaml"}, "status": "ok"}, {"checked_count": 249, "finding_count": 0, "gate": "api", "policy": {"path": "examples/architecture_policy.yaml"}, "status": "ok"}, {"checked_count": 0, "finding_count": 0, "gate": "mcp_config", "policy": {"path": ".agent-guard/mcp-policy.yaml"}, "status": "ok"}, {"checked_count": 1, "finding_count": 0, "gate": "context_lock", "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, {"checked_count": 6, "finding_count": 0, "gate": "digest", "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, {"checked_count": 25, "finding_count": 0, "gate": "workflow", "policy": {"path": ".agent-guard/workflow-policy.yaml"}, "status": "ok"}, {"checked_count": 46, "finding_count": 0, "gate": "policy_spec_drift", "status": "ok"}], "missing_count": 0, "schema_version": "agent-guard.evidence_coverage.v1"}, "evidence_pack_manifest": {"artifacts": [{"path": "docs/evidence-samples/agent-guard-report.json", "role": "report"}], "conformance": {"finding_count": 0, "profile": "recommended", "schema_version": "agent-guard.conformance.v1", "status": "ok"}, "gates": [{"finding_count": 0, "gate": "context", "status": "ok"}, {"finding_count": 0, "gate": "surface_inventory", "status": "ok"}, {"finding_count": 0, "gate": "path", "status": "ok"}, {"finding_count": 0, "gate": "content", "status": "ok"}, {"finding_count": 0, "gate": "api", "status": "ok"}, {"finding_count": 0, "gate": "mcp_config", "status": "ok"}, {"finding_count": 0, "gate": "context_lock", "status": "ok"}, {"finding_count": 0, "gate": "digest", "status": "ok"}, {"finding_count": 0, "gate": "workflow", "status": "ok"}, {"finding_count": 0, "gate": "policy_spec_drift", "status": "ok"}], "report": {"finding_count": 0, "format": "json", "schema_version": "agent-guard.report_evidence.v1", "scope": "context+path+content+api+mcp+digest+workflow+drift", "status": "ok"}, "sanitized": true, "schema_version": "agent-guard.evidence_pack_manifest.v1", "summary": {"enabled_gate_count": 10, "failing_gate_count": 0, "gate_count": 10, "missing_gate_count": 0, "surface_count": 142}, "tool": {"name": "agent-guard", "version": "0.3.4"}}, "exit_code": 0, "finding_count": 0, "findings": [], "inventory": {"context_files": [{"evidence": [{"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:14:approval_boundary_mention", "line": 14, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:15:approval_boundary_mention", "line": 15, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:28:approval_boundary_mention", "line": 28, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:30:approval_boundary_mention", "line": 30, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:32:approval_boundary_mention", "line": 32, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:49:approval_boundary_mention", "line": 49, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:50:approval_boundary_mention", "line": 50, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:51:approval_boundary_mention", "line": 51, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:52:approval_boundary_mention", "line": 52, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:53:approval_boundary_mention", "line": 53, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:54:approval_boundary_mention", "line": 54, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:55:approval_boundary_mention", "line": 55, "rule_id": "approval_boundary_mention"}, {"category": "destructive_action_boundary", "evidence_id": "destructive_action_boundary:AGENTS.md:31:destructive_action_boundary_mention", "line": 31, "rule_id": "destructive_action_boundary_mention"}, {"category": "destructive_action_boundary", "evidence_id": "destructive_action_boundary:AGENTS.md:38:destructive_action_boundary_mention", "line": 38, "rule_id": "destructive_action_boundary_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:9:local_verification_mention", "line": 9, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:23:local_verification_mention", "line": 23, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:40:local_verification_mention", "line": 40, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:41:local_verification_mention", "line": 41, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:46:local_verification_mention", "line": 46, "rule_id": "local_verification_mention"}, {"category": "network_boundary", "evidence_id": "network_boundary:AGENTS.md:32:network_boundary_mention", "line": 32, "rule_id": "network_boundary_mention"}, {"category": "secret_handling", "evidence_id": "secret_handling:AGENTS.md:22:secret_handling_mention", "line": 22, "rule_id": "secret_handling_mention"}, {"category": "secret_handling", "evidence_id": "secret_handling:AGENTS.md:36:secret_handling_mention", "line": 36, "rule_id": "secret_handling_mention"}, {"category": "secret_handling", "evidence_id": "secret_handling:AGENTS.md:37:secret_handling_mention", "line": 37, "rule_id": "secret_handling_mention"}, {"category": "tool_permission_boundary", "evidence_id": "tool_permission_boundary:AGENTS.md:32:tool_permission_boundary_mention", "line": 32, "rule_id": "tool_permission_boundary_mention"}], "kind": "agents_md", "line_count": 56, "path": "AGENTS.md", "read_status": "scanned", "size_bytes": 3176}], "permission_boundaries": [{"category": "approval_boundary", "evidence_ids": ["approval_boundary:AGENTS.md:14:approval_boundary_mention", "approval_boundary:AGENTS.md:15:approval_boundary_mention", "approval_boundary:AGENTS.md:28:approval_boundary_mention", "approval_boundary:AGENTS.md:30:approval_boundary_mention", "approval_boundary:AGENTS.md:32:approval_boundary_mention", "approval_boundary:AGENTS.md:49:approval_boundary_mention", "approval_boundary:AGENTS.md:50:approval_boundary_mention", "approval_boundary:AGENTS.md:51:approval_boundary_mention", "approval_boundary:AGENTS.md:52:approval_boundary_mention", "approval_boundary:AGENTS.md:53:approval_boundary_mention", "approval_boundary:AGENTS.md:54:approval_boundary_mention", "approval_boundary:AGENTS.md:55:approval_boundary_mention"], "status": "present"}, {"category": "tool_permission_boundary", "evidence_ids": ["tool_permission_boundary:AGENTS.md:32:tool_permission_boundary_mention"], "status": "present"}, {"category": "network_boundary", "evidence_ids": ["network_boundary:AGENTS.md:32:network_boundary_mention"], "status": "present"}, {"category": "secret_handling", "evidence_ids": ["secret_handling:AGENTS.md:22:secret_handling_mention", "secret_handling:AGENTS.md:36:secret_handling_mention", "secret_handling:AGENTS.md:37:secret_handling_mention"], "status": "present"}, {"category": "destructive_action_boundary", "evidence_ids": ["destructive_action_boundary:AGENTS.md:31:destructive_action_boundary_mention", "destructive_action_boundary:AGENTS.md:38:destructive_action_boundary_mention"], "status": "present"}, {"category": "local_verification", "evidence_ids": ["local_verification:AGENTS.md:23:local_verification_mention", "local_verification:AGENTS.md:40:local_verification_mention", "local_verification:AGENTS.md:41:local_verification_mention", "local_verification:AGENTS.md:46:local_verification_mention", "local_verification:AGENTS.md:9:local_verification_mention"], "status": "present"}], "schema_version": "agent-guard.context_inventory.v1"}, "mcp_config": {"checked_count": 0, "finding_count": 0, "findings": [], "policy": {"fail_on_parse_error": true, "forbidden_risky_patterns": ["broad_authorization_scope", "filesystem_root_reference", "inline_authorization_value", "inline_env_value", "instruction_like_description", "latest_package", "secret_shaped_inline_value", "unpinned_package", "unsafe_url_scheme"], "path": ".agent-guard/mcp-policy.yaml"}, "status": "ok", "surfaces": []}, "path": {"checked_count": 737, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/path-policy.yaml"}, "status": "ok"}, "policy": {"path": ".agent-guard/context-policy.yaml"}, "policy_spec_drift": {"checked_count": 46, "finding_count": 0, "findings": [], "profile": "recommended", "schema_version": "agent-guard.policy_spec_drift.v2", "status": "ok"}, "report": {"format": "json", "sanitized": true, "schema_version": "agent-guard.report_evidence.v1", "scope": "context+path+content+api+mcp+digest+workflow+drift"}, "scanned_files": 1, "scanner": "context", "schema_version": "agent-guard.result.v1", "status": "ok", "summary": {"api_checked_count": 249, "api_finding_count": 0, "conformance_checked_count": 17, "conformance_finding_count": 0, "content_checked_count": 40, "content_finding_count": 0, "context_file_count": 1, "context_lock_checked_count": 1, "context_lock_covered_count": 1, "context_lock_finding_count": 0, "coverage_enabled_count": 10, "coverage_failing_count": 0, "coverage_missing_count": 0, "digest_checked_count": 6, "digest_finding_count": 0, "drift_checked_count": 46, "drift_finding_count": 0, "evidence_count": 24, "finding_count": 0, "mcp_config_checked_count": 0, "mcp_config_finding_count": 0, "path_checked_count": 737, "path_finding_count": 0, "scanned_count": 1, "scanned_unit": "files", "surface_count": 142, "workflow_checked_count": 25, "workflow_finding_count": 0}, "surface_inventory": {"schema_version": "agent-guard.agent_surface_inventory.v2", "summary": {"by_surface": {"agent_context": 1, "documented_guard_command": 98, "evidence_artifact": 1, "evidence_artifact_reference": 7, "policy_file": 6, "workflow_file": 3, "workflow_reference": 26}, "surface_count": 142}, "surfaces": [{"kind": "content_policy", "path": ".agent-guard/content-policy.yaml", "size_bytes": 2007, "status": "present", "surface": "policy_file"}, {"kind": "digest_policy", "path": ".agent-guard/context-digest-policy.yaml", "size_bytes": 1040, "status": "present", "surface": "policy_file"}, {"kind": "context_policy", "path": ".agent-guard/context-policy.yaml", "size_bytes": 1410, "status": "present", "surface": "policy_file"}, {"kind": "agent_guard_policy", "path": ".agent-guard/mcp-policy.yaml", "size_bytes": 535, "status": "present", "surface": "policy_file"}, {"kind": "path_policy", "path": ".agent-guard/path-policy.yaml", "size_bytes": 1453, "status": "present", "surface": "policy_file"}, {"kind": "workflow_policy", "path": ".agent-guard/workflow-policy.yaml", "size_bytes": 4035, "status": "present", "surface": "policy_file"}, {"artifact_path": "$report", "command": {"command": "", "scanner": "report"}, "job_id": "windows-cli-smoke", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 5, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence/agent-guard-report.json", "command": {"command": "", "scanner": "report"}, "job_id": "test", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence/agent-guard-report.md", "command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence/agent-guard-results.sarif", "command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence", "job_id": "test", "kind": "github_artifact", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 10, "surface": "evidence_artifact_reference"}, {"kind": "github_actions", "path": ".github/workflows/ci.yml", "status": "scanned", "surface": "workflow_file"}, {"command": {"command": "", "scanner": "report"}, "job_id": "windows-cli-smoke", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 5, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "content"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "lock", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "path"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "mcp"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "workflow"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "path"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "lock", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "lock", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "content"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "mcp"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "workflow"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "inventory", "scanner": "surface"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "drift"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "conformance"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"artifact_path": "release-notes.md", "job_id": "prepare-github-release", "kind": "github_artifact", "path": ".github/workflows/github-release.yml", "status": "referenced", "step_index": 10, "surface": "evidence_artifact_reference"}, {"kind": "github_actions", "path": ".github/workflows/github-release.yml", "status": "scanned", "surface": "workflow_file"}, {"artifact_path": "dist", "job_id": "build", "kind": "github_artifact", "path": ".github/workflows/release.yml", "status": "referenced", "step_index": 12, "surface": "evidence_artifact_reference"}, {"kind": "github_actions", "path": ".github/workflows/release.yml", "status": "scanned", "surface": "workflow_file"}, {"kind": "agents_md", "line_count": 56, "path": "AGENTS.md", "size_bytes": 3176, "status": "scanned", "surface": "agent_context"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 68, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 83, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 85, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 87, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 258, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 259, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 260, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "api"}, "kind": "documentation_recipe", "line": 266, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 267, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 268, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 269, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 270, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 271, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 272, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 273, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 320, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 321, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 322, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 323, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 324, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 325, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 326, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 327, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 328, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 329, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 344, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 345, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 346, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "delta", "scanner": "surface"}, "kind": "documentation_recipe", "line": 365, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 509, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 510, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 545, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 546, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 547, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 548, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 549, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 550, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 551, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 552, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 553, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 554, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 555, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 556, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 986, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 988, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 990, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 1001, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "api"}, "kind": "documentation_recipe", "line": 1002, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 1003, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 1004, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 1005, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 1006, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 1007, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 1008, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 1009, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 1010, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 1011, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 1012, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 1013, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 1014, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "delta", "scanner": "surface"}, "kind": "documentation_recipe", "line": 1015, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 1016, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 124, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 125, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 126, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 127, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 128, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 129, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 130, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 131, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 132, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 133, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 134, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 135, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 136, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 144, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 152, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"kind": "committed_evidence_sample", "path": "docs/evidence-samples/agent-guard-report.json", "size_bytes": 40504, "status": "present", "surface": "evidence_artifact"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 227, "path": "docs/github-actions-evidence.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 228, "path": "docs/github-actions-evidence.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 16, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 34, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 36, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 38, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 172, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 179, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 200, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 201, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 202, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 203, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 220, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 221, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 232, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 233, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 234, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 235, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 236, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 261, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 265, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}]}, "tool": {"name": "agent-guard", "version": "0.3.4"}, "workflow": {"checked_count": 25, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/workflow-policy.yaml"}, "status": "ok"}} +{"api": {"checked_count": 251, "finding_count": 0, "findings": [], "policy": {"path": "examples/architecture_policy.yaml"}, "status": "ok"}, "command": "report", "conformance": {"checked_count": 17, "finding_count": 0, "findings": [], "mcp_config_checked_count": 0, "profile": "recommended", "required_artifact_roles": [], "required_gates": ["context", "surface_inventory", "path", "content", "mcp_config", "workflow", "policy_spec_drift"], "required_policy_files": [".agent-guard/context-policy.yaml", ".agent-guard/path-policy.yaml", ".agent-guard/content-policy.yaml", ".agent-guard/mcp-policy.yaml", ".agent-guard/workflow-policy.yaml"], "required_report_sections": [], "required_surfaces": ["agent_context", "policy_file", "workflow_file", "workflow_reference"], "schema_version": "agent-guard.conformance.v1", "status": "ok"}, "content": {"checked_count": 40, "finding_count": 0, "findings": [], "mode": "registered", "policy": {"path": ".agent-guard/content-policy.yaml"}, "scan_dir": ".", "status": "ok"}, "context_lock": {"checked_count": 1, "covered": [{"check_id": "root_agents_md", "kind": "agents_md", "path": "AGENTS.md", "status": "covered"}], "covered_count": 1, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, "digest": {"checked_count": 6, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, "evidence_coverage": {"enabled_count": 10, "failing_count": 0, "gate_count": 10, "gates": [{"checked_count": 1, "finding_count": 0, "gate": "context", "policy": {"path": ".agent-guard/context-policy.yaml"}, "status": "ok"}, {"checked_count": 142, "finding_count": 0, "gate": "surface_inventory", "policy": {"path": ".agent-guard/context-policy.yaml"}, "status": "ok"}, {"checked_count": 739, "finding_count": 0, "gate": "path", "policy": {"path": ".agent-guard/path-policy.yaml"}, "status": "ok"}, {"checked_count": 40, "finding_count": 0, "gate": "content", "policy": {"path": ".agent-guard/content-policy.yaml"}, "status": "ok"}, {"checked_count": 251, "finding_count": 0, "gate": "api", "policy": {"path": "examples/architecture_policy.yaml"}, "status": "ok"}, {"checked_count": 0, "finding_count": 0, "gate": "mcp_config", "policy": {"path": ".agent-guard/mcp-policy.yaml"}, "status": "ok"}, {"checked_count": 1, "finding_count": 0, "gate": "context_lock", "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, {"checked_count": 6, "finding_count": 0, "gate": "digest", "policy": {"path": ".agent-guard/context-digest-policy.yaml"}, "status": "ok"}, {"checked_count": 25, "finding_count": 0, "gate": "workflow", "policy": {"path": ".agent-guard/workflow-policy.yaml"}, "status": "ok"}, {"checked_count": 46, "finding_count": 0, "gate": "policy_spec_drift", "status": "ok"}], "missing_count": 0, "schema_version": "agent-guard.evidence_coverage.v1"}, "evidence_pack_manifest": {"artifacts": [{"path": "docs/evidence-samples/agent-guard-report.json", "role": "report"}], "conformance": {"finding_count": 0, "profile": "recommended", "schema_version": "agent-guard.conformance.v1", "status": "ok"}, "gates": [{"finding_count": 0, "gate": "context", "status": "ok"}, {"finding_count": 0, "gate": "surface_inventory", "status": "ok"}, {"finding_count": 0, "gate": "path", "status": "ok"}, {"finding_count": 0, "gate": "content", "status": "ok"}, {"finding_count": 0, "gate": "api", "status": "ok"}, {"finding_count": 0, "gate": "mcp_config", "status": "ok"}, {"finding_count": 0, "gate": "context_lock", "status": "ok"}, {"finding_count": 0, "gate": "digest", "status": "ok"}, {"finding_count": 0, "gate": "workflow", "status": "ok"}, {"finding_count": 0, "gate": "policy_spec_drift", "status": "ok"}], "report": {"finding_count": 0, "format": "json", "schema_version": "agent-guard.report_evidence.v1", "scope": "context+path+content+api+mcp+digest+workflow+drift", "status": "ok"}, "sanitized": true, "schema_version": "agent-guard.evidence_pack_manifest.v1", "summary": {"enabled_gate_count": 10, "failing_gate_count": 0, "gate_count": 10, "missing_gate_count": 0, "surface_count": 142}, "tool": {"name": "agent-guard", "version": "0.3.4"}}, "exit_code": 0, "finding_count": 0, "findings": [], "inventory": {"context_files": [{"evidence": [{"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:14:approval_boundary_mention", "line": 14, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:15:approval_boundary_mention", "line": 15, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:28:approval_boundary_mention", "line": 28, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:30:approval_boundary_mention", "line": 30, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:32:approval_boundary_mention", "line": 32, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:49:approval_boundary_mention", "line": 49, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:50:approval_boundary_mention", "line": 50, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:51:approval_boundary_mention", "line": 51, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:52:approval_boundary_mention", "line": 52, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:53:approval_boundary_mention", "line": 53, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:54:approval_boundary_mention", "line": 54, "rule_id": "approval_boundary_mention"}, {"category": "approval_boundary", "evidence_id": "approval_boundary:AGENTS.md:55:approval_boundary_mention", "line": 55, "rule_id": "approval_boundary_mention"}, {"category": "destructive_action_boundary", "evidence_id": "destructive_action_boundary:AGENTS.md:31:destructive_action_boundary_mention", "line": 31, "rule_id": "destructive_action_boundary_mention"}, {"category": "destructive_action_boundary", "evidence_id": "destructive_action_boundary:AGENTS.md:38:destructive_action_boundary_mention", "line": 38, "rule_id": "destructive_action_boundary_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:9:local_verification_mention", "line": 9, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:23:local_verification_mention", "line": 23, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:40:local_verification_mention", "line": 40, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:41:local_verification_mention", "line": 41, "rule_id": "local_verification_mention"}, {"category": "local_verification", "evidence_id": "local_verification:AGENTS.md:46:local_verification_mention", "line": 46, "rule_id": "local_verification_mention"}, {"category": "network_boundary", "evidence_id": "network_boundary:AGENTS.md:32:network_boundary_mention", "line": 32, "rule_id": "network_boundary_mention"}, {"category": "secret_handling", "evidence_id": "secret_handling:AGENTS.md:22:secret_handling_mention", "line": 22, "rule_id": "secret_handling_mention"}, {"category": "secret_handling", "evidence_id": "secret_handling:AGENTS.md:36:secret_handling_mention", "line": 36, "rule_id": "secret_handling_mention"}, {"category": "secret_handling", "evidence_id": "secret_handling:AGENTS.md:37:secret_handling_mention", "line": 37, "rule_id": "secret_handling_mention"}, {"category": "tool_permission_boundary", "evidence_id": "tool_permission_boundary:AGENTS.md:32:tool_permission_boundary_mention", "line": 32, "rule_id": "tool_permission_boundary_mention"}], "kind": "agents_md", "line_count": 56, "path": "AGENTS.md", "read_status": "scanned", "size_bytes": 3176}], "permission_boundaries": [{"category": "approval_boundary", "evidence_ids": ["approval_boundary:AGENTS.md:14:approval_boundary_mention", "approval_boundary:AGENTS.md:15:approval_boundary_mention", "approval_boundary:AGENTS.md:28:approval_boundary_mention", "approval_boundary:AGENTS.md:30:approval_boundary_mention", "approval_boundary:AGENTS.md:32:approval_boundary_mention", "approval_boundary:AGENTS.md:49:approval_boundary_mention", "approval_boundary:AGENTS.md:50:approval_boundary_mention", "approval_boundary:AGENTS.md:51:approval_boundary_mention", "approval_boundary:AGENTS.md:52:approval_boundary_mention", "approval_boundary:AGENTS.md:53:approval_boundary_mention", "approval_boundary:AGENTS.md:54:approval_boundary_mention", "approval_boundary:AGENTS.md:55:approval_boundary_mention"], "status": "present"}, {"category": "tool_permission_boundary", "evidence_ids": ["tool_permission_boundary:AGENTS.md:32:tool_permission_boundary_mention"], "status": "present"}, {"category": "network_boundary", "evidence_ids": ["network_boundary:AGENTS.md:32:network_boundary_mention"], "status": "present"}, {"category": "secret_handling", "evidence_ids": ["secret_handling:AGENTS.md:22:secret_handling_mention", "secret_handling:AGENTS.md:36:secret_handling_mention", "secret_handling:AGENTS.md:37:secret_handling_mention"], "status": "present"}, {"category": "destructive_action_boundary", "evidence_ids": ["destructive_action_boundary:AGENTS.md:31:destructive_action_boundary_mention", "destructive_action_boundary:AGENTS.md:38:destructive_action_boundary_mention"], "status": "present"}, {"category": "local_verification", "evidence_ids": ["local_verification:AGENTS.md:23:local_verification_mention", "local_verification:AGENTS.md:40:local_verification_mention", "local_verification:AGENTS.md:41:local_verification_mention", "local_verification:AGENTS.md:46:local_verification_mention", "local_verification:AGENTS.md:9:local_verification_mention"], "status": "present"}], "schema_version": "agent-guard.context_inventory.v1"}, "mcp_config": {"checked_count": 0, "finding_count": 0, "findings": [], "policy": {"fail_on_parse_error": true, "forbidden_risky_patterns": ["broad_authorization_scope", "filesystem_root_reference", "inline_authorization_value", "inline_env_value", "instruction_like_description", "latest_package", "secret_shaped_inline_value", "unpinned_package", "unsafe_url_scheme"], "path": ".agent-guard/mcp-policy.yaml"}, "status": "ok", "surfaces": []}, "path": {"checked_count": 739, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/path-policy.yaml"}, "status": "ok"}, "policy": {"path": ".agent-guard/context-policy.yaml"}, "policy_spec_drift": {"checked_count": 46, "finding_count": 0, "findings": [], "profile": "recommended", "schema_version": "agent-guard.policy_spec_drift.v2", "status": "ok"}, "report": {"format": "json", "sanitized": true, "schema_version": "agent-guard.report_evidence.v1", "scope": "context+path+content+api+mcp+digest+workflow+drift"}, "scanned_files": 1, "scanner": "context", "schema_version": "agent-guard.result.v1", "status": "ok", "summary": {"api_checked_count": 251, "api_finding_count": 0, "conformance_checked_count": 17, "conformance_finding_count": 0, "content_checked_count": 40, "content_finding_count": 0, "context_file_count": 1, "context_lock_checked_count": 1, "context_lock_covered_count": 1, "context_lock_finding_count": 0, "coverage_enabled_count": 10, "coverage_failing_count": 0, "coverage_missing_count": 0, "digest_checked_count": 6, "digest_finding_count": 0, "drift_checked_count": 46, "drift_finding_count": 0, "evidence_count": 24, "finding_count": 0, "mcp_config_checked_count": 0, "mcp_config_finding_count": 0, "path_checked_count": 739, "path_finding_count": 0, "scanned_count": 1, "scanned_unit": "files", "surface_count": 142, "workflow_checked_count": 25, "workflow_finding_count": 0}, "surface_inventory": {"schema_version": "agent-guard.agent_surface_inventory.v2", "summary": {"by_surface": {"agent_context": 1, "documented_guard_command": 98, "evidence_artifact": 1, "evidence_artifact_reference": 7, "policy_file": 6, "workflow_file": 3, "workflow_reference": 26}, "surface_count": 142}, "surfaces": [{"kind": "content_policy", "path": ".agent-guard/content-policy.yaml", "size_bytes": 2007, "status": "present", "surface": "policy_file"}, {"kind": "digest_policy", "path": ".agent-guard/context-digest-policy.yaml", "size_bytes": 1040, "status": "present", "surface": "policy_file"}, {"kind": "context_policy", "path": ".agent-guard/context-policy.yaml", "size_bytes": 1410, "status": "present", "surface": "policy_file"}, {"kind": "agent_guard_policy", "path": ".agent-guard/mcp-policy.yaml", "size_bytes": 535, "status": "present", "surface": "policy_file"}, {"kind": "path_policy", "path": ".agent-guard/path-policy.yaml", "size_bytes": 1453, "status": "present", "surface": "policy_file"}, {"kind": "workflow_policy", "path": ".agent-guard/workflow-policy.yaml", "size_bytes": 4035, "status": "present", "surface": "policy_file"}, {"artifact_path": "$report", "command": {"command": "", "scanner": "report"}, "job_id": "windows-cli-smoke", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 5, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence/agent-guard-report.json", "command": {"command": "", "scanner": "report"}, "job_id": "test", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence/agent-guard-report.md", "command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence/agent-guard-results.sarif", "command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_output", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "evidence_artifact_reference"}, {"artifact_path": ".agent-guard/evidence", "job_id": "test", "kind": "github_artifact", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 10, "surface": "evidence_artifact_reference"}, {"kind": "github_actions", "path": ".github/workflows/ci.yml", "status": "scanned", "surface": "workflow_file"}, {"command": {"command": "", "scanner": "report"}, "job_id": "windows-cli-smoke", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 5, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "content"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "lock", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "path"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "mcp"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "workflow"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 8, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "path"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "lock", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "lock", "scanner": "context"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "digest"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "content"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "mcp"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "workflow"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "inventory", "scanner": "surface"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "drift"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "check", "scanner": "conformance"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"command": {"command": "", "scanner": "render-report"}, "job_id": "test", "kind": "agent_guard_command", "path": ".github/workflows/ci.yml", "status": "referenced", "step_index": 9, "surface": "workflow_reference"}, {"artifact_path": "release-notes.md", "job_id": "prepare-github-release", "kind": "github_artifact", "path": ".github/workflows/github-release.yml", "status": "referenced", "step_index": 10, "surface": "evidence_artifact_reference"}, {"kind": "github_actions", "path": ".github/workflows/github-release.yml", "status": "scanned", "surface": "workflow_file"}, {"artifact_path": "dist", "job_id": "build", "kind": "github_artifact", "path": ".github/workflows/release.yml", "status": "referenced", "step_index": 12, "surface": "evidence_artifact_reference"}, {"kind": "github_actions", "path": ".github/workflows/release.yml", "status": "scanned", "surface": "workflow_file"}, {"kind": "agents_md", "line_count": 56, "path": "AGENTS.md", "size_bytes": 3176, "status": "scanned", "surface": "agent_context"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 68, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 83, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 85, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 87, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 258, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 259, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 260, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "api"}, "kind": "documentation_recipe", "line": 266, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 267, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 268, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 269, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 270, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 271, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 272, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 273, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 320, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 321, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 322, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 323, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 324, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 325, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 326, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 327, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 328, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 329, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 344, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 345, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 346, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "delta", "scanner": "surface"}, "kind": "documentation_recipe", "line": 367, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 511, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 512, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 547, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 548, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 549, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 550, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 551, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 552, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 553, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 554, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 555, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 556, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 557, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 558, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 991, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 993, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 995, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 1006, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "api"}, "kind": "documentation_recipe", "line": 1007, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "content"}, "kind": "documentation_recipe", "line": 1008, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 1009, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 1010, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 1011, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 1012, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 1013, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 1014, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 1015, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "path"}, "kind": "documentation_recipe", "line": 1016, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "digest"}, "kind": "documentation_recipe", "line": 1017, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 1018, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 1019, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "delta", "scanner": "surface"}, "kind": "documentation_recipe", "line": 1020, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 1021, "path": "README.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 124, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 125, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 126, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 127, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 128, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 129, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 130, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 131, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 132, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 133, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 134, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 135, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 136, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 144, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 153, "path": "docs/evidence-contracts.md", "status": "documented", "surface": "documented_guard_command"}, {"kind": "committed_evidence_sample", "path": "docs/evidence-samples/agent-guard-report.json", "size_bytes": 40504, "status": "present", "surface": "evidence_artifact"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 227, "path": "docs/github-actions-evidence.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 228, "path": "docs/github-actions-evidence.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 16, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 34, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "init"}, "kind": "documentation_recipe", "line": 36, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 38, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 172, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 179, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "context"}, "kind": "documentation_recipe", "line": 200, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "context"}, "kind": "documentation_recipe", "line": 201, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 202, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "inventory", "scanner": "surface"}, "kind": "documentation_recipe", "line": 203, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 220, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "lock", "scanner": "context"}, "kind": "documentation_recipe", "line": 221, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "workflow"}, "kind": "documentation_recipe", "line": 232, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "mcp"}, "kind": "documentation_recipe", "line": 233, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "drift"}, "kind": "documentation_recipe", "line": 234, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "report"}, "kind": "documentation_recipe", "line": 235, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "", "scanner": "render-report"}, "kind": "documentation_recipe", "line": 236, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "check", "scanner": "conformance"}, "kind": "documentation_recipe", "line": 261, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}, {"command": {"command": "manifest", "scanner": "evidence-pack"}, "kind": "documentation_recipe", "line": 265, "path": "docs/quickstart-existing-repo.md", "status": "documented", "surface": "documented_guard_command"}]}, "tool": {"name": "agent-guard", "version": "0.3.4"}, "workflow": {"checked_count": 25, "finding_count": 0, "findings": [], "policy": {"path": ".agent-guard/workflow-policy.yaml"}, "status": "ok"}} diff --git a/docs/quickstart-existing-repo.md b/docs/quickstart-existing-repo.md index 785d2d4..56f0cc7 100644 --- a/docs/quickstart-existing-repo.md +++ b/docs/quickstart-existing-repo.md @@ -273,14 +273,15 @@ recommended report already contains the same conformance and manifest sections. If a reviewed `agent-policy` admission event already exists, add the same -`--agent-policy-audit-event path/to/reviewed-policy-admission-event.json` option +`--agent-policy-audit-event path/to/reviewed-policy-admission-event.json` and +`--agent-policy-audit-event-profile agent-policy.audit_event.v1.1` options to both the `report` and standalone `evidence-pack manifest` commands, then -generate both artifacts again. The public bundle consumer requires the -standalone manifest to match the manifest embedded in the report. Keep the -event outside `.agent-guard/evidence`; it is not one of the seven allowed public -bundle files. `agent-guard` records only the sanitized path and does not verify -the event's existence or content, so pass it only after the producer has written -it and a maintainer has reviewed it. +generate both artifacts again. The event must be a repo-local regular JSON +file. If the standalone manifest is present, the public bundle consumer +requires it to match the manifest embedded in the report. It also requires the +event and expected profile again to verify its canonical-content binding. Keep +the event outside `.agent-guard/evidence`; it is not one of the seven allowed +public bundle files, and its body is never copied into public evidence. ## 5. Consume Evidence Safely diff --git a/scripts/check_wheel_contract.py b/scripts/check_wheel_contract.py index 78308c9..c4132a8 100644 --- a/scripts/check_wheel_contract.py +++ b/scripts/check_wheel_contract.py @@ -5,6 +5,7 @@ from __future__ import annotations +import base64 from email.parser import BytesParser from email.policy import compat32 import hashlib @@ -1904,6 +1905,18 @@ def main() -> int: assert conformance_payload["status"] == "ok" assert conformance_payload["conformance"]["schema_version"] == "agent-guard.conformance.v1" + audit_event_profile = "agent-policy.audit_event.v1.1" + audit_event_marker = "reviewed-wheel-contract-event" + audit_event_path = report_output.parent / "policy-admission-event.json" + audit_event_payload = { + "schema_version": audit_event_profile, + "status": "reviewed", + "marker": audit_event_marker, + } + audit_event_path.write_text( + json.dumps(audit_event_payload, sort_keys=True), + encoding="utf-8", + ) manifest_cli = run( isolated_module_command( python, @@ -1919,7 +1932,9 @@ def main() -> int: "--artifact", r"C:\Users\alice\secret\agent-guard-report.json", "--agent-policy-audit-event", - str(repo / ".agent-guard" / "evidence" / "policy-admission-event.json"), + str(audit_event_path), + "--agent-policy-audit-event-profile", + audit_event_profile, "--json", ), cwd=temp, @@ -1927,13 +1942,45 @@ def main() -> int: manifest_payload = json.loads(manifest_cli.stdout) assert manifest_payload["status"] == "ok" assert manifest_payload["evidence_pack_manifest"]["schema_version"] == "agent-guard.evidence_pack_manifest.v1" - assert manifest_payload["evidence_pack_manifest"]["artifacts"] == [ + manifest_artifacts = manifest_payload["evidence_pack_manifest"]["artifacts"] + assert manifest_artifacts[:2] == [ {"path": ".agent-guard/evidence/agent-guard-report.json", "role": "report"}, {"path": "agent-guard-report.json", "role": "report"}, - {"path": ".agent-guard/evidence/policy-admission-event.json", "role": "agent-policy-audit-event"}, ] + audit_artifact = manifest_artifacts[2] + assert audit_artifact["path"] == ".agent-guard/evidence/policy-admission-event.json" + assert audit_artifact["role"] == "agent-policy-audit-event" + binding = audit_artifact["content_binding"] + canonical_event = json.dumps( + audit_event_payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + digest_domain = ( + b"agent-guard.agent_policy_audit_event_binding.v1\0" + + audit_event_profile.encode("ascii") + + b"\0" + + canonical_event + ) + expected_digest = ( + "b" + + base64.b32encode(hashlib.sha256(digest_domain).digest()) + .decode("ascii") + .rstrip("=") + .lower() + ) + assert binding == { + "schema_version": "agent-guard.agent_policy_audit_event_binding.v1", + "event_profile": audit_event_profile, + "canonicalization": "canonical-json-v1", + "digest_algorithm": "sha256", + "digest_encoding": "base32-lower-no-padding", + "digest": expected_digest, + } assert r"C:\Users\alice" not in manifest_cli.stdout assert str(temp) not in manifest_cli.stdout + assert audit_event_marker not in manifest_cli.stdout print(f"wheel contract OK: {wheel.name}") return 0 diff --git a/src/agent_guard/cli/evidence_pack.py b/src/agent_guard/cli/evidence_pack.py index abfbd19..48419d7 100644 --- a/src/agent_guard/cli/evidence_pack.py +++ b/src/agent_guard/cli/evidence_pack.py @@ -25,6 +25,11 @@ def add_evidence_pack_parser(top) -> None: default=[], help="optional repo-relative agent-policy audit event artifact path", ) + evidence_pack_manifest.add_argument( + "--agent-policy-audit-event-profile", + default="", + help="validated public profile identifier for every attached agent-policy audit event", + ) evidence_pack_manifest.add_argument("--json", action="store_true", help="emit JSON") @@ -37,6 +42,7 @@ def run_evidence_pack_manifest(args: argparse.Namespace) -> int: report_payload=payload, artifact_paths=list(args.artifact or []), agent_policy_audit_event_paths=list(args.agent_policy_audit_event or []), + agent_policy_audit_event_profile=str(args.agent_policy_audit_event_profile), root=root, ) except Exception as exc: @@ -71,4 +77,3 @@ def run_evidence_pack_manifest(args: argparse.Namespace) -> int: else: print(json.dumps(manifest, ensure_ascii=False, sort_keys=True)) return 0 - diff --git a/src/agent_guard/cli/report.py b/src/agent_guard/cli/report.py index 778cb9e..f5457f9 100644 --- a/src/agent_guard/cli/report.py +++ b/src/agent_guard/cli/report.py @@ -12,7 +12,10 @@ from ..context_guard import collect_context_inventory, load_context_policy, scan_context_files from ..digest_guard import load_digest_policy, scan_digests from ..drift_guard import build_policy_spec_drift_report -from ..evidence_pack import build_evidence_pack_manifest +from ..evidence_pack import ( + build_agent_policy_audit_event_artifacts, + build_evidence_pack_manifest, +) from ..mcp_guard import build_mcp_config_report, load_mcp_policy from ..profiles import PROFILE_NAMES from ..report_render import emit_report_output, render_report_output @@ -112,6 +115,11 @@ def add_report_parser(top) -> None: default=[], help="optional repo-relative agent-policy audit event artifact path for the embedded evidence-pack manifest", ) + report.add_argument( + "--agent-policy-audit-event-profile", + default="", + help="validated public profile identifier for every attached agent-policy audit event", + ) report.add_argument( "--format", choices=("markdown", "json", "github-annotations", "sarif"), @@ -197,6 +205,10 @@ def run_report(args: argparse.Namespace) -> int: args.mcp_config_check = True digest_policy_arg = str(args.digest_policy).strip() workflow_policy_arg = str(args.workflow_policy).strip() + audit_event_paths = list(args.agent_policy_audit_event or []) + audit_event_profile = str(args.agent_policy_audit_event_profile).strip() + if audit_event_paths: + args.evidence_pack_manifest = True safe_context_policy_path = safe_policy_path(args.context_policy, root) surface_inventory_version = args.surface_inventory_version scope = report_scope( @@ -210,6 +222,11 @@ def run_report(args: argparse.Namespace) -> int: ) try: + audit_event_artifacts = build_agent_policy_audit_event_artifacts( + audit_event_paths, + event_profile=audit_event_profile, + root=root, + ) policy = load_context_policy(policy_path) findings, scanned_files = scan_context_files(root=root, policy=policy) inventory = collect_context_inventory(root=root, policy=policy) @@ -602,7 +619,7 @@ def run_report(args: argparse.Namespace) -> int: evidence_pack_manifest = build_evidence_pack_manifest( report_payload=payload, artifact_paths=artifact_paths, - agent_policy_audit_event_paths=list(args.agent_policy_audit_event or []), + agent_policy_audit_event_artifacts=audit_event_artifacts, root=root, ) payload["evidence_pack_manifest"] = evidence_pack_manifest @@ -628,7 +645,7 @@ def run_report(args: argparse.Namespace) -> int: evidence_pack_manifest = build_evidence_pack_manifest( report_payload=payload, artifact_paths=artifact_paths, - agent_policy_audit_event_paths=list(args.agent_policy_audit_event or []), + agent_policy_audit_event_artifacts=audit_event_artifacts, root=root, ) payload["evidence_pack_manifest"] = evidence_pack_manifest diff --git a/src/agent_guard/consumer/__init__.py b/src/agent_guard/consumer/__init__.py index 68d829c..2e83e26 100644 --- a/src/agent_guard/consumer/__init__.py +++ b/src/agent_guard/consumer/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations +from ._bindings import validate_agent_policy_audit_event_files from ._bundle import ( ALLOWED_EVIDENCE_ARTIFACT_NAMES, validate_evidence_bundle, @@ -64,6 +65,7 @@ "require_sequence", "schema_condition_matches", "validate_against_schema", + "validate_agent_policy_audit_event_files", "validate_conformance", "validate_evidence_bundle", "validate_evidence_pack_manifest", diff --git a/src/agent_guard/consumer/_bindings.py b/src/agent_guard/consumer/_bindings.py new file mode 100644 index 0000000..601e255 --- /dev/null +++ b/src/agent_guard/consumer/_bindings.py @@ -0,0 +1,68 @@ +"""Validate explicitly supplied agent-policy audit events against report bindings.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from ..evidence_pack import ( + build_agent_policy_audit_event_binding, + validate_agent_policy_audit_event_binding_shape, + validate_agent_policy_audit_event_profile, +) +from ._schema import require, require_mapping, require_sequence + + +ERROR_AUDIT_EVENT_BINDING_INVALID = "agent-policy audit event binding is invalid" + + +def _bound_audit_event_artifacts(report: Mapping[str, Any]) -> list[Mapping[str, Any]]: + manifest = report.get("evidence_pack_manifest") + if manifest is None: + return [] + manifest_obj = require_mapping(manifest, ERROR_AUDIT_EVENT_BINDING_INVALID) + artifacts = require_sequence( + manifest_obj.get("artifacts"), + ERROR_AUDIT_EVENT_BINDING_INVALID, + ) + return [ + require_mapping(item, ERROR_AUDIT_EVENT_BINDING_INVALID) + for item in artifacts + if isinstance(item, Mapping) and item.get("role") == "agent-policy-audit-event" + ] + + +def validate_agent_policy_audit_event_files( + report: Mapping[str, Any], + paths: Sequence[Path], + *, + event_profile: str, +) -> None: + """Pair each path positionally with the same-index bound audit artifact.""" + + artifacts = _bound_audit_event_artifacts(report) + if not artifacts: + require(not paths and not event_profile, ERROR_AUDIT_EVENT_BINDING_INVALID) + return + try: + profile = validate_agent_policy_audit_event_profile(event_profile) + except ValueError: + raise ValueError(ERROR_AUDIT_EVENT_BINDING_INVALID) from None + require(len(paths) == len(artifacts), ERROR_AUDIT_EVENT_BINDING_INVALID) + + for artifact, path in zip(artifacts, paths, strict=True): + try: + expected = validate_agent_policy_audit_event_binding_shape( + artifact.get("content_binding") + ) + actual = build_agent_policy_audit_event_binding( + path, + event_profile=profile, + ) + except ValueError: + raise ValueError(ERROR_AUDIT_EVENT_BINDING_INVALID) from None + require( + expected.get("event_profile") == profile and actual == expected, + ERROR_AUDIT_EVENT_BINDING_INVALID, + ) diff --git a/src/agent_guard/consumer/_bundle.py b/src/agent_guard/consumer/_bundle.py index 9ffa098..b87aa8c 100644 --- a/src/agent_guard/consumer/_bundle.py +++ b/src/agent_guard/consumer/_bundle.py @@ -15,6 +15,7 @@ from typing import Any from ..report_render import render_report_output +from ._bindings import validate_agent_policy_audit_event_files from ._redaction import validate_public_evidence_shape, validate_public_text_shape from ._report import validate_report from ._schema import ( @@ -312,6 +313,9 @@ def _validate_sarif(payload: object) -> None: def _validate_evidence_bundle( evidence_dir: Path, report_path: Path, + *, + agent_policy_audit_event_paths: tuple[Path, ...] = (), + agent_policy_audit_event_profile: str = "", ) -> tuple[dict[str, Any], bytes | None]: """Validate a bundle and retain the exact annotation bytes read during validation.""" @@ -324,6 +328,11 @@ def _validate_evidence_bundle( require(report_path.is_file() and not report_path.is_symlink(), "report artifact is invalid") report = _load_limited_payload(report_path, limit=MAX_REPORT_JSON_BYTES) summary = validate_report(report, load_report_schema()) + validate_agent_policy_audit_event_files( + report, + agent_policy_audit_event_paths, + event_profile=agent_policy_audit_event_profile, + ) bundle_report_path = evidence_dir / "agent-guard-report.json" if bundle_report_path.is_file(): @@ -440,8 +449,19 @@ def _validate_evidence_bundle( return summary, annotation_bytes -def validate_evidence_bundle(evidence_dir: Path, report_path: Path) -> dict[str, Any]: +def validate_evidence_bundle( + evidence_dir: Path, + report_path: Path, + *, + agent_policy_audit_event_paths: tuple[Path, ...] = (), + agent_policy_audit_event_profile: str = "", +) -> dict[str, Any]: """Validate an allowlisted public evidence directory against a report file.""" - summary, _ = _validate_evidence_bundle(evidence_dir, report_path) + summary, _ = _validate_evidence_bundle( + evidence_dir, + report_path, + agent_policy_audit_event_paths=agent_policy_audit_event_paths, + agent_policy_audit_event_profile=agent_policy_audit_event_profile, + ) return summary diff --git a/src/agent_guard/consumer/_cli.py b/src/agent_guard/consumer/_cli.py index ad1de1b..1254b7f 100644 --- a/src/agent_guard/consumer/_cli.py +++ b/src/agent_guard/consumer/_cli.py @@ -10,6 +10,7 @@ from pathlib import Path import sys +from ._bindings import validate_agent_policy_audit_event_files from ._bundle import _validate_evidence_bundle from ._report import validate_report from ._schema import load_payload, load_report_schema @@ -31,6 +32,18 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Emit only the canonical annotation bytes buffered while validating the bundle", ) + parser.add_argument( + "--agent-policy-audit-event", + action="append", + default=[], + type=Path, + help="Audit-event file to verify against a bound manifest entry; repeat in manifest order", + ) + parser.add_argument( + "--agent-policy-audit-event-profile", + default="", + help="Expected public profile identifier for every supplied audit event", + ) args = parser.parse_args(argv) if args.emit_annotations and args.evidence_dir is None: parser.error("--emit-annotations requires --evidence-dir") @@ -39,9 +52,16 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + event_paths = tuple(args.agent_policy_audit_event or ()) + event_profile = str(args.agent_policy_audit_event_profile).strip() if args.evidence_dir is not None: try: - summary, annotation_bytes = _validate_evidence_bundle(args.evidence_dir, args.report) + summary, annotation_bytes = _validate_evidence_bundle( + args.evidence_dir, + args.report, + agent_policy_audit_event_paths=event_paths, + agent_policy_audit_event_profile=event_profile, + ) except Exception: print(BUNDLE_VALIDATION_ERROR, file=sys.stderr) return 1 @@ -58,7 +78,13 @@ def main(argv: list[str] | None = None) -> int: return 0 try: - summary = validate_report(load_payload(args.report), load_report_schema()) + report = load_payload(args.report) + summary = validate_report(report, load_report_schema()) + validate_agent_policy_audit_event_files( + report, + event_paths, + event_profile=event_profile, + ) except Exception as exc: print(f"agent-guard evidence invalid: {exc}", file=sys.stderr) return 1 diff --git a/src/agent_guard/consumer/_sections.py b/src/agent_guard/consumer/_sections.py index 382706d..6e43a3c 100644 --- a/src/agent_guard/consumer/_sections.py +++ b/src/agent_guard/consumer/_sections.py @@ -8,9 +8,9 @@ from collections.abc import Mapping, Sequence from typing import Any +from ..evidence_pack import validate_agent_policy_audit_event_binding_shape from ._schema import require, require_int, require_mapping, require_sequence - REVIEWED_MCP_POLICY_PATH = ".agent-guard/mcp-policy.yaml" REQUIRED_MCP_RISK_LABELS = frozenset( { @@ -186,6 +186,34 @@ def validate_evidence_pack_manifest(manifest: Mapping[str, Any], payload: Mappin require(failing_count == failing, "$.evidence_pack_manifest.summary.failing_gate_count must match gate statuses") _validate_manifest_coverage(manifest_gates, payload) _validate_manifest_conformance(manifest, payload) + _validate_manifest_artifacts(manifest) + + +def _validate_manifest_artifacts(manifest: Mapping[str, Any]) -> None: + artifacts = require_sequence(manifest.get("artifacts"), "$.evidence_pack_manifest.artifacts") + for index, raw_artifact in enumerate(artifacts): + artifact = require_mapping(raw_artifact, f"$.evidence_pack_manifest.artifacts[{index}]") + role = artifact.get("role") + require( + role in {"report", "agent-policy-audit-event"}, + f"$.evidence_pack_manifest.artifacts[{index}].role is invalid", + ) + require( + isinstance(artifact.get("path"), str) and bool(str(artifact.get("path")).strip()), + f"$.evidence_pack_manifest.artifacts[{index}].path must be a non-empty string", + ) + if role != "agent-policy-audit-event": + continue + require( + set(artifact) == {"path", "role", "content_binding"}, + f"$.evidence_pack_manifest.artifacts[{index}] has invalid fields", + ) + try: + validate_agent_policy_audit_event_binding_shape(artifact.get("content_binding")) + except ValueError: + raise ValueError( + f"$.evidence_pack_manifest.artifacts[{index}].content_binding is invalid" + ) from None def _manifest_gate_map(gates: Sequence[Any]) -> tuple[dict[str, Mapping[str, Any]], int, int, int]: diff --git a/src/agent_guard/context_guard.py b/src/agent_guard/context_guard.py index 91adccd..ab45725 100644 --- a/src/agent_guard/context_guard.py +++ b/src/agent_guard/context_guard.py @@ -12,6 +12,23 @@ import yaml +from .bounded_scan import run_isolated_scan +from .bounded_yaml import ( + BoundedYamlInvalidError, + BoundedYamlLimitError, + load_bounded_yaml, +) + + +ERROR_CONTEXT_POLICY_NOT_FOUND = "policy file not found" +ERROR_CONTEXT_POLICY_INVALID = "context policy YAML is not parseable" +ERROR_CONTEXT_POLICY_LIMIT = "context policy exceeds configured limits" +ERROR_CONTEXT_SCAN_TIMEOUT = "context scan exceeded execution budget" +ERROR_CONTEXT_SCAN_RUNTIME = "context scan could not complete safely" +MAX_CONTEXT_POLICY_BYTES = 256 * 1024 +MAX_CONTEXT_POLICY_REGEX_COUNT = 64 +MAX_CONTEXT_POLICY_REGEX_LENGTH = 4_096 + DEFAULT_INCLUDE = [ "AGENTS.md", @@ -310,16 +327,39 @@ def to_dict(self) -> dict[str, object]: } -def load_context_policy(path: Path) -> dict[str, object]: - if not path.exists(): - raise FileNotFoundError(f"policy file not found: {path}") +def _read_context_policy_text(path: Path) -> str: + try: + with path.open("rb") as handle: + raw = handle.read(MAX_CONTEXT_POLICY_BYTES + 1) + except FileNotFoundError: + raise FileNotFoundError(f"{ERROR_CONTEXT_POLICY_NOT_FOUND}: {path}") from None + except OSError: + raise ValueError(ERROR_CONTEXT_POLICY_INVALID) from None + + if len(raw) > MAX_CONTEXT_POLICY_BYTES: + raise ValueError(ERROR_CONTEXT_POLICY_LIMIT) + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + raise ValueError(ERROR_CONTEXT_POLICY_INVALID) from None + +def load_context_policy(path: Path) -> dict[str, object]: try: - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as exc: - raise ValueError(f"context policy YAML is not parseable: {path}") from exc - if not isinstance(loaded, dict): - raise ValueError(f"policy file must be YAML object: {path}") + loaded = load_bounded_yaml( + _read_context_policy_text(path), + construct=yaml.safe_load, + ) + if loaded is None: + loaded = {} + if not isinstance(loaded, dict): + raise BoundedYamlInvalidError + except BoundedYamlLimitError: + raise ValueError(ERROR_CONTEXT_POLICY_LIMIT) from None + except BoundedYamlInvalidError: + raise ValueError(ERROR_CONTEXT_POLICY_INVALID) from None + except (MemoryError, OverflowError, RecursionError): + raise ValueError(ERROR_CONTEXT_POLICY_LIMIT) from None return loaded @@ -578,13 +618,18 @@ def normalize_rule_patterns(policy: dict[str, object]) -> list[dict[str, object] def build_rules(policy: dict[str, object]) -> list[dict[str, object]]: rules: list[dict[str, object]] = [] - for item in normalize_rule_patterns(policy): + raw_rules = normalize_rule_patterns(policy) + if len(raw_rules) > MAX_CONTEXT_POLICY_REGEX_COUNT: + raise ValueError(ERROR_CONTEXT_POLICY_LIMIT) + for item in raw_rules: if not isinstance(item, dict): continue rule_id = str(item.get("id", "")).strip() pattern_text = str(item.get("pattern", "")).strip() if not rule_id or not pattern_text: continue + if len(pattern_text) > MAX_CONTEXT_POLICY_REGEX_LENGTH: + raise ValueError(ERROR_CONTEXT_POLICY_LIMIT) try: regex = re.compile(pattern_text) except re.error as exc: @@ -772,7 +817,10 @@ def line_allows_rule(line: str, rule_id: str) -> bool: return "all" in allowed or rule_id in allowed -def scan_context_files(*, root: Path, policy: dict[str, object]) -> tuple[list[ContextGuardFinding], int]: +def _scan_context_files_unbounded( + root: Path, + policy: dict[str, object], +) -> tuple[list[ContextGuardFinding], int]: root = root.resolve() rules = build_rules(policy) paths = iter_context_files(root=root, policy=policy) @@ -802,3 +850,14 @@ def scan_context_files(*, root: Path, policy: dict[str, object]) -> tuple[list[C ) ) return findings, len(paths) + + +def scan_context_files(*, root: Path, policy: dict[str, object]) -> tuple[list[ContextGuardFinding], int]: + return run_isolated_scan( + _scan_context_files_unbounded, + root, + policy, + timeout_error=ERROR_CONTEXT_SCAN_TIMEOUT, + runtime_error=ERROR_CONTEXT_SCAN_RUNTIME, + safe_errors=(ERROR_CONTEXT_POLICY_LIMIT,), + ) diff --git a/src/agent_guard/evidence_pack.py b/src/agent_guard/evidence_pack.py index f016f36..b27cf9b 100644 --- a/src/agent_guard/evidence_pack.py +++ b/src/agent_guard/evidence_pack.py @@ -5,12 +5,34 @@ from __future__ import annotations +import base64 +import hashlib +import json +import os +import re +import stat from pathlib import Path, PureWindowsPath +from typing import Any from .public_redaction import contains_raw_url, sanitize_public_mapping - EVIDENCE_PACK_MANIFEST_SCHEMA_VERSION = "agent-guard.evidence_pack_manifest.v1" +AGENT_POLICY_AUDIT_EVENT_BINDING_SCHEMA_VERSION = ( + "agent-guard.agent_policy_audit_event_binding.v1" +) +AGENT_POLICY_AUDIT_EVENT_CANONICALIZATION = "canonical-json-v1" +AGENT_POLICY_AUDIT_EVENT_DIGEST_ALGORITHM = "sha256" +AGENT_POLICY_AUDIT_EVENT_DIGEST_ENCODING = "base32-lower-no-padding" +MAX_AGENT_POLICY_AUDIT_EVENT_BYTES = 1 * 1024 * 1024 +ERROR_AUDIT_EVENT_INVALID = "agent-policy audit event is not valid bounded JSON" +ERROR_AUDIT_EVENT_PATH = "agent-policy audit event must be a repository file" +ERROR_AUDIT_EVENT_PROFILE = "agent-policy audit event profile is invalid" +_AUDIT_EVENT_PROFILE_RE = re.compile(r"^[a-z][a-z0-9._-]{0,127}$") +_AUDIT_EVENT_DIGEST_RE = re.compile(r"^b[a-z2-7]{52}$") + + +class _JSONNumber(str): + """A parser-validated JSON number retained without binary-float coercion.""" def safe_artifact_path(path: str, *, root: Path | None = None) -> str: @@ -33,11 +55,406 @@ def safe_artifact_path(path: str, *, root: Path | None = None) -> str: return raw.as_posix() +def validate_agent_policy_audit_event_profile(profile: str) -> str: + normalized = str(profile).strip() + if not _AUDIT_EVENT_PROFILE_RE.fullmatch(normalized): + raise ValueError(ERROR_AUDIT_EVENT_PROFILE) + public_profile = {"event_profile": normalized} + try: + sanitized_profile = sanitize_public_mapping(public_profile) + except ValueError: + raise ValueError(ERROR_AUDIT_EVENT_PROFILE) from None + if sanitized_profile != public_profile: + raise ValueError(ERROR_AUDIT_EVENT_PROFILE) + return normalized + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + payload: dict[str, Any] = {} + for key, value in pairs: + if key in payload: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + payload[key] = value + return payload + + +def _reject_nonstandard_json_constant(_value: str) -> None: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + + +def _canonical_json_value(value: object) -> bytes: + if isinstance(value, _JSONNumber): + return value.encode("ascii") + if value is None: + return b"null" + if value is True: + return b"true" + if value is False: + return b"false" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False).encode("utf-8") + if isinstance(value, list): + return b"[" + b",".join(_canonical_json_value(item) for item in value) + b"]" + if isinstance(value, dict): + members: list[bytes] = [] + for key in sorted(value): + if not isinstance(key, str): + raise TypeError(ERROR_AUDIT_EVENT_INVALID) + members.append( + json.dumps(key, ensure_ascii=False).encode("utf-8") + + b":" + + _canonical_json_value(value[key]) + ) + return b"{" + b",".join(members) + b"}" + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + + +def _repo_relative_audit_event_path(path: Path, repo_root: Path) -> tuple[Path, Path]: + """Return a lexical in-root path without dereferencing its components.""" + + try: + lexical_root = Path(os.path.abspath(repo_root)) + resolved_root = repo_root.resolve(strict=True) + candidate = path if path.is_absolute() else lexical_root / path + lexical_candidate = Path(os.path.abspath(candidate)) + try: + relative_path = lexical_candidate.relative_to(lexical_root) + except ValueError: + relative_path = lexical_candidate.relative_to(resolved_root) + except (OSError, RuntimeError, TypeError, ValueError): + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + if not relative_path.parts: + raise ValueError(ERROR_AUDIT_EVENT_PATH) + return resolved_root, relative_path + + +def _open_agent_policy_audit_event_posix(repo_root: Path, relative_path: Path) -> int: + """Open an in-repository regular file without following any path component.""" + + nofollow = getattr(os, "O_NOFOLLOW", 0) + directory = getattr(os, "O_DIRECTORY", 0) + if ( + not nofollow + or not directory + or os.open not in os.supports_dir_fd + or relative_path.is_absolute() + or not relative_path.parts + or any(component in {".", ".."} for component in relative_path.parts) + ): + raise ValueError(ERROR_AUDIT_EVENT_PATH) + + directory_flags = os.O_RDONLY | nofollow | directory | getattr(os, "O_CLOEXEC", 0) + file_flags = os.O_RDONLY | nofollow | getattr(os, "O_CLOEXEC", 0) + directory_fd: int | None = None + file_fd: int | None = None + try: + directory_fd = os.open(repo_root, directory_flags) + for component in relative_path.parts[:-1]: + next_fd = os.open(component, directory_flags, dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = next_fd + file_fd = os.open(relative_path.parts[-1], file_flags, dir_fd=directory_fd) + if not stat.S_ISREG(os.fstat(file_fd).st_mode): + raise OSError + return file_fd + except (OSError, TypeError, ValueError): + if file_fd is not None: + os.close(file_fd) + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + finally: + if directory_fd is not None: + os.close(directory_fd) + + +def _open_exact_agent_policy_audit_event_posix(path: Path) -> int: + """Open the exact supplied regular file without following its final component.""" + + nofollow = getattr(os, "O_NOFOLLOW", 0) + if not nofollow: + raise ValueError(ERROR_AUDIT_EVENT_PATH) + file_fd: int | None = None + try: + file_fd = os.open(path, os.O_RDONLY | nofollow | getattr(os, "O_CLOEXEC", 0)) + if not stat.S_ISREG(os.fstat(file_fd).st_mode): + raise OSError + return file_fd + except (OSError, TypeError, ValueError): + if file_fd is not None: + os.close(file_fd) + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + + +def _windows_final_handle_path(file_fd: int) -> str: + import ctypes + import msvcrt + from ctypes import wintypes + + get_final_path = ctypes.WinDLL("kernel32", use_last_error=True).GetFinalPathNameByHandleW + get_final_path.argtypes = [ + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ] + get_final_path.restype = wintypes.DWORD + handle = msvcrt.get_osfhandle(file_fd) + capacity = 512 + while capacity <= 32_768: + buffer = ctypes.create_unicode_buffer(capacity) + length = get_final_path(handle, buffer, capacity, 0) + if length == 0: + raise OSError + if length < capacity: + final_path = buffer.value + if final_path.startswith("\\\\?\\UNC\\"): + return "\\\\" + final_path[8:] + if final_path.startswith("\\\\?\\"): + return final_path[4:] + return final_path + capacity = length + raise OSError + + +def _open_agent_policy_audit_event_windows( + path: Path, + *, + repo_root: Path | None, +) -> int: + """Open a regular file, reject path redirection, and enforce an optional root.""" + + file_fd: int | None = None + try: + requested_path = os.path.normcase(os.path.normpath(os.path.abspath(path))) + file_fd = os.open( + path, + os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0), + ) + if not stat.S_ISREG(os.fstat(file_fd).st_mode): + raise OSError + final_path = os.path.normcase(os.path.normpath(_windows_final_handle_path(file_fd))) + if final_path != requested_path: + raise OSError + if repo_root is not None: + normalized_root = os.path.normcase(os.path.normpath(str(repo_root))) + if os.path.commonpath((normalized_root, final_path)) != normalized_root: + raise OSError + return file_fd + except (OSError, TypeError, ValueError): + if file_fd is not None: + os.close(file_fd) + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + + +def _open_agent_policy_audit_event( + path: Path, + *, + repo_root: Path | None, +) -> tuple[int, str | None]: + if repo_root is None: + if os.name == "nt": + return _open_agent_policy_audit_event_windows(path, repo_root=None), None + return _open_exact_agent_policy_audit_event_posix(path), None + + resolved_root, relative_path = _repo_relative_audit_event_path(path, repo_root) + if os.name == "nt": + file_fd = _open_agent_policy_audit_event_windows( + resolved_root / relative_path, + repo_root=resolved_root, + ) + else: + file_fd = _open_agent_policy_audit_event_posix(resolved_root, relative_path) + return file_fd, relative_path.as_posix() + + +def _read_agent_policy_audit_event( + path: Path, + *, + repo_root: Path | None, +) -> tuple[bytes, str | None]: + file_fd, relative_path = _open_agent_policy_audit_event(path, repo_root=repo_root) + try: + handle = os.fdopen(file_fd, "rb") + except OSError: + try: + os.close(file_fd) + except OSError: + pass + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + try: + with handle: + raw = handle.read(MAX_AGENT_POLICY_AUDIT_EVENT_BYTES + 1) + except OSError: + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + if len(raw) > MAX_AGENT_POLICY_AUDIT_EVENT_BYTES: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + return raw, relative_path + + +def _canonical_agent_policy_audit_event(raw: bytes) -> bytes: + try: + text = raw.decode("utf-8") + payload = json.loads( + text, + object_pairs_hook=_reject_duplicate_json_keys, + parse_constant=_reject_nonstandard_json_constant, + parse_float=_JSONNumber, + parse_int=_JSONNumber, + ) + if not isinstance(payload, dict): + raise TypeError(ERROR_AUDIT_EVENT_INVALID) + canonical = _canonical_json_value(payload) + except (UnicodeError, json.JSONDecodeError, RecursionError, TypeError, ValueError): + raise ValueError(ERROR_AUDIT_EVENT_INVALID) from None + return canonical + + +def _build_agent_policy_audit_event_binding( + path: Path, + *, + event_profile: str, + repo_root: Path | None, +) -> tuple[dict[str, str], str | None]: + profile = validate_agent_policy_audit_event_profile(event_profile) + raw, relative_path = _read_agent_policy_audit_event(path, repo_root=repo_root) + canonical = _canonical_agent_policy_audit_event(raw) + domain = ( + AGENT_POLICY_AUDIT_EVENT_BINDING_SCHEMA_VERSION.encode("ascii") + + b"\0" + + profile.encode("ascii") + + b"\0" + + canonical + ) + digest = ( + "b" + + base64.b32encode(hashlib.sha256(domain).digest()) + .decode("ascii") + .rstrip("=") + .lower() + ) + return ( + { + "schema_version": AGENT_POLICY_AUDIT_EVENT_BINDING_SCHEMA_VERSION, + "event_profile": profile, + "canonicalization": AGENT_POLICY_AUDIT_EVENT_CANONICALIZATION, + "digest_algorithm": AGENT_POLICY_AUDIT_EVENT_DIGEST_ALGORITHM, + "digest_encoding": AGENT_POLICY_AUDIT_EVENT_DIGEST_ENCODING, + "digest": digest, + }, + relative_path, + ) + + +def build_agent_policy_audit_event_binding( + path: Path, + *, + event_profile: str, + repo_root: Path | None = None, +) -> dict[str, str]: + binding, _ = _build_agent_policy_audit_event_binding( + path, + event_profile=event_profile, + repo_root=repo_root, + ) + return binding + + +def validate_agent_policy_audit_event_binding_shape(binding: object) -> dict[str, str]: + if not isinstance(binding, dict) or set(binding) != { + "schema_version", + "event_profile", + "canonicalization", + "digest_algorithm", + "digest_encoding", + "digest", + }: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + if binding.get("schema_version") != AGENT_POLICY_AUDIT_EVENT_BINDING_SCHEMA_VERSION: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + profile = validate_agent_policy_audit_event_profile(str(binding.get("event_profile", ""))) + if ( + binding.get("canonicalization") != AGENT_POLICY_AUDIT_EVENT_CANONICALIZATION + or binding.get("digest_algorithm") != AGENT_POLICY_AUDIT_EVENT_DIGEST_ALGORITHM + or binding.get("digest_encoding") != AGENT_POLICY_AUDIT_EVENT_DIGEST_ENCODING + or not _AUDIT_EVENT_DIGEST_RE.fullmatch(str(binding.get("digest", ""))) + ): + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + return {key: str(value) for key, value in binding.items()} | {"event_profile": profile} + + +def _validate_agent_policy_audit_event_artifact_shape( + artifact: object, + *, + root: Path | None, +) -> dict[str, object]: + if not isinstance(artifact, dict) or set(artifact) != { + "path", + "role", + "content_binding", + }: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + if artifact.get("role") != "agent-policy-audit-event": + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + path = artifact.get("path") + if not isinstance(path, str): + raise TypeError(ERROR_AUDIT_EVENT_INVALID) + normalized_path = path.strip() + if ( + not normalized_path + or safe_artifact_path(normalized_path, root=root) != normalized_path + or sanitize_public_mapping({"path": normalized_path}) != {"path": normalized_path} + ): + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + binding = validate_agent_policy_audit_event_binding_shape( + artifact.get("content_binding") + ) + return { + "path": normalized_path, + "role": "agent-policy-audit-event", + "content_binding": binding, + } + + +def build_agent_policy_audit_event_artifacts( + paths: list[str], + *, + event_profile: str, + root: Path, +) -> list[dict[str, object]]: + if not paths: + if str(event_profile).strip(): + raise ValueError(ERROR_AUDIT_EVENT_PROFILE) + return [] + profile = validate_agent_policy_audit_event_profile(event_profile) + artifacts: list[dict[str, object]] = [] + for raw_path in paths: + try: + candidate = Path(raw_path) + except (TypeError, ValueError): + raise ValueError(ERROR_AUDIT_EVENT_PATH) from None + binding, relative_path = _build_agent_policy_audit_event_binding( + candidate, + event_profile=profile, + repo_root=root, + ) + if relative_path is None: + raise ValueError(ERROR_AUDIT_EVENT_PATH) + artifacts.append( + { + "path": relative_path, + "role": "agent-policy-audit-event", + "content_binding": binding, + } + ) + return artifacts + + def build_evidence_pack_manifest( *, report_payload: dict[str, object], artifact_paths: list[str] | None = None, agent_policy_audit_event_paths: list[str] | None = None, + agent_policy_audit_event_profile: str = "", + agent_policy_audit_event_artifacts: list[dict[str, object]] | None = None, root: Path | None = None, ) -> dict[str, object]: report = report_payload.get("report", {}) @@ -65,10 +482,25 @@ def build_evidence_pack_manifest( safe_path = safe_artifact_path(path, root=root) if safe_path: artifacts.append({"path": safe_path, "role": "report"}) - for path in agent_policy_audit_event_paths or []: - safe_path = safe_artifact_path(path, root=root) - if safe_path: - artifacts.append({"path": safe_path, "role": "agent-policy-audit-event"}) + if agent_policy_audit_event_artifacts is not None: + if agent_policy_audit_event_paths: + raise ValueError(ERROR_AUDIT_EVENT_INVALID) + artifacts.extend( + _validate_agent_policy_audit_event_artifact_shape(artifact, root=root) + for artifact in agent_policy_audit_event_artifacts + ) + elif agent_policy_audit_event_paths: + if root is None: + raise ValueError(ERROR_AUDIT_EVENT_PATH) + artifacts.extend( + build_agent_policy_audit_event_artifacts( + list(agent_policy_audit_event_paths), + event_profile=agent_policy_audit_event_profile, + root=root, + ) + ) + elif str(agent_policy_audit_event_profile).strip(): + raise ValueError(ERROR_AUDIT_EVENT_PROFILE) manifest: dict[str, object] = { "schema_version": EVIDENCE_PACK_MANIFEST_SCHEMA_VERSION, diff --git a/src/agent_guard/schemas/agent-guard.evidence_pack_manifest.v1.schema.json b/src/agent_guard/schemas/agent-guard.evidence_pack_manifest.v1.schema.json index 3450359..70c91e3 100644 --- a/src/agent_guard/schemas/agent-guard.evidence_pack_manifest.v1.schema.json +++ b/src/agent_guard/schemas/agent-guard.evidence_pack_manifest.v1.schema.json @@ -77,8 +77,42 @@ "required": ["path", "role"], "properties": { "path": {"type": "string"}, - "role": {"type": "string", "enum": ["report", "agent-policy-audit-event"]} + "role": {"type": "string", "enum": ["report", "agent-policy-audit-event"]}, + "content_binding": { + "type": "object", + "required": [ + "schema_version", + "event_profile", + "canonicalization", + "digest_algorithm", + "digest_encoding", + "digest" + ], + "properties": { + "schema_version": {"const": "agent-guard.agent_policy_audit_event_binding.v1"}, + "event_profile": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + }, + "canonicalization": {"const": "canonical-json-v1"}, + "digest_algorithm": {"const": "sha256"}, + "digest_encoding": {"const": "base32-lower-no-padding"}, + "digest": {"type": "string", "pattern": "^b[a-z2-7]{52}$"} + }, + "additionalProperties": false + } }, + "allOf": [ + { + "if": {"properties": {"role": {"const": "agent-policy-audit-event"}}}, + "then": { + "required": ["content_binding"], + "propertyNames": { + "enum": ["path", "role", "content_binding"] + } + } + } + ], "additionalProperties": true } } diff --git a/src/agent_guard/schemas/agent-guard.report_evidence.v1.schema.json b/src/agent_guard/schemas/agent-guard.report_evidence.v1.schema.json index 30cff3a..6d248af 100644 --- a/src/agent_guard/schemas/agent-guard.report_evidence.v1.schema.json +++ b/src/agent_guard/schemas/agent-guard.report_evidence.v1.schema.json @@ -310,8 +310,42 @@ "required": ["path", "role"], "properties": { "path": {"type": "string"}, - "role": {"type": "string", "enum": ["report", "agent-policy-audit-event"]} + "role": {"type": "string", "enum": ["report", "agent-policy-audit-event"]}, + "content_binding": { + "type": "object", + "required": [ + "schema_version", + "event_profile", + "canonicalization", + "digest_algorithm", + "digest_encoding", + "digest" + ], + "properties": { + "schema_version": {"const": "agent-guard.agent_policy_audit_event_binding.v1"}, + "event_profile": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + }, + "canonicalization": {"const": "canonical-json-v1"}, + "digest_algorithm": {"const": "sha256"}, + "digest_encoding": {"const": "base32-lower-no-padding"}, + "digest": {"type": "string", "pattern": "^b[a-z2-7]{52}$"} + }, + "additionalProperties": false + } }, + "allOf": [ + { + "if": {"properties": {"role": {"const": "agent-policy-audit-event"}}}, + "then": { + "required": ["content_binding"], + "propertyNames": { + "enum": ["path", "role", "content_binding"] + } + } + } + ], "additionalProperties": true } } diff --git a/tests/cli/test_evidence_pack.py b/tests/cli/test_evidence_pack.py index ae46e7a..9deeaef 100644 --- a/tests/cli/test_evidence_pack.py +++ b/tests/cli/test_evidence_pack.py @@ -5,11 +5,22 @@ from __future__ import annotations import json +import os from pathlib import Path +import pytest + +from agent_guard import evidence_pack from agent_guard.consumer import validate_public_evidence_shape +from agent_guard.evidence_pack import ( + build_agent_policy_audit_event_artifacts, + build_agent_policy_audit_event_binding, + build_evidence_pack_manifest, +) from tests.cli.helpers import run_cli +AUDIT_EVENT_PROFILE = "agent-policy.audit_event.v1.1" + def test_evidence_pack_manifest_cli_is_sanitized(tmp_path: Path) -> None: report = tmp_path / "report.json" @@ -36,6 +47,9 @@ def test_evidence_pack_manifest_cli_is_sanitized(tmp_path: Path) -> None: ), encoding="utf-8", ) + event = tmp_path / ".agent-guard" / "evidence" / "policy-admission-event.json" + event.parent.mkdir(parents=True) + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") result = run_cli( "evidence-pack", @@ -55,7 +69,9 @@ def test_evidence_pack_manifest_cli_is_sanitized(tmp_path: Path) -> None: "--artifact", "file://localhost/home/synthetic/private/report.json", "--agent-policy-audit-event", - str(tmp_path / ".agent-guard" / "evidence" / "policy-admission-event.json"), + str(event), + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, "--json", ) @@ -64,14 +80,24 @@ def test_evidence_pack_manifest_cli_is_sanitized(tmp_path: Path) -> None: manifest = payload["evidence_pack_manifest"] assert manifest["schema_version"] == "agent-guard.evidence_pack_manifest.v1" assert manifest["sanitized"] is True - assert manifest["artifacts"] == [ + assert manifest["artifacts"][:-1] == [ {"path": ".agent-guard/evidence/report.json", "role": "report"}, {"path": "outside-report.json", "role": "report"}, {"path": "agent-guard-report.json", "role": "report"}, {"path": "agent-guard-report.json", "role": "report"}, {"path": "", "role": "report"}, - {"path": ".agent-guard/evidence/policy-admission-event.json", "role": "agent-policy-audit-event"}, ] + audit_artifact = manifest["artifacts"][-1] + assert audit_artifact["path"] == ".agent-guard/evidence/policy-admission-event.json" + assert audit_artifact["role"] == "agent-policy-audit-event" + binding = audit_artifact["content_binding"] + assert binding["schema_version"] == "agent-guard.agent_policy_audit_event_binding.v1" + assert binding["event_profile"] == AUDIT_EVENT_PROFILE + assert binding["canonicalization"] == "canonical-json-v1" + assert binding["digest_algorithm"] == "sha256" + assert binding["digest_encoding"] == "base32-lower-no-padding" + assert len(binding["digest"]) == 53 + assert binding["digest"].startswith("b") assert str(tmp_path) not in result.stdout assert r"C:\Users\alice" not in result.stdout assert r"\\server\share" not in result.stdout @@ -131,8 +157,6 @@ def test_evidence_pack_manifest_cli_sanitizes_copied_report_metadata(tmp_path: P str(report), "--artifact", raw_url, - "--agent-policy-audit-event", - raw_url, "--json", ) @@ -149,13 +173,468 @@ def test_evidence_pack_manifest_cli_sanitizes_copied_report_metadata(tmp_path: P assert manifest["conformance"]["profile"] == "recommended" assert manifest["artifacts"] == [ {"path": "", "role": "report"}, - {"path": "", "role": "agent-policy-audit-event"}, ] for value in (secret_shaped, raw_url, local_path, windows_path, unc_path, hash_shaped): assert value not in result.stdout validate_public_evidence_shape(payload) +def test_evidence_pack_manifest_cli_rejects_unbound_or_external_audit_event( + tmp_path: Path, +) -> None: + report = tmp_path / "report.json" + report.write_text( + json.dumps( + { + "status": "ok", + "finding_count": 0, + "report": { + "schema_version": "agent-guard.report_evidence.v1", + "format": "json", + "scope": "context", + }, + "evidence_coverage": {"gates": []}, + } + ), + encoding="utf-8", + ) + event = tmp_path / "event.json" + event.write_text("{}\n", encoding="utf-8") + + missing_profile = run_cli( + "evidence-pack", + "manifest", + "--root", + str(tmp_path), + "--report", + str(report), + "--agent-policy-audit-event", + str(event), + "--json", + ) + external = run_cli( + "evidence-pack", + "manifest", + "--root", + str(tmp_path), + "--report", + str(report), + "--agent-policy-audit-event", + str(tmp_path.parent / "outside.json"), + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, + "--json", + ) + + assert missing_profile.returncode == 2 + assert external.returncode == 2 + assert AUDIT_EVENT_PROFILE not in external.stdout + assert str(tmp_path) not in missing_profile.stdout + external.stdout + + +def test_audit_event_artifacts_preserve_nested_paths_for_duplicate_basenames( + tmp_path: Path, +) -> None: + root = tmp_path / "repository" + first = root / "evidence" / "first" / "policy-admission-event.json" + second = root / "evidence" / "second" / "policy-admission-event.json" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_text('{"status":"first"}\n', encoding="utf-8") + second.write_text('{"status":"second"}\n', encoding="utf-8") + + artifacts = build_agent_policy_audit_event_artifacts( + [ + first.relative_to(root).as_posix(), + second.relative_to(root).as_posix(), + ], + event_profile=AUDIT_EVENT_PROFILE, + root=root, + ) + + paths = [artifact["path"] for artifact in artifacts] + assert paths == [ + "evidence/first/policy-admission-event.json", + "evidence/second/policy-admission-event.json", + ] + assert len(set(paths)) == 2 + + +def test_audit_event_binding_is_canonical_and_detects_content_change(tmp_path: Path) -> None: + event = tmp_path / "event.json" + event.write_text('{"decision":{"mode":"auto_allow"},"capability":"read"}\n', encoding="utf-8") + first = build_agent_policy_audit_event_binding(event, event_profile=AUDIT_EVENT_PROFILE) + + event.write_text( + '{\n "capability": "read",\n "decision": {"mode": "auto_allow"}\n}\n', + encoding="utf-8", + ) + equivalent = build_agent_policy_audit_event_binding(event, event_profile=AUDIT_EVENT_PROFILE) + event.write_text('{"capability":"write","decision":{"mode":"auto_allow"}}\n', encoding="utf-8") + changed = build_agent_policy_audit_event_binding(event, event_profile=AUDIT_EVENT_PROFILE) + + assert equivalent == first + assert changed["digest"] != first["digest"] + + +def test_audit_event_binding_preserves_distinct_large_number_lexemes( + tmp_path: Path, +) -> None: + event = tmp_path / "event.json" + event.write_text('{"sequence":9007199254740992.0}\n', encoding="utf-8") + first = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + + event.write_text('{"sequence":9007199254740993.0}\n', encoding="utf-8") + changed = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + + assert changed["digest"] != first["digest"] + + +def test_manifest_rejects_extra_fields_in_prebuilt_audit_event_artifact( + tmp_path: Path, +) -> None: + marker = "synthetic-private-passphrase" + event = tmp_path / "event.json" + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + binding = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + + with pytest.raises( + ValueError, + match="^agent-policy audit event is not valid bounded JSON$", + ) as exc_info: + build_evidence_pack_manifest( + report_payload={}, + agent_policy_audit_event_artifacts=[ + { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": binding, + "event_body": {"passphrase": marker}, + } + ], + root=tmp_path, + ) + + assert marker not in str(exc_info.value) + + +def test_manifest_rejects_invalid_prebuilt_audit_event_artifact_shape( + tmp_path: Path, +) -> None: + event = tmp_path / "event.json" + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + binding = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + valid_artifact: dict[str, object] = { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": binding, + } + manifest = build_evidence_pack_manifest( + report_payload={}, + agent_policy_audit_event_artifacts=[valid_artifact], + root=tmp_path, + ) + assert manifest["artifacts"] == [valid_artifact] + + invalid_artifacts = [ + valid_artifact | {"role": "report"}, + valid_artifact | {"path": "../outside/event.json"}, + valid_artifact | {"content_binding": binding | {"digest": "invalid"}}, + ] + for artifact in invalid_artifacts: + with pytest.raises( + ValueError, + match="^agent-policy audit event is not valid bounded JSON$", + ): + build_evidence_pack_manifest( + report_payload={}, + agent_policy_audit_event_artifacts=[artifact], + root=tmp_path, + ) + + +@pytest.mark.parametrize( + "event_profile", + ( + "sk-" + ("x" * 16), + "a" * 64, + ), +) +def test_audit_event_profile_rejects_public_sanitization_changes_before_digest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + event_profile: str, +) -> None: + event = tmp_path / "event.json" + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + assert evidence_pack._AUDIT_EVENT_PROFILE_RE.fullmatch(event_profile) + + def unexpected_digest(_value: bytes) -> object: + raise AssertionError("digest computation must not run") + + monkeypatch.setattr(evidence_pack.hashlib, "sha256", unexpected_digest) + + with pytest.raises( + ValueError, + match="^agent-policy audit event profile is invalid$", + ) as exc_info: + build_agent_policy_audit_event_binding( + event, + event_profile=event_profile, + ) + + assert event_profile not in str(exc_info.value) + + +def test_audit_event_binding_enforces_one_mib_read_bound_before_parse( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + event = tmp_path / "event.json" + event.write_bytes(b"x" * (evidence_pack.MAX_AGENT_POLICY_AUDIT_EVENT_BYTES + 1)) + + def unexpected_parse(_raw: bytes) -> bytes: + raise AssertionError("oversized event must not be parsed") + + monkeypatch.setattr( + evidence_pack, + "_canonical_agent_policy_audit_event", + unexpected_parse, + ) + + with pytest.raises( + ValueError, + match="^agent-policy audit event is not valid bounded JSON$", + ): + build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + + +@pytest.mark.skipif(os.name != "posix", reason="exercises POSIX descriptor traversal") +def test_audit_event_binding_reads_opened_descriptor_after_final_path_swap( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "repo" + event = root / "reviewed" / "event.json" + external = tmp_path / "external-event.json" + event.parent.mkdir(parents=True) + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + external.write_text('{"status":"external"}\n', encoding="utf-8") + expected = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + repo_root=root, + ) + original_open = evidence_pack._open_agent_policy_audit_event_posix + + def open_then_swap(repo_root: Path, relative_path: Path) -> int: + file_fd = original_open(repo_root, relative_path) + event.unlink() + event.symlink_to(external) + return file_fd + + monkeypatch.setattr( + evidence_pack, + "_open_agent_policy_audit_event_posix", + open_then_swap, + ) + + artifacts = build_agent_policy_audit_event_artifacts( + ["reviewed/event.json"], + event_profile=AUDIT_EVENT_PROFILE, + root=root, + ) + + assert artifacts == [ + { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": expected, + } + ] + + +@pytest.mark.skipif(os.name != "posix", reason="exercises POSIX no-follow traversal") +def test_audit_event_binding_rejects_final_file_symlink_swap_without_leak( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "repo" + event = root / "reviewed" / "event.json" + held = event.with_name("held-event.json") + external = tmp_path / "external-event.json" + external_marker = "synthetic-external-event-marker" + event.parent.mkdir(parents=True) + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + external.write_text(json.dumps({"marker": external_marker}), encoding="utf-8") + original_open = evidence_pack._open_agent_policy_audit_event_posix + + def swap_before_open(repo_root: Path, relative_path: Path) -> int: + event.rename(held) + event.symlink_to(external) + return original_open(repo_root, relative_path) + + monkeypatch.setattr( + evidence_pack, + "_open_agent_policy_audit_event_posix", + swap_before_open, + ) + + with pytest.raises( + ValueError, + match="^agent-policy audit event must be a repository file$", + ) as exc_info: + build_agent_policy_audit_event_artifacts( + ["reviewed/event.json"], + event_profile=AUDIT_EVENT_PROFILE, + root=root, + ) + + assert external_marker not in str(exc_info.value) + assert str(external) not in str(exc_info.value) + + +@pytest.mark.skipif(os.name != "posix", reason="exercises POSIX no-follow traversal") +def test_audit_event_binding_rejects_ancestor_symlink_swap_without_leak( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "repo" + event_dir = root / "reviewed" / "nested" + event = event_dir / "event.json" + held = event_dir.with_name("held-nested") + external_dir = tmp_path / "external" + external_marker = "synthetic-external-ancestor-marker" + event_dir.mkdir(parents=True) + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + external_dir.mkdir() + (external_dir / "event.json").write_text( + json.dumps({"marker": external_marker}), + encoding="utf-8", + ) + original_open = evidence_pack._open_agent_policy_audit_event_posix + + def swap_before_open(repo_root: Path, relative_path: Path) -> int: + event_dir.rename(held) + event_dir.symlink_to(external_dir, target_is_directory=True) + return original_open(repo_root, relative_path) + + monkeypatch.setattr( + evidence_pack, + "_open_agent_policy_audit_event_posix", + swap_before_open, + ) + + with pytest.raises( + ValueError, + match="^agent-policy audit event must be a repository file$", + ) as exc_info: + build_agent_policy_audit_event_artifacts( + ["reviewed/nested/event.json"], + event_profile=AUDIT_EVENT_PROFILE, + root=root, + ) + + assert external_marker not in str(exc_info.value) + assert str(external_dir) not in str(exc_info.value) + + +def test_evidence_pack_manifest_rejects_ambiguous_audit_event_json(tmp_path: Path) -> None: + report = tmp_path / "report.json" + report.write_text( + json.dumps( + { + "status": "ok", + "finding_count": 0, + "report": { + "schema_version": "agent-guard.report_evidence.v1", + "format": "json", + "scope": "context", + }, + "evidence_coverage": {"gates": []}, + } + ), + encoding="utf-8", + ) + event = tmp_path / "event.json" + event.write_text('{"decision":"first","decision":"second"}\n', encoding="utf-8") + + result = run_cli( + "evidence-pack", + "manifest", + "--root", + str(tmp_path), + "--report", + str(report), + "--agent-policy-audit-event", + str(event), + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, + "--json", + ) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["error"] == "agent-policy audit event is not valid bounded JSON" + assert "first" not in result.stdout + result.stderr + assert "second" not in result.stdout + result.stderr + + +def test_evidence_pack_manifest_rejects_non_unicode_audit_event_json(tmp_path: Path) -> None: + report = tmp_path / "report.json" + report.write_text( + json.dumps( + { + "status": "ok", + "finding_count": 0, + "report": { + "schema_version": "agent-guard.report_evidence.v1", + "format": "json", + "scope": "context", + }, + "evidence_coverage": {"gates": []}, + } + ), + encoding="utf-8", + ) + event = tmp_path / "event.json" + event.write_bytes(b'{"value":"\\ud800"}\n') + + result = run_cli( + "evidence-pack", + "manifest", + "--root", + str(tmp_path), + "--report", + str(report), + "--agent-policy-audit-event", + str(event), + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, + "--json", + ) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["error"] == "agent-policy audit event is not valid bounded JSON" + assert "ud800" not in result.stdout + result.stderr + + def test_evidence_pack_manifest_cli_fails_closed_on_sanitized_key_collision(tmp_path: Path) -> None: first = "field=https://one.invalid/a alpha" second = "field=https://two.invalid/b beta" diff --git a/tests/cli/test_report_output.py b/tests/cli/test_report_output.py index 6af773f..10d0057 100644 --- a/tests/cli/test_report_output.py +++ b/tests/cli/test_report_output.py @@ -200,6 +200,9 @@ def test_report_cli_json_output_writes_file_and_suppresses_stdout(tmp_path: Path f"Run pytest before reporting completion. {content_marker}\n", ) output = tmp_path / "evidence" / "agent-guard-report.json" + event = tmp_path / "evidence" / "policy-admission-event.json" + event.parent.mkdir(parents=True) + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") result = run_cli( "report", @@ -211,7 +214,9 @@ def test_report_cli_json_output_writes_file_and_suppresses_stdout(tmp_path: Path "json", "--evidence-pack-manifest", "--agent-policy-audit-event", - str(tmp_path / "evidence" / "policy-admission-event.json"), + str(event), + "--agent-policy-audit-event-profile", + "agent-policy.audit_event.v1.1", "--output", str(output), ) @@ -225,10 +230,11 @@ def test_report_cli_json_output_writes_file_and_suppresses_stdout(tmp_path: Path assert payload["report"]["schema_version"] == "agent-guard.report_evidence.v1" assert payload["report"]["format"] == "json" assert payload["report"]["sanitized"] is True - assert payload["evidence_pack_manifest"]["artifacts"] == [ - {"path": "evidence/agent-guard-report.json", "role": "report"}, - {"path": "evidence/policy-admission-event.json", "role": "agent-policy-audit-event"}, - ] + artifacts = payload["evidence_pack_manifest"]["artifacts"] + assert artifacts[0] == {"path": "evidence/agent-guard-report.json", "role": "report"} + assert artifacts[1]["path"] == "evidence/policy-admission-event.json" + assert artifacts[1]["role"] == "agent-policy-audit-event" + assert artifacts[1]["content_binding"]["event_profile"] == "agent-policy.audit_event.v1.1" serialized = json.dumps(payload, ensure_ascii=False) assert str(tmp_path) not in serialized assert content_marker not in serialized @@ -236,6 +242,64 @@ def test_report_cli_json_output_writes_file_and_suppresses_stdout(tmp_path: Path assert "matched_text" not in serialized +def test_report_cli_audit_event_implies_evidence_pack_manifest(tmp_path: Path) -> None: + policy = tmp_path / "context_policy.yaml" + policy.write_text("{}\n", encoding="utf-8") + write( + tmp_path / "AGENTS.md", + "Require approval before shell writes.\n" + "Run pytest before reporting completion.\n", + ) + event = tmp_path / "evidence" / "nested" / "policy-admission-event.json" + event.parent.mkdir(parents=True) + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + + result = run_cli( + "report", + "--root", + str(tmp_path), + "--context-policy", + str(policy), + "--agent-policy-audit-event", + event.relative_to(tmp_path).as_posix(), + "--agent-policy-audit-event-profile", + "agent-policy.audit_event.v1.1", + "--format", + "json", + ) + + assert result.returncode == 0 + payload = json.loads(result.stdout) + artifact = payload["evidence_pack_manifest"]["artifacts"][0] + assert artifact["path"] == "evidence/nested/policy-admission-event.json" + assert artifact["role"] == "agent-policy-audit-event" + assert artifact["content_binding"]["event_profile"] == "agent-policy.audit_event.v1.1" + assert str(tmp_path) not in result.stdout + + +def test_report_cli_rejects_audit_event_profile_without_path(tmp_path: Path) -> None: + policy = tmp_path / "context_policy.yaml" + policy.write_text("{}\n", encoding="utf-8") + + result = run_cli( + "report", + "--root", + str(tmp_path), + "--context-policy", + str(policy), + "--agent-policy-audit-event-profile", + "agent-policy.audit_event.v1.1", + "--format", + "json", + ) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["error"] == "agent-policy audit event profile is invalid" + assert payload["report"]["sanitized"] is True + assert str(tmp_path) not in result.stdout + + def test_report_cli_stderr_summary_ok_after_output_write_is_sanitized(tmp_path: Path) -> None: policy = tmp_path / "context_policy.yaml" policy.write_text("{}\n", encoding="utf-8") diff --git a/tests/test_context_guard.py b/tests/test_context_guard.py index 33f75f7..6ed8103 100644 --- a/tests/test_context_guard.py +++ b/tests/test_context_guard.py @@ -6,12 +6,22 @@ from __future__ import annotations from pathlib import Path +import time import pytest import yaml +import agent_guard.bounded_scan as bounded_scan +import agent_guard.bounded_yaml as bounded_yaml +import agent_guard.context_guard as context_guard from agent_guard.context_guard import ( ContextGuardFinding, + ERROR_CONTEXT_POLICY_INVALID, + ERROR_CONTEXT_POLICY_LIMIT, + ERROR_CONTEXT_SCAN_TIMEOUT, + MAX_CONTEXT_POLICY_BYTES, + MAX_CONTEXT_POLICY_REGEX_COUNT, + MAX_CONTEXT_POLICY_REGEX_LENGTH, build_rules, collect_context_inventory, iter_context_files, @@ -407,6 +417,67 @@ def test_context_guard_rejects_malformed_policy(tmp_path: Path) -> None: load_context_policy(bad) +@pytest.mark.parametrize("payload", ("[]\n", "false\n", "0\n", '""\n')) +def test_context_guard_rejects_falsy_non_mapping_policy( + tmp_path: Path, + payload: str, +) -> None: + bad = tmp_path / "bad.yaml" + bad.write_text(payload, encoding="utf-8") + + with pytest.raises(ValueError, match=rf"^{ERROR_CONTEXT_POLICY_INVALID}$"): + load_context_policy(bad) + + +def test_context_policy_rejects_exactly_one_byte_over_limit_before_yaml_parse( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = "synthetic-oversized-context-policy-marker" + raw_policy = marker.encode("utf-8") + b" " * ( + MAX_CONTEXT_POLICY_BYTES + 1 - len(marker.encode("utf-8")) + ) + assert len(raw_policy) == MAX_CONTEXT_POLICY_BYTES + 1 + policy_path = tmp_path / "context-policy.yaml" + policy_path.write_bytes(raw_policy) + + def unexpected_safe_load(_text: str) -> object: + raise AssertionError("oversized context policy reached YAML parsing") + + monkeypatch.setattr(context_guard.yaml, "safe_load", unexpected_safe_load) + + with pytest.raises(ValueError, match=f"^{ERROR_CONTEXT_POLICY_LIMIT}$") as exc_info: + load_context_policy(policy_path) + + assert marker not in str(exc_info.value) + + +def test_context_policy_rejects_nested_yaml_structure_before_object_construction( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + marker = "synthetic-nested-context-policy-marker" + raw_policy = ( + "root: " + + ("[" * (bounded_yaml.MAX_YAML_DEPTH + 1)) + + marker + + ("]" * (bounded_yaml.MAX_YAML_DEPTH + 1)) + + "\n" + ) + policy_path = tmp_path / "context-policy.yaml" + policy_path.write_text(raw_policy, encoding="utf-8") + + def unexpected_safe_load(_text: str) -> object: + raise AssertionError("nested context policy reached YAML construction") + + monkeypatch.setattr(context_guard.yaml, "safe_load", unexpected_safe_load) + + with pytest.raises(ValueError, match=f"^{ERROR_CONTEXT_POLICY_LIMIT}$") as exc_info: + load_context_policy(policy_path) + + assert marker not in str(exc_info.value) + + def test_context_guard_rejects_invalid_regex(tmp_path: Path) -> None: bad_policy = policy_file( tmp_path, @@ -421,3 +492,54 @@ def test_context_guard_rejects_invalid_regex(tmp_path: Path) -> None: with pytest.raises(ValueError, match="invalid forbidden_patterns regex"): build_rules(load_context_policy(bad_policy)) + + +def test_context_guard_enforces_regex_execution_budget( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(bounded_scan, "ISOLATED_SCAN_TIMEOUT_SECONDS", 0.25) + sentinel = "sk-" + ("r" * 24) + policy = { + "scan": {"include": ["AGENTS.md"], "exclude": []}, + "policy": { + "forbidden_patterns": [ + { + "id": "catastrophic", + "pattern": f"(?# {sentinel})(a+)+$", + "message": "synthetic context rule", + } + ] + }, + } + write(tmp_path / "AGENTS.md", ("a" * 30) + "!\n") + + started = time.monotonic() + with pytest.raises(RuntimeError, match=f"^{ERROR_CONTEXT_SCAN_TIMEOUT}$") as exc_info: + scan_context_files(root=tmp_path, policy=policy) + + assert time.monotonic() - started < 3 + assert sentinel not in str(exc_info.value) + + +@pytest.mark.parametrize( + "rules", + [ + [{"id": "bounded", "pattern": "safe"}] * (MAX_CONTEXT_POLICY_REGEX_COUNT + 1), + [{"id": "bounded", "pattern": "a" * (MAX_CONTEXT_POLICY_REGEX_LENGTH + 1)}], + ], +) +def test_context_guard_rejects_policy_regex_limits_without_echo( + tmp_path: Path, + rules: list[dict[str, str]], +) -> None: + write(tmp_path / "AGENTS.md", "safe\n") + + with pytest.raises(ValueError, match=f"^{ERROR_CONTEXT_POLICY_LIMIT}$"): + scan_context_files( + root=tmp_path, + policy={ + "scan": {"include": ["AGENTS.md"], "exclude": []}, + "policy": {"forbidden_patterns": rules}, + }, + ) diff --git a/tests/test_contract_stability.py b/tests/test_contract_stability.py index a21a85c..1966670 100644 --- a/tests/test_contract_stability.py +++ b/tests/test_contract_stability.py @@ -156,6 +156,8 @@ def test_changelog_records_latest_release_entry() -> None: ] assert normalized_unreleased == " ".join( [ + "- Isolated repository-controlled context-policy regular-expression matching behind the existing bounded scanner worker and added fixed pattern-count and pattern-length limits. Timeout and limit failures remain deterministic, sanitized configuration errors; no raw pattern or context text is emitted.", + "- Content-bound optional `agent-policy` audit-event references with a canonical-JSON, profile-bound, public-safe digest. Producers now require a reviewed repo-local event file and explicit profile, while consumers require the separately supplied event and reject missing, malformed, or replaced content. The event body remains outside the fixed seven-file public bundle.", "- Defined a bounded demand-validation window through 2026-09-20 and froze feature releases pending an explicit maintainer decision after the 2026-09-21 review. Marketplace publication remains separately prohibited without explicit authorization.", "- Locked the release build toolchain, pinned copyable GitHub Action examples to the immutable v0.3.4 release commit, and documented the post-release pin refresh contract.", "- Simplified reviewed bootstrap and monorepo onboarding, added explicit Python interpreter checks, and tightened guidance for copying public-safe evidence.", diff --git a/tests/test_docs_contract.py b/tests/test_docs_contract.py index 56a3518..69d59f1 100644 --- a/tests/test_docs_contract.py +++ b/tests/test_docs_contract.py @@ -529,8 +529,10 @@ def test_optional_agent_policy_event_stays_outside_public_bundle() -> None: assert ".agent-guard/evidence/policy-admission-event.json" not in documents assert "outside `.agent-guard/evidence`" in single_line - assert "does not verify" in single_line - assert "existence or content" in single_line + assert "profile-bound" in single_line + assert "reads and canonicalizes" in single_line + assert "never embeds the event body" in single_line + assert "requires the event separately" in single_line assert "both the `report`" in single_line assert "manifest embedded in the report" in single_line diff --git a/tests/test_evidence_consumer.py b/tests/test_evidence_consumer.py index 5e51b5f..ff256c9 100644 --- a/tests/test_evidence_consumer.py +++ b/tests/test_evidence_consumer.py @@ -5,8 +5,8 @@ from __future__ import annotations -import json import importlib.util +import json import os import subprocess import sys @@ -20,17 +20,21 @@ RAW_URL_RE, load_payload, load_report_schema, - main as packaged_consumer_main, + validate_agent_policy_audit_event_files, validate_report, ) +from agent_guard.consumer import ( + main as packaged_consumer_main, +) from agent_guard.consumer._bundle import MAX_MARKDOWN_BYTES +from agent_guard.evidence_pack import build_agent_policy_audit_event_binding from agent_guard.report_render import emit_report_output, render_report_output - REPO_ROOT = Path(__file__).resolve().parents[1] SRC = REPO_ROOT / "src" SCRIPT = REPO_ROOT / "examples" / "evidence_consumer.py" SAMPLE = REPO_ROOT / "docs" / "evidence-samples" / "agent-guard-report.json" +AUDIT_EVENT_PROFILE = "agent-policy.audit_event.v1.1" def run_consumer(path: Path) -> subprocess.CompletedProcess[str]: @@ -59,6 +63,114 @@ def run_packaged_consumer_cli(*args: str) -> subprocess.CompletedProcess[str]: ) +def test_packaged_consumer_rejects_unbound_legacy_audit_event_reference() -> None: + payload = json.loads(SAMPLE.read_text(encoding="utf-8")) + payload["evidence_pack_manifest"]["artifacts"].append( + { + "path": "reviewed/policy-admission-event.json", + "role": "agent-policy-audit-event", + } + ) + + with pytest.raises(ValueError, match="content_binding is required"): + validate_report(payload, load_report_schema()) + + +def test_packaged_consumer_rejects_extra_audit_event_artifact_fields_without_leak( + tmp_path: Path, +) -> None: + marker = "synthetic-private-passphrase" + event = tmp_path / "event.json" + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + binding = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + payload = json.loads(SAMPLE.read_text(encoding="utf-8")) + payload["evidence_pack_manifest"]["artifacts"].append( + { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": binding, + "event_body": {"passphrase": marker}, + } + ) + + with pytest.raises(ValueError, match="invalid fields") as exc_info: + validate_report(payload, load_report_schema()) + + assert marker not in str(exc_info.value) + + +@pytest.mark.skipif(os.name != "posix", reason="exercises POSIX final-component no-follow") +def test_packaged_consumer_rejects_final_audit_event_symlink_without_leak( + tmp_path: Path, +) -> None: + event = tmp_path / "event.json" + external = tmp_path / "external.json" + external_marker = "synthetic-consumer-external-marker" + event.write_text('{"status":"reviewed"}\n', encoding="utf-8") + binding = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + report = json.loads(SAMPLE.read_text(encoding="utf-8")) + report["evidence_pack_manifest"]["artifacts"].append( + { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": binding, + } + ) + external.write_text(json.dumps({"marker": external_marker}), encoding="utf-8") + event.unlink() + event.symlink_to(external) + + with pytest.raises( + ValueError, + match=r"^agent-policy audit event binding is invalid$", + ) as exc_info: + validate_agent_policy_audit_event_files( + report, + (event,), + event_profile=AUDIT_EVENT_PROFILE, + ) + + assert external_marker not in str(exc_info.value) + assert str(external) not in str(exc_info.value) + + +def test_packaged_consumer_rejects_large_number_audit_event_substitution( + tmp_path: Path, +) -> None: + event = tmp_path / "event.json" + event.write_text('{"sequence":9007199254740992.0}\n', encoding="utf-8") + binding = build_agent_policy_audit_event_binding( + event, + event_profile=AUDIT_EVENT_PROFILE, + ) + report = json.loads(SAMPLE.read_text(encoding="utf-8")) + report["evidence_pack_manifest"]["artifacts"].append( + { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": binding, + } + ) + + event.write_text('{"sequence":9007199254740993.0}\n', encoding="utf-8") + + with pytest.raises( + ValueError, + match=r"^agent-policy audit event binding is invalid$", + ): + validate_agent_policy_audit_event_files( + report, + (event,), + event_profile=AUDIT_EVENT_PROFILE, + ) + + def _prepend_duplicate_json_member(text: str, *, key: str, value: object) -> str: assert text.startswith("{") member = f"{json.dumps(key, ensure_ascii=False)}:{json.dumps(value, ensure_ascii=False)}" diff --git a/tests/test_evidence_contract_examples.py b/tests/test_evidence_contract_examples.py index aaad87f..949fde5 100644 --- a/tests/test_evidence_contract_examples.py +++ b/tests/test_evidence_contract_examples.py @@ -23,6 +23,7 @@ SCRIPT = REPO_ROOT / "examples" / "evidence_contracts_ci.sh" SAMPLE = REPO_ROOT / "docs" / "evidence-samples" / "agent-guard-report.json" ACTION_METADATA = REPO_ROOT / "action.yml" +AUDIT_EVENT_PROFILE = "agent-policy.audit_event.v1.1" PUBLIC_ARTIFACT_NAMES = ( "agent-guard-report.json", @@ -371,7 +372,12 @@ def generate_recommended_report( ] if agent_policy_audit_event: command.extend( - ["--agent-policy-audit-event", agent_policy_audit_event] + [ + "--agent-policy-audit-event", + agent_policy_audit_event, + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, + ] ) result = subprocess.run( command, @@ -414,6 +420,8 @@ def test_reviewed_audit_event_handoff_produces_consistent_public_bundle( str(report.relative_to(repo)), "--agent-policy-audit-event", event_path, + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, "--json", ], cwd=repo, @@ -430,6 +438,22 @@ def test_reviewed_audit_event_handoff_produces_consistent_public_bundle( encoding="utf-8", ) + missing_event_result = subprocess.run( + [ + sys.executable, + "-I", + "-m", + "agent_guard.consumer", + "--evidence-dir", + str(evidence_dir), + str(report), + ], + cwd=repo, + env=example_env(), + capture_output=True, + text=True, + check=False, + ) consumer_result = subprocess.run( [ sys.executable, @@ -438,6 +462,10 @@ def test_reviewed_audit_event_handoff_produces_consistent_public_bundle( "agent_guard.consumer", "--evidence-dir", str(evidence_dir), + "--agent-policy-audit-event", + event_path, + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, str(report), ], cwd=repo, @@ -447,11 +475,68 @@ def test_reviewed_audit_event_handoff_produces_consistent_public_bundle( check=False, ) + assert missing_event_result.returncode == 1 + assert missing_event_result.stdout == "" + assert missing_event_result.stderr.strip() == "agent-guard evidence bundle invalid" assert consumer_result.returncode == 0, ( consumer_result.stdout + consumer_result.stderr ) manifest = json.loads(manifest_result.stdout)["evidence_pack_manifest"] - assert {"path": event_path, "role": "agent-policy-audit-event"} in manifest["artifacts"] + event_artifact = next( + item for item in manifest["artifacts"] if item["role"] == "agent-policy-audit-event" + ) + assert event_artifact["path"] == event_path + assert event_artifact["content_binding"]["event_profile"] == AUDIT_EVENT_PROFILE + + wrong_profile_result = subprocess.run( + [ + sys.executable, + "-I", + "-m", + "agent_guard.consumer", + "--evidence-dir", + str(evidence_dir), + "--agent-policy-audit-event", + event_path, + "--agent-policy-audit-event-profile", + "agent-policy.audit_event.v1", + str(report), + ], + cwd=repo, + env=example_env(), + capture_output=True, + text=True, + check=False, + ) + assert wrong_profile_result.returncode == 1 + assert wrong_profile_result.stdout == "" + assert wrong_profile_result.stderr.strip() == "agent-guard evidence bundle invalid" + + write(repo / event_path, '{"status":"replaced"}\n') + replaced_result = subprocess.run( + [ + sys.executable, + "-I", + "-m", + "agent_guard.consumer", + "--evidence-dir", + str(evidence_dir), + "--agent-policy-audit-event", + event_path, + "--agent-policy-audit-event-profile", + AUDIT_EVENT_PROFILE, + str(report), + ], + cwd=repo, + env=example_env(), + capture_output=True, + text=True, + check=False, + ) + assert replaced_result.returncode == 1 + assert replaced_result.stdout == "" + assert replaced_result.stderr.strip() == "agent-guard evidence bundle invalid" + assert "replaced" not in replaced_result.stderr def test_packaged_consumer_module_entrypoint_accepts_public_sample() -> None: diff --git a/tests/test_schemas.py b/tests/test_schemas.py index bc89ac2..cde970d 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -13,7 +13,7 @@ from pathlib import Path import pytest - +from jsonschema import Draft202012Validator REPO_ROOT = Path(__file__).resolve().parents[1] SRC = REPO_ROOT / "src" @@ -190,6 +190,38 @@ def test_report_schema_allows_conformance_and_evidence_pack_manifest() -> None: assert artifact_role["enum"] == ["report", "agent-policy-audit-event"] +def test_evidence_schemas_reject_extra_audit_fields_but_allow_report_metadata() -> None: + binding = { + "schema_version": "agent-guard.agent_policy_audit_event_binding.v1", + "event_profile": "agent-policy.audit_event.v1.1", + "canonicalization": "canonical-json-v1", + "digest_algorithm": "sha256", + "digest_encoding": "base32-lower-no-padding", + "digest": "b" + ("a" * 52), + } + for schema_name in ( + "agent-guard.evidence_pack_manifest.v1.schema.json", + "agent-guard.report_evidence.v1.schema.json", + ): + report = json.loads(EVIDENCE_SAMPLE_REPORT.read_text(encoding="utf-8")) + value = report["evidence_pack_manifest"] if "manifest" in schema_name else report + manifest = value if "manifest" in schema_name else value["evidence_pack_manifest"] + validator = Draft202012Validator(load_schema(schema_name)) + + manifest["artifacts"][0]["review_metadata"] = "synthetic-public-metadata" + assert validator.is_valid(value) + + manifest["artifacts"].append( + { + "path": "reviewed/event.json", + "role": "agent-policy-audit-event", + "content_binding": binding, + "event_body": {"passphrase": "synthetic-private-passphrase"}, + } + ) + assert not validator.is_valid(value) + + def test_surface_delta_schema_requires_details_only_when_base_resolves() -> None: unresolved = { "schema_version": "agent-guard.surface_delta.v1", diff --git a/tests/test_windows_file_boundaries.py b/tests/test_windows_file_boundaries.py index fb4da47..04700bd 100644 --- a/tests/test_windows_file_boundaries.py +++ b/tests/test_windows_file_boundaries.py @@ -8,10 +8,12 @@ import pytest -from agent_guard import api_guard, content_guard, workflow_guard +from agent_guard import api_guard, content_guard, evidence_pack, workflow_guard +from agent_guard.consumer import validate_agent_policy_audit_event_files pytestmark = pytest.mark.skipif(os.name != "nt", reason="requires native Windows handles") +AUDIT_EVENT_PROFILE = "agent-policy.audit_event.v1.1" def test_windows_repo_bound_readers_accept_in_root_regular_files(tmp_path: Path) -> None: @@ -19,10 +21,12 @@ def test_windows_repo_bound_readers_accept_in_root_regular_files(tmp_path: Path) api_path = repo / "src" / "api.py" content_path = repo / "docs" / "note.md" workflow_path = repo / ".github" / "workflows" / "ci.yml" + audit_event_path = repo / "reviewed" / "policy-admission-event.json" for path, text in ( (api_path, "def handler():\n return 'ok'\n"), (content_path, "Reviewed documentation.\n"), (workflow_path, "name: ci\njobs: {}\n"), + (audit_event_path, '{"status":"reviewed"}\n'), ): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(text.encode("utf-8")) @@ -36,6 +40,16 @@ def test_windows_repo_bound_readers_accept_in_root_regular_files(tmp_path: Path) repo, max_bytes=1024, ) == b"name: ci\njobs: {}\n" + artifacts = evidence_pack.build_agent_policy_audit_event_artifacts( + ["reviewed/policy-admission-event.json"], + event_profile=AUDIT_EVENT_PROFILE, + root=repo, + ) + validate_agent_policy_audit_event_files( + {"evidence_pack_manifest": {"artifacts": artifacts}}, + (audit_event_path,), + event_profile=AUDIT_EVENT_PROFILE, + ) def test_windows_repo_bound_readers_reject_outside_junction(tmp_path: Path) -> None: @@ -63,6 +77,15 @@ def test_windows_repo_bound_readers_reject_outside_junction(tmp_path: Path) -> N content_guard._read_scan_text(linked, repo) with pytest.raises(ValueError, match="^workflow scan target must stay under repo root$"): workflow_guard._read_repo_bound_bytes(linked, repo, max_bytes=1024) + with pytest.raises( + ValueError, + match="^agent-policy audit event must be a repository file$", + ): + evidence_pack.build_agent_policy_audit_event_binding( + linked, + event_profile=AUDIT_EVENT_PROFILE, + repo_root=repo, + ) # The final-handle check also rejects an already-external path passed # directly to the opener, independently of caller-side resolution. diff --git a/tests/test_workflow_hardening.py b/tests/test_workflow_hardening.py index a48f8b5..57d96af 100644 --- a/tests/test_workflow_hardening.py +++ b/tests/test_workflow_hardening.py @@ -318,9 +318,12 @@ def test_github_release_uses_least_privilege_prepare_and_publish_jobs(tmp_path: def run_tag_step(output_name: str, **overrides: str) -> subprocess.CompletedProcess[str]: output_path = tmp_path / output_name + isolated_home = tmp_path / "home" + isolated_home.mkdir(exist_ok=True) environment = { "GITHUB_EVENT_NAME": "workflow_run", "GITHUB_OUTPUT": str(output_path), + "HOME": str(isolated_home), "INPUT_TAG": "", "PATH": os.environ["PATH"], "UPSTREAM_EVENT": "push",