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