diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c12de..1c0474d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ new version heading in the same commit. ## [Unreleased] +## [0.284.0] — 2026-07-31 +### Added +- **Policy v2, Tier A — set-membership and cross-arg conditions in the pure rule engine** + (`src/governance/policy.ts`). A rule's `when` clause gains two shapes beyond the existing + one-arg-vs-one-constant compare, both still stateless and still JSON: + - **`in` / `nin`** — set membership against an array value, e.g. + `{ arg: "status", op: "nin", value: ["paid","shipped","refunded"] }` → `never` (invalid-enum guard). + - **`argRef`** — compare one arg to *another arg* instead of a constant, e.g. + `{ arg: "payee", op: "ne", argRef: "buyer" }` → `ask` owner (wrong-recipient guard). + The `applyProposal` tighten-only safety proof stays sound: `sampleArgDomains` now emits every enum + member and seeds both sides of a cross-arg pair with one shared domain, and `firstLoosening`'s rare + fallback path gets explicit joint coverage of each `argRef` pair (independent variation can't see + `arg == argRef`). New test `scripts/tier-a-policy-test.cjs` (runs under `npm run test:governance`); + the 130-case conformance suite is unchanged (backward-compatible — the bundled default policy uses + no new ops). + ## [0.283.0] — 2026-07-31 ### Fixed - **A Codex run silently inherited a Claude model from the workspace default and died.** Found on the diff --git a/package-lock.json b/package-lock.json index 2a18e43..d303580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.283.0", + "version": "0.284.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.283.0", + "version": "0.284.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index d694daa..1761273 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.283.0", + "version": "0.284.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,8 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/governance-conformance.cjs", + "test:governance": "node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs", + "test:policy-tier-a": "node scripts/tier-a-policy-test.cjs", "test:enrich": "node scripts/test-enrich-patterns.cjs", "test:context": "node scripts/context-injection-test.cjs", "test:policy-context": "node scripts/test-policy-context.cjs", diff --git a/scripts/tier-a-policy-test.cjs b/scripts/tier-a-policy-test.cjs new file mode 100644 index 0000000..739e1af --- /dev/null +++ b/scripts/tier-a-policy-test.cjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Tier-A policy-engine test — set membership (`in`/`nin`) + cross-arg compare (`argRef`). + * + * Runs the BUILT gate (dist/), like governance-conformance. Covers three things: + * 1. runtime semantics — evalWhen via classify for in/nin and argRef. + * 2. validation — the two new `when` shapes accept the valid forms and reject the malformed ones. + * 3. the monotonicity SWEEP reaches the new-op decision boundaries — the soundness linchpin. If + * sampleArgDomains failed to emit enum members / pair argRef args, describeProposal would report + * "no effective change" for a real tightening, and applyProposal could mis-sweep. We assert the + * sweep both SEES a genuine new-op tightening and does NOT spuriously reject it as a loosening. + * + * npm run build && node scripts/tier-a-policy-test.cjs + */ +const path = require('path'); +const { JsonPolicyEngine, validatePolicyDocument, applyProposal, describeProposal } = + require(path.resolve(__dirname, '..', 'dist/governance/policy')); + +const ctx = { run: { id: 'r', tenant: 't', principal: 'a' } }; +let pass = 0; +const failures = []; +const check = (name, cond) => { if (cond) pass++; else failures.push(name); }; + +// A Decision → compact tag. +const tag = (d) => (d.effect === 'allow' ? 'allow' : d.effect === 'deny' ? 'never' : `ask:${d.level}`); +const classify = (doc, cap, args) => { + const e = new JsonPolicyEngine(doc); + e.setThresholds(() => ({})); + return tag(e.classify({ capabilityId: cap, args, reasoning: '' }, ctx)); +}; + +// ── 1. runtime semantics ─────────────────────────────────────────────────────────────────────── +const runtimeDoc = { + id: 'tierA-runtime', + default: { action: 'allow' }, + rules: [ + { match: { capability: 'order.setStatus', when: { arg: 'status', op: 'nin', value: ['paid', 'shipped', 'refunded'] } }, action: 'never' }, + { match: { capability: 'payout.send', when: { arg: 'payee', op: 'ne', argRef: 'buyer' } }, action: 'ask', approver: 'owner' }, + { match: { capability: 'refund.issue', when: { arg: 'amount', op: 'gt', argRef: 'orig' } }, action: 'ask', approver: 'admin' }, + ], +}; +// set membership +check('nin: valid enum → allow', classify(runtimeDoc, 'order.setStatus', { status: 'paid' }) === 'allow'); +check('nin: invalid enum → never', classify(runtimeDoc, 'order.setStatus', { status: 'probably shipped' }) === 'never'); +check('nin: missing arg → never (fail-closed for a never guard)', classify(runtimeDoc, 'order.setStatus', {}) === 'never'); +// cross-arg equality +check('argRef ne: payee == buyer → allow', classify(runtimeDoc, 'payout.send', { payee: 'A', buyer: 'A' }) === 'allow'); +check('argRef ne: payee != buyer → ask:owner', classify(runtimeDoc, 'payout.send', { payee: 'A', buyer: 'B' }) === 'ask:owner'); +check('argRef ne: missing referenced arg → allow (rule fails closed = does not fire)', classify(runtimeDoc, 'payout.send', { payee: 'A' }) === 'allow'); +// cross-arg numeric ordering (approver `admin` maps to the internal level `head`, per toLevel) +check('argRef gt: amount > orig → ask:head', classify(runtimeDoc, 'refund.issue', { amount: 100, orig: 50 }) === 'ask:head'); +check('argRef gt: amount <= orig → allow', classify(runtimeDoc, 'refund.issue', { amount: 50, orig: 100 }) === 'allow'); + +// ── 2. validation ────────────────────────────────────────────────────────────────────────────── +check('validate: valid tierA doc', validatePolicyDocument(runtimeDoc) === null); +check('validate: in with argRef is rejected', !!validatePolicyDocument({ + id: 'x', default: { action: 'allow' }, + rules: [{ match: { capability: 'c', when: { arg: 'a', op: 'in', argRef: 'b' } }, action: 'never' }], +})); +check('validate: in with non-array value is rejected', !!validatePolicyDocument({ + id: 'x', default: { action: 'allow' }, + rules: [{ match: { capability: 'c', when: { arg: 'a', op: 'in', value: 'paid' } }, action: 'never' }], +})); +check('validate: in with empty array is rejected', !!validatePolicyDocument({ + id: 'x', default: { action: 'allow' }, + rules: [{ match: { capability: 'c', when: { arg: 'a', op: 'in', value: [] } }, action: 'never' }], +})); +check('validate: argRef + value together is rejected', !!validatePolicyDocument({ + id: 'x', default: { action: 'allow' }, + rules: [{ match: { capability: 'c', when: { arg: 'a', op: 'ne', value: 1, argRef: 'b' } }, action: 'never' }], +})); + +// ── 3. the sweep reaches the new-op boundaries (soundness linchpin) ─────────────────────────────── +// A doc that allows the capability unconditionally; propose ADDing a new-op guardrail (a strict tighten). +// describeProposal must SEE the tightening (not "no effective change"), and applyProposal must ACCEPT it +// (not falsely flag a loosening). Both fail loudly if sampleArgDomains doesn't emit the new-op values. +const allowPayout = { id: 't', default: { action: 'allow' }, rules: [{ match: { capability: 'payout.send' }, action: 'allow' }] }; +const argRefDelta = { kind: 'add', match: { capability: 'payout.send', when: { arg: 'payee', op: 'ne', argRef: 'buyer' } }, outcome: { action: 'ask', approver: 'owner' } }; +const argRefDesc = describeProposal(allowPayout, argRefDelta, {}); +check('sweep sees argRef tightening (not "no effective change")', + 'preview' in argRefDesc && /payout\.send/.test(argRefDesc.preview) && /ask owner/.test(argRefDesc.preview)); +check('sweep accepts argRef tightening (no false loosening)', 'doc' in applyProposal(allowPayout, argRefDelta, {})); + +const allowStatus = { id: 't', default: { action: 'allow' }, rules: [{ match: { capability: 'order.setStatus' }, action: 'allow' }] }; +const ninDelta = { kind: 'add', match: { capability: 'order.setStatus', when: { arg: 'status', op: 'nin', value: ['paid', 'shipped'] } }, outcome: { action: 'never' } }; +const ninDesc = describeProposal(allowStatus, ninDelta, {}); +check('sweep sees nin tightening (not "no effective change")', + 'preview' in ninDesc && /order\.setStatus/.test(ninDesc.preview) && /never/.test(ninDesc.preview)); +check('sweep accepts nin tightening (no false loosening)', 'doc' in applyProposal(allowStatus, ninDelta, {})); + +// A proposal cannot ADD an allow (loosening) — still refused with a new-op when clause. +check('add allow with in-clause is refused (tighten-only)', + 'error' in applyProposal(allowStatus, { kind: 'add', match: { capability: 'order.setStatus', when: { arg: 'status', op: 'in', value: ['x'] } }, outcome: { action: 'allow' } }, {})); + +const total = pass + failures.length; +if (failures.length) { + console.error(`\nTIER-A POLICY: ${pass}/${total} passed, ${failures.length} FAILED\n`); + for (const f of failures) console.error(' ✗ ' + f); + console.error(''); + process.exit(1); +} +console.log(`TIER-A POLICY: ${pass}/${total} passed ✓`); diff --git a/src/governance/policy.ts b/src/governance/policy.ts index ec8ab66..cd7ed94 100644 --- a/src/governance/policy.ts +++ b/src/governance/policy.ts @@ -13,10 +13,25 @@ */ import { ActionAttempt, ApprovalLevel, Decision, PolicyEngine, RunContext, riskClassForLevel } from '../types'; -type Op = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne'; +type Op = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne' | 'in' | 'nin'; export type PolicyAction = 'allow' | 'ask' | 'never'; export type Approver = 'admin' | 'owner'; +/** + * A single condition on the CURRENT attempt's args (Tier A — still stateless, still one arg-focused + * clause per rule). Three shapes: + * - constant compare — `{ arg, op: gt|gte|lt|lte|eq|ne, value }` (a `"$name"` value is a threshold ref). + * - set membership — `{ arg, op: in|nin, value: [...] }` (value is a non-empty array of strings/numbers). + * - cross-arg compare — `{ arg, op: gt|…|ne, argRef }` compares `args[arg]` to `args[argRef]`, not a + * constant (e.g. payout `payee ≠ buyer`). `argRef` and `value` are mutually exclusive. + */ +interface When { + arg: string; + op: Op; + value?: number | string | boolean | Array; + argRef?: string; +} + /** The decision a rule (or the default) yields. `approver` is required only when action is `ask`. */ interface PolicyOutcome { action: PolicyAction; @@ -24,7 +39,7 @@ interface PolicyOutcome { } interface PolicyRule extends PolicyOutcome { - match: { capability: string; when?: { arg: string; op: Op; value: number | string | boolean } }; + match: { capability: string; when?: When }; } export interface PolicyDocument { @@ -37,14 +52,14 @@ export interface PolicyDocument { const ACTIONS: PolicyAction[] = ['allow', 'ask', 'never']; const APPROVERS: Approver[] = ['admin', 'owner']; -const OPS = ['gt', 'gte', 'lt', 'lte', 'eq', 'ne']; +const OPS = ['gt', 'gte', 'lt', 'lte', 'eq', 'ne', 'in', 'nin']; /** `admin` approves at the internal `head` level; `owner` at `owner`. (See canApprove in types.ts.) */ function toLevel(approver: Approver | undefined): ApprovalLevel { return approver === 'owner' ? 'owner' : 'head'; } -const OP_PHRASE: Record = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', ne: '≠' }; +const OP_PHRASE: Record = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', ne: '≠', in: '∈', nin: '∉' }; /** * The human `reason` a decision carries — names the rule + the CONDITION that tripped it, so an approver @@ -56,7 +71,12 @@ function describeMatch(rule: PolicyRule | undefined, thresholds: Record typeof v === 'string' || typeof v === 'number')) { + return `rule ${i + 1}: "${w.op}" needs when.value to be a non-empty array of strings/numbers`; + } + } else { + const hasRef = w.argRef !== undefined; + const hasVal = w.value !== undefined && w.value !== null; + if (hasRef && hasVal) return `rule ${i + 1}: when.argRef and when.value are mutually exclusive`; + if (hasRef) { + if (typeof w.argRef !== 'string' || !w.argRef.trim()) return `rule ${i + 1}: when.argRef (string) is required`; + } else if (!hasVal || !['number', 'string', 'boolean'].includes(typeof w.value)) { + return `rule ${i + 1}: when.value must be a number, string, or boolean (or set when.argRef)`; + } } } } @@ -266,21 +298,48 @@ function sampleCapabilities(before: PolicyDocument, after: PolicyDocument): stri return [...caps]; } +/** The cross-arg (`argRef`) pairs any rule branches on, deduped — `[arg, argRef]`. Used to give the + * monotonicity sweep JOINT coverage of the two args (independent variation can't see `arg == argRef`). */ +function argRefPairs(before: PolicyDocument, after: PolicyDocument): Array<[string, string]> { + const pairs = new Map(); + for (const doc of [before, after]) { + for (const r of doc.rules) { + const w = r.match.when; + if (w?.argRef !== undefined) pairs.set(`${w.arg}${w.argRef}`, [w.arg, w.argRef]); + } + } + return [...pairs.values()]; +} + /** For each `when.arg` the rules branch on, the set of values worth testing. Booleans → {true,false}; - * a numeric/threshold comparison → boundary values around the resolved cap. This is exhaustive because - * `classify` reads ONLY args named in a `when` clause — nothing else can change an outcome. */ + * a numeric/threshold comparison → boundary values around the resolved cap; a set membership (`in`/`nin`) + * → every enum member (in-set) plus true/false (out-of-set); a cross-arg (`argRef`) comparison → seed BOTH + * args with one SHARED comparable domain so the cartesian sweep hits equal AND unequal pairings. This is + * exhaustive because `classify` reads ONLY args named in a `when` clause — nothing else changes an outcome — + * provided the sweep enumerates each op's decision boundaries, which is exactly what this covers. */ function sampleArgDomains(before: PolicyDocument, after: PolicyDocument, thresholds: Record): Map> { const domains = new Map>(); const add = (arg: string, v: unknown) => { (domains.get(arg) ?? domains.set(arg, new Set()).get(arg)!).add(v); }; + // A shared domain for the two sides of a cross-arg compare: numbers (order boundaries) + string sentinels + // (equality), so (x,y) pairs include x==y and x≠y for both eq/ne and gt/lt. + const seedComparable = (arg: string) => { for (const v of [0, 1, 2, 'refA', 'refB']) add(arg, v); }; for (const doc of [before, after]) { for (const r of doc.rules) { const w = r.match.when; if (!w) continue; // Always cover the boolean truth table for the flag. add(w.arg, true); add(w.arg, false); - // For a numeric/threshold comparison, cover the boundary either side of the cap. - if (['gt', 'gte', 'lt', 'lte'].includes(w.op)) { - const resolved = resolveValue(w.value, thresholds); + if (w.op === 'in' || w.op === 'nin') { + // Every enum member exercises the in-set branch; true/false (added above) are guaranteed non-members. + if (Array.isArray(w.value)) for (const m of w.value) add(w.arg, m); + } else if (w.argRef !== undefined) { + // Cross-arg: seed both args with the same comparable domain (+ booleans) so the product covers + // arg == argRef and arg ≠ argRef. Joint coverage in the rare fallback path is added in firstLoosening. + seedComparable(w.arg); seedComparable(w.argRef); + add(w.argRef, true); add(w.argRef, false); + } else if (['gt', 'gte', 'lt', 'lte'].includes(w.op)) { + // For a numeric/threshold comparison, cover the boundary either side of the cap. + const resolved = resolveValue(w.value as number | string | boolean, thresholds); const n = typeof resolved === 'number' ? resolved : Number(resolved); if (Number.isFinite(n)) { add(w.arg, n - 1); add(w.arg, n); add(w.arg, n + 1); add(w.arg, 0); } } else if (typeof w.value !== 'boolean') { @@ -329,12 +388,21 @@ function firstLoosening(before: PolicyDocument, after: PolicyDocument, threshold } return null; } - // Fallback (rare): vary each arg independently against an all-false/zero baseline. + // Fallback (rare huge product): vary each arg independently against an all-false/zero baseline, PLUS + // jointly sweep each cross-arg (argRef) pair — independent variation holds the other arg at the baseline, + // so it never exercises arg == argRef with non-baseline values, which a cross-arg compare turns on. + const domainMap = new Map(domains); + const pairs = argRefPairs(before, after); for (const cap of caps) { const base: Record = {}; for (const [arg] of domains) base[arg] = false; const hit0 = check(cap, base); if (hit0) return hit0; for (const [arg, vals] of domains) for (const v of vals) { const hit = check(cap, { ...base, [arg]: v }); if (hit) return hit; } + for (const [a, b] of pairs) { + for (const x of domainMap.get(a) ?? []) for (const y of domainMap.get(b) ?? []) { + const hit = check(cap, { ...base, [a]: x, [b]: y }); if (hit) return hit; + } + } } return null; } @@ -452,18 +520,24 @@ export function describeProposal(doc: PolicyDocument, delta: PolicyDelta, thresh } function evalWhen( - when: NonNullable, + when: When, args: Record, thresholds: Record, ): boolean { const actual = args[when.arg]; const { op } = when; - const value = resolveValue(when.value, thresholds); - if (value === undefined) return false; // unresolved threshold ref → fail closed (no match) - if (op === 'eq') return actual === value; - if (op === 'ne') return actual !== value; + // Set membership — value is an array; no threshold resolution of members. + if (op === 'in' || op === 'nin') { + const member = Array.isArray(when.value) && (when.value as Array).includes(actual as string | number); + return op === 'in' ? member : !member; + } + // Comparison — the RHS is another arg (`argRef`) or a constant/threshold (`$ref`). + const rhs = when.argRef !== undefined ? args[when.argRef] : resolveValue(when.value as number | string | boolean, thresholds); + if (rhs === undefined) return false; // unresolved threshold ref, or missing referenced arg → fail closed (no match) + if (op === 'eq') return actual === rhs; + if (op === 'ne') return actual !== rhs; const a = Number(actual); - const b = Number(value); + const b = Number(rhs); if (Number.isNaN(a) || Number.isNaN(b)) return false; if (op === 'gt') return a > b; if (op === 'gte') return a >= b;