UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

897 lines (896 loc) • 45.1 kB
import { randomBytes } from "node:crypto"; import { escapeForDisclosure, hasUnrenderableCharacters, parseRuleText, ruleAdmitsCommand, ruleBreadthWarningsOf, segmentCoverageOf, suggestRulesForCommand, RULE_BEHAVIORS, RULE_BEHAVIOR_PRECEDENCE, isRuleBehavior, } from "./permission-rule-model.js"; import { effectiveOrThrow } from "./permission-rule-provider.js"; import { errText, isValidConsentScope, landRuleAdd, sameRuleOwner, sameScope, writerOf } from "./permission-rule-store.js"; export class InMemoryRuleApprovalRecordStore { rows = new Map(); async get(id) { const r = this.rows.get(id); if (r === undefined) return undefined; if (r.schema !== 4) { return { staleSchema: true, id: r.id, ...(typeof r.principal === "string" ? { principal: r.principal } : {}), ...(r.owner !== undefined ? { owner: structuredClone(r.owner) } : {}), }; } return structuredClone(r); } async create(record) { if (this.rows.has(record.id)) throw new Error(`approval record ${record.id} already exists`); this.rows.set(record.id, structuredClone(record)); } async cas(id, expectRev, next) { const cur = this.rows.get(id); if (cur === undefined || cur.rev !== expectRev) return false; this.rows.set(id, structuredClone(next)); return true; } size() { return this.rows.size; } } function nowIso(deps) { return (deps.now?.() ?? new Date()).toISOString(); } function mintId(deps, prefix) { if (deps.newId !== undefined) return deps.newId(); return `${prefix}_${Date.now().toString(36)}_${randomBytes(9).toString("base64url")}`; } function requirePrincipal(principal, entry) { if (typeof principal !== "string" || principal === "") { throw new Error(`${entry} requires a verified principal — refusing to create an approval record without one`); } return principal; } function resolveCallerOwner(principal, owner, entry) { if (owner !== undefined) { if (owner.kind === "principal") { const p = requirePrincipal(owner.principal, entry); if (typeof principal === "string" && principal !== "" && principal !== p) { throw new Error(`${entry}: contradictory identity — principal "${principal}" and owner principal "${p}" disagree`); } return { kind: "principal", principal: p }; } if (typeof principal === "string" && principal !== "") { throw new Error(`${entry}: contradictory identity — a principal and a local-owner were both given; a bucket has one owner`); } return { kind: "local-owner" }; } return { kind: "principal", principal: requirePrincipal(principal, entry) }; } function ownerOfRecord(rec) { if (rec.owner !== undefined) { if (rec.owner.kind === "principal") { if (typeof rec.owner.principal !== "string" || rec.owner.principal === "") return undefined; if (typeof rec.principal === "string" && rec.principal !== "" && rec.principal !== rec.owner.principal) return undefined; return rec.owner; } if (typeof rec.principal === "string" && rec.principal !== "") return undefined; return { kind: "local-owner" }; } return typeof rec.principal === "string" && rec.principal !== "" ? { kind: "principal", principal: rec.principal } : undefined; } function recordIdentityOf(owner) { return owner.kind === "principal" ? { principal: owner.principal } : { owner: { kind: "local-owner" } }; } function storeForOwner(provider, owner) { if (owner.kind === "principal") return provider.forPrincipal(owner.principal); const store = provider.forLocalOwner?.(); if (store === undefined) { throw new Error("this deployment's permission-rule provider has no local-owner bucket (forLocalOwner is not implemented)"); } return store; } function isStale(rec) { return rec.staleSchema === true; } const STALE_RECORD_MESSAGE = "this approval record predates the current record form, and the decision it carried is void — re-trigger the command to draw a fresh card"; function offerMembersOf(offer) { return offer.kind === "single" ? [offer.candidate] : offer.candidates; } function approvalRecordDamageOf(rec) { if (!Array.isArray(rec.candidates)) return "the record carries no candidate list"; if (!Array.isArray(rec.offers) || rec.offers.length === 0) return "the record carries no offers"; const claimed = new Set(); for (const [at, offer] of rec.offers.entries()) { if (typeof offer !== "object" || offer === null) return `offer ${at} is not an offer object`; if (offer.kind !== "single" && offer.kind !== "batch") return `offer ${at} has an unknown kind`; if (offer.kind === "batch" && (!Array.isArray(offer.candidates) || offer.candidates.length === 0)) { return `batch offer ${at} has no members`; } const members = offerMembersOf(offer); const seen = new Set(); for (const member of members) { if (!Number.isInteger(member) || member < 0 || rec.candidates[member] === undefined) { return `offer ${at} references candidate ${escapeForDisclosure(member)}, which the record does not carry`; } const cand = rec.candidates[member]; if (typeof cand !== "object" || cand === null || typeof cand.rule !== "string") { return `offer ${at} references candidate ${member}, which is not a candidate row`; } if (!isRuleBehavior(cand.behavior)) { return `offer ${at} references candidate ${member}, whose behavior is not one of ${RULE_BEHAVIORS.join("/")}`; } if (rec.kind === "card" && cand.behavior !== "allow") { return `card offer ${at} references candidate ${member}, a ${cand.behavior} rule — a card mints allow candidates only`; } if (!isValidConsentScope(cand.scope)) { return `offer ${at} references candidate ${member}, whose scope is not a consent-face scope (global, project, or session)`; } if (seen.has(member)) return `batch offer ${at} references candidate ${member} twice`; seen.add(member); if (claimed.has(member)) return `candidate ${member} belongs to more than one offer`; claimed.add(member); } if (offer.kind === "batch") { if (rec.kind === "card") { if (!Array.isArray(offer.segments) || offer.segments.length !== offer.candidates.length || !offer.segments.every((s) => typeof s === "string")) { return `card batch offer ${at} must carry exactly one source segment per member`; } if (!Number.isInteger(offer.uncoveredSegments) || offer.uncoveredSegments < 0) { return `card batch offer ${at} must carry a non-negative uncovered-segment count`; } const detail = offer.uncoveredDetail; if (!Array.isArray(detail)) return `card batch offer ${at} must carry its uncovered-segment reason rows`; if (detail.length !== offer.uncoveredSegments) { return `card batch offer ${at} carries ${detail.length} reason rows for an uncovered-segment count of ${offer.uncoveredSegments} — one fact, two seats, and they must agree`; } for (const [ri, row] of detail.entries()) { if (typeof row !== "object" || row === null || typeof row.segment !== "string" || (row.reason !== "redirection" && row.reason !== "no_rule_form" && row.reason !== "cap_overflow")) { return `card batch offer ${at} reason row ${ri} is not a segment/reason row from the closed reason set`; } } if (detail.length > 0) { const rows = detail; const table = typeof rec.command === "string" ? segmentCoverageOf(rec.command, { persisted: [] }, { tool: CARD_RULE_TOOL, cwd: undefined }) : undefined; if (table === undefined) { return `card batch offer ${at} carries reason rows but the record's command cannot back them — a row without a readable source command is a row nobody can audit`; } let cursor = 0; for (const [ri, row] of rows.entries()) { let found = -1; for (let j = cursor; j < table.length; j++) { if (table[j]?.segment === row.segment) { found = j; break; } } if (found === -1) return `card batch offer ${at} reason row ${ri} names a segment the record's command does not carry at or after that position`; cursor = found + 1; } } } else if (offer.segments !== undefined || offer.uncoveredSegments !== undefined || offer.uncoveredDetail !== undefined) { return `${escapeForDisclosure(rec.kind)} batch offer ${at} must not carry segment metadata — there is no compound command behind it`; } } } for (const [at] of rec.candidates.entries()) { if (!claimed.has(at)) return `candidate ${at} belongs to no offer — every candidate row is claimed by exactly one offer`; } if (rec.edited !== undefined) { if (typeof rec.edited !== "object" || rec.edited === null) return "the edited seat is not an object"; const w = rec.edited.warnings; if (w !== undefined) { if (!Array.isArray(w) || w.length !== 1 || (w[0] !== "broad_prefix" && w[0] !== "compound_prefix")) { return "the edited seat's warnings are not a single-code array from the closed breadth-code set"; } } } return undefined; } export function ruleOffersOfRecord(rec) { const tripleOf = (index) => { const cand = rec.candidates[index]; const text = cand?.rule; if (cand === undefined || text === undefined) throw new Error(`approval-record offer references candidate ${index}, which the record does not carry`); const parsed = parseRuleText(text, cand.behavior); if ("reject" in parsed) { throw new Error(`approval-record candidate ${index} does not parse as a rule (${parsed.reject.code}) — refusing to render it as one`); } return { rule: parsed.rule.rule, match: parsed.rule.match, command: parsed.rule.command }; }; return rec.offers.map((offer) => { if (offer.kind === "single") { const t = tripleOf(offer.candidate); if (t.match === "subpath") { throw new Error("approval-record single offer references a directory rule — a shape this engine does not mint; refusing to render it"); } return { kind: "single", ...t }; } const segments = offer.segments; if (segments === undefined || offer.uncoveredSegments === undefined || offer.uncoveredDetail === undefined) { throw new Error("a batch offer without mint-time segment metadata has no card face to project — import/starter batches render as previews, not offer cards"); } const rawDetail = offer.uncoveredDetail; const count = offer.uncoveredSegments; const detail = []; const detailDamaged = (() => { if (!Array.isArray(rawDetail) || !Number.isInteger(count) || count < 0 || rawDetail.length !== count) return true; for (let i = 0; i < rawDetail.length; i++) { const row = rawDetail[i]; if (typeof row !== "object" || row === null) return true; const segment = row.segment; const reason = row.reason; if (typeof segment !== "string" || (reason !== "redirection" && reason !== "no_rule_form" && reason !== "cap_overflow")) return true; detail.push({ segment, reason }); } return false; })(); if (detailDamaged) { throw new Error("a batch offer's uncovered-segment reason rows do not match their count or the closed reason set — refusing to render damaged mint-time facts as a card face"); } return { kind: "batch", rules: offer.candidates.map((candidate, i) => { const segment = segments[i]; if (segment === undefined) throw new Error(`batch offer member ${i} has no source segment recorded`); const t = tripleOf(candidate); return t.match === "subpath" ? { kind: "directoryRead", rule: t.rule, directory: t.command, segment } : { kind: "command", rule: t.rule, match: t.match, command: t.command, segment }; }), uncoveredSegments: count, uncoveredDetail: detail, }; }); } export function mintRuleTicket(recordId, candidateIndex) { return `rt.${candidateIndex}.${recordId}`; } function parseRuleTicket(ticket) { const m = /^rt\.(\d+)\.(.+)$/.exec(ticket); if (m === null) return undefined; return { index: Number(m[1]), recordId: m[2] }; } export async function prepareCardApproval(opts) { const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCardApproval"); if ("candidates" in opts) { const e = new Error("prepareCardApproval does not accept caller candidates — card options are minted by the engine from the adjudicated command"); e.code = "config.invalid_argument"; throw e; } if (opts.toolName !== CARD_RULE_TOOL) return undefined; const scope = (() => { if (opts.scope !== undefined) { if (!isValidConsentScope(opts.scope)) { const e = new Error("prepareCardApproval got a scope that is not a consent-face scope (global, project with a non-empty root, or session with a non-empty sessionId) — refusing to guess where this consent should land"); e.code = "config.invalid_argument"; throw e; } return opts.scope; } if (opts.cwd !== undefined) { if (typeof opts.cwd !== "string" || opts.cwd === "") { const e = new Error("prepareCardApproval got a cwd that is not a usable path — it cannot anchor the derived project scope, and refusing beats minting a rule scoped to garbage"); e.code = "config.invalid_argument"; throw e; } return { kind: "project", root: opts.cwd }; } const e = new Error("prepareCardApproval was given neither a scope nor a cwd — there is nowhere for this consent to land, and no direction of silence is honest (config.missing_scope): pass an explicit scope, or thread the adjudicated call's cwd"); e.code = "config.missing_scope"; throw e; })(); let coverage; try { const view = await effectiveOrThrow(storeForOwner(opts.deps.provider, owner), opts.sessionId !== undefined ? { sessionId: opts.sessionId } : undefined); const table = view.rules; coverage = segmentCoverageOf(opts.command, { persisted: table }, { tool: CARD_RULE_TOOL, cwd: opts.cwd, sessionId: opts.sessionId, ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}) }); } catch { coverage = undefined; } const suggested = suggestRulesForCommand(opts.command, { ...(coverage !== undefined ? { coverage } : {}), scope, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), ...(opts.execCwd !== undefined ? { execCwd: opts.execCwd } : {}), ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}), ...(opts.deps.deniesDirectoryRead !== undefined ? { deniesDirectoryRead: opts.deps.deniesDirectoryRead } : {}), }); const candidates = []; const claimedBy = new Map(); const claim = (text) => { if (claimedBy.has(text)) throw new Error(`engine defect: rule "${text}" was minted into two offers on one card`); const index = candidates.length; candidates.push({ behavior: "allow", rule: text, scope }); claimedBy.set(text, index); return index; }; const offers = suggested.map((offer) => offer.kind === "single" ? { kind: "single", candidate: claim(offer.rule) } : { kind: "batch", candidates: offer.rules.map((r) => claim(r.rule)), segments: offer.rules.map((r) => r.segment), uncoveredSegments: offer.uncoveredSegments, uncoveredDetail: offer.uncoveredDetail ?? [], }); if (candidates.length === 0) return undefined; const record = { id: mintId(opts.deps, "rar"), ...recordIdentityOf(owner), schema: 4, kind: "card", state: "pending", rev: 0, candidates, offers, command: opts.command, createdAt: nowIso(opts.deps), ...(opts.toolCallId !== undefined ? { toolCallId: opts.toolCallId } : {}), ...(opts.boundInputHash !== undefined ? { boundInputHash: opts.boundInputHash } : {}), }; await opts.deps.approvals.create(record); return { approvalId: record.id, offers: ruleOffersOfRecord(record), tickets: candidates.map((_, i) => mintRuleTicket(record.id, i)), candidates, }; } const CARD_RULE_TOOL = "Bash"; export async function confirmRuleApproval(opts) { const caller = resolveCallerOwner(opts.principal, opts.owner, "confirmRuleApproval"); const no = (reason, detail) => ({ ok: false, reason, ...(detail !== undefined ? { detail } : {}), }); if (opts.editedCandidate !== undefined && opts.selectedOffer !== undefined) { const e = new Error("confirmRuleApproval takes selectedOffer OR editedCandidate, never both — one confirmation carries one choice"); e.code = "config.invalid_argument"; throw e; } if ("selectedCandidate" in opts) { const e = new Error("confirmRuleApproval no longer takes selectedCandidate — selection is by OFFER index (selectedOffer) against the prepared record's offers; re-fetch the card and choose an offer"); e.code = "config.invalid_argument"; throw e; } const rec = await opts.deps.approvals.get(opts.approvalId); const recOwner = rec === undefined ? undefined : ownerOfRecord(rec); if (rec === undefined || recOwner === undefined || !sameRuleOwner(recOwner, caller)) return no("record_not_found"); if (isStale(rec)) return no("record_schema_stale", { message: STALE_RECORD_MESSAGE }); const damage = approvalRecordDamageOf(rec); if (damage !== undefined) return no("record_malformed", { message: damage }); if (opts.editedCandidate !== undefined) return await confirmEditedCandidate(rec, opts.editedCandidate, opts.deps); const chosen = opts.selectedOffer === undefined ? (rec.offers.length === 1 ? 0 : undefined) : opts.selectedOffer; if (chosen === undefined) return no("selection_missing"); if (!Number.isInteger(chosen) || rec.offers[chosen] === undefined) return no("selection_invalid"); if (rec.state === "approved" || rec.state === "redeemed") { const stored = rec.selectedOffer; if (stored === undefined || !Number.isInteger(stored) || rec.offers[stored] === undefined) { return no("record_malformed", { message: "the settled record does not name a valid chosen offer" }); } return stored === chosen ? { ok: true } : no("selection_mismatch"); } if (rec.state !== "pending") return no("not_pending"); if (rec.selectedOffer !== undefined) return no("record_malformed", { message: "a pending record must not already carry a chosen offer" }); const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedOffer: chosen }); if (won) return { ok: true }; const again = await opts.deps.approvals.get(rec.id); if (again === undefined) return no("record_not_found"); if (isStale(again)) return no("record_schema_stale", { message: STALE_RECORD_MESSAGE }); if (again.state === "pending") return no("conflict"); const settled = again.selectedOffer; if (settled === undefined || !Number.isInteger(settled) || again.offers[settled] === undefined) { return no("record_malformed", { message: "the settled record does not name a valid chosen offer" }); } return settled === chosen ? { ok: true } : no("selection_mismatch"); } function normalizeEditedSpelling(text) { const m = /^([A-Za-z][A-Za-z0-9_]*)\((.+) \*\)$/.exec(text); if (m === null) return text; const head = m[1]; const body = m[2]; if (head === undefined || body === undefined || body.includes("*")) return text; return `${head}(${body}:*)`; } function parseSubmittedSpelling(text) { return parseRuleText(normalizeEditedSpelling(text), "allow"); } function checkEditedRuleText(text, command) { const parsed = parseSubmittedSpelling(text); if ("reject" in parsed) return { ok: false, code: parsed.reject.code, message: parsed.reject.message }; if (!ruleAdmitsCommand(parsed.rule, command)) { return { ok: false, message: `the edited rule "${parsed.rule.rule}" does not admit the command that was decided ("${escapeForDisclosure(command)}") — a card's edit may widen how much the rule covers, never move it to a different grant`, }; } const warnings = ruleBreadthWarningsOf(parsed.rule); return { ok: true, canonicalRule: parsed.rule.rule, ...(warnings.length > 0 ? { warnings } : {}) }; } export function precheckEditedRuleText(text, command) { if (typeof text !== "string") { const e = new Error(`precheckEditedRuleText takes the edited rule text as a string (got ${typeof text}) — a non-string is a caller bug, not a rule a person could have typed`); e.code = "config.invalid_argument"; throw e; } if (typeof command !== "string" || command === "") { const e = new Error("precheckEditedRuleText takes the adjudicated command the card was drawn for — coverage cannot be judged without it, and reporting the absence as a rejected rule would blame the person's text"); e.code = "config.invalid_argument"; throw e; } if (hasUnrenderableCharacters(command)) { const e = new Error("precheckEditedRuleText takes a command this lane can read — one carrying control or format characters is refused at this preflight face (likelier a caller wiring fault than a card's command; the in-engine confirmation arm still judges such a card, with the command escaped on its refusal line)"); e.code = "config.invalid_argument"; throw e; } return checkEditedRuleText(text, command); } async function confirmEditedCandidate(rec, edit, deps) { const no = (reason, detail) => ({ ok: false, reason, ...(detail !== undefined ? { detail } : {}), }); if (rec.kind !== "card") { return no("edit_rejected", { message: "a batch record takes no edited candidate — its confirmation covers the previewed list whole" }); } if (typeof edit.boundInputHash !== "string" || edit.boundInputHash === "") return no("edit_binding_mismatch"); if (rec.boundInputHash === undefined) { return no("edit_binding_mismatch"); } if (edit.boundInputHash !== rec.boundInputHash) return no("edit_binding_mismatch"); if (rec.state === "approved" || rec.state === "redeemed") { if (rec.edited === undefined) return no("selection_mismatch"); const canonical = rec.candidates[rec.edited.index]?.rule; if (canonical === undefined) return no("selection_mismatch"); let hit = edit.text === rec.edited.text; if (!hit) { const reparsed = parseSubmittedSpelling(edit.text); hit = "rule" in reparsed && reparsed.rule.rule === canonical; } if (!hit) return no("selection_mismatch"); return { ok: true, mintedCandidate: { index: rec.edited.index, rule: canonical, ticket: mintRuleTicket(rec.id, rec.edited.index) } }; } if (rec.state !== "pending") return no("not_pending"); if (deps.cardEdits !== undefined && typeof deps.cardEdits !== "boolean") { throw new Error(`RuleConsentDeps.cardEdits must be a boolean when present (got ${typeof deps.cardEdits}) — refusing to guess whether the card-edit face is enabled`); } if (deps.cardEdits !== true) return no("edit_disabled"); const scope = rec.candidates[0]?.scope; if (scope === undefined || !rec.candidates.every((c) => sameScope(c.scope, scope))) { return no("edit_rejected", { message: "the record's candidates carry no single common scope — an edited candidate inherits the card's scope, and a record without one is malformed" }); } if (rec.command === undefined) { return no("edit_rejected", { message: "the record does not carry the adjudicated command (minted before card edits existed) — coverage cannot be verified, so the edit is refused" }); } const checked = checkEditedRuleText(edit.text, rec.command); if (!checked.ok) { return no("edit_rejected", { ...(checked.code !== undefined ? { code: checked.code } : {}), message: checked.message }); } const index = rec.candidates.length; const next = { ...rec, rev: rec.rev + 1, state: "approved", candidates: [...rec.candidates, { behavior: "allow", rule: checked.canonicalRule, scope }], offers: [...rec.offers, { kind: "single", candidate: index }], selectedOffer: rec.offers.length, edited: { index, text: edit.text, at: nowIso(deps), ...(checked.warnings !== undefined ? { warnings: checked.warnings.map((w) => w.code) } : {}) }, }; const won = await deps.approvals.cas(rec.id, rec.rev, next); if (!won) { const again = await deps.approvals.get(rec.id); if (again === undefined || isStale(again)) return no("record_not_found"); if (again.state === "pending") return no("conflict"); return await confirmEditedCandidate(again, edit, deps); } return { ok: true, mintedCandidate: { index, rule: checked.canonicalRule, ticket: mintRuleTicket(rec.id, index) } }; } export async function redeemRuleTicket(opts) { const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleTicket"); const parsed = parseRuleTicket(opts.ticket); if (parsed === undefined) return { status: "refused", reason: "malformed ticket" }; const rec = await opts.deps.approvals.get(parsed.recordId); if (rec === undefined) return { status: "refused", reason: "no such approval record" }; const recOwner = ownerOfRecord(rec); if (recOwner === undefined || !sameRuleOwner(recOwner, caller)) return { status: "refused", reason: "the ticket belongs to a different principal" }; if (isStale(rec)) return { status: "refused", reason: `record_schema_stale: ${STALE_RECORD_MESSAGE}` }; if (rec.state === "pending") return { status: "refused", reason: "the approval record has not been confirmed" }; const damage = approvalRecordDamageOf(rec); if (damage !== undefined) return { status: "refused", reason: `the approval record is malformed: ${damage}` }; const candidate = rec.candidates[parsed.index]; if (candidate === undefined) return { status: "refused", reason: "the ticket names no candidate on this record" }; const chosen = rec.selectedOffer; const chosenOffer = chosen !== undefined && Number.isInteger(chosen) ? rec.offers[chosen] : undefined; if (chosenOffer === undefined) { return { status: "refused", reason: "the approval record is malformed: it is settled but names no valid chosen offer" }; } if (!offerMembersOf(chosenOffer).includes(parsed.index)) { return { status: "refused", reason: "the ticket names an option the person did not choose" }; } const siblingsOf = candidate.behavior === "allow" ? { rec, offer: chosenOffer, index: parsed.index } : undefined; const parsedRule = parseRuleText(candidate.rule, candidate.behavior); if ("reject" in parsedRule) return { status: "refused", reason: `${parsedRule.reject.code}: ${parsedRule.reject.message}` }; let store; try { store = storeForOwner(opts.deps.provider, caller); } catch (err) { return { status: "refused", reason: errText(err) }; } const writer = writerOf(store); if (writer === undefined) return { status: "refused", reason: "the resolved permission-rule store has no write face" }; const known = rec.redeemedDots?.[parsed.index]; const dot = known ?? (await writer.nextDot()); if (known === undefined) { const next = { ...rec, rev: rec.rev + 1, state: "redeemed", redeemedDots: { ...(rec.redeemedDots ?? {}), [parsed.index]: dot }, }; const ok = await opts.deps.approvals.cas(rec.id, rec.rev, next); if (!ok) { const again = await opts.deps.approvals.get(rec.id); const theirs = again === undefined || isStale(again) ? undefined : again.redeemedDots?.[parsed.index]; if (theirs === undefined) return { status: "refused", reason: "the approval record changed state concurrently" }; return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot: theirs, recordId: rec.id, kind: rec.kind, replay: true, ...(siblingsOf !== undefined ? { siblingsOf } : {}) }); } } return await applyRedemption({ deps: opts.deps, owner: caller, candidate, parsedRule: parsedRule.rule, dot, recordId: rec.id, kind: rec.kind, replay: known !== undefined, ...(siblingsOf !== undefined ? { siblingsOf } : {}) }); } async function tighteningSiblingsStand(rec, offer, index, store, durableSnapshot) { for (const member of offerMembersOf(offer)) { if (member === index) continue; const sibling = rec.candidates[member]; if (sibling === undefined || sibling.behavior === "allow") continue; const dot = rec.redeemedDots?.[member]; if (dot === undefined) return false; const view = sibling.scope.kind === "session" ? await effectiveOrThrow(store, { sessionId: sibling.scope.sessionId }) : durableSnapshot; const live = view.rules.some((r) => r.behavior === sibling.behavior && r.rule === sibling.rule && sameScope(r.scope, sibling.scope) && r.adds.some((a) => a.dot.actor === dot.actor && a.dot.counter === dot.counter)); if (!live) return false; } return true; } async function applyRedemption(args) { let store; try { store = storeForOwner(args.deps.provider, args.owner); } catch (err) { return { status: "refused", reason: errText(err) }; } const writer = writerOf(store); if (writer === undefined) return { status: "refused", reason: "the resolved permission-rule store has no write face" }; const origin = originOfRecordKind(args.kind); const siblings = args.siblingsOf; const landing = await landRuleAdd({ store, writer, delta: { kind: "redemption-add", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, tool: args.parsedRule.tool, match: args.parsedRule.match, command: args.parsedRule.command, add: { dot: args.dot, origin, createdAt: nowIso(args.deps) }, redemption: { recordId: args.recordId, ...(args.owner.kind === "principal" ? { principal: args.owner.principal } : { owner: args.owner }), }, }, ...(siblings !== undefined ? { preflight: async (snapshot) => (await tighteningSiblingsStand(siblings.rec, siblings.offer, siblings.index, store, snapshot)) ? undefined : "withheld: this allow belongs to a batch whose deny/ask members do not all stand in the store — an allow must not stand wider than the list that was confirmed; redeem the batch (redeemRuleBatch) so the tightening members land first, or draw a fresh card", } : {}), }); if (!landing.landed) return { status: "refused", reason: landing.reason }; return { status: "redeemed", behavior: args.parsedRule.behavior, rule: args.parsedRule.rule, scope: args.candidate.scope, dot: args.dot, rev: landing.rev, alreadyRedeemed: args.replay }; } function originOfRecordKind(kind) { return kind === "import" ? "imported-cc" : kind === "starter" ? "starter" : "user"; } const IMPORT_UNCOVERED_LAYERS = { flagSettings: "not-imported-v1", policySettings: "not-imported-v1" }; export async function prepareCcImport(opts) { const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareCcImport"); const candidates = []; const skipped = []; const translated = []; const layers = []; for (const layer of opts.layers) { let raw; try { raw = await layer.readFile(layer.path); } catch { raw = undefined; } if (raw === undefined) { layers.push({ path: layer.path, layer: layer.layer, found: false }); continue; } layers.push({ path: layer.path, layer: layer.layer, found: true }); let settingsRoot; try { settingsRoot = JSON.parse(raw); } catch (err) { skipped.push({ rule: layer.path, reason: `settings file is not valid JSON (${errText(err)})` }); continue; } let container; if (settingsRoot === null) container = undefined; else if (typeof settingsRoot === "object" && !Array.isArray(settingsRoot)) container = settingsRoot.permissions; else { skipped.push({ rule: layer.path, reason: `settings file is not a JSON object — no bucket in this layer could be read, so it is reported as unread rather than as empty (it stays in the settings file either way)`, }); continue; } let permissions; if (container === undefined || container === null) permissions = undefined; else if (typeof container === "object" && !Array.isArray(container)) permissions = container; else { skipped.push({ rule: layer.path, reason: `settings "permissions" is not an object — no bucket in this layer could be read, so it is reported as unread rather than as empty (it stays in the settings file either way)`, }); continue; } const scope = layer.layer === "userSettings" ? { kind: "global" } : { kind: "project", root: layer.root }; for (const behavior of RULE_BEHAVIORS) { const entries = permissions?.[behavior]; if (entries === undefined || entries === null) continue; if (!Array.isArray(entries)) { skipped.push({ rule: layer.path, reason: `settings ${behavior} bucket is not an array — no entry in it could be read (it stays in the settings file either way)`, }); continue; } for (const entry of entries) { if (typeof entry !== "string") { skipped.push({ rule: String(entry), reason: `settings ${behavior} entry is not a string` }); continue; } if (behavior === "allow" && entry.startsWith("Read(") && "reject" in parseRuleText(entry, behavior)) { skipped.push({ rule: entry, reason: "unsupported.form: only the Read(//abs-dir/**) directory form imports in v1 — this entry stays in the settings file, unimported", }); continue; } if (!/^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)) { const reason = /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry) ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported" : "unsupported.form: not a command rule spelling this version imports — it stays in the settings file, unimported"; skipped.push({ rule: entry, reason }); continue; } const parsed = parseRuleText(entry, behavior); if ("reject" in parsed) { skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` }); continue; } if (candidates.some((c) => c.behavior === behavior && c.rule === parsed.rule.rule && sameScope(c.scope, scope))) continue; candidates.push({ behavior, rule: parsed.rule.rule, scope }); } } } const preview = { candidates, skipped, translated, layers, uncovered: { ...IMPORT_UNCOVERED_LAYERS } }; if (candidates.length === 0) return { preview }; const record = { id: mintId(opts.deps, "rar"), ...recordIdentityOf(owner), schema: 4, kind: "import", state: "pending", rev: 0, candidates, offers: [{ kind: "batch", candidates: candidates.map((_, i) => i) }], createdAt: nowIso(opts.deps), }; await opts.deps.approvals.create(record); return { preview, approvalId: record.id }; } export const STARTER_RULES = [ "Bash(ls)", "Bash(ls -la)", "Bash(pwd)", "Bash(whoami)", "Bash(uname -a)", "Bash(date)", "Bash(df -h)", "Bash(node --version)", "Bash(npm --version)", ]; export async function prepareStarterBatch(opts) { const owner = resolveCallerOwner(opts.principal, opts.owner, "prepareStarterBatch"); const candidates = []; for (const text of STARTER_RULES) { const parsed = parseRuleText(text, "allow"); if ("reject" in parsed) throw new Error(`starter rule "${text}" is not a valid rule: ${parsed.reject.message}`); candidates.push({ behavior: "allow", rule: parsed.rule.rule, scope: { kind: "global" } }); } const record = { id: mintId(opts.deps, "rar"), ...recordIdentityOf(owner), schema: 4, kind: "starter", state: "pending", rev: 0, candidates, offers: [{ kind: "batch", candidates: candidates.map((_, i) => i) }], createdAt: nowIso(opts.deps), }; await opts.deps.approvals.create(record); return { preview: candidates, approvalId: record.id }; } export async function redeemRuleBatch(opts) { const caller = resolveCallerOwner(opts.principal, opts.owner, "redeemRuleBatch"); const rec = await opts.deps.approvals.get(opts.approvalId); if (rec === undefined) return { status: "refused", reason: "no such approval record" }; const recOwner = ownerOfRecord(rec); if (recOwner === undefined || !sameRuleOwner(recOwner, caller)) return { status: "refused", reason: "the record belongs to a different principal" }; if (isStale(rec)) return { status: "refused", reason: `record_schema_stale: ${STALE_RECORD_MESSAGE}` }; if (rec.state === "pending") return { status: "refused", reason: "the approval record has not been confirmed" }; const damage = approvalRecordDamageOf(rec); if (damage !== undefined) return { status: "refused", reason: `the approval record is malformed: ${damage}` }; const chosen = rec.selectedOffer; const chosenOffer = chosen !== undefined && Number.isInteger(chosen) ? rec.offers[chosen] : undefined; if (chosenOffer === undefined) { return { status: "refused", reason: "the approval record is malformed: it is settled but names no valid chosen offer" }; } let store; try { store = storeForOwner(opts.deps.provider, caller); } catch (err) { return { status: "refused", reason: errText(err) }; } const members = []; let rev = 0; const ordered = [...offerMembersOf(chosenOffer)].sort((a, b) => { const ra = RULE_BEHAVIOR_PRECEDENCE[rec.candidates[a].behavior]; const rb = RULE_BEHAVIOR_PRECEDENCE[rec.candidates[b].behavior]; return ra !== rb ? ra - rb : a - b; }); let tighteningFailed; for (const candidateIndex of ordered) { const candidate = rec.candidates[candidateIndex]; const row = { candidateIndex, behavior: candidate.behavior, rule: candidate.rule, scope: candidate.scope }; if (candidate.behavior === "allow" && tighteningFailed !== undefined) { members.push({ ...row, status: "refused", reason: `withheld: candidate ${tighteningFailed} (a deny/ask member of this batch) did not land, and an allow must not stand wider than the list that was confirmed — redeem the batch again once the store accepts the tightening member` }); continue; } let alreadyThere = false; { try { const snap = await effectiveOrThrow(store, candidate.scope.kind === "session" ? { sessionId: candidate.scope.sessionId } : undefined); rev = snap.rev; alreadyThere = snap.rules.some((r) => r.behavior === candidate.behavior && r.rule === candidate.rule && sameScope(r.scope, candidate.scope)); } catch (err) { members.push({ ...row, status: "refused", reason: `could not read the permission-rule store: ${errText(err)}` }); if (candidate.behavior !== "allow") tighteningFailed ??= candidateIndex; continue; } } const res = await redeemRuleTicket({ ticket: mintRuleTicket(rec.id, candidateIndex), ...(caller.kind === "principal" ? { principal: caller.principal } : { owner: caller }), deps: opts.deps, }); if (res.status === "refused") { members.push({ ...row, status: "refused", reason: res.reason }); if (candidate.behavior !== "allow") tighteningFailed ??= candidateIndex; continue; } if (candidate.behavior !== "allow") { let live = false; try { const snap = await effectiveOrThrow(store, candidate.scope.kind === "session" ? { sessionId: candidate.scope.sessionId } : undefined); live = snap.rules.some((r) => r.behavior === candidate.behavior && r.rule === candidate.rule && sameScope(r.scope, candidate.scope) && r.adds.some((a) => a.dot.actor === res.dot.actor && a.dot.counter === res.dot.counter)); } catch { live = false; } if (!live) { members.push({ ...row, status: "refused", reason: `this ${candidate.behavior} member's redeemed dot ${res.dot.actor}#${res.dot.counter} is not live in the store (removed since it was redeemed) — a removed rule is not re-landed by replaying its record; a new consent is required` }); tighteningFailed ??= candidateIndex; continue; } } rev = candidate.scope.kind === "session" ? Math.max(rev, res.rev) : res.rev; members.push({ ...row, status: alreadyThere ? "deduped" : "persisted", alreadyRedeemed: res.alreadyRedeemed, dot: res.dot }); } return { members, rev }; }