Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 20 additions & 7 deletions conformance/runner/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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()
)
Expand All @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down
6 changes: 3 additions & 3 deletions packages/typescript/src/propagation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {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<string, string> {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<string, string | number | boolean>): 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<string, string>, fields: {runId: string; agentId: string; workflowId?: string; otelContext?: Context}): void {
Expand All @@ -19,6 +19,6 @@ export function injectContext(carrier: Record<string, string>, fields: {runId: s
export function extractContext(carrier: Record<string, string>): 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<string, string>, 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<string, string>, name: string): string | undefined {const found = header(carrier, name); return found === undefined ? undefined : safe(name, found);}
function header(carrier: Record<string, string>, 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;}
4 changes: 3 additions & 1 deletion packages/typescript/src/trace-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface TraceCodec<Signed extends Record<string, unknown> = Record<stri
verifyRecord(record: Signed, verificationKey: unknown, options: {maxAgeSeconds: number | null}): void;
}
export class TraceFinalizationError extends Error {}
const TRACE_DIGEST_RE = /^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$/;

export function finalizeTrace<Signed extends Record<string, unknown>>(snapshot: EvidenceSnapshot, config: TraceConfiguration, options: {signingKey: unknown; codec: TraceCodec<Signed>}): Signed {
requireFinalizable(snapshot, config, options);
Expand Down Expand Up @@ -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<string, unknown>).bundle_digest as Record<string, unknown> | 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<string, unknown>).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<string, unknown>[]; 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<string, unknown>); 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)]));
Expand Down
31 changes: 31 additions & 0 deletions packages/typescript/test/propagation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
});
});
32 changes: 32 additions & 0 deletions packages/typescript/test/trace-finalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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<string, unknown>).bundle_digest as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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;
Expand Down
Loading