SPB Git

Repository Public

approval.ts 5.0 KB 93 lines typescript

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
Files main
Menu

Theme