@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { AgentError } from "../errors.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capabilities that must never modify project files.
|
|
7
|
+
* Includes read-only roles (scout, reviewer) and coordination roles (lead).
|
|
8
|
+
* Only "builder" and "merger" are allowed to modify files.
|
|
9
|
+
*/
|
|
10
|
+
const NON_IMPLEMENTATION_CAPABILITIES = new Set([
|
|
11
|
+
"scout",
|
|
12
|
+
"reviewer",
|
|
13
|
+
"lead",
|
|
14
|
+
"coordinator",
|
|
15
|
+
"supervisor",
|
|
16
|
+
"monitor",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Capabilities that coordinate work and need git add/commit for syncing
|
|
21
|
+
* beads, mulch, and other metadata — but must NOT git push.
|
|
22
|
+
*/
|
|
23
|
+
const COORDINATION_CAPABILITIES = new Set(["coordinator", "supervisor", "monitor"]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Additional safe Bash prefixes for coordination capabilities.
|
|
27
|
+
* Allows git add/commit for beads sync, mulch records, etc.
|
|
28
|
+
* git push remains blocked via DANGEROUS_BASH_PATTERNS.
|
|
29
|
+
*/
|
|
30
|
+
const COORDINATION_SAFE_PREFIXES = ["git add", "git commit"];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Claude Code native team/task tools that bypass overstory orchestration.
|
|
34
|
+
* All overstory agents must use `overstory sling` for delegation, not these.
|
|
35
|
+
*/
|
|
36
|
+
const NATIVE_TEAM_TOOLS = [
|
|
37
|
+
"Task",
|
|
38
|
+
"TeamCreate",
|
|
39
|
+
"TeamDelete",
|
|
40
|
+
"SendMessage",
|
|
41
|
+
"TaskCreate",
|
|
42
|
+
"TaskUpdate",
|
|
43
|
+
"TaskList",
|
|
44
|
+
"TaskGet",
|
|
45
|
+
"TaskOutput",
|
|
46
|
+
"TaskStop",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Tools that non-implementation agents must not use. */
|
|
50
|
+
const WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bash commands that modify files and must be blocked for non-implementation agents.
|
|
54
|
+
* Each pattern is a regex fragment used inside a grep -qE check.
|
|
55
|
+
*/
|
|
56
|
+
const DANGEROUS_BASH_PATTERNS = [
|
|
57
|
+
"sed\\s+-i",
|
|
58
|
+
"sed\\s+--in-place",
|
|
59
|
+
"echo\\s+.*>",
|
|
60
|
+
"printf\\s+.*>",
|
|
61
|
+
"cat\\s+.*>",
|
|
62
|
+
"tee\\s",
|
|
63
|
+
"\\bvim\\b",
|
|
64
|
+
"\\bnano\\b",
|
|
65
|
+
"\\bvi\\b",
|
|
66
|
+
"\\bmv\\s",
|
|
67
|
+
"\\bcp\\s",
|
|
68
|
+
"\\brm\\s",
|
|
69
|
+
"\\bmkdir\\s",
|
|
70
|
+
"\\btouch\\s",
|
|
71
|
+
"\\bchmod\\s",
|
|
72
|
+
"\\bchown\\s",
|
|
73
|
+
">>",
|
|
74
|
+
"\\bgit\\s+add\\b",
|
|
75
|
+
"\\bgit\\s+commit\\b",
|
|
76
|
+
"\\bgit\\s+merge\\b",
|
|
77
|
+
"\\bgit\\s+push\\b",
|
|
78
|
+
"\\bgit\\s+reset\\b",
|
|
79
|
+
"\\bgit\\s+checkout\\b",
|
|
80
|
+
"\\bgit\\s+rebase\\b",
|
|
81
|
+
"\\bgit\\s+stash\\b",
|
|
82
|
+
"\\bnpm\\s+install\\b",
|
|
83
|
+
"\\bbun\\s+install\\b",
|
|
84
|
+
"\\bbun\\s+add\\b",
|
|
85
|
+
// Runtime eval flags — bypass shell pattern guards by executing JS/Python directly
|
|
86
|
+
"\\bbun\\s+-e\\b",
|
|
87
|
+
"\\bbun\\s+--eval\\b",
|
|
88
|
+
"\\bnode\\s+-e\\b",
|
|
89
|
+
"\\bnode\\s+--eval\\b",
|
|
90
|
+
"\\bdeno\\s+eval\\b",
|
|
91
|
+
"\\bpython3?\\s+-c\\b",
|
|
92
|
+
"\\bperl\\s+-e\\b",
|
|
93
|
+
"\\bruby\\s+-e\\b",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Bash commands that are always safe for non-implementation agents.
|
|
98
|
+
* If a command starts with any of these prefixes, it bypasses the dangerous command check.
|
|
99
|
+
* This whitelist is checked BEFORE the blocklist.
|
|
100
|
+
*/
|
|
101
|
+
const SAFE_BASH_PREFIXES = [
|
|
102
|
+
"overstory ",
|
|
103
|
+
"bd ",
|
|
104
|
+
"sd ",
|
|
105
|
+
"git status",
|
|
106
|
+
"git log",
|
|
107
|
+
"git diff",
|
|
108
|
+
"git show",
|
|
109
|
+
"git blame",
|
|
110
|
+
"git branch",
|
|
111
|
+
"mulch ",
|
|
112
|
+
"bun test",
|
|
113
|
+
"bun run lint",
|
|
114
|
+
"bun run typecheck",
|
|
115
|
+
"bun run biome",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** Hook entry shape matching Claude Code's settings.local.json format. */
|
|
119
|
+
interface HookEntry {
|
|
120
|
+
matcher: string;
|
|
121
|
+
hooks: Array<{ type: string; command: string }>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the path to the hooks template file.
|
|
126
|
+
* The template lives at `templates/hooks.json.tmpl` relative to the repo root.
|
|
127
|
+
*/
|
|
128
|
+
function getTemplatePath(): string {
|
|
129
|
+
// src/agents/hooks-deployer.ts -> repo root is ../../
|
|
130
|
+
return join(dirname(import.meta.dir), "..", "templates", "hooks.json.tmpl");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Env var guard prefix for hook commands.
|
|
135
|
+
*
|
|
136
|
+
* When hooks are deployed to the project root (e.g. for the coordinator),
|
|
137
|
+
* they affect ALL Claude Code sessions in that directory. This prefix
|
|
138
|
+
* ensures hooks only activate for overstory-managed agent sessions
|
|
139
|
+
* (which have OVERSTORY_AGENT_NAME set in their environment) and are
|
|
140
|
+
* no-ops for the user's own Claude Code session.
|
|
141
|
+
*/
|
|
142
|
+
const ENV_GUARD = '[ -z "$OVERSTORY_AGENT_NAME" ] && exit 0;';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build a PreToolUse guard script that validates file paths are within
|
|
146
|
+
* the agent's worktree boundary.
|
|
147
|
+
*
|
|
148
|
+
* Applied to Write, Edit, and NotebookEdit tools. Uses the
|
|
149
|
+
* OVERSTORY_WORKTREE_PATH env var set during tmux session creation
|
|
150
|
+
* to determine the allowed path boundary.
|
|
151
|
+
*
|
|
152
|
+
* @param filePathField - The JSON field name containing the file path
|
|
153
|
+
* ("file_path" for Write/Edit, "notebook_path" for NotebookEdit)
|
|
154
|
+
*/
|
|
155
|
+
export function buildPathBoundaryGuardScript(filePathField: string): string {
|
|
156
|
+
const script = [
|
|
157
|
+
// Only enforce for overstory agent sessions
|
|
158
|
+
ENV_GUARD,
|
|
159
|
+
// Skip if worktree path is not set (e.g., orchestrator)
|
|
160
|
+
'[ -z "$OVERSTORY_WORKTREE_PATH" ] && exit 0;',
|
|
161
|
+
"read -r INPUT;",
|
|
162
|
+
// Extract file path from JSON (sed -n + p = empty if no match)
|
|
163
|
+
`FILE_PATH=$(echo "$INPUT" | sed -n 's/.*"${filePathField}": *"\\([^"]*\\)".*/\\1/p');`,
|
|
164
|
+
// No path extracted — fail open (tool may be called differently)
|
|
165
|
+
'[ -z "$FILE_PATH" ] && exit 0;',
|
|
166
|
+
// Resolve relative paths against cwd
|
|
167
|
+
'case "$FILE_PATH" in /*) ;; *) FILE_PATH="$(pwd)/$FILE_PATH" ;; esac;',
|
|
168
|
+
// Allow if path is inside the worktree (exact match or subpath)
|
|
169
|
+
'case "$FILE_PATH" in "$OVERSTORY_WORKTREE_PATH"/*) exit 0 ;; "$OVERSTORY_WORKTREE_PATH") exit 0 ;; esac;',
|
|
170
|
+
// Block: path is outside the worktree boundary
|
|
171
|
+
'echo \'{"decision":"block","reason":"Path boundary violation: file is outside your assigned worktree. All writes must target files within your worktree."}\';',
|
|
172
|
+
].join(" ");
|
|
173
|
+
return script;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate PreToolUse guards that enforce worktree path boundaries.
|
|
178
|
+
*
|
|
179
|
+
* Returns guards for Write (file_path), Edit (file_path), and
|
|
180
|
+
* NotebookEdit (notebook_path). Applied to ALL agent capabilities
|
|
181
|
+
* as defense-in-depth (non-implementation agents already have these
|
|
182
|
+
* tools blocked, but the path guard catches any bypass).
|
|
183
|
+
*/
|
|
184
|
+
export function getPathBoundaryGuards(): HookEntry[] {
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
matcher: "Write",
|
|
188
|
+
hooks: [{ type: "command", command: buildPathBoundaryGuardScript("file_path") }],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
matcher: "Edit",
|
|
192
|
+
hooks: [{ type: "command", command: buildPathBoundaryGuardScript("file_path") }],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
matcher: "NotebookEdit",
|
|
196
|
+
hooks: [{ type: "command", command: buildPathBoundaryGuardScript("notebook_path") }],
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build a PreToolUse guard that blocks a specific tool.
|
|
203
|
+
*
|
|
204
|
+
* Returns a JSON response with decision=block so Claude Code rejects
|
|
205
|
+
* the tool call before execution.
|
|
206
|
+
*/
|
|
207
|
+
function blockGuard(toolName: string, reason: string): HookEntry {
|
|
208
|
+
const response = JSON.stringify({ decision: "block", reason });
|
|
209
|
+
return {
|
|
210
|
+
matcher: toolName,
|
|
211
|
+
hooks: [
|
|
212
|
+
{
|
|
213
|
+
type: "command",
|
|
214
|
+
command: `${ENV_GUARD} echo '${response}'`,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build a Bash guard script that inspects the command from stdin JSON.
|
|
222
|
+
*
|
|
223
|
+
* Claude Code PreToolUse hooks receive `{"tool_name": "Bash", "tool_input": {"command": "..."}, ...}` on stdin.
|
|
224
|
+
* This builds a bash script that reads stdin, extracts the command, and checks for
|
|
225
|
+
* dangerous patterns (push to canonical branch, hard reset, wrong branch naming).
|
|
226
|
+
*/
|
|
227
|
+
function buildBashGuardScript(agentName: string): string {
|
|
228
|
+
// The script reads JSON from stdin, extracts the command field, then checks patterns.
|
|
229
|
+
// Uses parameter expansion to avoid requiring jq (zero runtime deps).
|
|
230
|
+
const script = [
|
|
231
|
+
// Only enforce for overstory agent sessions (skip for user's own Claude Code)
|
|
232
|
+
ENV_GUARD,
|
|
233
|
+
"read -r INPUT;",
|
|
234
|
+
// Extract command value from JSON — grab everything after "command": (with optional space)
|
|
235
|
+
'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
|
|
236
|
+
// Check 1: Block all git push — agents must never push to remote
|
|
237
|
+
"if echo \"$CMD\" | grep -qE '\\bgit\\s+push\\b'; then",
|
|
238
|
+
' echo \'{"decision":"block","reason":"git push is blocked — use overstory merge to integrate changes, push manually when ready"}\';',
|
|
239
|
+
" exit 0;",
|
|
240
|
+
"fi;",
|
|
241
|
+
// Check 2: Block git reset --hard
|
|
242
|
+
"if echo \"$CMD\" | grep -qE 'git\\s+reset\\s+--hard'; then",
|
|
243
|
+
' echo \'{"decision":"block","reason":"git reset --hard is not allowed — it destroys uncommitted work"}\';',
|
|
244
|
+
" exit 0;",
|
|
245
|
+
"fi;",
|
|
246
|
+
// Check 3: Warn on git checkout -b with wrong naming convention
|
|
247
|
+
"if echo \"$CMD\" | grep -qE 'git\\s+checkout\\s+-b\\s'; then",
|
|
248
|
+
` BRANCH=$(echo "$CMD" | sed 's/.*git\\s*checkout\\s*-b\\s*\\([^ ]*\\).*/\\1/');`,
|
|
249
|
+
` if ! echo "$BRANCH" | grep -qE '^overstory/${agentName}/'; then`,
|
|
250
|
+
` echo '{"decision":"block","reason":"Branch must follow overstory/${agentName}/{bead-id} convention"}';`,
|
|
251
|
+
" exit 0;",
|
|
252
|
+
" fi;",
|
|
253
|
+
"fi;",
|
|
254
|
+
].join(" ");
|
|
255
|
+
return script;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generate Bash-level PreToolUse guards for dangerous operations.
|
|
260
|
+
*
|
|
261
|
+
* Applied to ALL agent capabilities. Inspects Bash tool commands for:
|
|
262
|
+
* - `git push` to canonical branches (main/master) — blocked
|
|
263
|
+
* - `git reset --hard` — blocked
|
|
264
|
+
* - `git checkout -b` with non-standard branch naming — blocked
|
|
265
|
+
*
|
|
266
|
+
* @param agentName - The agent name, used for branch naming validation
|
|
267
|
+
*/
|
|
268
|
+
export function getDangerGuards(agentName: string): HookEntry[] {
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
matcher: "Bash",
|
|
272
|
+
hooks: [
|
|
273
|
+
{
|
|
274
|
+
type: "command",
|
|
275
|
+
command: buildBashGuardScript(agentName),
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build a Bash guard script that blocks file-modifying commands for non-implementation agents.
|
|
284
|
+
*
|
|
285
|
+
* Uses a whitelist-first approach: if the command matches a known-safe prefix, it passes.
|
|
286
|
+
* Otherwise, it checks against dangerous patterns and blocks if any match.
|
|
287
|
+
*
|
|
288
|
+
* @param capability - The agent capability, included in block reason messages
|
|
289
|
+
* @param extraSafePrefixes - Additional safe prefixes for this capability (e.g. git add/commit for coordinators)
|
|
290
|
+
*/
|
|
291
|
+
export function buildBashFileGuardScript(
|
|
292
|
+
capability: string,
|
|
293
|
+
extraSafePrefixes: string[] = [],
|
|
294
|
+
): string {
|
|
295
|
+
// Build the safe prefix check: if command starts with any safe prefix, allow it
|
|
296
|
+
const allSafePrefixes = [...SAFE_BASH_PREFIXES, ...extraSafePrefixes];
|
|
297
|
+
const safePrefixChecks = allSafePrefixes
|
|
298
|
+
.map((prefix) => `if echo "$CMD" | grep -qE '^\\s*${prefix}'; then exit 0; fi;`)
|
|
299
|
+
.join(" ");
|
|
300
|
+
|
|
301
|
+
// Build the dangerous pattern check
|
|
302
|
+
const dangerPattern = DANGEROUS_BASH_PATTERNS.join("|");
|
|
303
|
+
|
|
304
|
+
const script = [
|
|
305
|
+
// Only enforce for overstory agent sessions (skip for user's own Claude Code)
|
|
306
|
+
ENV_GUARD,
|
|
307
|
+
"read -r INPUT;",
|
|
308
|
+
// Extract command value from JSON (with optional space after colon)
|
|
309
|
+
'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
|
|
310
|
+
// First: whitelist safe commands
|
|
311
|
+
safePrefixChecks,
|
|
312
|
+
// Then: check for dangerous patterns
|
|
313
|
+
`if echo "$CMD" | grep -qE '${dangerPattern}'; then`,
|
|
314
|
+
` echo '{"decision":"block","reason":"${capability} agents cannot modify files — this command is not allowed"}';`,
|
|
315
|
+
" exit 0;",
|
|
316
|
+
"fi;",
|
|
317
|
+
].join(" ");
|
|
318
|
+
return script;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Capabilities that are allowed to modify files via Bash commands.
|
|
323
|
+
* These get the Bash path boundary guard instead of a blanket file-modification block.
|
|
324
|
+
*/
|
|
325
|
+
const IMPLEMENTATION_CAPABILITIES = new Set(["builder", "merger"]);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Bash patterns that modify files and require path boundary validation.
|
|
329
|
+
* Each entry is a regex fragment matched against the extracted command.
|
|
330
|
+
* When matched, all absolute paths in the command are checked against the worktree boundary.
|
|
331
|
+
*/
|
|
332
|
+
const FILE_MODIFYING_BASH_PATTERNS = [
|
|
333
|
+
"sed\\s+-i",
|
|
334
|
+
"sed\\s+--in-place",
|
|
335
|
+
"echo\\s+.*>",
|
|
336
|
+
"printf\\s+.*>",
|
|
337
|
+
"cat\\s+.*>",
|
|
338
|
+
"tee\\s",
|
|
339
|
+
"\\bmv\\s",
|
|
340
|
+
"\\bcp\\s",
|
|
341
|
+
"\\brm\\s",
|
|
342
|
+
"\\bmkdir\\s",
|
|
343
|
+
"\\btouch\\s",
|
|
344
|
+
"\\bchmod\\s",
|
|
345
|
+
"\\bchown\\s",
|
|
346
|
+
">>",
|
|
347
|
+
"\\binstall\\s",
|
|
348
|
+
"\\brsync\\s",
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Build a Bash PreToolUse guard script that validates file-modifying commands
|
|
353
|
+
* keep their target paths within the agent's worktree boundary.
|
|
354
|
+
*
|
|
355
|
+
* Applied to builder/merger agents. For file-modifying Bash commands (sed -i,
|
|
356
|
+
* echo >, cp, mv, tee, install, rsync, etc.), extracts all absolute paths
|
|
357
|
+
* from the command and verifies they resolve within the worktree.
|
|
358
|
+
*
|
|
359
|
+
* Limitations (documented by design):
|
|
360
|
+
* - Cannot detect paths constructed via variable expansion ($VAR/file)
|
|
361
|
+
* - Cannot detect paths reached via cd + relative path
|
|
362
|
+
* - Cannot detect paths inside subshells or backtick evaluation
|
|
363
|
+
* - Relative paths are assumed safe (tmux cwd IS the worktree)
|
|
364
|
+
*
|
|
365
|
+
* Uses OVERSTORY_WORKTREE_PATH env var set during tmux session creation.
|
|
366
|
+
*/
|
|
367
|
+
export function buildBashPathBoundaryScript(): string {
|
|
368
|
+
const fileModifyPattern = FILE_MODIFYING_BASH_PATTERNS.join("|");
|
|
369
|
+
|
|
370
|
+
const script = [
|
|
371
|
+
// Only enforce for overstory agent sessions
|
|
372
|
+
ENV_GUARD,
|
|
373
|
+
// Skip if worktree path is not set (e.g., orchestrator)
|
|
374
|
+
'[ -z "$OVERSTORY_WORKTREE_PATH" ] && exit 0;',
|
|
375
|
+
"read -r INPUT;",
|
|
376
|
+
// Extract command value from JSON (with optional space after colon)
|
|
377
|
+
'CMD=$(echo "$INPUT" | sed \'s/.*"command": *"\\([^"]*\\)".*/\\1/\');',
|
|
378
|
+
// Only check file-modifying commands — non-modifying commands pass through
|
|
379
|
+
`if ! echo "$CMD" | grep -qE '${fileModifyPattern}'; then exit 0; fi;`,
|
|
380
|
+
// Extract all absolute paths (tokens starting with /) from the command.
|
|
381
|
+
// Uses tr to split on whitespace, grep to find /paths, sed to strip trailing quotes/semicolons.
|
|
382
|
+
"PATHS=$(echo \"$CMD\" | tr ' \\t' '\\n\\n' | grep '^/' | sed 's/[\";>]*$//');",
|
|
383
|
+
// If no absolute paths found, allow (relative paths resolve from worktree cwd)
|
|
384
|
+
'[ -z "$PATHS" ] && exit 0;',
|
|
385
|
+
// Check each absolute path against the worktree boundary
|
|
386
|
+
'echo "$PATHS" | while IFS= read -r P; do',
|
|
387
|
+
' case "$P" in',
|
|
388
|
+
' "$OVERSTORY_WORKTREE_PATH"/*) ;;',
|
|
389
|
+
' "$OVERSTORY_WORKTREE_PATH") ;;',
|
|
390
|
+
" /dev/*) ;;",
|
|
391
|
+
" /tmp/*) ;;",
|
|
392
|
+
' *) echo \'{"decision":"block","reason":"Bash path boundary violation: command targets a path outside your worktree. All file modifications must stay within your assigned worktree."}\'; exit 0; ;;',
|
|
393
|
+
" esac;",
|
|
394
|
+
"done;",
|
|
395
|
+
].join(" ");
|
|
396
|
+
return script;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Generate Bash path boundary guards for implementation capabilities.
|
|
401
|
+
*
|
|
402
|
+
* Returns a single Bash PreToolUse guard that checks file-modifying commands
|
|
403
|
+
* for absolute paths outside the worktree boundary.
|
|
404
|
+
*
|
|
405
|
+
* Only applied to builder/merger agents (implementation capabilities).
|
|
406
|
+
* Non-implementation agents already have all file-modifying Bash commands
|
|
407
|
+
* blocked via buildBashFileGuardScript().
|
|
408
|
+
*/
|
|
409
|
+
export function getBashPathBoundaryGuards(): HookEntry[] {
|
|
410
|
+
return [
|
|
411
|
+
{
|
|
412
|
+
matcher: "Bash",
|
|
413
|
+
hooks: [{ type: "command", command: buildBashPathBoundaryScript() }],
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Generate capability-specific PreToolUse guards.
|
|
420
|
+
*
|
|
421
|
+
* Non-implementation capabilities (scout, reviewer, lead, coordinator, supervisor, monitor) get:
|
|
422
|
+
* - Write, Edit, NotebookEdit tool blocks
|
|
423
|
+
* - Bash file-modification command guards (sed -i, echo >, mv, rm, etc.)
|
|
424
|
+
* - Coordination capabilities (coordinator, supervisor) get git add/commit whitelisted
|
|
425
|
+
*
|
|
426
|
+
* Implementation capabilities (builder, merger) get:
|
|
427
|
+
* - Bash path boundary guards (validates absolute paths stay in worktree)
|
|
428
|
+
*
|
|
429
|
+
* All overstory-managed agents get:
|
|
430
|
+
* - Claude Code native team/task tool blocks (Task, TeamCreate, SendMessage, etc.)
|
|
431
|
+
* to ensure delegation goes through overstory sling
|
|
432
|
+
*
|
|
433
|
+
* Note: All capabilities also receive Bash danger guards via getDangerGuards().
|
|
434
|
+
*/
|
|
435
|
+
export function getCapabilityGuards(capability: string): HookEntry[] {
|
|
436
|
+
const guards: HookEntry[] = [];
|
|
437
|
+
|
|
438
|
+
// Block Claude Code native team/task tools for ALL overstory agents.
|
|
439
|
+
// Agents must use `overstory sling` for delegation, not native Task/Team tools.
|
|
440
|
+
const teamToolGuards = NATIVE_TEAM_TOOLS.map((tool) =>
|
|
441
|
+
blockGuard(
|
|
442
|
+
tool,
|
|
443
|
+
`Overstory agents must use 'overstory sling' for delegation — ${tool} is not allowed`,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
guards.push(...teamToolGuards);
|
|
447
|
+
|
|
448
|
+
if (NON_IMPLEMENTATION_CAPABILITIES.has(capability)) {
|
|
449
|
+
const toolGuards = WRITE_TOOLS.map((tool) =>
|
|
450
|
+
blockGuard(tool, `${capability} agents cannot modify files — ${tool} is not allowed`),
|
|
451
|
+
);
|
|
452
|
+
guards.push(...toolGuards);
|
|
453
|
+
|
|
454
|
+
// Coordination capabilities get git add/commit whitelisted for beads/mulch sync
|
|
455
|
+
const extraSafe = COORDINATION_CAPABILITIES.has(capability) ? COORDINATION_SAFE_PREFIXES : [];
|
|
456
|
+
const bashFileGuard: HookEntry = {
|
|
457
|
+
matcher: "Bash",
|
|
458
|
+
hooks: [
|
|
459
|
+
{
|
|
460
|
+
type: "command",
|
|
461
|
+
command: buildBashFileGuardScript(capability, extraSafe),
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
};
|
|
465
|
+
guards.push(bashFileGuard);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Implementation capabilities get Bash path boundary validation
|
|
469
|
+
// (non-implementation agents already block all file-modifying Bash commands)
|
|
470
|
+
if (IMPLEMENTATION_CAPABILITIES.has(capability)) {
|
|
471
|
+
guards.push(...getBashPathBoundaryGuards());
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return guards;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Check whether a hook entry is overstory-managed.
|
|
479
|
+
*
|
|
480
|
+
* Overstory hook commands always reference either `overstory` (CLI commands)
|
|
481
|
+
* or `OVERSTORY_` (env var guards like OVERSTORY_AGENT_NAME, OVERSTORY_WORKTREE_PATH).
|
|
482
|
+
* User hooks will not contain these patterns.
|
|
483
|
+
*/
|
|
484
|
+
export function isOverstoryHookEntry(entry: HookEntry): boolean {
|
|
485
|
+
return entry.hooks.some(
|
|
486
|
+
(h) => h.command.includes("overstory") || h.command.includes("OVERSTORY_"),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Deploy hooks config to an agent's worktree as `.claude/settings.local.json`.
|
|
492
|
+
*
|
|
493
|
+
* Reads `templates/hooks.json.tmpl`, replaces `{{AGENT_NAME}}`, then merges
|
|
494
|
+
* capability-specific PreToolUse guards into the resulting config.
|
|
495
|
+
*
|
|
496
|
+
* When the target file already exists (e.g. at the project root for coordinator/
|
|
497
|
+
* supervisor/monitor), preserves non-hooks keys and user-defined hook entries.
|
|
498
|
+
* Stale overstory hook entries are stripped and replaced with the new set.
|
|
499
|
+
* Overstory hooks are placed before user hooks per event type so security
|
|
500
|
+
* guards run first.
|
|
501
|
+
*
|
|
502
|
+
* @param worktreePath - Absolute path to the agent's git worktree (or project root)
|
|
503
|
+
* @param agentName - The unique name of the agent
|
|
504
|
+
* @param capability - Agent capability (builder, scout, reviewer, lead, merger)
|
|
505
|
+
* @throws {AgentError} If the template is not found or the write fails
|
|
506
|
+
*/
|
|
507
|
+
export async function deployHooks(
|
|
508
|
+
worktreePath: string,
|
|
509
|
+
agentName: string,
|
|
510
|
+
capability = "builder",
|
|
511
|
+
): Promise<void> {
|
|
512
|
+
const templatePath = getTemplatePath();
|
|
513
|
+
const file = Bun.file(templatePath);
|
|
514
|
+
const exists = await file.exists();
|
|
515
|
+
|
|
516
|
+
if (!exists) {
|
|
517
|
+
throw new AgentError(`Hooks template not found: ${templatePath}`, {
|
|
518
|
+
agentName,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let template: string;
|
|
523
|
+
try {
|
|
524
|
+
template = await file.text();
|
|
525
|
+
} catch (err) {
|
|
526
|
+
throw new AgentError(`Failed to read hooks template: ${templatePath}`, {
|
|
527
|
+
agentName,
|
|
528
|
+
cause: err instanceof Error ? err : undefined,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Replace all occurrences of {{AGENT_NAME}}
|
|
533
|
+
let content = template;
|
|
534
|
+
while (content.includes("{{AGENT_NAME}}")) {
|
|
535
|
+
content = content.replace("{{AGENT_NAME}}", agentName);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Parse the base config and merge guards into PreToolUse
|
|
539
|
+
const config = JSON.parse(content) as { hooks: Record<string, HookEntry[]> };
|
|
540
|
+
const pathGuards = getPathBoundaryGuards();
|
|
541
|
+
const dangerGuards = getDangerGuards(agentName);
|
|
542
|
+
const capabilityGuards = getCapabilityGuards(capability);
|
|
543
|
+
const allGuards = [...pathGuards, ...dangerGuards, ...capabilityGuards];
|
|
544
|
+
|
|
545
|
+
if (allGuards.length > 0) {
|
|
546
|
+
const preToolUse = config.hooks.PreToolUse ?? [];
|
|
547
|
+
config.hooks.PreToolUse = [...allGuards, ...preToolUse];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const claudeDir = join(worktreePath, ".claude");
|
|
551
|
+
const outputPath = join(claudeDir, "settings.local.json");
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
await mkdir(claudeDir, { recursive: true });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
throw new AgentError(`Failed to create .claude/ directory at: ${claudeDir}`, {
|
|
557
|
+
agentName,
|
|
558
|
+
cause: err instanceof Error ? err : undefined,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Read existing settings.local.json to preserve user hooks and non-hooks keys
|
|
563
|
+
let existingConfig: Record<string, unknown> = {};
|
|
564
|
+
const existingFile = Bun.file(outputPath);
|
|
565
|
+
if (await existingFile.exists()) {
|
|
566
|
+
try {
|
|
567
|
+
const existingContent = await existingFile.text();
|
|
568
|
+
existingConfig = JSON.parse(existingContent) as Record<string, unknown>;
|
|
569
|
+
} catch {
|
|
570
|
+
// Malformed existing file — start fresh
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Separate non-hooks keys (permissions, env, $schema, etc.) from hooks
|
|
575
|
+
const { hooks: existingHooksRaw, ...nonHooksKeys } = existingConfig;
|
|
576
|
+
|
|
577
|
+
// Partition existing hooks: keep user entries, discard stale overstory entries
|
|
578
|
+
const existingHooks = (existingHooksRaw ?? {}) as Record<string, HookEntry[]>;
|
|
579
|
+
const userHooks: Record<string, HookEntry[]> = {};
|
|
580
|
+
for (const [eventType, entries] of Object.entries(existingHooks)) {
|
|
581
|
+
const userEntries = entries.filter((e) => !isOverstoryHookEntry(e));
|
|
582
|
+
if (userEntries.length > 0) {
|
|
583
|
+
userHooks[eventType] = userEntries;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Merge: overstory hooks first (security guards must run first), then user hooks
|
|
588
|
+
const mergedHooks: Record<string, HookEntry[]> = {};
|
|
589
|
+
const allEventTypes = new Set([...Object.keys(config.hooks), ...Object.keys(userHooks)]);
|
|
590
|
+
for (const eventType of allEventTypes) {
|
|
591
|
+
const overstoryEntries = config.hooks[eventType] ?? [];
|
|
592
|
+
const userEntries = userHooks[eventType] ?? [];
|
|
593
|
+
mergedHooks[eventType] = [...overstoryEntries, ...userEntries];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const finalConfig = { ...nonHooksKeys, hooks: mergedHooks };
|
|
597
|
+
const finalContent = `${JSON.stringify(finalConfig, null, "\t")}\n`;
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
await Bun.write(outputPath, finalContent);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
throw new AgentError(`Failed to write hooks config to: ${outputPath}`, {
|
|
603
|
+
agentName,
|
|
604
|
+
cause: err instanceof Error ? err : undefined,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|