UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

91 lines (90 loc) • 6.68 kB
import assert from "node:assert"; import { PERMISSION_RULE_WRITER, isRuleRevision } from "../permission-rule-store.js"; import { beginContract } from "./contract-harness.js"; const GLOBAL = { kind: "global" }; function add(actor, counter) { return { dot: { actor, counter }, origin: "user", createdAt: "2026-01-01T00:00:00.000Z" }; } const IDENTITY = { behavior: "deny", rule: "Bash(curl:*)", scope: GLOBAL, tool: "Bash", match: "prefix", command: "curl" }; function tombstoneFor(removed, deletedBy) { return { behavior: IDENTITY.behavior, rule: IDENTITY.rule, scope: GLOBAL, removedDots: removed.map((a) => a.dot), deletedBy }; } function liveDots(rules) { return new Set(rules.flatMap((r) => r.adds.map((a) => `${r.behavior}|${r.rule}|${a.dot.actor}#${a.dot.counter}`))); } async function rawShape(part) { const raw = await part[PERMISSION_RULE_WRITER].readRaw(); const dots = [...liveDots(raw.rules)].sort(); const tombs = raw.tombstones.map((t) => `${t.behavior}|${t.rule}|${t.deletedBy.actor}#${t.deletedBy.counter}`).sort(); return JSON.stringify({ rev: raw.rev, dots, tombs }); } function assertDecline(outcome, what) { const answer = (outcome ?? {}); assert.equal(answer.conflict, true, `${what} must answer \`conflict: true\` — a decline tagged with anything else is an answer the engine reads as unreadable, so a kit accepting it certifies a backend the engine cannot use`); assert.equal(answer.refused, undefined, `${what} must not wear the refusal tag as well — an answer that is two arms is an answer about neither`); } function assertRevision(outcome, what) { const rev = outcome?.rev; assert.ok(isRuleRevision(rev), `${what} must carry a revision — a non-negative safe integer — and this answer carried ${rev === undefined ? "none at all" : `a ${typeof rev}`}`); } export async function permissionRuleWriteContract(hooks) { const { run, settle } = beginContract(hooks.runAssertion); run("the named arms land, a landing ADVANCES the revision, and a spent revision is ANSWERED (positive control)", async () => { const part = await hooks.make(); const writer = part[PERMISSION_RULE_WRITER]; const a1 = add("kit", 1); const held = (await part.list()).rev; const first = await writer.apply({ kind: "tighten-add", ...IDENTITY, add: a1 }, { expectedRev: held }); assert.ok(!("conflict" in first) && !("refused" in first), "a well-formed tighten-add must land"); assertRevision(first, "the tighten-add landing"); const afterAdd = await part.list(); assert.deepEqual(liveDots(afterAdd.rules), new Set([`deny|${IDENTITY.rule}|kit#1`]), "the added dot must be visible through list()"); assert.ok(afterAdd.rev > held, "a landed write must ADVANCE the revision — a constant one is not a fence, it is a number"); assert.equal((await part[PERMISSION_RULE_WRITER].readRaw()).rev, afterAdd.rev, "every face must report ONE revision"); const rawBefore = await rawShape(part); const stale = await writer.apply({ kind: "tighten-add", ...IDENTITY, add: add("kit", 2) }, { expectedRev: held }); assertDecline(stale, "a spent expectedRev"); assertRevision(stale, "the declining conflict"); const staleDelete = await writer.apply({ kind: "tighten-delete", tombstone: tombstoneFor([a1], { actor: "kit", counter: 8 }) }, { expectedRev: held }); assertDecline(staleDelete, "a spent expectedRev on the delete arm"); assertRevision(staleDelete, "the declining conflict on the delete arm"); assert.equal(await rawShape(part), rawBefore, "a declined write must move nothing — rows, tombstones or revision"); const beforeDelete = (await part.list()).rev; const removed = await writer.apply({ kind: "tighten-delete", tombstone: tombstoneFor([a1], { actor: "kit", counter: 9 }) }, { expectedRev: beforeDelete }); assert.ok(!("conflict" in removed) && !("refused" in removed), "a well-formed tighten-delete must land"); assertRevision(removed, "the tighten-delete landing"); assert.deepEqual(liveDots((await part.list()).rules), new Set(), "the tombstoned dot must leave the live view"); assert.ok((await part.list()).rev > beforeDelete, "a landed delete must advance the revision too"); const raw = await part[PERMISSION_RULE_WRITER].readRaw(); assert.ok(liveDots(raw.rules).has(`deny|${IDENTITY.rule}|kit#1`), "the raw face must RETAIN a tombstoned add (list() filters; the evidence may not be filtered too)"); const tomb = raw.tombstones.find((t) => t.deletedBy.actor === "kit" && t.deletedBy.counter === 9); assert.ok(tomb !== undefined, "the raw face must retain the tombstone a landed delete wrote"); assert.deepEqual({ behavior: tomb.behavior, rule: tomb.rule, scope: tomb.scope, removedDots: tomb.removedDots }, { behavior: IDENTITY.behavior, rule: IDENTITY.rule, scope: GLOBAL, removedDots: [a1.dot] }, "the retained tombstone must be the WHOLE one — a partial one identifies nothing"); }); run("a delta whose kind is outside the union is REFUSED — not folded as the arm it resembles", async () => { for (const [label, payload] of [ ["① a legal tombstone under an unnamed kind", { kind: "tighten-purge", tombstone: tombstoneFor([add("kit", 1)], { actor: "kit", counter: 9 }) }], ["② a legal add payload under an unnamed kind", { kind: "import-add", ...IDENTITY, add: add("kit", 2) }], ]) { const part = await hooks.make(); const writer = part[PERMISSION_RULE_WRITER]; const seeded = add("kit", 7); await writer.apply({ kind: "tighten-add", ...IDENTITY, add: seeded }, { expectedRev: (await part.list()).rev }); const before = await part.list(); const rawShapeBefore = await rawShape(part); let threw = false; try { await writer.apply(payload, { expectedRev: before.rev }); } catch { threw = true; } assert.equal(threw, true, `${label}: the write face must refuse an unnamed delta kind`); const after = await part.list(); assert.deepEqual(liveDots(after.rules), liveDots(before.rules), `${label}: a refused delta must not change the rows`); assert.equal(after.rev, before.rev, `${label}: a refused delta must not advance the revision`); assert.equal(await rawShape(part), rawShapeBefore, `${label}: a refused delta must move nothing in the RAW state either`); } }); await settle(); }