SPB Git

Repository Public

policy.ts 10.9 KB 223 lines typescript

1/**2 * Policy (CONTRACT §6): hard denies → any deny rule → any ask rule → any allow rule → risk defaults,3 * evaluated by the engine, never by the model.4 *5 * `decide()` is a PURE function of journaled data (the tool input from a model step, the rule snapshot6 * from a `policy` step, mission facts from the `run_meta` step): a replay takes the same decision and7 * therefore makes the same kernel calls. It never touches the filesystem — the symlink-safe path checks8 * are done again by the tools themselves at execution time.9 */10import path from 'node:path';11import { KernelStorageError, type Kernel } from '@aos/kernel';12import { AosSettings, type RiskClass } from '@aos/shared';13import { protectedReason } from '@aos/tools';14import { dbQuery } from './db.js';1516export type Decision = 'allow' | 'ask' | 'deny';1718export interface PolicyRule {19  id: string;20  projectId: string | null;21  decision: Decision;22  tool: string;23  match: Record<string, unknown>;24  note: string;25}26export interface PolicySnapshot {27  rules: PolicyRule[];28  approvalTimeoutHours: number;29}30export interface PolicyVerdict {31  decision: Decision;32  /** Short, console-facing explanation (`policy.decision` payload and the denial text shown to the model). */33  reason: string;34  source: 'hard' | 'rule' | 'default';35  ruleId?: string;36}37export interface PolicyQuery {38  tool: string;39  input: unknown;40  risk: RiskClass;41  network: 'none' | 'open';42  projectId: string | null;43  /** Lexical workspace root, to relativise absolute tool paths. */44  workspaceAbs: string;45  /** false while the mission has no confirmed done criteria: every non-read tool is refused. */46  criteriaConfirmed: boolean;47}4849const FILE_WRITE_TOOLS = new Set(['edit_file', 'write_file']);50const SHELL_OPERATORS = /[;&|`<>\n\r]|\$\(/;51const str = (v: unknown): string | null => (typeof v === 'string' ? v : null);52const field = (input: unknown, key: string): string | null => (input && typeof input === 'object' ? str((input as Record<string, unknown>)[key]) : null);5354/** Lexical, workspace-relative POSIX form of a tool path; null when it points outside the workspace. */55export function lexicalRel(p: string): string | null {56  if (p.includes('\u0000')) return null;57  const norm = path.posix.normalize(p.replaceAll('\\', '/'));58  if (path.posix.isAbsolute(norm) || norm === '..' || norm.startsWith('../')) return null;59  return norm === '.' ? '' : norm.replace(/\/+$/, '');60}6162/**63 * Prefix remembered for a shell approval: the first token, plus the second one when it looks like a64 * sub-command (`npm install`, `git fetch`) rather than an option, a path or a URL.65 */66export function commandPrefix(command: string): string {67  const tokens = command.trim().split(/\s+/).filter((t) => t.length > 0);68  const first = tokens[0] ?? '';69  const second = tokens[1];70  return second && /^[A-Za-z][A-Za-z0-9:_-]*$/.test(second) ? `${first} ${second}` : first;71}7273function prefixMatches(command: string, prefix: string): boolean {74  const c = command.trim().replace(/\s+/g, ' ');75  const p = prefix.trim().replace(/\s+/g, ' ');76  return p.length > 0 && (c === p || c.startsWith(`${p} `));77}7879/**80 * Does a rule apply to this call? Matchers: `commandPrefix` (shell), `pathPrefix` (file tools), `{}` = any.81 * An ALLOW rule with a commandPrefix only covers a simple command: anything with shell operators82 * (`;`, `&&`, `|`, redirections, `$(…)`, several lines) is not covered, otherwise "allow npm test" would83 * also allow `npm test; curl …`. A matcher the engine does not know makes an allow rule NOT apply and a84 * deny/ask rule apply (fail safe in both directions).85 */86export function ruleMatches(rule: PolicyRule, tool: string, input: unknown): boolean {87  if (rule.tool !== '*' && rule.tool !== tool) return false;88  for (const [key, want] of Object.entries(rule.match ?? {})) {89    if (key === 'commandPrefix' && typeof want === 'string') {90      const command = field(input, 'command');91      if (command === null) return false;92      if (!prefixMatches(command, want)) return false;93      if (rule.decision === 'allow' && SHELL_OPERATORS.test(command)) return false;94    } else if (key === 'pathPrefix' && typeof want === 'string') {95      const p = field(input, 'path');96      const rel = p === null ? null : lexicalRel(p);97      const w = lexicalRel(want);98      if (rel === null || w === null) return rule.decision !== 'allow';99      if (!(w === '' || rel === w || rel.startsWith(`${w}/`))) return false;100    } else if (rule.decision === 'allow') {101      return false;102    }103  }104  return true;105}106107/**108 * Owner decision (2026-11-10): a `deny` rule wins regardless of scope — a global deny beats a project109 * allow. Order: any matching deny → any matching ask → any matching allow. Within one decision the110 * project-scoped rule is reported before a global one (it is the more specific explanation).111 */112const ORDER: readonly Decision[] = ['deny', 'ask', 'allow'];113function pickRule(rules: PolicyRule[], projectId: string | null): PolicyRule | null {114  const visible = rules.filter((r) => r.projectId === null || (projectId !== null && r.projectId === projectId));115  for (const d of ORDER) {116    const hit = visible.find((r) => r.decision === d && r.projectId !== null) ?? visible.find((r) => r.decision === d);117    if (hit) return hit;118  }119  return null;120}121122const DEFAULT_BY_RISK: Record<RiskClass, { decision: Decision; reason: string }> = {123  read: { decision: 'allow', reason: 'default: read' },124  workspace_write: { decision: 'allow', reason: 'default: workspace write (sandboxed, mission branch)' },125  network: { decision: 'ask', reason: 'default: network access needs approval' },126  external: { decision: 'ask', reason: 'default: external effect needs approval' },127};128129export function decide(q: PolicyQuery, snapshot: PolicySnapshot): PolicyVerdict {130  // 1. Hard denies: no rule can override them.131  if (FILE_WRITE_TOOLS.has(q.tool)) {132    const p = field(q.input, 'path');133    // An absolute path is relativised lexically; when it is not under the workspace as written, the tool's134    // own realpath-based check decides (it refuses anything that really is outside).135    const abs = p !== null && path.posix.isAbsolute(p);136    const inside = abs && (p === q.workspaceAbs || p.startsWith(`${q.workspaceAbs}/`));137    const rel = p === null || (abs && !inside) ? null : lexicalRel(inside ? p.slice(q.workspaceAbs.length + 1) : p);138    if (p !== null && !abs && rel === null) return { decision: 'deny', source: 'hard', reason: 'the path is outside the workspace' };139    const why = rel === null ? null : protectedReason(rel);140    if (why) return { decision: 'deny', source: 'hard', reason: `protected path: ${why}` };141  }142  if (q.risk === 'network' && q.network === 'none') return { decision: 'deny', source: 'hard', reason: 'network access is disabled for this mission' };143  if (!q.criteriaConfirmed && q.risk !== 'read') {144    return { decision: 'deny', source: 'hard', reason: 'the done criteria are not confirmed yet: call propose_criteria and wait for the owner before any write, shell or git-write call' };145  }146  // 2. Owner rules (this project's and global ones): deny > ask > allow, whatever the scope.147  const rule = pickRule(snapshot.rules.filter((r) => ruleMatches(r, q.tool, q.input)), q.projectId);148  if (rule) {149    const scope = rule.projectId === null ? 'global' : 'project';150    return { decision: rule.decision, source: 'rule', ruleId: rule.id, reason: `${scope} rule: ${rule.decision} ${rule.tool}${rule.note ? ` (${rule.note})` : ''}` };151  }152  // 3. Defaults by risk.153  return { ...DEFAULT_BY_RISK[q.risk], source: 'default' };154}155156/** What an approval with `remember` turns into: an allow rule for this tool (+ command prefix for shell). */157export function rememberedRule(tool: string, input: unknown): { tool: string; match: Record<string, unknown> } {158  const command = tool === 'shell' ? field(input, 'command') : null;159  return command !== null ? { tool, match: { commandPrefix: commandPrefix(command) } } : { tool, match: {} };160}161162// ---- data access (called inside journaled steps only) ---------------------------------------------------163164interface RuleRow {165  id: string;166  project_id: string | null;167  decision: Decision;168  tool: string;169  match: Record<string, unknown> | null;170  note: string;171}172const toRule = (r: RuleRow): PolicyRule => ({ id: r.id, projectId: r.project_id, decision: r.decision, tool: r.tool, match: r.match ?? {}, note: r.note });173174/** Owner rules visible to a mission (its project's + global) and the owner's approval timeout. */175export async function loadPolicySnapshot(k: Kernel, ownerId: string, projectId: string | null): Promise<PolicySnapshot> {176  const rules = await dbQuery<RuleRow>(177    k,178    `SELECT id, project_id, decision, tool, match, note FROM policy_rules179      WHERE owner_id = $1 AND (project_id IS NULL OR project_id = $2) ORDER BY created_at, id`,180    [ownerId, projectId],181  );182  const s = await dbQuery<{ settings?: unknown }>(k, 'SELECT settings FROM application_settings WHERE user_id = $1', [ownerId]);183  const parsed = AosSettings.safeParse(s[0]?.settings ?? {});184  const settings = parsed.success ? parsed.data : AosSettings.parse({});185  return { rules: rules.map(toRule), approvalTimeoutHours: settings.approvalTimeoutHours };186}187188/** Idempotent insert of a project-scoped allow rule: an identical rule is reused, never duplicated. */189export async function insertAllowRule(190  k: Kernel, ownerId: string, projectId: string, rule: { tool: string; match: Record<string, unknown> }, note: string,191): Promise<{ rule: PolicyRule; inserted: boolean }> {192  const match = JSON.stringify(rule.match);193  const client = await k.pool.connect().catch((err: Error) => {194    throw new KernelStorageError(`engine storage error: ${err.message}`, { cause: err });195  });196  try {197    await client.query('BEGIN');198    // Serialise concurrent inserts of the same owner's rules (two runs approving the same thing).199    await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`aos-policy:${ownerId}`]);200    const existing = await client.query(201      `SELECT id, project_id, decision, tool, match, note FROM policy_rules202        WHERE owner_id = $1 AND project_id = $2 AND decision = 'allow' AND tool = $3 AND match = $4::jsonb ORDER BY created_at, id LIMIT 1`,203      [ownerId, projectId, rule.tool, match],204    );205    if (existing.rows.length > 0) {206      await client.query('COMMIT');207      return { rule: toRule(existing.rows[0] as RuleRow), inserted: false };208    }209    const ins = await client.query(210      `INSERT INTO policy_rules (owner_id, project_id, decision, tool, match, note) VALUES ($1, $2, 'allow', $3, $4::jsonb, $5)211       RETURNING id, project_id, decision, tool, match, note`,212      [ownerId, projectId, rule.tool, match, note],213    );214    await client.query('COMMIT');215    return { rule: toRule(ins.rows[0] as RuleRow), inserted: true };216  } catch (err) {217    await client.query('ROLLBACK').catch(() => undefined);218    throw new KernelStorageError(`engine storage error: ${(err as Error).message}`, { cause: err });219  } finally {220    client.release();221  }222}223
Files main
Menu

Theme