Repository Public
1/**2 * Policy gate of one tool call (CONTRACT §6): evaluate, emit `policy.decision`, and when the verdict is3 * "ask" park the run on a durable approval wait. Expiry = deny. "Approve and remember" inserts a4 * project-scoped allow rule inside a journaled step (replay-safe, idempotent) and announces it.5 */6import { z } from 'zod';7import type { RiskClass } from '@aos/shared';8import type { Tool } from '@aos/tools';9import { decide, insertAllowRule, rememberedRule, type PolicyRule, type PolicySnapshot, type PolicyVerdict } from './policy.js';10import { isExpired, ownerWait, type RunState } from './state.js';1112const ApprovalResolution = z.object({13 decision: z.enum(['approve', 'deny']),14 remember: z.boolean().optional(),15 note: z.string().optional(),16});1718export interface GateInput {19 call: { id: string; name: string };20 tool: Tool;21 input: unknown;22 risk: RiskClass;23}24export type GateOutcome = { allowed: true } | { allowed: false; message: string };2526export function verdictFor(st: RunState, g: GateInput, snapshot: PolicySnapshot): PolicyVerdict {27 return decide(28 {29 tool: g.call.name, input: g.input, risk: g.risk, network: st.meta.network, projectId: st.meta.projectId,30 workspaceAbs: st.ctx.workspaceAbs, criteriaConfirmed: st.criteriaConfirmed,31 },32 { ...snapshot, rules: [...snapshot.rules, ...st.batchRules] },33 );34}3536export function emitDecision(st: RunState, g: GateInput, v: { decision: string; reason: string; source: string }, extra: Record<string, unknown> = {}): Promise<void> {37 return st.ctx.emit('policy.decision', { tool: g.call.name, callId: g.call.id, risk: g.risk, decision: v.decision, reason: v.reason, source: v.source, ...extra }, 'system');38}3940function describe(g: GateInput): { title: string; detail: string; diff?: string } {41 try {42 const d = g.tool.describeForApproval?.(g.input);43 if (d) return d;44 } catch {45 /* fall through to the generic description */46 }47 return { title: `Run tool ${g.call.name}`, detail: JSON.stringify(g.input, null, 2).slice(0, 4000) };48}4950async function remember(st: RunState, g: GateInput): Promise<void> {51 const { ctx, meta, deps } = st;52 const projectId = meta.projectId;53 if (projectId === null) {54 await emitDecision(st, g, { decision: 'allow', reason: 'approved by owner; not remembered (the mission has no project to scope a rule to)', source: 'owner' });55 return;56 }57 const wanted = rememberedRule(g.call.name, g.input);58 const res = await ctx.step<Record<string, unknown>, { rule: PolicyRule; inserted: boolean }>(59 { kind: 'policy', name: 'remember_rule', effect: 'idempotent_write', request: { tool: wanted.tool, match: wanted.match, projectId } },60 async () => ({ result: await insertAllowRule(deps.kernel, meta.ownerId, projectId, wanted, 'Remembered from an approval') }),61 );62 st.batchRules.push(res.rule);63 const what = typeof wanted.match.commandPrefix === 'string' ? `${wanted.tool} commands starting with "${wanted.match.commandPrefix}"` : wanted.tool;64 await emitDecision(st, g, { decision: 'allow', reason: `approved by owner; rule ${res.inserted ? 'created' : 'already present'}: allow ${what} in this project`, source: 'owner' }, {65 remembered: true, rule: { id: res.rule.id, tool: res.rule.tool, match: res.rule.match, projectId: res.rule.projectId, decision: 'allow' },66 });67}6869/** Applies an already computed verdict: emits it, and waits for the owner when it is "ask". */70export async function applyVerdict(st: RunState, g: GateInput, v: PolicyVerdict, snapshot: PolicySnapshot): Promise<GateOutcome> {71 await emitDecision(st, g, v, v.ruleId ? { ruleId: v.ruleId } : {});72 if (v.decision === 'allow') return { allowed: true };73 if (v.decision === 'deny') return { allowed: false, message: `Denied by policy: ${v.reason}.` };7475 const d = describe(g);76 const payload: Record<string, unknown> = { tool: g.call.name, input: g.input, risk: g.risk, title: d.title, detail: d.detail, reason: v.reason };77 if (d.diff) payload.diff = d.diff;78 const raw = await ownerWait<unknown>(st, 'approval', payload, Math.round(snapshot.approvalTimeoutHours * 3600));79 if (isExpired(raw)) {80 await emitDecision(st, g, { decision: 'deny', reason: 'the approval request expired without an answer', source: 'expiry' });81 return { allowed: false, message: 'Denied: the approval request expired without an answer from the owner. Do not retry it; continue without this action or finish with "blocked".' };82 }83 const res = ApprovalResolution.safeParse(raw);84 if (!res.success || res.data.decision !== 'approve') {85 const note = res.success ? res.data.note?.trim() : undefined;86 await emitDecision(st, g, { decision: 'deny', reason: note ? `denied by owner: ${note}` : 'denied by owner', source: 'owner' });87 return { allowed: false, message: `Denied by owner${note ? `: ${note}` : '.'}` };88 }89 if (res.data.remember) await remember(st, g);90 else await emitDecision(st, g, { decision: 'allow', reason: res.data.note?.trim() ? `approved by owner: ${res.data.note.trim()}` : 'approved by owner', source: 'owner' });91 return { allowed: true };92}93