noa-receipt
Version:
NOA Agent Action Receipt — open, offline-verifiable provenance for AI-agent actions. The governance/receipt organ only; the NOA brain is separate and proprietary.
183 lines (182 loc) • 10.1 kB
JavaScript
import { RECEIPT_SPEC } from "./types.js";
import { parseDocument } from "./bytes.js";
import { arrayPush, arrayIncludes, arrayConcat, hasOwn, objectKeys, isArray, arrayLength, strIsWellFormed, strCodePointCount, isSafeInteger } from "./intrinsics.js";
import { membership } from "./inert.js";
import { isSha256Hash, isParamsHash, isRfc3339Instant } from "./scan.js";
const isRiskClass = membership(["LOW", "MEDIUM", "HIGH", "CRITICAL", "IRREVERSIBLE"]);
const isPrincipal = membership(["HUMAN", "SERVICE", "POLICY", "SANDBOX_SIM"]);
const isMode = membership(["off", "shadow", "approvals_on", "on"]);
const isVerdict = membership([
"ALLOWED",
"BLOCKED",
"DEFERRED",
"EXECUTED",
"FAILED",
"ROLLED_BACK",
"SIMULATED",
]);
function isPlainObject(v) {
return typeof v === "object" && v !== null && !isArray(v);
}
function has(obj, k) {
return hasOwn(obj, k);
}
function checkExactKeys(obj, required, optional, path, errors) {
const allowed = arrayConcat(required, optional);
const keys = objectKeys(obj);
const kn = arrayLength(keys);
for (let ki = 0; ki < kn; ki++) {
const k = keys[ki];
if (!arrayIncludes(allowed, k))
arrayPush(errors, `${path}: unknown field "${k}"`);
}
const rn = arrayLength(required);
for (let ri = 0; ri < rn; ri++) {
const k = required[ri];
if (!has(obj, k))
arrayPush(errors, `${path}: missing required field "${k}"`);
}
}
function str(v) {
return typeof v === "string" && strIsWellFormed(v);
}
export function validateReceiptShape(receipt) {
const parsed = parseDocument(receipt, "receipt");
if (!parsed.ok)
return { ok: false, errors: [parsed.reason] };
return validateReceiptShapeParsed(parsed.value);
}
export function validateReceiptShapeParsed(value) {
const errors = [];
if (!isPlainObject(value)) {
return { ok: false, errors: ["receipt: not an object"] };
}
const r = value;
try {
checkExactKeys(r, ["spec", "id", "ts", "scope", "agent", "action", "governance", "chain", "sig"], [], "receipt", errors);
if (r.spec !== RECEIPT_SPEC)
arrayPush(errors, `receipt.spec: must be "${RECEIPT_SPEC}"`);
if (!str(r.id) || r.id.length === 0 || strCodePointCount(r.id) > 128)
arrayPush(errors, "receipt.id: non-empty string ≤128 chars");
if (!str(r.ts) || !isRfc3339Instant(r.ts))
arrayPush(errors, "receipt.ts: must be RFC 3339 UTC timestamp");
if (isPlainObject(r.scope)) {
checkExactKeys(r.scope, ["chain"], ["tenant"], "receipt.scope", errors);
if (!str(r.scope.chain) || r.scope.chain.length === 0)
arrayPush(errors, "receipt.scope.chain: non-empty string");
if (has(r.scope, "tenant") && !str(r.scope.tenant))
arrayPush(errors, "receipt.scope.tenant: string");
}
else
arrayPush(errors, "receipt.scope: object required");
if (isPlainObject(r.agent)) {
checkExactKeys(r.agent, ["id", "principal"], ["model"], "receipt.agent", errors);
if (!str(r.agent.id) || r.agent.id.length === 0)
arrayPush(errors, "receipt.agent.id: non-empty string");
if (!isPrincipal(r.agent.principal))
arrayPush(errors, "receipt.agent.principal: invalid enum");
if (has(r.agent, "model") && r.agent.model !== null && !str(r.agent.model))
arrayPush(errors, "receipt.agent.model: string or null");
}
else
arrayPush(errors, "receipt.agent: object required");
if (isPlainObject(r.action)) {
checkExactKeys(r.action, ["id", "canonical", "riskClass", "paramsHash", "reversible"], ["rollbackRef"], "receipt.action", errors);
if (!str(r.action.id) || r.action.id.length === 0)
arrayPush(errors, "receipt.action.id: non-empty string");
if (!str(r.action.canonical) || r.action.canonical.length === 0)
arrayPush(errors, "receipt.action.canonical: non-empty string");
if (!isRiskClass(r.action.riskClass))
arrayPush(errors, "receipt.action.riskClass: invalid enum");
if (!str(r.action.paramsHash) || !isParamsHash(r.action.paramsHash))
arrayPush(errors, "receipt.action.paramsHash: must match (sha256|hmac-sha256):<64 hex>");
if (typeof r.action.reversible !== "boolean")
arrayPush(errors, "receipt.action.reversible: boolean");
if (has(r.action, "rollbackRef") && r.action.rollbackRef !== null && !str(r.action.rollbackRef))
arrayPush(errors, "receipt.action.rollbackRef: string or null");
}
else
arrayPush(errors, "receipt.action: object required");
if (isPlainObject(r.governance)) {
checkExactKeys(r.governance, ["mode", "verdict", "sandboxed"], ["ruleId", "approval", "compliance"], "receipt.governance", errors);
if (!isMode(r.governance.mode))
arrayPush(errors, "receipt.governance.mode: invalid enum");
if (!isVerdict(r.governance.verdict))
arrayPush(errors, "receipt.governance.verdict: invalid enum");
if (typeof r.governance.sandboxed !== "boolean")
arrayPush(errors, "receipt.governance.sandboxed: boolean");
if (has(r.governance, "ruleId") && r.governance.ruleId !== null && !str(r.governance.ruleId))
arrayPush(errors, "receipt.governance.ruleId: string or null");
if (has(r.governance, "approval") && r.governance.approval !== null) {
if (isPlainObject(r.governance.approval)) {
checkExactKeys(r.governance.approval, ["by", "at"], [], "receipt.governance.approval", errors);
if (!str(r.governance.approval.by))
arrayPush(errors, "receipt.governance.approval.by: string");
if (!str(r.governance.approval.at) || !isRfc3339Instant(r.governance.approval.at))
arrayPush(errors, "receipt.governance.approval.at: RFC 3339 UTC");
}
else
arrayPush(errors, "receipt.governance.approval: object or null");
}
if (has(r.governance, "compliance") && r.governance.compliance !== null) {
const c = r.governance.compliance;
if (isPlainObject(c)) {
checkExactKeys(c, ["policyHash", "readSetHash", "inputsHash"], ["verdict"], "receipt.governance.compliance", errors);
const cflds = ["policyHash", "readSetHash", "inputsHash"];
for (let cfi = 0; cfi < 3; cfi++) {
const k = cflds[cfi];
if (!str(c[k]) || !isSha256Hash(c[k]))
arrayPush(errors, `receipt.governance.compliance.${k}: sha256:<64 hex>`);
}
if (has(c, "verdict") && c.verdict !== "ALLOW" && c.verdict !== "DENY")
arrayPush(errors, 'receipt.governance.compliance.verdict: must be "ALLOW" or "DENY"');
}
else
arrayPush(errors, "receipt.governance.compliance: object or null");
}
}
else
arrayPush(errors, "receipt.governance: object required");
if (isPlainObject(r.agent) && isPlainObject(r.action) && isPlainObject(r.governance)) {
const principal = r.agent.principal;
const verdict = r.governance.verdict;
const sandboxed = r.governance.sandboxed;
const reversible = r.action.reversible;
const rollbackRefPresent = has(r.action, "rollbackRef") && r.action.rollbackRef !== null;
if (principal === "SANDBOX_SIM" && sandboxed === false)
arrayPush(errors, 'receipt.governance.sandboxed: must be true when agent.principal is "SANDBOX_SIM"');
if (verdict === "SIMULATED" && sandboxed === false)
arrayPush(errors, 'receipt.governance.sandboxed: must be true when governance.verdict is "SIMULATED"');
if (reversible === false && rollbackRefPresent)
arrayPush(errors, "receipt.action.rollbackRef: must be absent or null when action.reversible is false");
if (verdict === "ROLLED_BACK" && reversible === false)
arrayPush(errors, 'receipt.action.reversible: must be true when governance.verdict is "ROLLED_BACK"');
}
if (isPlainObject(r.chain)) {
checkExactKeys(r.chain, ["seq", "prevHash", "hash"], [], "receipt.chain", errors);
if (typeof r.chain.seq !== "number" || !isSafeInteger(r.chain.seq) || r.chain.seq < 0)
arrayPush(errors, "receipt.chain.seq: non-negative safe integer");
if (r.chain.prevHash !== null && (!str(r.chain.prevHash) || !isSha256Hash(r.chain.prevHash)))
arrayPush(errors, "receipt.chain.prevHash: sha256:<64 hex> or null");
if (!str(r.chain.hash) || !isSha256Hash(r.chain.hash))
arrayPush(errors, "receipt.chain.hash: sha256:<64 hex>");
}
else
arrayPush(errors, "receipt.chain: object required");
if (isPlainObject(r.sig)) {
checkExactKeys(r.sig, ["alg", "kid", "value"], [], "receipt.sig", errors);
if (r.sig.alg !== "ed25519")
arrayPush(errors, 'receipt.sig.alg: must be "ed25519"');
if (!str(r.sig.kid) || r.sig.kid.length === 0)
arrayPush(errors, "receipt.sig.kid: non-empty string");
if (!str(r.sig.value) || r.sig.value.length === 0)
arrayPush(errors, "receipt.sig.value: non-empty string");
}
else
arrayPush(errors, "receipt.sig: object required (signatures are mandatory in v0.1)");
}
catch (e) {
return { ok: false, errors: [`receipt: structural-validation error: ${e.message}`] };
}
return { ok: errors.length === 0, errors };
}