UNPKG

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
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; }