@matt82198/aesop 0.1.0 → 0.2.0
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.
- package/CHANGELOG.md +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -1,434 +0,0 @@
|
|
|
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
|
-
}
|
package/hooks/CLAUDE.md
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
## Hook: pre-commit-waveguard.sh
|
|
34
|
-
|
|
35
|
-
Prevents accidental commits to the PRIMARY aesop tree during a wave cycle. Runs on `git commit` via `.git/hooks/pre-commit`.
|
|
36
|
-
|
|
37
|
-
**Purpose**: During orchestrated waves, the orchestrator sets a marker file (`state/.wave-in-flight`) in the PRIMARY tree only. Sibling worktrees do not inherit this marker (separate working trees), so fleet agents commit freely in worktrees while stray commits to the primary tree are rejected.
|
|
38
|
-
|
|
39
|
-
**Mechanism**:
|
|
40
|
-
1. **Marker Contract**: Orchestrator writes `state/.wave-in-flight` in the PRIMARY tree before dispatching wave work. The marker is git-ignored, so sibling worktrees checked out during the wave do NOT carry it.
|
|
41
|
-
2. **Pre-Commit Check**: Hook resolves the marker relative to the CURRENT working tree via `git rev-parse --show-toplevel` (NOT a hardcoded `$HOME/aesop` — that resolved to the primary tree from every worktree and blocked the whole fleet mid-wave, the wave-24 incident). Primary tree (has marker during a wave) → exit 1 (reject); sibling worktree (no marker) → exit 0 (allow).
|
|
42
|
-
3. **Override**: User or orchestrator may delete `state/.wave-in-flight` to manually allow commits to primary tree.
|
|
43
|
-
|
|
44
|
-
**Exit Contract**:
|
|
45
|
-
- Exit 0: Marker absent, commit allowed (normal operation)
|
|
46
|
-
- Exit 1: Marker present, commit blocked with clear error message
|
|
47
|
-
|
|
48
|
-
**Error Message**:
|
|
49
|
-
```
|
|
50
|
-
Error: Wave in flight. Commit from a sibling worktree, or clear <marker_path> to override.
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
**Installation**:
|
|
54
|
-
- Run `bash hooks/install-waveguard.sh` to idempotently install into `.git/hooks/pre-commit`.
|
|
55
|
-
- If a pre-commit hook already exists, installer backs it up (`.git/hooks/pre-commit.waveguard-backup`) and wraps both (waveguard first, then existing hook if present).
|
|
56
|
-
|
|
57
|
-
**Idempotency**:
|
|
58
|
-
- Installer checks if hook already calls waveguard; skips if already installed.
|
|
59
|
-
- Safe to re-run multiple times.
|
|
60
|
-
|
|
61
|
-
## Hook: hooks/claude/force-model-policy.mjs
|
|
62
|
-
|
|
63
|
-
Claude Code hook enforcing subagent Haiku dispatch (cost optimization).
|
|
64
|
-
|
|
65
|
-
**Trigger**: On skill invocation or task delegation from main orchestrator thread. Examines Claude API request and enforces model constraint.
|
|
66
|
-
|
|
67
|
-
**Policy**:
|
|
68
|
-
- **Main orchestrator** (Fable/Opus on primary): no override (uses native model)
|
|
69
|
-
- **Subagent dispatch** (fleet workers): **enforce Haiku** (claude-haiku-4-5-*). Exit 1 on non-Haiku model request.
|
|
70
|
-
- **Specialists** (typed dispatches): Pin model to Haiku in the dispatch call; hook validates + blocks if violated.
|
|
71
|
-
|
|
72
|
-
**Logging**:
|
|
73
|
-
- On policy violation: log to `state/SECURITY-ALERTS.log` with timestamp, model-name, worker-id, and reason.
|
|
74
|
-
- No alerts on compliant requests.
|
|
75
|
-
|
|
76
|
-
**Self-Test**:
|
|
77
|
-
- `node hooks/claude/force-model-policy.mjs --test` validates:
|
|
78
|
-
1. Haiku model allowed on subagents
|
|
79
|
-
2. Non-Haiku (e.g., Opus) blocked on subagents
|
|
80
|
-
3. Orchestrator not subject to policy
|
|
81
|
-
4. JSON logging format is valid
|
|
82
|
-
- Exit 0 = all pass; exit 1 = any fail
|
|
83
|
-
|
|
84
|
-
## Invariants
|
|
85
|
-
|
|
86
|
-
- POSIX sh compatible, CRLF-safe (no line continuations)
|
|
87
|
-
- Tolerate git pre-push stdin (ref list) + optional args without choking
|
|
88
|
-
- Fail-open only for missing optional tooling (secret_scan.py absent → allow); fail-closed for policy checks
|
|
89
|
-
- Use `AESOP_ROOT` env var or `$HOME/aesop` fallback; no hardcoded machine paths/usernames
|