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.
158 lines (157 loc) • 6.54 kB
JavaScript
import { POLICY_SPEC } from "./dsl.js";
import { canonicalize, MAX_DEPTH } from "../jcs.js";
import { parseDocument } from "../bytes.js";
import { arrayPush, arrayIncludes, arrayEvery, arrayJoin, objectKeys, setHas, setAdd, newSet, isArray, isSafeInteger } from "../intrinsics.js";
import { membership } from "../inert.js";
const isCmpOp = membership(["eq", "ne", "lt", "le", "gt", "ge"]);
function isScalar(v) {
const t = typeof v;
if (t === "string" || t === "boolean")
return true;
if (t === "number")
return isSafeInteger(v);
return false;
}
function scalarType(v) {
return typeof v;
}
function noExtraKeys(obj, allowed, path, errors) {
const keys = objectKeys(obj);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!arrayIncludes(allowed, k))
arrayPush(errors, `${path}: unknown key "${k}" (closed grammar)`);
}
}
function validateCondition(c, path, errors, depth) {
if (depth > MAX_DEPTH) {
arrayPush(errors, `${path}: condition nesting too deep`);
return;
}
if (typeof c !== "object" || c === null) {
arrayPush(errors, `${path}: condition must be an object`);
return;
}
const op = c.op;
if (typeof op !== "string") {
arrayPush(errors, `${path}: condition.op must be a string`);
return;
}
const cond = c;
if (op === "and" || op === "or") {
noExtraKeys(cond, ["op", "clauses"], `${path}.${op}`, errors);
const cl = cond.clauses;
if (!isArray(cl) || cl.length === 0)
arrayPush(errors, `${path}.${op}: clauses must be a non-empty array`);
else
for (let i = 0; i < cl.length; i++)
validateCondition(cl[i], `${path}.${op}[${i}]`, errors, depth + 1);
return;
}
if (op === "not") {
noExtraKeys(cond, ["op", "clause"], `${path}.not`, errors);
validateCondition(cond.clause, `${path}.not`, errors, depth + 1);
return;
}
if (op === "exists" || op === "absent") {
noExtraKeys(cond, ["op", "path"], `${path}.${op}`, errors);
if (typeof cond.path !== "string" || cond.path.length === 0)
arrayPush(errors, `${path}.${op}: path must be a non-empty string`);
return;
}
if (op === "in") {
noExtraKeys(cond, ["op", "path", "values"], `${path}.in`, errors);
if (typeof cond.path !== "string" || cond.path.length === 0)
arrayPush(errors, `${path}.in: path must be a non-empty string`);
const vals = cond.values;
if (!isArray(vals) || vals.length === 0) {
arrayPush(errors, `${path}.in: values must be a non-empty array`);
}
else {
let firstType = null;
for (let i = 0; i < vals.length; i++) {
if (!isScalar(vals[i])) {
arrayPush(errors, `${path}.in.values[${i}]: not an allowed scalar (string|boolean|safe-int)`);
continue;
}
const tt = scalarType(vals[i]);
if (firstType === null)
firstType = tt;
else if (tt !== firstType)
arrayPush(errors, `${path}.in.values: mixed scalar types (${firstType} vs ${tt}) — comparison is undefined`);
}
}
return;
}
if (isCmpOp(op)) {
noExtraKeys(cond, ["op", "path", "value"], `${path}.${op}`, errors);
if (typeof cond.path !== "string" || cond.path.length === 0)
arrayPush(errors, `${path}.${op}: path must be a non-empty string`);
if (!isScalar(cond.value))
arrayPush(errors, `${path}.${op}.value: not an allowed scalar (string|boolean|safe-int)`);
return;
}
arrayPush(errors, `${path}: unknown op "${op}" (allowed: eq/ne/lt/le/gt/ge/in/exists/absent/and/or/not)`);
}
export function validatePolicy(policy) {
const parsed = parseDocument(policy, "policy");
if (!parsed.ok)
return { ok: false, errors: [parsed.reason] };
return validatePolicyParsed(parsed.value);
}
export function validatePolicyParsed(p) {
const errors = [];
if (typeof p !== "object" || p === null)
return { ok: false, errors: ["policy: not an object"] };
const pol = p;
noExtraKeys(pol, ["spec", "id", "requiredPaths", "rules"], "policy", errors);
if (pol.spec !== POLICY_SPEC)
arrayPush(errors, `policy.spec: must be "${POLICY_SPEC}"`);
if (typeof pol.id !== "string" || pol.id.length === 0)
arrayPush(errors, "policy.id: non-empty string");
if (!isArray(pol.requiredPaths) || !arrayEvery(pol.requiredPaths, (x) => typeof x === "string" && x.length > 0)) {
arrayPush(errors, "policy.requiredPaths: array of non-empty strings");
}
if (!isArray(pol.rules)) {
arrayPush(errors, "policy.rules: must be an array");
}
else {
const seenIds = newSet();
for (let i = 0; i < pol.rules.length; i++) {
const r = pol.rules[i];
if (typeof r !== "object" || r === null) {
arrayPush(errors, `policy.rules[${i}]: must be an object`);
continue;
}
const rule = r;
noExtraKeys(rule, ["id", "when", "then"], `policy.rules[${i}]`, errors);
if (typeof rule.id !== "string" || rule.id.length === 0)
arrayPush(errors, `policy.rules[${i}].id: non-empty string`);
else if (setHas(seenIds, rule.id))
arrayPush(errors, `policy.rules[${i}].id: duplicate rule id "${rule.id}"`);
else
setAdd(seenIds, rule.id);
if (rule.then !== "ALLOW" && rule.then !== "DENY")
arrayPush(errors, `policy.rules[${i}].then: must be exactly "ALLOW" or "DENY"`);
validateCondition(rule.when, `policy.rules[${i}].when`, errors, 0);
}
}
if (errors.length === 0) {
try {
canonicalize(p);
}
catch {
arrayPush(errors, `policy: not canonicalizable (exceeds the depth-${MAX_DEPTH} identity-hash limit)`);
}
}
return { ok: errors.length === 0, errors };
}
export function assertValidPolicy(policy) {
const parsed = parseDocument(policy, "policy");
if (!parsed.ok)
throw new Error(`invalid policy: ${parsed.reason}`);
const v = validatePolicyParsed(parsed.value);
if (!v.ok)
throw new Error(`invalid policy: ${arrayJoin(v.errors, "; ")}`);
return parsed.value;
}