diff --git a/CHANGELOG.md b/CHANGELOG.md index da2c0ae..cee6c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Fixed + +- Python schema and conformance privacy validation now walks `tuple` containers in + `_prohibited_paths`, `prohibited_paths`, and `_json_shape_error`, and rejects + duplicate JSON object keys when loading JSON files. (#101) +- TypeScript `extractContext` now rejects array-valued and non-string AgenTrust + carrier headers with `PropagationError`, and `ExtractedContext.eventFields` + checks `!== undefined` rather than truthiness, matching Python + `extract_context`. (#101) +- TRACE finalization in both SDKs (`trace_adapter.py` and `trace-finalizer.ts`) + now validates `build_digest` / `buildDigest` and policy `bundle_digest` + (`algorithm` and `value`) against canonical lowercase `sha256` / `sha384` hex + digest syntax before signing. (#101) + ### Changed - CI checks the existing four job-level OIDC grants across all workflow files, diff --git a/conformance/runner/validate.py b/conformance/runner/validate.py index 7490dc1..8380f7d 100644 --- a/conformance/runner/validate.py +++ b/conformance/runner/validate.py @@ -53,6 +53,19 @@ def schema_name(event_type: str) -> str: raise ValueError(f"unsupported event_type: {event_type!r}") +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON object key: {key!r}") + result[key] = value + return result + + +def _loads_json(text: str) -> Any: + return json.loads(text, object_pairs_hook=_reject_duplicate_keys) + + def prohibited_paths(value: Any, prefix: str = "$") -> list[str]: found: list[str] = [] if isinstance(value, dict): @@ -61,7 +74,7 @@ def prohibited_paths(value: Any, prefix: str = "$") -> list[str]: if key.lower() in PROHIBITED_METADATA_KEYS: found.append(path) found.extend(prohibited_paths(child, path)) - elif isinstance(value, list): + elif isinstance(value, (list, tuple)): for index, child in enumerate(value): found.extend(prohibited_paths(child, f"{prefix}[{index}]")) return found @@ -70,7 +83,7 @@ def prohibited_paths(value: Any, prefix: str = "$") -> list[str]: def schema_registry() -> Registry: registry = Registry() for path in SCHEMA_DIR.glob("*.schema.json"): - contents = json.loads(path.read_text(encoding="utf-8")) + contents = _loads_json(path.read_text(encoding="utf-8")) resource = Resource.from_contents(contents) registry = registry.with_resource(contents["$id"], resource) registry = registry.with_resource(path.as_uri(), resource) @@ -79,7 +92,7 @@ def schema_registry() -> Registry: def load_validator(name: str) -> Draft202012Validator: schema_path = SCHEMA_DIR / name - schema = json.loads(schema_path.read_text(encoding="utf-8")) + schema = _loads_json(schema_path.read_text(encoding="utf-8")) return Draft202012Validator( schema, registry=schema_registry(), format_checker=FormatChecker() ) @@ -101,8 +114,8 @@ def validate_record(record: dict[str, Any]) -> list[str]: def validate_fixture(path: Path) -> list[str]: try: - record = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + record = _loads_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: return [f"invalid JSON: {exc}"] if not isinstance(record, dict): return ["top-level value must be an object"] @@ -131,8 +144,8 @@ def validate_appraisal_case(path: Path) -> list[str]: finalizes the case; this runner checks only what it can without an SDK. """ try: - case = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + case = _loads_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: return [f"invalid JSON: {exc}"] if not isinstance(case, dict) or not isinstance(case.get("events"), list): return ["case must be an object with an events array"] diff --git a/packages/typescript/src/propagation.ts b/packages/typescript/src/propagation.ts index 774f41a..27a6e4c 100644 --- a/packages/typescript/src/propagation.ts +++ b/packages/typescript/src/propagation.ts @@ -8,7 +8,7 @@ export class PropagationError extends Error {} export class ExtractedContext { constructor(readonly otelContext: Context, readonly runId?: string, readonly workflowId?: string, readonly upstreamAgentId?: string) {} - eventFields(agentId: string): Record {return {agent_id: safe("agent_id", agentId), ...(this.runId ? {run_id: this.runId} : {}), ...(this.workflowId ? {workflow_id: this.workflowId} : {}), ...(this.upstreamAgentId ? {parent_agent_id: this.upstreamAgentId} : {})};} + eventFields(agentId: string): Record {return {agent_id: safe("agent_id", agentId), ...(this.runId !== undefined ? {run_id: this.runId} : {}), ...(this.workflowId !== undefined ? {workflow_id: this.workflowId} : {}), ...(this.upstreamAgentId !== undefined ? {parent_agent_id: this.upstreamAgentId} : {})};} link(attributes?: Record): Link {const spanContext = trace.getSpanContext(this.otelContext); if (!spanContext || !isSpanContextValid(spanContext)) throw new PropagationError("extracted context has no valid remote span to link"); return {context: spanContext, ...(attributes ? {attributes} : {})};} } export function injectContext(carrier: Record, fields: {runId: string; agentId: string; workflowId?: string; otelContext?: Context}): void { @@ -19,6 +19,6 @@ export function injectContext(carrier: Record, fields: {runId: s export function extractContext(carrier: Record): ExtractedContext { return new ExtractedContext(propagation.extract(context.active(), carrier, {keys: (c) => Object.keys(c), get: (c, key) => header(c, key)}), value(carrier, RUN_ID_HEADER), value(carrier, WORKFLOW_ID_HEADER), value(carrier, UPSTREAM_AGENT_ID_HEADER)); } -function value(carrier: Record, name: string): string | undefined {const found = header(carrier, name); return found === undefined ? undefined : safe(name, Array.isArray(found) ? found[0]! : found);} +function value(carrier: Record, name: string): string | undefined {const found = header(carrier, name); return found === undefined ? undefined : safe(name, found);} function header(carrier: Record, name: string): string | string[] | undefined {const key = Object.keys(carrier).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); return key ? carrier[key] : undefined;} -function safe(name: string, value: string): string {if (typeof value !== "string" || !value) throw new PropagationError(`${name} must be a non-empty string`); if (value.length > MAX) throw new PropagationError(`${name} exceeds ${MAX} characters`); if (/\r|\n/.test(value)) throw new PropagationError(`${name} contains a prohibited line break`); return value;} +function safe(name: string, value: unknown): string {if (typeof value !== "string" || !value) throw new PropagationError(`${name} must be a non-empty string`); if (value.length > MAX) throw new PropagationError(`${name} exceeds ${MAX} characters`); if (/\r|\n/.test(value)) throw new PropagationError(`${name} contains a prohibited line break`); return value;} diff --git a/packages/typescript/src/trace-finalizer.ts b/packages/typescript/src/trace-finalizer.ts index 1780107..3b9feb5 100644 --- a/packages/typescript/src/trace-finalizer.ts +++ b/packages/typescript/src/trace-finalizer.ts @@ -14,6 +14,7 @@ export interface TraceCodec = Record>(snapshot: EvidenceSnapshot, config: TraceConfiguration, options: {signingKey: unknown; codec: TraceCodec}): Signed { requireFinalizable(snapshot, config, options); @@ -63,9 +64,10 @@ function requireFinalizable(snapshot: EvidenceSnapshot, config: TraceConfigurati if (!options.codec || !options.codec.profileV02 || ![options.codec.signRecord, options.codec.validateRecord, options.codec.publicKey, options.codec.verifyRecord].every((value) => typeof value === "function")) throw new TraceFinalizationError("a caller-supplied official TRACE codec is required"); const required = {subject: config.subject, modelProvider: config.modelProvider, modelId: config.modelId, buildDigest: config.buildDigest, originProducer: config.originProducer, appraisalVerifier: config.appraisalVerifier}; const missing = Object.entries(required).filter(([, value]) => !value).map(([name]) => name).sort(); if (missing.length) throw new TraceFinalizationError(`trusted TRACE configuration is missing: ${missing.join(", ")}`); + if (typeof config.buildDigest !== "string" || !TRACE_DIGEST_RE.test(config.buildDigest)) throw new TraceFinalizationError("buildDigest must match ^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$"); if (new Set(config.classificationOrder).size !== config.classificationOrder.length) throw new TraceFinalizationError("classificationOrder contains duplicates"); } -function policyBinding(events: readonly NormalizedEvent[]): [string, string] { const decisions = events.filter((event) => event.event_type === "policy.decision"); if (!decisions.length) throw new TraceFinalizationError("no policy decision evidence is present"); const bindings = decisions.map((event) => (event.policy as Record).bundle_digest as Record | undefined); if (bindings.some((value) => !value)) throw new TraceFinalizationError("every policy decision must carry bundle_digest"); const algorithms = new Set(bindings.map((value) => String(value!.algorithm))); const unsupported = [...algorithms].filter((value) => !new Set(["sha256", "sha384"]).has(value)).sort(); if (unsupported.length) throw new TraceFinalizationError(`TRACE does not support observed policy digest algorithms: ${unsupported.join(", ")}`); const bundles = new Set(bindings.map((value) => `${String(value!.algorithm)}:${String(value!.value)}`)); if (bundles.size !== 1) throw new TraceFinalizationError("conflicting policy bundle digests are present"); const modes = new Set(decisions.map((event) => String(event.enforcement_mode))); if (modes.size !== 1) throw new TraceFinalizationError("conflicting policy enforcement modes are present"); return [[...bundles][0]!, new Map([["enforce", "enforce"], ["monitor", "advisory"], ["disabled", "declared"]]).get([...modes][0]!)!]; } +function policyBinding(events: readonly NormalizedEvent[]): [string, string] { const decisions = events.filter((event) => event.event_type === "policy.decision"); if (!decisions.length) throw new TraceFinalizationError("no policy decision evidence is present"); const bindings = decisions.map((event) => (event.policy as Record).bundle_digest); if (bindings.some((value) => typeof value !== "object" || value === null || Array.isArray(value))) throw new TraceFinalizationError("every policy decision must carry bundle_digest"); const digestRecords = bindings as Record[]; if (digestRecords.some((value) => typeof value.algorithm !== "string" || typeof value.value !== "string")) throw new TraceFinalizationError("policy bundle_digest requires string algorithm and value"); const algorithms = new Set(digestRecords.map((value) => value.algorithm as string)); const unsupported = [...algorithms].filter((value) => !new Set(["sha256", "sha384"]).has(value)).sort(); if (unsupported.length) throw new TraceFinalizationError(`TRACE does not support observed policy digest algorithms: ${unsupported.join(", ")}`); const bundles = new Set(digestRecords.map((value) => `${value.algorithm as string}:${value.value as string}`)); if ([...bundles].some((bundle) => !TRACE_DIGEST_RE.test(bundle))) throw new TraceFinalizationError("policy bundle_digest must match ^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$"); if (bundles.size !== 1) throw new TraceFinalizationError("conflicting policy bundle digests are present"); const modes = new Set(decisions.map((event) => String(event.enforcement_mode))); if (modes.size !== 1) throw new TraceFinalizationError("conflicting policy enforcement modes are present"); return [[...bundles][0]!, new Map([["enforce", "enforce"], ["monitor", "advisory"], ["disabled", "declared"]]).get([...modes][0]!)!]; } function highestDataClass(events: readonly NormalizedEvent[], config: TraceConfiguration): string { const flows = events.filter((event) => event.event_type === "data_flow.observed"); if (!flows.length) throw new TraceFinalizationError("no classified data-flow evidence is present"); const classifications = flows.map((event) => event.classification as Record); if (classifications.some((value) => value.taxonomy !== config.classificationTaxonomy)) throw new TraceFinalizationError("data-flow taxonomy conflicts with TRACE configuration"); const rank = new Map(config.classificationOrder.map((value, index) => [value, index])); const values = classifications.map((value) => String(value.value)); const unknown = [...new Set(values.filter((value) => !rank.has(value)))].sort(); if (unknown.length) throw new TraceFinalizationError(`unranked data classifications are present: ${unknown.join(", ")}`); return values.reduce((highest, value) => rank.get(value)! > rank.get(highest)! ? value : highest); } function appraisal(events: readonly NormalizedEvent[]): string { const decisions = new Map(events.filter((event) => event.event_type === "policy.decision").map((event) => [event.event_id, String(event.decision)])); diff --git a/packages/typescript/test/propagation.test.ts b/packages/typescript/test/propagation.test.ts index ed921ea..a90daef 100644 --- a/packages/typescript/test/propagation.test.ts +++ b/packages/typescript/test/propagation.test.ts @@ -20,3 +20,34 @@ test("headers are case-insensitive and reject line breaks", () => { assert.equal(extractContext({"X-AgenTrust-Run-Id": "run-1"}).runId, "run-1"); assert.throws(() => injectContext({}, {runId: "run\r\nattack", agentId: "agent-a"}), PropagationError); }); + +test("extractContext rejects array-valued and non-string AgenTrust carrier headers and eventFields checks !== undefined", () => { + assert.throws( + () => extractContext({"x-agentrust-run-id": ["attacker-run-id", "trusted-run-id"] as unknown as string}), + PropagationError, + ); + assert.throws( + () => extractContext({"x-agentrust-workflow-id": ["wf-1", "wf-2"] as unknown as string}), + PropagationError, + ); + assert.throws( + () => extractContext({"x-agentrust-agent-id": ["agent-1"] as unknown as string}), + PropagationError, + ); + assert.throws( + () => extractContext({"x-agentrust-run-id": 123 as unknown as string}), + PropagationError, + ); + const direct = new (extractContext({}).constructor as typeof import("../src/index.js").ExtractedContext)( + context.active(), + "", + "", + "", + ); + assert.deepEqual(direct.eventFields("agent-b"), { + agent_id: "agent-b", + run_id: "", + workflow_id: "", + parent_agent_id: "", + }); +}); diff --git a/packages/typescript/test/trace-finalizer.test.ts b/packages/typescript/test/trace-finalizer.test.ts index 8349f6b..c21dd42 100644 --- a/packages/typescript/test/trace-finalizer.test.ts +++ b/packages/typescript/test/trace-finalizer.test.ts @@ -3,6 +3,7 @@ import {readdirSync, readFileSync} from "node:fs"; import test from "node:test"; import {fileURLToPath} from "node:url"; import {EvidenceAccumulator, finalizeTrace, SchemaValidator, TraceFinalizationError} from "../src/index.js"; +import {entryDigest} from "../src/evidence-digest.js"; import type {NormalizedEvent, TraceCodec, TraceConfiguration} from "../src/index.js"; const root = fileURLToPath(new URL("../../../", import.meta.url)); @@ -67,6 +68,37 @@ test("finalization refuses incomplete evidence, conflicts, unranked data, and mi assert.throws(() => finalizeTrace(snapshot(), {...config, classificationOrder: ["public", "internal"]}, {signingKey: "private", codec: new RecordingCodec()}), /unranked/); const conflicting = fixture("policy-decision.json"); conflicting.event_id = "018f0f7d-7a13-7cc2-8000-000000000099"; ((conflicting.policy as Record).bundle_digest as Record).value = "f".repeat(64); assert.throws(() => finalizeTrace(snapshot([conflicting]), config, {signingKey: "private", codec: new RecordingCodec()}), /conflicting policy bundle/); + for (const badDigest of ["not-a-digest", "sha256:short", `sha256:${"B".repeat(64)}`, `sha512:${"b".repeat(128)}`, `sha256:${"b".repeat(96)}`, `sha384:${"b".repeat(64)}`, `sha256:${"a".repeat(64)}\n`, `sha256:${"b".repeat(64)}\n`]) { + assert.throws(() => finalizeTrace(snapshot(), {...config, buildDigest: badDigest}, {signingKey: "private", codec: new RecordingCodec()}), /buildDigest must match/); + } + const sha384Record = finalizeTrace(snapshot(), {...config, buildDigest: `sha384:${"b".repeat(96)}`}, {signingKey: "private", codec: new RecordingCodec()}); + assert.equal((sha384Record.build_provenance as Record).digest, `sha384:${"b".repeat(96)}`); +}); +test("finalizer refuses non-string or malformed policy bundle_digest fields", () => { + for (const [badBundleDigest, pattern] of [ + [{algorithm: undefined, value: "a".repeat(64)}, /string algorithm and value/], + [{algorithm: "sha256", value: 123}, /string algorithm and value/], + [{algorithm: 256, value: "a".repeat(64)}, /string algorithm and value/], + [{algorithm: "sha256", value: "A".repeat(64)}, /policy bundle_digest must match/], + [{algorithm: "sha256", value: "a".repeat(96)}, /policy bundle_digest must match/], + [{algorithm: "sha384", value: "a".repeat(64)}, /policy bundle_digest must match/], + [{algorithm: "sha256", value: `${"a".repeat(64)}\n`}, /policy bundle_digest must match/], + ] as const) { + const decision = fixture("policy-decision.json"); + (decision.policy as Record).bundle_digest = badBundleDigest; + const flow = fixture("data-flow.json"); + const d0 = entryDigest(0, undefined, decision); + const d1 = entryDigest(1, d0, flow); + const crafted = { + ...snapshot(), + entries: [ + {sequence: 0, eventId: decision.event_id, previousDigest: undefined, digest: d0, event: decision}, + {sequence: 1, eventId: flow.event_id, previousDigest: d0, digest: d1, event: flow}, + ], + chainDigest: d1, + }; + assert.throws(() => finalizeTrace(crafted, config, {signingKey: "private", codec: new RecordingCodec()}), pattern); + } }); test("codec validation or self-verification failure is fail-closed", () => { const codec = new RecordingCodec(); codec.failVerify = true; diff --git a/src/agentrust_telemetry/trace_adapter.py b/src/agentrust_telemetry/trace_adapter.py index 56932a9..a8f8a92 100644 --- a/src/agentrust_telemetry/trace_adapter.py +++ b/src/agentrust_telemetry/trace_adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import re from dataclasses import dataclass from typing import Any, Literal @@ -13,6 +14,7 @@ OriginKind = Literal["self", "third-party-control-plane", "log-import"] +_TRACE_DIGEST_RE = re.compile(r"^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})\Z") @dataclass(frozen=True) @@ -134,6 +136,13 @@ def _require_finalizable( missing = sorted(name for name, value in required.items() if not value) if missing: raise TraceFinalizationError(f"trusted TRACE configuration is missing: {missing}") + if ( + not isinstance(config.build_digest, str) + or _TRACE_DIGEST_RE.match(config.build_digest) is None + ): + raise TraceFinalizationError( + "build_digest must match ^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$" + ) if len(set(config.classification_order)) != len(config.classification_order): raise TraceFinalizationError("classification_order contains duplicates") @@ -180,8 +189,19 @@ def _policy_binding(events: list[dict[str, Any]]) -> tuple[str, str]: decisions = [event for event in events if event["event_type"] == "policy.decision"] if not decisions: raise TraceFinalizationError("no policy decision evidence is present") - if any("bundle_digest" not in event["policy"] for event in decisions): + if any( + not isinstance(event["policy"].get("bundle_digest"), dict) + for event in decisions + ): raise TraceFinalizationError("every policy decision must carry bundle_digest") + for event in decisions: + digest = event["policy"]["bundle_digest"] + if not isinstance(digest.get("algorithm"), str) or not isinstance( + digest.get("value"), str + ): + raise TraceFinalizationError( + "policy bundle_digest requires string algorithm and value" + ) algorithms = { event["policy"]["bundle_digest"]["algorithm"] for event in decisions } @@ -194,6 +214,10 @@ def _policy_binding(events: list[dict[str, Any]]) -> tuple[str, str]: f"{event['policy']['bundle_digest']['algorithm']}:{event['policy']['bundle_digest']['value']}" for event in decisions } + if any(_TRACE_DIGEST_RE.match(bundle) is None for bundle in bundles): + raise TraceFinalizationError( + "policy bundle_digest must match ^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$" + ) if len(bundles) != 1: raise TraceFinalizationError("conflicting policy bundle digests are present") modes = {event["enforcement_mode"] for event in decisions} diff --git a/src/agentrust_telemetry/validation.py b/src/agentrust_telemetry/validation.py index 3346a49..5305a10 100644 --- a/src/agentrust_telemetry/validation.py +++ b/src/agentrust_telemetry/validation.py @@ -42,6 +42,15 @@ def _schema_name(event_type: str) -> str: _MAX_DEPTH = 32 +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise EventValidationError(f"duplicate JSON object key: {key!r}") + result[key] = value + return result + + def _json_shape_error(value: Any) -> str | None: """Return why ``value`` is not a JSON value this validator can walk, if so.""" stack: list[tuple[Any, str, int]] = [(value, "$", 0)] @@ -54,7 +63,7 @@ def _json_shape_error(value: Any) -> str | None: if not isinstance(key, str): return f"{path} has a non-string key of type {type(key).__name__}" stack.append((child, f"{path}.{key}", depth + 1)) - elif isinstance(item, list): + elif isinstance(item, (list, tuple)): for index, child in enumerate(item): stack.append((child, f"{path}[{index}]", depth + 1)) elif isinstance(item, float) and not math.isfinite(item): @@ -70,7 +79,7 @@ def _prohibited_paths(value: Any, prefix: str = "$") -> list[str]: if key.lower() in PROHIBITED_KEYS: found.append(path) found.extend(_prohibited_paths(child, path)) - elif isinstance(value, list): + elif isinstance(value, (list, tuple)): for index, child in enumerate(value): found.extend(_prohibited_paths(child, f"{prefix}[{index}]")) return found @@ -94,7 +103,10 @@ def __init__( schemas: dict[str, dict[str, Any]] = {} registry = Registry() for path in self.schema_directory.glob("*.schema.json"): - contents = json.loads(path.read_text(encoding="utf-8")) + contents = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_reject_duplicate_keys, + ) Draft202012Validator.check_schema(contents) schemas[path.name] = contents resource = Resource.from_contents(contents) diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 4ffb6ea..5128a06 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -56,6 +56,30 @@ def test_action_payload_field_is_rejected_by_schema_and_privacy_gate(self): self.assertTrue(any("unevaluated" in error.lower() for error in errors)) self.assertTrue(any("tool_arguments" in error and "privacy" in error for error in errors)) + def test_tuple_prohibited_key_is_rejected_by_privacy_gate(self): + record = json.loads( + (ROOT / "conformance" / "fixtures" / "valid" / "action.json").read_text() + ) + record["extra"] = ({"prompt": "secret"},) + errors = validate.validate_record(record) + self.assertTrue( + any("$.extra[0].prompt" in error and "privacy" in error for error in errors) + ) + + def test_duplicate_json_keys_are_rejected_in_fixtures_and_appraisal_cases(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + duplicate_path = Path(tmp) / "duplicate.json" + duplicate_path.write_text('{"a": 1, "a": 2}', encoding="utf-8") + fixture_errors = validate.validate_fixture(duplicate_path) + self.assertEqual(len(fixture_errors), 1) + self.assertIn("duplicate JSON object key", fixture_errors[0]) + + appraisal_errors = validate.validate_appraisal_case(duplicate_path) + self.assertEqual(len(appraisal_errors), 1) + self.assertIn("duplicate JSON object key", appraisal_errors[0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sdk.py b/tests/test_sdk.py index a5c5e84..48248b9 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -101,6 +101,42 @@ def test_privacy_violation_rejects_before_projection(self): TelemetryClient(self.validator, span_resolver=lambda: span).emit(event) self.assertEqual(span.events, []) + def test_tuple_privacy_violation_is_rejected(self): + event = fixture("usage.json") + event["extra"] = ({"prompt": "sensitive", "authorization": "Bearer secret"},) + with self.assertRaises(EventValidationError) as caught: + self.validator.validate(event) + message = str(caught.exception) + self.assertIn("$.extra[0].prompt is prohibited by metadata_only", message) + self.assertIn("$.extra[0].authorization is prohibited by metadata_only", message) + + def test_tuple_json_shape_errors_are_rejected(self): + event_bad_key = fixture("usage.json") + event_bad_key["extra"] = ({1: "value"},) + with self.assertRaisesRegex(EventValidationError, "non-string key"): + self.validator.validate(event_bad_key) + + event_nan = fixture("usage.json") + event_nan["extra"] = (float("nan"),) + with self.assertRaisesRegex(EventValidationError, "finite"): + self.validator.validate(event_nan) + + def test_duplicate_json_keys_in_schema_are_rejected(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for schema_file in (ROOT / "spec" / "schema").glob("*.schema.json"): + (tmp_path / schema_file.name).write_bytes(schema_file.read_bytes()) + (tmp_path / "usage.schema.json").write_text( + '{"$schema": "https://json-schema.org/draft/2020-12/schema", ' + '"$id": "https://agentrust-io.com/telemetry/v0.1/schema/usage.schema.json", ' + '"type": "object", "type": "object"}', + encoding="utf-8", + ) + with self.assertRaisesRegex(EventValidationError, "duplicate JSON object key"): + SchemaValidator(tmp_path) + def test_extension_attributes_are_deny_by_default(self): event = fixture("usage.json") event["attributes"] = {"vendor.note": "could contain raw content"} diff --git a/tests/test_trace_adapter_refusals.py b/tests/test_trace_adapter_refusals.py index d572294..513249f 100644 --- a/tests/test_trace_adapter_refusals.py +++ b/tests/test_trace_adapter_refusals.py @@ -114,6 +114,69 @@ def test_a_repeated_classification_cannot_rank_anything(self): config=config, ) + def test_a_malformed_build_digest_is_refused(self): + for bad_digest in ( + "not-a-digest", + "sha256:short", + "sha256:" + "B" * 64, + "sha512:" + "b" * 128, + "sha256:" + "b" * 96, + "sha384:" + "b" * 64, + "sha256:" + "b" * 64 + "\n", + ): + with self.subTest(build_digest=bad_digest): + config = replace(self.config, build_digest=bad_digest) + self.assertRefuses( + "build_digest must match", + [fixture("policy-decision.json"), fixture("data-flow.json")], + config=config, + ) + valid_384 = replace(self.config, build_digest="sha384:" + "b" * 96) + record = self.finalize( + [fixture("policy-decision.json"), fixture("data-flow.json")], + config=valid_384, + ) + self.assertEqual(record["build_provenance"]["digest"], "sha384:" + "b" * 96) + + def test_a_malformed_policy_bundle_digest_is_refused(self): + from agentrust_telemetry.evidence import ( + CANONICALIZATION_PROFILE, + EvidenceEntry, + EvidenceSnapshot, + _entry_digest, + ) + + for bad_bundle_digest, expected_message in ( + ({"algorithm": None, "value": "a" * 64}, "string algorithm and value"), + ({"algorithm": "sha256", "value": 123}, "string algorithm and value"), + ({"algorithm": "sha256", "value": "A" * 64}, "policy bundle_digest must match"), + ({"algorithm": "sha256", "value": "a" * 96}, "policy bundle_digest must match"), + ({"algorithm": "sha384", "value": "a" * 64}, "policy bundle_digest must match"), + ({"algorithm": "sha256", "value": "a" * 64 + "\n"}, "policy bundle_digest must match"), + ): + with self.subTest(bundle_digest=bad_bundle_digest): + decision = copy.deepcopy(fixture("policy-decision.json")) + decision["policy"]["bundle_digest"] = bad_bundle_digest + entries = [] + previous = None + for sequence, event in enumerate([decision, fixture("data-flow.json")]): + digest = _entry_digest(sequence, previous, event) + entries.append( + EvidenceEntry(sequence, event["event_id"], previous, digest, event) + ) + previous = digest + snapshot = EvidenceSnapshot( + run_id="run-governed-sdlc-001", + entries=tuple(entries), + chain_digest=previous, + canonicalization_profile=CANONICALIZATION_PROFILE, + completeness="complete", + sealed=True, + ) + with self.assertRaises(TraceFinalizationError) as caught: + finalize_trace(snapshot, self.config, signing_key=self.key) + self.assertIn(expected_message, str(caught.exception)) + # -- the policy binding --------------------------------------------------- def test_a_run_with_no_policy_decision_has_no_policy_to_bind(self):