@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ // strip-tools-hook.mjs — SPIKE PROTOTYPE (wave 11, NOT wired into live settings).
3
+ //
4
+ // PreToolUse hook enforcing the tiered cognition/execution architecture:
5
+ // Haiku is the ONLY tier that reads/writes/executes; Sonnet/Opus/Fable are
6
+ // pure cognition (reason + message + dispatch). Two enforcement layers in
7
+ // one script (selected by payload.tool_name):
8
+ //
9
+ // LAYER 1 — dispatch guard (matcher "Agent|Task"):
10
+ // A subagent dispatch resolving to a cognition-tier model (sonnet/opus/
11
+ // fable) is rewritten to the `aesop-cognition` shim agent type, whose
12
+ // definition frontmatter allowlists ONLY cognition tools
13
+ // (Agent, SendMessage, TaskStop, Monitor). The original requested type is
14
+ // preserved in a COGNITION CONTRACT header prepended to the prompt.
15
+ // NOTE: the Agent tool input schema has NO `tools` field
16
+ // (additionalProperties: false), so per-dispatch tool lists CANNOT be set
17
+ // via updatedInput — the agent-definition `tools:` frontmatter is the only
18
+ // supported stripping mechanism today. Hence the shim rewrite.
19
+ //
20
+ // LAYER 2 — call-time backstop (matcher on the denied tools below):
21
+ // When an I/O/exec tool call fires inside a session whose transcript shows
22
+ // a non-haiku model, the call is DENIED with a reason that redirects the
23
+ // agent to emit a WORK-ORDER v1 and dispatch a Haiku executor instead
24
+ // (see DESIGN.md §3). Haiku sessions pass untouched.
25
+ //
26
+ // Escape hatch: [[ALLOW-TIER-TOOLS]] in the dispatch prompt keeps the agent's
27
+ // tools ([[ALLOW-NON-HAIKU]] is honored too during migration — today's
28
+ // escalations are hands-on missions). Never silent: every use is announced via
29
+ // permissionDecisionReason and appended as a JSON line to
30
+ // ${AESOP_ROOT:-~/aesop}/state/TIER-POLICY-ESCAPES.log.
31
+ //
32
+ // Reliability: fail-open everywhere (malformed payload / missing transcript /
33
+ // unreadable agents dir => no opinion, pass). stdin raced against a 2s timer.
34
+ // See FINDINGS.md for the fail-open-vs-fail-closed discussion.
35
+ //
36
+ // Self-test: `node strip-tools-hook.mjs --self-test` (no stdin needed; builds
37
+ // its own fixtures in a temp dir, exits 0 on PASS / 1 on FAIL).
38
+
39
+ import fs from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+
43
+ // ---------------------------------------------------------------- constants
44
+
45
+ export const ESCAPE_TOKENS = ['[[ALLOW-TIER-TOOLS]]', '[[ALLOW-NON-HAIKU]]'];
46
+ export const SHIM_AGENT = 'aesop-cognition';
47
+ export const ESCAPE_LOG_NAME = 'TIER-POLICY-ESCAPES.log';
48
+ const STDIN_TIMEOUT_MS = 2000;
49
+
50
+ // Cognition tiers KEEP exactly these (reason + message + dispatch):
51
+ export const KEEP_TOOLS = ['Agent', 'SendMessage', 'TaskStop', 'Monitor'];
52
+
53
+ // Cognition tiers are DENIED all I/O + exec + retrieval (Haiku's job):
54
+ export const DENY_TOOLS = [
55
+ 'Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
56
+ 'Bash', 'PowerShell', 'BashOutput', 'KillShell',
57
+ 'Glob', 'Grep', 'WebFetch', 'WebSearch',
58
+ 'Skill', 'Artifact', 'ToolSearch', 'EnterWorktree', 'ExitWorktree',
59
+ ];
60
+
61
+ const DISPATCH_TOOLS = new Set(['Agent', 'Task']);
62
+ const DENY_SET = new Set(DENY_TOOLS);
63
+
64
+ // --------------------------------------------------------------- tier logic
65
+
66
+ /** Map a model string to a tier: 'haiku' | 'cognition' | null (unknown). */
67
+ export function tierOf(model) {
68
+ const m = String(model || '').toLowerCase();
69
+ if (!m) return null;
70
+ if (m.includes('haiku')) return 'haiku';
71
+ if (m.includes('sonnet') || m.includes('opus') || m.includes('fable')) return 'cognition';
72
+ return null; // unrecognized model string — no opinion
73
+ }
74
+
75
+ /** Resolve tier from an installed agent definition's `model:` frontmatter
76
+ * (matches basename or `name:` field). Returns tier or null. */
77
+ export function tierFromAgentDef(type, agentsDir) {
78
+ if (!type) return null;
79
+ let files = [];
80
+ try { files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')); } catch { return null; }
81
+ for (const f of files) {
82
+ let head = '';
83
+ try { head = fs.readFileSync(path.join(agentsDir, f), 'utf8').slice(0, 600); } catch { continue; }
84
+ const base = f.replace(/\.md$/, '').toLowerCase();
85
+ let match = base === type;
86
+ if (!match) {
87
+ const nm = head.match(/^name:\s*(.+)$/m);
88
+ match = !!nm && nm[1].trim().toLowerCase() === type;
89
+ }
90
+ if (match) {
91
+ const mm = head.match(/^model:\s*(.+)$/m);
92
+ return mm ? tierOf(mm[1].trim()) : null;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // -------------------------------------------------------------- audit trail
99
+
100
+ function stateDir(opts) {
101
+ const root = (opts && opts.stateRoot) || process.env.AESOP_ROOT || path.join(os.homedir(), 'aesop');
102
+ return path.join(root, 'state');
103
+ }
104
+
105
+ /** Best-effort JSON-line audit record for escape-hatch use; never blocks. */
106
+ function logEscapeUse(payload, opts) {
107
+ try {
108
+ const dir = stateDir(opts);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ const input = payload.tool_input || {};
111
+ const rec = {
112
+ ts: new Date().toISOString(),
113
+ event: 'tier_policy_escape',
114
+ tool: payload.tool_name,
115
+ session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
116
+ cwd: typeof payload.cwd === 'string' ? payload.cwd : null,
117
+ description: typeof input.description === 'string' ? input.description : null,
118
+ requested_model: typeof input.model === 'string' ? input.model : null,
119
+ prompt_head: typeof input.prompt === 'string' ? input.prompt.slice(0, 200) : null,
120
+ };
121
+ fs.appendFileSync(path.join(dir, ESCAPE_LOG_NAME), JSON.stringify(rec) + '\n');
122
+ } catch { /* audit is best-effort */ }
123
+ }
124
+
125
+ // ------------------------------------------------------- transcript sniffer
126
+
127
+ /** Read a Claude Code transcript (JSONL) and return { tier, escaped }.
128
+ * tier: from the LAST assistant entry carrying message.model.
129
+ * escaped: true if any user entry contains an escape token (the subagent's
130
+ * dispatch prompt is the first user message of its transcript).
131
+ * Fail-open: unreadable/unparseable => { tier: null, escaped: false }. */
132
+ export function sniffTranscript(transcriptPath) {
133
+ const out = { tier: null, escaped: false };
134
+ let raw = '';
135
+ try { raw = fs.readFileSync(transcriptPath, 'utf8'); } catch { return out; }
136
+ for (const line of raw.split('\n')) {
137
+ if (!line.trim()) continue;
138
+ let j;
139
+ try { j = JSON.parse(line); } catch { continue; }
140
+ const msg = j && j.message;
141
+ if (!msg || typeof msg !== 'object') continue;
142
+ if ((j.type === 'assistant' || msg.role === 'assistant') && typeof msg.model === 'string') {
143
+ const t = tierOf(msg.model);
144
+ if (t) out.tier = t; // keep the LAST seen — model can change mid-session
145
+ }
146
+ if (j.type === 'user' || msg.role === 'user') {
147
+ const text = typeof msg.content === 'string'
148
+ ? msg.content
149
+ : Array.isArray(msg.content)
150
+ ? msg.content.map(c => (c && typeof c.text === 'string') ? c.text : '').join('\n')
151
+ : '';
152
+ if (ESCAPE_TOKENS.some(tok => text.includes(tok))) out.escaped = true;
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // ------------------------------------------------------------ contract text
159
+
160
+ export function contractHeader(originalType, tier) {
161
+ return (
162
+ `[COGNITION CONTRACT v1] You are a COGNITION-tier agent` +
163
+ (originalType ? ` acting in the role of '${originalType}'` : '') +
164
+ ` (tier: ${tier}). You have NO file/exec/retrieval tools. Do not attempt ` +
165
+ `Read/Write/Edit/Bash/Glob/Grep — they are stripped. Produce your ` +
166
+ `technical output as a WORK-ORDER v1 (unified diff + command list + ` +
167
+ `verify steps; schema in docs/spikes/tiered-cognition/DESIGN.md) and ` +
168
+ `dispatch a Haiku executor agent to apply it, or return the work-order ` +
169
+ `to your caller. All facts you need must arrive via your prompt or via ` +
170
+ `Haiku courier briefs you dispatch.\n\n`
171
+ );
172
+ }
173
+
174
+ // -------------------------------------------------------------- core policy
175
+
176
+ /**
177
+ * Pure decision function (testable). payload = parsed hook stdin JSON.
178
+ * opts = { agentsDir, stateRoot } (injectable for tests).
179
+ * Returns { action, output } where output is the object to write to stdout
180
+ * (or null for "no opinion").
181
+ * action: 'pass' | 'strip' | 'escape' | 'deny' | 'allow'
182
+ */
183
+ export function decide(payload, opts = {}) {
184
+ const pass = { action: 'pass', output: null };
185
+ if (!payload || typeof payload !== 'object') return pass;
186
+ const toolName = payload.tool_name;
187
+
188
+ // ---- LAYER 1: dispatch guard --------------------------------------
189
+ if (DISPATCH_TOOLS.has(toolName)) {
190
+ const input = payload.tool_input;
191
+ if (!input || typeof input !== 'object') return pass; // fail-open
192
+ const prompt = String(input.prompt || '');
193
+ const type = String(input.subagent_type || input.agentType || '').toLowerCase();
194
+ if (type === 'fork') return pass; // forks inherit parent context/model
195
+
196
+ const agentsDir = opts.agentsDir || path.join(os.homedir(), '.claude', 'agents');
197
+ // Tier resolution order: explicit model > agent-def frontmatter > default
198
+ // haiku (the model-policy hook lands every other dispatch on haiku).
199
+ const tier = tierOf(input.model) || tierFromAgentDef(type, agentsDir) || 'haiku';
200
+ if (tier === 'haiku') return pass;
201
+
202
+ if (ESCAPE_TOKENS.some(tok => prompt.includes(tok))) {
203
+ logEscapeUse(payload, opts);
204
+ return {
205
+ action: 'escape',
206
+ output: {
207
+ systemMessage: `⛓ tier policy BYPASSED via escape token — recorded in state/${ESCAPE_LOG_NAME}`,
208
+ hookSpecificOutput: {
209
+ hookEventName: 'PreToolUse',
210
+ permissionDecision: 'allow',
211
+ permissionDecisionReason:
212
+ `Tier policy BYPASSED via escape token: this ${toolName} dispatch ` +
213
+ `keeps its full toolset on model "${input.model || '(default)'}". ` +
214
+ `Use recorded in state/${ESCAPE_LOG_NAME}.`,
215
+ },
216
+ },
217
+ };
218
+ }
219
+
220
+ return {
221
+ action: 'strip',
222
+ strippedTools: DENY_TOOLS,
223
+ keptTools: KEEP_TOOLS,
224
+ output: {
225
+ systemMessage:
226
+ `⛓ cognition-tier dispatch (${tier}) → tools stripped via '${SHIM_AGENT}' shim ` +
227
+ `(kept: ${KEEP_TOOLS.join(', ')})`,
228
+ suppressOutput: true,
229
+ hookSpecificOutput: {
230
+ hookEventName: 'PreToolUse',
231
+ permissionDecision: 'allow',
232
+ permissionDecisionReason:
233
+ 'Tier policy: cognition tiers (sonnet/opus/fable) are message-only; ' +
234
+ 'Haiku executors perform all I/O ([[ALLOW-TIER-TOOLS]] to override).',
235
+ updatedInput: {
236
+ ...input,
237
+ subagent_type: SHIM_AGENT,
238
+ prompt: contractHeader(type, tier) + prompt,
239
+ },
240
+ },
241
+ },
242
+ };
243
+ }
244
+
245
+ // ---- LAYER 2: call-time backstop ----------------------------------
246
+ if (DENY_SET.has(toolName)) {
247
+ const t = payload.transcript_path;
248
+ if (!t || typeof t !== 'string') return pass; // fail-open
249
+ const { tier, escaped } = sniffTranscript(t);
250
+ if (tier === null) return pass; // unreadable/unknown transcript: fail-open, no opinion
251
+ if (tier === 'haiku' || escaped) {
252
+ return { action: 'allow', output: null }; // haiku substrate / escaped session
253
+ }
254
+ return {
255
+ action: 'deny',
256
+ output: {
257
+ hookSpecificOutput: {
258
+ hookEventName: 'PreToolUse',
259
+ permissionDecision: 'deny',
260
+ permissionDecisionReason:
261
+ `Tier policy: this session runs on a cognition-tier model, which never ` +
262
+ `touches files/exec directly. Do NOT retry ${toolName}. Instead emit a ` +
263
+ `WORK-ORDER v1 (unified diff / command list / verify steps — schema in ` +
264
+ `docs/spikes/tiered-cognition/DESIGN.md) and dispatch a Haiku executor ` +
265
+ `agent to apply it and report back an EXEC-BRIEF.`,
266
+ },
267
+ },
268
+ };
269
+ }
270
+
271
+ return pass; // tool not governed
272
+ }
273
+
274
+ // ----------------------------------------------------------------- plumbing
275
+
276
+ function readStdin(timeoutMs = STDIN_TIMEOUT_MS) {
277
+ return new Promise((resolve) => {
278
+ let settled = false;
279
+ const finish = (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v); } };
280
+ const timer = setTimeout(() => finish(''), timeoutMs);
281
+ let data = '';
282
+ process.stdin.setEncoding('utf8');
283
+ process.stdin.on('data', (c) => { data += c; });
284
+ process.stdin.on('end', () => finish(data));
285
+ process.stdin.on('error', () => finish(''));
286
+ });
287
+ }
288
+
289
+ async function runAsHook() {
290
+ const raw = await readStdin();
291
+ let payload = null;
292
+ try { payload = JSON.parse(raw); } catch { /* fail-open */ }
293
+ const { output } = decide(payload);
294
+ if (output) process.stdout.write(JSON.stringify(output) + '\n');
295
+ process.exit(0);
296
+ }
297
+
298
+ // ----------------------------------------------------------------- selftest
299
+
300
+ function selfTest() {
301
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tiercog-'));
302
+ const agentsDir = path.join(tmp, 'agents');
303
+ fs.mkdirSync(agentsDir);
304
+ fs.writeFileSync(path.join(agentsDir, 'typescript-pro.md'),
305
+ '---\nname: typescript-pro\ndescription: test fixture\nmodel: claude-sonnet-5\n---\nbody\n');
306
+ fs.writeFileSync(path.join(agentsDir, 'hooktest-haiku.md'),
307
+ '---\nname: hooktest-haiku\ndescription: test fixture\nmodel: haiku\n---\nbody\n');
308
+
309
+ const mkTranscript = (name, lines) => {
310
+ const p = path.join(tmp, name);
311
+ fs.writeFileSync(p, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
312
+ return p;
313
+ };
314
+ const sonnetTx = mkTranscript('sonnet.jsonl', [
315
+ { type: 'user', message: { role: 'user', content: 'implement the parser' } },
316
+ { type: 'assistant', message: { role: 'assistant', model: 'claude-sonnet-5', content: [] } },
317
+ ]);
318
+ const haikuTx = mkTranscript('haiku.jsonl', [
319
+ { type: 'user', message: { role: 'user', content: 'apply this work-order' } },
320
+ { type: 'assistant', message: { role: 'assistant', model: 'claude-haiku-4-5', content: [] } },
321
+ ]);
322
+ const escapedTx = mkTranscript('escaped.jsonl', [
323
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: '[[ALLOW-NON-HAIKU]] mission spike, hands-on' }] } },
324
+ { type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5', content: [] } },
325
+ ]);
326
+
327
+ const opts = { agentsDir, stateRoot: tmp };
328
+ const dispatch = (input) => ({ tool_name: 'Agent', tool_input: input, session_id: 's1', cwd: tmp });
329
+ const call = (tool, tx) => ({ tool_name: tool, tool_input: { file_path: 'x' }, transcript_path: tx });
330
+
331
+ let failures = 0;
332
+ const check = (name, cond, detail) => {
333
+ console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}${cond ? '' : ' <-- ' + (detail || '')}`);
334
+ if (!cond) failures++;
335
+ };
336
+
337
+ console.log('LAYER 1 — dispatch guard');
338
+ // 1. sonnet dispatch -> stripped via shim
339
+ let r = decide(dispatch({ model: 'sonnet', prompt: 'design the schema', description: 'd' }), opts);
340
+ check('sonnet dispatch is stripped', r.action === 'strip', `action=${r.action}`);
341
+ const ui = r.output && r.output.hookSpecificOutput.updatedInput;
342
+ check(' rewritten to shim agent type', !!ui && ui.subagent_type === SHIM_AGENT, JSON.stringify(ui));
343
+ check(' contract header prepended', !!ui && ui.prompt.startsWith('[COGNITION CONTRACT v1]'));
344
+ check(' original prompt preserved', !!ui && ui.prompt.endsWith('design the schema'));
345
+ check(' all I/O+exec tools in stripped set',
346
+ ['Read', 'Write', 'Edit', 'Bash', 'PowerShell', 'Glob', 'Grep', 'NotebookEdit', 'WebFetch']
347
+ .every(t => r.strippedTools.includes(t)));
348
+ check(' messaging/dispatch tools kept',
349
+ ['Agent', 'SendMessage'].every(t => r.keptTools.includes(t)) &&
350
+ r.keptTools.every(t => !r.strippedTools.includes(t)));
351
+
352
+ // 2. opus + fable dispatches -> stripped
353
+ r = decide(dispatch({ model: 'opus', prompt: 'p' }), opts);
354
+ check('opus dispatch is stripped', r.action === 'strip', `action=${r.action}`);
355
+ r = decide(dispatch({ model: 'fable', prompt: 'p' }), opts);
356
+ check('fable dispatch is stripped', r.action === 'strip', `action=${r.action}`);
357
+
358
+ // 3. haiku dispatch -> untouched
359
+ r = decide(dispatch({ model: 'haiku', prompt: 'apply the patch' }), opts);
360
+ check('haiku dispatch passes untouched', r.action === 'pass' && r.output === null, `action=${r.action}`);
361
+
362
+ // 4. blank model, generic type -> defaults haiku (model-policy hook lands it there) -> untouched
363
+ r = decide(dispatch({ prompt: 'p', subagent_type: 'general-purpose' }), opts);
364
+ check('blank-model generic dispatch passes (defaults haiku)', r.action === 'pass', `action=${r.action}`);
365
+
366
+ // 5. blank model, specialist type with sonnet frontmatter -> stripped
367
+ r = decide(dispatch({ prompt: 'p', subagent_type: 'typescript-pro' }), opts);
368
+ check('blank-model sonnet specialist is stripped', r.action === 'strip', `action=${r.action}`);
369
+
370
+ // 6. blank model, haiku-frontmatter agent -> untouched
371
+ r = decide(dispatch({ prompt: 'p', subagent_type: 'hooktest-haiku' }), opts);
372
+ check('haiku-frontmatter agent passes', r.action === 'pass', `action=${r.action}`);
373
+
374
+ // 7. escape-token dispatch -> allowed + audited
375
+ r = decide(dispatch({ model: 'sonnet', prompt: '[[ALLOW-TIER-TOOLS]] hands-on mission' }), opts);
376
+ check('escape-token dispatch keeps tools', r.action === 'escape', `action=${r.action}`);
377
+ check(' no updatedInput on escape', !r.output.hookSpecificOutput.updatedInput);
378
+ const logPath = path.join(tmp, 'state', ESCAPE_LOG_NAME);
379
+ let audited = false;
380
+ try { audited = fs.readFileSync(logPath, 'utf8').includes('tier_policy_escape'); } catch { }
381
+ check(' escape use audited to state log', audited, logPath);
382
+
383
+ // 8. legacy [[ALLOW-NON-HAIKU]] also escapes (migration grandfathering)
384
+ r = decide(dispatch({ model: 'opus', prompt: '[[ALLOW-NON-HAIKU]] mission' }), opts);
385
+ check('legacy ALLOW-NON-HAIKU token also escapes', r.action === 'escape', `action=${r.action}`);
386
+
387
+ // 9. fork passes untouched
388
+ r = decide(dispatch({ model: 'sonnet', prompt: 'p', subagent_type: 'fork' }), opts);
389
+ check('fork dispatch passes untouched', r.action === 'pass', `action=${r.action}`);
390
+
391
+ // 10. malformed payloads fail open
392
+ check('malformed payload fails open', decide(null, opts).action === 'pass');
393
+ check('missing tool_input fails open', decide({ tool_name: 'Agent' }, opts).action === 'pass');
394
+
395
+ console.log('LAYER 2 — call-time backstop');
396
+ // 11. Write/Bash under a sonnet transcript -> denied with redirect reason
397
+ r = decide(call('Write', sonnetTx), opts);
398
+ check('Write under sonnet session is DENIED', r.action === 'deny', `action=${r.action}`);
399
+ check(' deny reason redirects to WORK-ORDER + Haiku executor',
400
+ r.action === 'deny' && /WORK-ORDER/.test(r.output.hookSpecificOutput.permissionDecisionReason) &&
401
+ r.output.hookSpecificOutput.permissionDecision === 'deny');
402
+ r = decide(call('Bash', sonnetTx), opts);
403
+ check('Bash under sonnet session is DENIED', r.action === 'deny', `action=${r.action}`);
404
+
405
+ // 12. same tools under a haiku transcript -> allowed
406
+ r = decide(call('Write', haikuTx), opts);
407
+ check('Write under haiku session is allowed', r.action === 'allow', `action=${r.action}`);
408
+ r = decide(call('Bash', haikuTx), opts);
409
+ check('Bash under haiku session is allowed', r.action === 'allow', `action=${r.action}`);
410
+
411
+ // 13. escaped fable session (this spike!) keeps its tools
412
+ r = decide(call('Edit', escapedTx), opts);
413
+ check('escape-token session keeps I/O tools', r.action === 'allow', `action=${r.action}`);
414
+
415
+ // 14. missing/unreadable transcript fails open
416
+ r = decide(call('Write', path.join(tmp, 'nope.jsonl')), opts);
417
+ check('missing transcript fails open (pass)', r.action === 'pass', `action=${r.action}`);
418
+
419
+ // 15. ungoverned tool passes
420
+ r = decide({ tool_name: 'SendMessage', tool_input: {}, transcript_path: sonnetTx }, opts);
421
+ check('ungoverned tool (SendMessage) passes', r.action === 'pass', `action=${r.action}`);
422
+
423
+ console.log(failures === 0 ? '\nSELF-TEST: ALL PASS' : `\nSELF-TEST: ${failures} FAILURE(S)`);
424
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { }
425
+ process.exit(failures === 0 ? 0 : 1);
426
+ }
427
+
428
+ // ----------------------------------------------------------------- dispatch
429
+
430
+ if (process.argv.includes('--self-test')) {
431
+ selfTest();
432
+ } else {
433
+ runAsHook();
434
+ }
@@ -0,0 +1,38 @@
1
+ # hooks/ — Installable org-policy git pre-push enforcement
2
+
3
+ **Purpose**: Ship executable git hooks that gate pushes with organization security policies (branch protection, secret scanning).
4
+
5
+ ## Hook: pre-push-policy.sh
6
+
7
+ Runs on `git push` via `.git/hooks/pre-push` symlink or copy.
8
+
9
+ **Checks & Exit Contract**:
10
+ 1. `check_branch_policy()` — blocks direct pushes to main/master; exit 1 on violation
11
+ 2. `check_secret_scan()` — runs `tools/secret_scan.py --staged`; exit 1 on failure
12
+ 3. Both trigger `log_block()` to append audit record before exit
13
+
14
+ **Audit-Ledger Contract**:
15
+ - Path: `${AESOP_ROOT:-$HOME/aesop}/state/SECURITY-AUDIT.log` (append-only, git-ignored)
16
+ - Format: JSON-lines (one record per line)
17
+ - Schema: `{"ts":"2025-07-12T14:32:01Z","repo":"aesop","event":"push_blocked","reason":"secret_scan_failure","user":"alice"}`
18
+ - All string values must be json_escaped (backslash → `\\`, quote → `\"`)
19
+
20
+ **Self-Test Convention**:
21
+ - `bash hooks/pre-push-policy.sh --test` runs the self-test suite, including:
22
+ 1. Branch policy blocks main/master
23
+ 2. Branch policy allows feature/* branches
24
+ 3. Audit log JSON format is valid
25
+ 4. JSON escaping handles special chars (quotes, backslashes)
26
+ 5. stdin handling (git pre-push pipe) doesn't crash hook
27
+ - Exit 0 = all pass; exit 1 = any fail
28
+
29
+ **Installation**:
30
+ - See `docs/HOOK-INSTALL.md` for symlink (Linux/macOS/Git Bash) and copy (Windows) methods
31
+ - Test with `bash hooks/pre-push-policy.sh --test` before org distribution
32
+
33
+ ## Invariants
34
+
35
+ - POSIX sh compatible, CRLF-safe (no line continuations)
36
+ - Tolerate git pre-push stdin (ref list) + optional args without choking
37
+ - Fail-open only for missing optional tooling (secret_scan.py absent → allow); fail-closed for policy checks
38
+ - Use `AESOP_ROOT` env var or `$HOME/aesop` fallback; no hardcoded machine paths/usernames
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // force-model-policy.mjs — Claude Code PreToolUse hook enforcing the
3
+ // "subagents are always Haiku" cardinal rule as versioned, executable policy.
4
+ //
5
+ // Wire it in settings.json under hooks.PreToolUse with matcher "Agent|Task"
6
+ // (see docs/HOOK-INSTALL.md). For every subagent dispatch:
7
+ //
8
+ // - model absent or non-compliant -> rewritten to the policy model ("haiku",
9
+ // or aesop.config.json cardinal_rules.subagent_model when present)
10
+ // - prompt contains [[ALLOW-NON-HAIKU]] -> deliberate escape hatch: input is
11
+ // left untouched, but the bypass is announced via permissionDecisionReason
12
+ // (visible in the transcript) AND appended as a JSON-line audit record to
13
+ // ${AESOP_ROOT:-~/aesop}/state/MODEL-POLICY-ESCAPES.log so every use is
14
+ // reviewable — prompts are untrusted text and can smuggle the marker.
15
+ // - anything else (compliant, other tools, malformed input) -> no output
16
+ //
17
+ // Output contract source: Claude Code hooks reference ("PreToolUse Decision
18
+ // Control", https://docs.anthropic.com/en/docs/claude-code/hooks) — a hook may
19
+ // emit on stdout:
20
+ // {"hookSpecificOutput":{"hookEventName":"PreToolUse",
21
+ // "permissionDecision":"allow","updatedInput":{...}}}
22
+ // where updatedInput replaces tool_input for the dispatched call. Emitting
23
+ // nothing (exit 0) means "no opinion" and the call proceeds unchanged.
24
+ //
25
+ // Reliability rule: this hook NEVER crashes the harness. Any parse or IO
26
+ // failure results in no output and exit 0 (fail-open), and nothing from the
27
+ // payload is ever logged on failure paths. stdin is raced against a 2s timer
28
+ // so a never-closing pipe cannot hang Agent/Task dispatch (fail-open, no
29
+ // rewrite). The only deliberate exception to "log nothing" is the escape-hatch
30
+ // audit record above.
31
+
32
+ import fs from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+
36
+ const ESCAPE_HATCH = '[[ALLOW-NON-HAIKU]]';
37
+ const DEFAULT_MODEL = 'haiku';
38
+ const GOVERNED_TOOLS = new Set(['Agent', 'Task']);
39
+ const STDIN_TIMEOUT_MS = 2000;
40
+ const ESCAPE_LOG_NAME = 'MODEL-POLICY-ESCAPES.log';
41
+
42
+ /** State root, consistent with the repo's resolution pattern
43
+ * (`${AESOP_ROOT:-$HOME/aesop}/state/` — see hooks/pre-push-policy.sh). */
44
+ function stateDir() {
45
+ const root = process.env.AESOP_ROOT || path.join(os.homedir(), 'aesop');
46
+ return path.join(root, 'state');
47
+ }
48
+
49
+ /** Append a JSON-line audit record for an escape-hatch use. Best-effort:
50
+ * an unwritable log never blocks the dispatch (fail-open). */
51
+ function logEscapeUse(payload) {
52
+ try {
53
+ const dir = stateDir();
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ const input = payload.tool_input || {};
56
+ const rec = {
57
+ ts: new Date().toISOString(),
58
+ event: 'model_policy_escape',
59
+ tool: payload.tool_name,
60
+ session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
61
+ cwd: typeof payload.cwd === 'string' ? payload.cwd : null,
62
+ description: typeof input.description === 'string' ? input.description : null,
63
+ requested_model: typeof input.model === 'string' ? input.model : null,
64
+ prompt_head: typeof input.prompt === 'string' ? input.prompt.slice(0, 200) : null
65
+ };
66
+ fs.appendFileSync(path.join(dir, ESCAPE_LOG_NAME), JSON.stringify(rec) + '\n');
67
+ } catch {
68
+ // audit logging is best-effort; never block or crash the harness
69
+ }
70
+ }
71
+
72
+ /** Resolve the policy model: aesop.config.json cardinal_rules.subagent_model,
73
+ * looked up in $AESOP_ROOT then cwd; falls back to "haiku". */
74
+ function policyModel() {
75
+ const roots = [process.env.AESOP_ROOT, process.cwd()].filter(Boolean);
76
+ for (const root of roots) {
77
+ try {
78
+ const cfg = JSON.parse(fs.readFileSync(path.join(root, 'aesop.config.json'), 'utf8'));
79
+ const m = cfg && cfg.cardinal_rules && cfg.cardinal_rules.subagent_model;
80
+ if (typeof m === 'string' && m.trim()) return m.trim();
81
+ } catch {
82
+ // missing or unreadable config in this root — try the next one
83
+ }
84
+ }
85
+ return DEFAULT_MODEL;
86
+ }
87
+
88
+ /** Read stdin raced against a timer: a pipe that never closes must not hang
89
+ * Agent/Task dispatch. On timeout, resolve '' (fail-open, no rewrite). */
90
+ function readStdin(timeoutMs = STDIN_TIMEOUT_MS) {
91
+ return new Promise((resolve) => {
92
+ let settled = false;
93
+ const finish = (value) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ clearTimeout(timer);
97
+ resolve(value);
98
+ };
99
+ const timer = setTimeout(() => finish(''), timeoutMs);
100
+ let data = '';
101
+ process.stdin.setEncoding('utf8');
102
+ process.stdin.on('data', (chunk) => { data += chunk; });
103
+ process.stdin.on('end', () => finish(data));
104
+ process.stdin.on('error', () => finish(''));
105
+ });
106
+ }
107
+
108
+ async function main() {
109
+ const raw = await readStdin();
110
+
111
+ let payload;
112
+ try {
113
+ payload = JSON.parse(raw);
114
+ } catch {
115
+ return; // malformed input: fail-open, no output, log nothing
116
+ }
117
+ if (!payload || typeof payload !== 'object') return;
118
+ if (!GOVERNED_TOOLS.has(payload.tool_name)) return;
119
+
120
+ const input = payload.tool_input;
121
+ if (!input || typeof input !== 'object') return;
122
+
123
+ const model = policyModel();
124
+
125
+ // Deliberate opt-out — but never silent: prompts are untrusted text, so the
126
+ // marker could be smuggled in from repo/file content. Every use is announced
127
+ // in the transcript (permissionDecisionReason) and appended to the audit log.
128
+ if (typeof input.prompt === 'string' && input.prompt.includes(ESCAPE_HATCH)) {
129
+ logEscapeUse(payload);
130
+ process.stdout.write(JSON.stringify({
131
+ hookSpecificOutput: {
132
+ hookEventName: 'PreToolUse',
133
+ permissionDecision: 'allow',
134
+ permissionDecisionReason:
135
+ `Model policy BYPASSED via ${ESCAPE_HATCH} escape hatch: this ` +
136
+ `${payload.tool_name} dispatch keeps model ` +
137
+ `"${typeof input.model === 'string' ? input.model : '(default)'}" ` +
138
+ `instead of policy model "${model}". Use recorded in ` +
139
+ `state/${ESCAPE_LOG_NAME}.`
140
+ }
141
+ }) + '\n');
142
+ return;
143
+ }
144
+
145
+ if (input.model === model) return; // already compliant — no opinion
146
+
147
+ process.stdout.write(JSON.stringify({
148
+ hookSpecificOutput: {
149
+ hookEventName: 'PreToolUse',
150
+ permissionDecision: 'allow',
151
+ permissionDecisionReason:
152
+ `Model policy: subagent dispatches run on "${model}" (cardinal rule; ` +
153
+ `override with ${ESCAPE_HATCH} in the prompt).`,
154
+ updatedInput: { ...input, model }
155
+ }
156
+ }) + '\n');
157
+ }
158
+
159
+ main().catch(() => { /* never crash the harness */ }).finally(() => process.exit(0));