Skip to content
Merged
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
9 changes: 6 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ const receipt = executeAccountableIntent(intent, {
```

### `verifyExecutionReceipt(receipt)`
Recomputes receipt integrity and verifies bound runtime artifacts. A receipt
missing its audit stream or attention ledger, or carrying a non-array audit,
cannot verify even if its outer hash is recalculated.
Recomputes receipt integrity and verifies bound runtime artifacts. The audit
stream must contain five ordered events whose timestamps, types, and payloads
agree with the receipt's intent, profile, AML, simulations, selected decision,
attention ledger, and output hashes. `audit_receipt_binding_valid` reports this
semantic check. A missing, malformed, or contradictory stream cannot verify
even if its chain and outer receipt hash are recalculated.

### `signExecutionReceipt(receipt, privateKeyPem, options?)`
Adds an Ed25519 attestation to a valid receipt. Rejects private keys of other algorithm types.
Expand Down
32 changes: 30 additions & 2 deletions compiler/accountablePipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// ĀML v1.3 — end-to-end accountable AI intent execution pipeline.

import crypto from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { generateAMLFromIntent } from "./intentCompiler.js";
import { compileSource } from "./compiler.js";
import { simulatePolicies } from "./policySimulator.js";
Expand Down Expand Up @@ -50,6 +51,31 @@ function resolveInitialAttentionBudget(context) {
return null;
}

function verifyExecutionAuditBinding(receipt) {
const stream = receipt.runtime_audit_stream;
const entries = stream?.entries;
if (stream?.version !== "1.0" || stream.created_at !== receipt.timestamp ||
!Array.isArray(entries) || entries.length !== 5) return false;

const expected = [
["intent_received", { intent_sha256: receipt.intent_sha256, profile_id: receipt.profile?.id }],
["aml_generated", { aml_sha256: receipt.aml_sha256 }],
["policy_simulated", { simulation_sha256: receipt.simulation_sha256, policies: receipt.profile?.policies }],
["policy_selected", {
policy_id: receipt.selected_render?.policy_id,
decision_sha256: receipt.decision_sha256,
attention_ledger_remaining: receipt.attention_ledger?.remaining,
attention_ledger_consumed: receipt.attention_ledger?.consumed
}],
["output_rendered", { output_sha256: receipt.output_sha256 }]
];
return expected.every(([type, payload], index) =>
Object.values(payload).every(value => value !== undefined) &&
entries[index]?.timestamp === receipt.timestamp &&
entries[index]?.event_type === type &&
isDeepStrictEqual(entries[index]?.payload, payload));
}

export function executeAccountableIntent(intent, options = {}) {
const timestamp = options.timestamp ?? new Date().toISOString();
const profile = resolvePolicyProfile(options.profile || "calm_default");
Expand Down Expand Up @@ -153,7 +179,8 @@ export function verifyExecutionReceipt(receipt) {
}
}

const bindingsValid = intentHashValid && amlHashValid && simulationHashValid && decisionHashValid && outputHashValid && selectedRenderCountsValid && auditHashValid && audit.verified && runtimeAuditFlagValid && ledgerHashValid && ledgerStructureValid;
const auditReceiptBindingValid = audit.verified && verifyExecutionAuditBinding(receipt);
const bindingsValid = intentHashValid && amlHashValid && simulationHashValid && decisionHashValid && outputHashValid && selectedRenderCountsValid && auditHashValid && auditReceiptBindingValid && runtimeAuditFlagValid && ledgerHashValid && ledgerStructureValid;
return {
verified: receiptHashValid && bindingsValid,
receipt_hash_valid: receiptHashValid,
Expand All @@ -164,7 +191,8 @@ export function verifyExecutionReceipt(receipt) {
decision_binding_valid: decisionHashValid,
output_binding_valid: outputHashValid,
selected_render_counts_valid: selectedRenderCountsValid,
audit_stream_valid: audit.verified && auditHashValid && runtimeAuditFlagValid,
audit_stream_valid: audit.verified && auditHashValid && auditReceiptBindingValid && runtimeAuditFlagValid,
audit_receipt_binding_valid: auditReceiptBindingValid,
runtime_audit_flag_valid: runtimeAuditFlagValid,
attention_ledger_valid: ledgerHashValid && ledgerStructureValid,
attention_ledger_hash_valid: ledgerHashValid,
Expand Down
2 changes: 1 addition & 1 deletion docs/ACCOUNTABLE_AI_PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ That makes policy differences inspectable instead of silently embedding them in

## Receipt integrity

`receipt_sha256` covers the unsigned execution receipt. A mutation to intent, context, simulations, decisions, AML source, or output causes `verifyExecutionReceipt()` to fail. Verification also requires the runtime audit stream and attention ledger and checks their hashes and structure. Recalculating an outer receipt hash cannot make missing evidence or a non-array audit pass.
`receipt_sha256` covers the unsigned execution receipt. A mutation to intent, context, simulations, decisions, AML source, or output causes `verifyExecutionReceipt()` to fail. Verification requires the runtime audit stream and attention ledger and checks their hashes and structure. It also checks that the five ordered audit events match the receipt's timestamp, intent and profile, generated AML, simulations, selected policy and decisions, ledger balances, and output. `audit_receipt_binding_valid` identifies that check. Recalculating the audit chain, outer receipt hash, or signature cannot make contradictory or missing evidence pass verification.

The receipt can additionally be signed with Ed25519, providing a detached identity/attestation layer on top of content integrity. Signing rejects RSA and EC private keys, and verification requires an actual Ed25519 public key and one canonical 64-byte Ed25519 signature encoding. A trusted fingerprint still has to come from outside the receipt.

Expand Down
4 changes: 2 additions & 2 deletions docs/EVIDENCE_CAPSULE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ node scripts/evidence-capsule.mjs verify capsule.json

Verification returns `verified: true` and exits 0 only when both capsule digests, the receipt's internal bindings, a present Ed25519 signature, and the derived summary all validate. A failed verification exits 1. An unsupported protocol, canonicalization, field, or malformed value fails closed.

A separate project-authored Python verifier checks capsules on the JSON subset for which its serializer reproduces the exact capsule digests. It also checks the receipt hash, intent/output/decision bindings, audit chain, ledger, counts, and derived summary. For signed v1.1 receipts it verifies Ed25519 and requires a separately supplied trusted receipt-key fingerprint; install `cryptography>=42,<51`:
A separate project-authored Python verifier checks capsules on the JSON subset for which its serializer reproduces the exact capsule digests. It also checks the receipt hash, intent/output/decision bindings, audit chain, the five audit events against their receipt fields (`audit_binding`), ledger, counts, and derived summary. For signed v1.1 receipts it verifies Ed25519 and requires a separately supplied trusted receipt-key fingerprint; install `cryptography>=42,<51`:

```bash
python3 independent/python/verify_evidence_capsule.py capsule.json
python3 independent/python/verify_evidence_capsule.py signed-capsule.json trusted-policy.json
```

Unsigned capsules need one argument. Signed v1.1 capsules require a second JSON file with `trusted_receipt_fingerprints` (an array of SHA-256 SPKI DER fingerprints) and optional `revoked_receipt_fingerprints`. The embedded key is not a trust source. Legacy v1.0 signatures are unsupported in Python because their signer and signed time were not bound. The two implementations and their mutation cases are exercised with `node scripts/check-evidence-capsule-python.mjs` on CI, which publishes a JSON result artifact. Signed receipts with small and large exponents, boundary integers, the maximum finite JavaScript number, array-index-like keys, UTF-16-ordered keys, and escaped lone surrogates are checked there. A locally assembled, JavaScript-valid signed ledger also checks that three charges totaling beyond the safe-integer boundary use binary64 arithmetic. Boolean and negative-zero balance mutations must fail, while a negative-zero sequence spelling that JavaScript accepts must verify. Receipts with a missing audit stream, missing ledger, or non-array audit entries must fail even when a test signer recalculates the receipt signature and capsule digests. RSA and EC signatures mislabeled as Ed25519, noncanonical signature base64, changed numerical values, escaped duplicate keys, and non-finite numbers must fail. This is cross-runtime engineering evidence maintained by the project, not an independent external witness.
Unsigned capsules need one argument. Signed v1.1 capsules require a second JSON file with `trusted_receipt_fingerprints` (an array of SHA-256 SPKI DER fingerprints) and optional `revoked_receipt_fingerprints`. The embedded key is not a trust source. Legacy v1.0 signatures are unsupported in Python because their signer and signed time were not bound. The two implementations and their mutation cases are exercised with `node scripts/check-evidence-capsule-python.mjs` on CI, which publishes a JSON result artifact. Signed receipts with small and large exponents, boundary integers, the maximum finite JavaScript number, array-index-like keys, UTF-16-ordered keys, and escaped lone surrogates are checked there. A locally assembled, JavaScript-valid signed ledger also checks that three charges totaling beyond the safe-integer boundary use binary64 arithmetic. Boolean and negative-zero balance mutations must fail, while a negative-zero sequence spelling that JavaScript accepts must verify. Receipts with a missing audit stream, missing ledger, non-array audit entries, a stale intent event, or an audit event contradicting the ledger must fail even when a test signer recalculates the receipt signature and capsule digests. RSA and EC signatures mislabeled as Ed25519, noncanonical signature base64, changed numerical values, escaped duplicate keys, and non-finite numbers must fail. This is cross-runtime engineering evidence maintained by the project, not an independent external witness.

For a saved response from the locked HTTP deployment endpoint, the [HTTP exchange pilot](../pilots/enterprise-http-gate/README.md#recheck-a-real-http-exchange-with-python) additionally checks host-owned expected inputs, nonce, HTTP envelope and effective decision in Python while reusing this signed capsule verifier. The capsule alone does not make those request and response claims.

Expand Down
57 changes: 46 additions & 11 deletions independent/python/verify_evidence_capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,29 @@ def js_integer(value):
return type(value) is int or (type(value) is float and math.isfinite(value) and value.is_integer())


def same_number(actual, expected, *, object_is=False):
# JS uses === for consumed values and Object.is for before/after/remaining.
# Python's True == 1 and -0.0 == 0.0 must not silently change that contract.
if actual is None or expected is None:
return actual is None and expected is None
if type(actual) not in (int, float) or type(expected) not in (int, float):
return False
if object_is and actual == expected == 0:
return math.copysign(1, actual) == math.copysign(1, expected)
return actual == expected


def same_json_value(actual, expected):
if type(actual) is dict and type(expected) is dict:
return (actual.keys() == expected.keys() and
all(same_json_value(actual[key], expected[key]) for key in actual))
if type(actual) is list and type(expected) is list:
return len(actual) == len(expected) and all(same_json_value(a, b) for a, b in zip(actual, expected))
if type(actual) in (int, float) or type(expected) in (int, float):
return same_number(actual, expected, object_is=True)
return type(actual) is type(expected) and actual == expected


def verify_signature(receipt, policy):
if not isinstance(policy, dict):
raise ValueError("external_receipt_trust_required")
Expand Down Expand Up @@ -183,17 +206,6 @@ def valid_ledger(ledger):
if budget is not None and (type(budget) not in (int, float) or budget < 0):
return False

def same_number(actual, expected, *, object_is=False):
# JS uses === for consumed values and Object.is for before/after/remaining.
# Python's True == 1 and -0.0 == 0.0 must not silently change that contract.
if actual is None or expected is None:
return actual is None and expected is None
if type(actual) not in (int, float) or type(expected) not in (int, float):
return False
if object_is and actual == expected == 0:
return math.copysign(1, actual) == math.copysign(1, expected)
return actual == expected

# JavaScript adds and subtracts binary64 Numbers, even when each input is a
# safe integer. Python integer arithmetic would diverge after the sum crosses
# 2**53, rejecting an otherwise valid signed receipt.
Expand Down Expand Up @@ -228,6 +240,28 @@ def valid_audit(stream):
return True


def valid_audit_receipt_binding(receipt):
stream = receipt["runtime_audit_stream"]
entries = stream["entries"]
if (stream.get("version") != "1.0" or stream.get("created_at") != receipt["timestamp"] or
not isinstance(entries, list) or len(entries) != 5):
return False
expected = [
("intent_received", {"intent_sha256": receipt["intent_sha256"], "profile_id": receipt["profile"]["id"]}),
("aml_generated", {"aml_sha256": receipt["aml_sha256"]}),
("policy_simulated", {"simulation_sha256": receipt["simulation_sha256"],
"policies": receipt["profile"]["policies"]}),
("policy_selected", {"policy_id": receipt["selected_render"]["policy_id"],
"decision_sha256": receipt["decision_sha256"],
"attention_ledger_remaining": receipt["attention_ledger"]["remaining"],
"attention_ledger_consumed": receipt["attention_ledger"]["consumed"]}),
("output_rendered", {"output_sha256": receipt["output_sha256"]}),
]
return all(entry.get("timestamp") == receipt["timestamp"] and entry.get("event_type") == event_type and
same_json_value(entry.get("payload"), payload)
for entry, (event_type, payload) in zip(entries, expected))


def verify(capsule, policy=None):
if set(capsule) != FIELDS or capsule["protocol"] != "aml-evidence-capsule/1" or capsule["canonicalization"] != "aml-sorted-json/1":
raise ValueError("unsupported capsule contract")
Expand Down Expand Up @@ -259,6 +293,7 @@ def verify(capsule, policy=None):
and selected["suppressed"] == sum(item["render_allowed"] is not True for item in decisions),
"audit": hash_value(receipt["runtime_audit_stream"]) == receipt["audit_stream_sha256"]
and valid_audit(receipt["runtime_audit_stream"]) and receipt["runtime_audit_verified"] is True,
"audit_binding": valid_audit_receipt_binding(receipt),
"ledger": hash_value(receipt["attention_ledger"]) == receipt["attention_ledger_sha256"]
and valid_ledger(receipt["attention_ledger"]),
"signature": not signed or verify_signature(receipt, policy),
Expand Down
3 changes: 3 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export interface ReceiptVerification {
verified: boolean;
receipt_hash_valid: boolean;
bindings_valid: boolean;
audit_stream_valid: boolean;
/** Checks the five ordered audit events against their receipt fields and timestamps. */
audit_receipt_binding_valid: boolean;
expected_sha256: string;
receipt_sha256: string;
[key: string]: unknown;
Expand Down
39 changes: 39 additions & 0 deletions scripts/check-evidence-capsule-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,29 @@ function hashValue(value) {
return crypto.createHash("sha256").update(stableStringify(value)).digest("hex");
}

function rehashAudit(receipt) {
let previous = null;
for (const entry of receipt.runtime_audit_stream.entries) {
entry.previous_hash = previous;
const { entry_hash, ...core } = entry;
entry.entry_hash = hashValue(core);
previous = entry.entry_hash;
}
receipt.audit_stream_sha256 = hashValue(receipt.runtime_audit_stream);
}

function rebindLedgerAudit(receipt) {
const selected = receipt.runtime_audit_stream.entries[3].payload;
selected.attention_ledger_remaining = receipt.attention_ledger.remaining;
selected.attention_ledger_consumed = receipt.attention_ledger.consumed;
rehashAudit(receipt);
}

function receiptWithLedger(base, ledger) {
const updated = structuredClone(base);
updated.attention_ledger = ledger;
updated.attention_ledger_sha256 = hashValue(ledger);
rebindLedgerAudit(updated);
const payload = { ...updated };
delete payload.receipt_sha256;
updated.receipt_sha256 = hashValue(payload);
Expand All @@ -55,6 +74,7 @@ function signModifiedReceipt(receipt, privateKey) {

function resign(receipt, privateKey) {
receipt.attention_ledger_sha256 = hashValue(receipt.attention_ledger);
rebindLedgerAudit(receipt);
signModifiedReceipt(receipt, privateKey);
}

Expand Down Expand Up @@ -100,6 +120,25 @@ try {
const signedPolicy = { trusted_receipt_fingerprints: [signed.signature.public_key_sha256] };
results.push(check("signed-external-trust-required", signedCapsule, false));
results.push(check("signed-valid", signedCapsule, true, signedPolicy));
const changedIntent = structuredClone(signedCapsule);
changedIntent.receipt.intent.audit_probe = "different declaration";
changedIntent.receipt.intent_sha256 = hashValue(changedIntent.receipt.intent);
signModifiedReceipt(changedIntent.receipt, privateKey);
changedIntent.summary.receipt_sha256 = changedIntent.receipt.receipt_sha256;
rehash(changedIntent);
if (verifyEvidenceCapsule(changedIntent).verified) throw new Error("JavaScript accepted a signed receipt with a stale audit intent");
results.push(check("signed-audit-intent-binding-rejected", changedIntent, false, signedPolicy,
null, { receipt_hash: true, intent: true, audit: true, audit_binding: false, signature: true, summary: true }));

const changedAudit = structuredClone(signedCapsule);
changedAudit.receipt.runtime_audit_stream.entries[3].payload.attention_ledger_consumed = 999;
rehashAudit(changedAudit.receipt);
signModifiedReceipt(changedAudit.receipt, privateKey);
changedAudit.summary.receipt_sha256 = changedAudit.receipt.receipt_sha256;
rehash(changedAudit);
if (verifyEvidenceCapsule(changedAudit).verified) throw new Error("JavaScript accepted a signed receipt with a conflicting audit event");
results.push(check("signed-audit-ledger-binding-rejected", changedAudit, false, signedPolicy,
null, { receipt_hash: true, audit: true, audit_binding: false, ledger: true, signature: true, summary: true }));
for (const field of ["runtime_audit_stream", "attention_ledger"]) {
const omitted = structuredClone(signedCapsule);
delete omitted.receipt[field];
Expand Down
Loading
Loading