@superjackfruitlabs/salaam 0.0.3 → 0.0.5

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 (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,797 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve, sep } from 'node:path';
3
+ const MAX_DEPTH = 3;
4
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish']);
5
+ const INTERPRETERS = new Set(['python', 'python3', 'node', 'ruby', 'perl', 'php', 'deno', 'bun']);
6
+ const has = (args, ...flags) => args.some((a) => flags.some((f) => a === f || a.startsWith(f + '=')));
7
+ /** A short flag cluster containing this letter: -rf, -fr, -f. */
8
+ const short = (args, letter) => args.some((a) => /^-[a-zA-Z]+$/.test(a) && a.includes(letter));
9
+ const positional = (args) => args.filter((a) => !a.startsWith('-'));
10
+ /** git's own options before the subcommand, so `git -C dir push -f` reads as `push -f`. */
11
+ function gitArgs(args) {
12
+ let i = 0;
13
+ while (i < args.length && args[i].startsWith('-')) {
14
+ if (['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(args[i]))
15
+ i++;
16
+ i++;
17
+ }
18
+ return args.slice(i);
19
+ }
20
+ const git = (sub, test) => (args) => {
21
+ const [s, ...rest] = gitArgs(args);
22
+ return s === sub && test(rest);
23
+ };
24
+ const sqlDestroys = (args) => args.some((a) => /\b(drop|truncate|alter)\b/i.test(a) || /\bdelete\s+from\b(?![\s\S]*\bwhere\b)/i.test(a));
25
+ /** The built-in rules, checked against every simple command (wrappers unwrapped). */
26
+ export const DANGER_RULES = [
27
+ // Privilege escalation
28
+ {
29
+ commands: ['sudo', 'su', 'doas', 'pkexec'],
30
+ category: 'privilege',
31
+ why: 'runs as another user, usually root',
32
+ },
33
+ {
34
+ commands: ['chmod'],
35
+ test: (a) => a.some((x) => /(^|[ugoa,])\+?[ugoa]*\+[rwx]*s|^[2467][0-7]{3}$/.test(x)) ||
36
+ ((short(a, 'R') || has(a, '--recursive')) && a.includes('777')),
37
+ category: 'privilege',
38
+ why: 'sets setuid/setgid or makes a tree world-writable',
39
+ },
40
+ {
41
+ commands: ['chown', 'chgrp'],
42
+ test: (a) => a.some((x) => /^root(:|$)/.test(x)),
43
+ category: 'privilege',
44
+ why: 'gives files to root',
45
+ },
46
+ // Destructive file operations
47
+ {
48
+ commands: ['rm'],
49
+ test: (a) => short(a, 'r') ||
50
+ short(a, 'R') ||
51
+ short(a, 'f') ||
52
+ has(a, '--force', '--recursive') ||
53
+ positional(a).some((p) => ['/', '*', '.', '..', '~'].includes(p)),
54
+ category: 'destructive-files',
55
+ why: 'deletes recursively or by force, with no undo',
56
+ },
57
+ {
58
+ commands: ['shred', 'srm', 'wipe'],
59
+ category: 'destructive-files',
60
+ why: 'overwrites files so they cannot be recovered',
61
+ },
62
+ {
63
+ commands: ['truncate'],
64
+ category: 'destructive-files',
65
+ why: 'cuts files to a size, losing their contents',
66
+ },
67
+ {
68
+ commands: ['find'],
69
+ test: (a) => a.includes('-delete'),
70
+ category: 'destructive-files',
71
+ why: 'find -delete removes every match',
72
+ },
73
+ // Git history and remotes
74
+ {
75
+ commands: ['git'],
76
+ test: (a) => a.slice(0, a.length - gitArgs(a).length).some((x) => x === '-c'),
77
+ category: 'git-history',
78
+ why: 'git -c can set a command git will run (sshCommand, hooks, aliases)',
79
+ },
80
+ {
81
+ commands: ['git'],
82
+ test: git('push', (r) => has(r, '--force', '--force-with-lease', '--delete', '--mirror', '--prune') ||
83
+ short(r, 'f') ||
84
+ short(r, 'd') ||
85
+ r.some((x) => /^[+:]/.test(x))),
86
+ category: 'git-history',
87
+ why: 'rewrites or deletes history on the remote',
88
+ },
89
+ {
90
+ commands: ['git'],
91
+ test: git('reset', (r) => has(r, '--hard', '--merge', '--keep')),
92
+ category: 'git-history',
93
+ why: 'discards uncommitted work',
94
+ },
95
+ {
96
+ commands: ['git'],
97
+ test: git('clean', (r) => short(r, 'f') || has(r, '--force')),
98
+ category: 'git-history',
99
+ why: 'deletes untracked files',
100
+ },
101
+ {
102
+ commands: ['git'],
103
+ test: git('branch', (r) => short(r, 'D') || (has(r, '--delete') && has(r, '--force'))),
104
+ category: 'git-history',
105
+ why: 'deletes a branch that may not be merged',
106
+ },
107
+ {
108
+ commands: ['git'],
109
+ test: git('tag', (r) => short(r, 'd') || has(r, '--delete')),
110
+ category: 'git-history',
111
+ why: 'deletes a tag',
112
+ },
113
+ {
114
+ commands: ['git'],
115
+ test: git('checkout', (r) => r.includes('--') || r.includes('.') || short(r, 'f') || has(r, '--force')),
116
+ category: 'git-history',
117
+ why: 'overwrites uncommitted changes in the working tree',
118
+ },
119
+ {
120
+ commands: ['git'],
121
+ test: git('restore', (r) => !has(r, '--staged') || has(r, '--worktree')),
122
+ category: 'git-history',
123
+ why: 'discards uncommitted changes',
124
+ },
125
+ {
126
+ commands: ['git'],
127
+ test: git('stash', (r) => ['drop', 'clear'].includes(r[0] ?? '')),
128
+ category: 'git-history',
129
+ why: 'drops stashed work',
130
+ },
131
+ {
132
+ commands: ['git'],
133
+ test: git('rm', (r) => short(r, 'f') || short(r, 'r') || has(r, '--force')),
134
+ category: 'git-history',
135
+ why: 'removes tracked files',
136
+ },
137
+ {
138
+ commands: ['git'],
139
+ test: (a) => {
140
+ const [s, ...r] = gitArgs(a);
141
+ return (['rebase', 'filter-branch', 'filter-repo'].includes(s ?? '') ||
142
+ (s === 'reflog' && r[0] === 'expire') ||
143
+ (s === 'gc' && r.some((x) => x.startsWith('--prune'))) ||
144
+ (s === 'update-ref' && short(r, 'd')));
145
+ },
146
+ category: 'git-history',
147
+ why: 'rewrites or expires history',
148
+ },
149
+ // Publishing, releases, deploys
150
+ {
151
+ commands: ['npm', 'pnpm', 'yarn', 'bun'],
152
+ sub: ['publish', 'unpublish', 'deprecate'],
153
+ category: 'publish',
154
+ why: 'publishes a package to a public registry',
155
+ },
156
+ {
157
+ commands: ['cargo', 'gem', 'twine'],
158
+ sub: ['publish', 'push', 'upload', 'yank'],
159
+ category: 'publish',
160
+ why: 'publishes a package to a public registry',
161
+ },
162
+ {
163
+ commands: ['docker', 'podman'],
164
+ sub: ['push'],
165
+ category: 'publish',
166
+ why: 'pushes an image to a registry',
167
+ },
168
+ {
169
+ commands: ['gh', 'tea'],
170
+ test: (a) => /^(release (create|delete|upload|edit)|pr merge|repo (delete|archive|edit|rename)|secret (set|delete)|api)\b/.test(positional(a).slice(0, 2).join(' ')),
171
+ category: 'publish',
172
+ why: 'changes a release, repository or merge on the forge',
173
+ },
174
+ {
175
+ commands: ['wrangler'],
176
+ sub: ['deploy', 'publish', 'delete', 'rollback', 'secret', 'versions'],
177
+ category: 'publish',
178
+ why: 'deploys or changes a live Worker',
179
+ },
180
+ {
181
+ commands: ['fly', 'flyctl', 'netlify', 'vercel', 'railway', 'heroku'],
182
+ test: (a) => /^(deploy|destroy|up|release|promote)/.test(positional(a)[0] ?? '') || has(a, '--prod'),
183
+ category: 'publish',
184
+ why: 'deploys to a live environment',
185
+ },
186
+ {
187
+ commands: ['terraform', 'tofu', 'pulumi'],
188
+ sub: ['apply', 'destroy', 'up', 'import', 'state'],
189
+ category: 'publish',
190
+ why: 'changes real infrastructure',
191
+ },
192
+ // Credentials
193
+ {
194
+ commands: ['security'],
195
+ sub: [/^(find-.*-password|dump-keychain|export|delete-)/],
196
+ category: 'credentials',
197
+ why: 'reads or changes the keychain',
198
+ },
199
+ {
200
+ commands: ['gpg'],
201
+ test: (a) => has(a, '--export-secret-keys', '--export-secret-subkeys'),
202
+ category: 'credentials',
203
+ why: 'exports private keys',
204
+ },
205
+ {
206
+ commands: ['ssh-add', 'ssh-keygen', 'ssh-agent'],
207
+ category: 'credentials',
208
+ why: 'loads or writes SSH keys',
209
+ },
210
+ {
211
+ commands: ['gh'],
212
+ test: (a) => positional(a).slice(0, 2).join(' ') === 'auth token',
213
+ category: 'credentials',
214
+ why: 'prints a forge token',
215
+ },
216
+ {
217
+ commands: ['printenv'],
218
+ category: 'credentials',
219
+ why: 'prints the environment, which may hold secrets',
220
+ },
221
+ // System configuration and persistence
222
+ {
223
+ commands: ['systemctl'],
224
+ test: (a) => !/^(status|is-active|is-enabled|list-.*|show|cat)$/.test(positional(a)[0] ?? 'status'),
225
+ category: 'system',
226
+ why: 'changes system services',
227
+ },
228
+ {
229
+ commands: ['launchctl'],
230
+ sub: [/^(load|unload|bootstrap|bootout|kickstart|enable|disable|remove|submit|setenv)$/],
231
+ category: 'system',
232
+ why: 'changes launch agents or daemons',
233
+ },
234
+ {
235
+ commands: ['crontab'],
236
+ test: (a) => !(a.length === 1 && a[0] === '-l'),
237
+ category: 'system',
238
+ why: 'changes scheduled jobs',
239
+ },
240
+ {
241
+ commands: ['defaults'],
242
+ sub: ['write', 'delete', 'import'],
243
+ category: 'system',
244
+ why: 'changes macOS preferences',
245
+ },
246
+ {
247
+ commands: [
248
+ 'scutil',
249
+ 'networksetup',
250
+ 'pfctl',
251
+ 'iptables',
252
+ 'ip6tables',
253
+ 'nft',
254
+ 'ufw',
255
+ 'nvram',
256
+ 'csrutil',
257
+ 'spctl',
258
+ 'tccutil',
259
+ 'systemsetup',
260
+ ],
261
+ category: 'system',
262
+ why: 'changes host network or security settings',
263
+ },
264
+ {
265
+ commands: ['sysctl'],
266
+ test: (a) => short(a, 'w') || a.some((x) => x.includes('=')),
267
+ category: 'system',
268
+ why: 'changes kernel settings',
269
+ },
270
+ // Disks and volumes
271
+ {
272
+ commands: [
273
+ 'dd',
274
+ 'fdisk',
275
+ 'sfdisk',
276
+ 'parted',
277
+ 'gdisk',
278
+ 'wipefs',
279
+ 'mount',
280
+ 'umount',
281
+ 'swapoff',
282
+ 'losetup',
283
+ ],
284
+ category: 'disk',
285
+ why: 'writes to or remounts disks',
286
+ },
287
+ { commands: ['mkfs', 'newfs'], category: 'disk', why: 'formats a volume' },
288
+ {
289
+ commands: ['diskutil'],
290
+ sub: [/^(erase|partition|zero|random|secureErase|unmount|eject|apfs|reformat|resize)/i],
291
+ category: 'disk',
292
+ why: 'erases, repartitions or unmounts a disk',
293
+ },
294
+ {
295
+ commands: ['hdiutil'],
296
+ sub: ['detach', 'eject', 'resize', 'compact'],
297
+ category: 'disk',
298
+ why: 'detaches or resizes a disk image',
299
+ },
300
+ {
301
+ commands: ['tmutil'],
302
+ sub: ['delete', 'disable', 'deletelocalsnapshots'],
303
+ category: 'disk',
304
+ why: 'deletes backups',
305
+ },
306
+ // Network: data leaving the machine, or remote control
307
+ {
308
+ commands: [
309
+ 'ssh',
310
+ 'scp',
311
+ 'sftp',
312
+ 'nc',
313
+ 'ncat',
314
+ 'netcat',
315
+ 'socat',
316
+ 'telnet',
317
+ 'ngrok',
318
+ 'cloudflared',
319
+ ],
320
+ category: 'network',
321
+ why: 'connects to another host or opens a tunnel',
322
+ },
323
+ {
324
+ commands: ['rsync'],
325
+ test: (a) => positional(a).some((p) => /^[^/]*:/.test(p)),
326
+ category: 'network',
327
+ why: 'copies files to or from another host',
328
+ },
329
+ {
330
+ commands: ['curl'],
331
+ test: (a) => has(a, '-T', '--upload-file', '-F', '--form') ||
332
+ a.some((x, i) => /^(-d|--data.*)$/.test(x) && a[i + 1]?.startsWith('@')) ||
333
+ a.some((x) => /^-d@/.test(x)),
334
+ category: 'network',
335
+ why: 'uploads a local file',
336
+ },
337
+ {
338
+ commands: ['wget'],
339
+ test: (a) => has(a, '--post-file', '--body-file'),
340
+ category: 'network',
341
+ why: 'uploads a local file',
342
+ },
343
+ // Processes and the host
344
+ {
345
+ commands: ['killall', 'pkill'],
346
+ category: 'process',
347
+ why: 'kills processes by name, including ones this session did not start',
348
+ },
349
+ {
350
+ commands: ['kill'],
351
+ test: (a) => a.includes('-1') || positional(a).some((p) => p === '1' || p === '0'),
352
+ category: 'process',
353
+ why: 'signals every process or init',
354
+ },
355
+ {
356
+ commands: ['shutdown', 'reboot', 'halt', 'poweroff'],
357
+ category: 'process',
358
+ why: 'stops the machine',
359
+ },
360
+ // Cloud and clusters
361
+ {
362
+ commands: ['aws'],
363
+ test: (a) => positional(a).some((p) => /^(rm|rb|delete-.*|terminate-.*|remove-.*)$/.test(p)),
364
+ category: 'cloud',
365
+ why: 'deletes cloud resources',
366
+ },
367
+ {
368
+ commands: ['gcloud', 'az', 'doctl', 'hcloud'],
369
+ test: (a) => positional(a).some((p) => /^(delete|destroy|remove)$/.test(p)),
370
+ category: 'cloud',
371
+ why: 'deletes cloud resources',
372
+ },
373
+ {
374
+ commands: ['kubectl', 'oc'],
375
+ sub: ['delete', 'drain', 'cordon', 'apply', 'replace', 'scale', 'patch'],
376
+ category: 'cloud',
377
+ why: 'changes a cluster',
378
+ },
379
+ {
380
+ commands: ['helm'],
381
+ sub: ['uninstall', 'delete', 'rollback', 'upgrade', 'install'],
382
+ category: 'cloud',
383
+ why: 'changes a cluster release',
384
+ },
385
+ {
386
+ commands: ['tailscale'],
387
+ sub: ['down', 'logout', 'up', 'set'],
388
+ category: 'cloud',
389
+ why: 'changes this machine’s tailnet',
390
+ },
391
+ // Databases
392
+ { commands: ['dropdb', 'dropuser'], category: 'database', why: 'drops a database' },
393
+ {
394
+ commands: ['redis-cli'],
395
+ test: (a) => a.some((x) => /^flush(all|db)$/i.test(x)),
396
+ category: 'database',
397
+ why: 'erases a Redis database',
398
+ },
399
+ {
400
+ commands: ['psql', 'mysql', 'mariadb', 'sqlite3', 'mongosh', 'mongo', 'clickhouse-client'],
401
+ test: sqlDestroys,
402
+ category: 'database',
403
+ why: 'drops, truncates or deletes without WHERE',
404
+ },
405
+ {
406
+ commands: ['prisma'],
407
+ test: (a) => positional(a).join(' ').startsWith('migrate reset') || has(a, '--force-reset'),
408
+ category: 'database',
409
+ why: 'resets the database',
410
+ },
411
+ {
412
+ commands: ['rails', 'rake'],
413
+ sub: [/^db:(drop|reset|purge|schema:load)/],
414
+ category: 'database',
415
+ why: 'drops or resets the database',
416
+ },
417
+ // Containers
418
+ {
419
+ commands: ['docker', 'podman'],
420
+ test: (a) => {
421
+ const p = positional(a).join(' ');
422
+ return ((/^(system|volume|image|container|network|builder) prune|^(volume|network) rm|^rmi\b|^compose down/.test(p) &&
423
+ (!p.startsWith('compose') || short(a, 'v') || has(a, '--volumes'))) ||
424
+ (/^rm\b/.test(p) && (short(a, 'f') || has(a, '--force'))) ||
425
+ has(a, '--privileged', '--pid', '--cap-add', '--security-opt') ||
426
+ a.some((x, i) => (x === '-v' || x === '--volume') && /^\/(:|$)/.test(a[i + 1] ?? '')));
427
+ },
428
+ category: 'containers',
429
+ why: 'removes containers, images or volumes, or gives a container the host',
430
+ },
431
+ // Installs that change the machine rather than the workspace
432
+ {
433
+ commands: ['npm', 'pnpm', 'bun'],
434
+ test: (a) => /^(i|install|add|update|uninstall|remove|link)$/.test(positional(a)[0] ?? '') &&
435
+ (short(a, 'g') || has(a, '--global', '--location')),
436
+ category: 'install-global',
437
+ why: 'installs or removes a global package',
438
+ },
439
+ {
440
+ commands: ['yarn'],
441
+ sub: ['global'],
442
+ category: 'install-global',
443
+ why: 'installs a global package',
444
+ },
445
+ {
446
+ commands: ['pip', 'pip3'],
447
+ test: (a) => has(a, '--break-system-packages', '--user', '--target', '--prefix'),
448
+ category: 'install-global',
449
+ why: 'installs outside a virtual environment',
450
+ },
451
+ {
452
+ commands: ['brew', 'port'],
453
+ sub: [
454
+ 'install',
455
+ 'uninstall',
456
+ 'reinstall',
457
+ 'upgrade',
458
+ 'remove',
459
+ 'tap',
460
+ 'untap',
461
+ 'link',
462
+ 'unlink',
463
+ 'services',
464
+ ],
465
+ category: 'install-global',
466
+ why: 'changes system packages',
467
+ },
468
+ {
469
+ commands: ['apt', 'apt-get', 'yum', 'dnf', 'pacman', 'apk', 'zypper', 'snap', 'dpkg', 'rpm'],
470
+ category: 'install-global',
471
+ why: 'changes system packages',
472
+ },
473
+ ];
474
+ /** Commands that write their path arguments, checked for paths outside the workspace. */
475
+ const WRITERS = new Set([
476
+ 'rm',
477
+ 'rmdir',
478
+ 'unlink',
479
+ 'mv',
480
+ 'cp',
481
+ 'ln',
482
+ 'tee',
483
+ 'install',
484
+ 'touch',
485
+ 'mkdir',
486
+ 'chmod',
487
+ 'chown',
488
+ 'chgrp',
489
+ 'truncate',
490
+ 'shred',
491
+ 'rsync',
492
+ 'ditto',
493
+ 'patch',
494
+ ]);
495
+ const IN_PLACE = new Set(['sed', 'perl', 'gsed']);
496
+ /** Wrappers whose real command is the rest of argv, after their own options. */
497
+ const WRAPPERS = {
498
+ env: (a) => {
499
+ let i = 0;
500
+ while (i < a.length &&
501
+ (a[i] === '-i' ||
502
+ a[i] === '--ignore-environment' ||
503
+ a[i] === '-u' ||
504
+ /^[A-Za-z_][A-Za-z0-9_]*=/.test(a[i]) ||
505
+ a[i - 1] === '-u'))
506
+ i++;
507
+ if (a[i] === '--')
508
+ i++;
509
+ return a.slice(i);
510
+ },
511
+ timeout: (a) => {
512
+ let i = 0;
513
+ while (i < a.length && a[i].startsWith('-'))
514
+ i += ['-s', '-k', '--signal', '--kill-after'].includes(a[i]) ? 2 : 1;
515
+ return a.slice(i + 1);
516
+ },
517
+ nice: (a) => (a[0] === '-n' ? a.slice(2) : /^-\d+$/.test(a[0] ?? '') ? a.slice(1) : a),
518
+ nohup: (a) => a,
519
+ time: (a) => a.filter((x, i) => !(x.startsWith('-') && i === 0)),
520
+ command: (a) => (a[0] === '-p' ? a.slice(1) : a[0] === '-v' || a[0] === '-V' ? [] : a),
521
+ builtin: (a) => a,
522
+ exec: (a) => a.filter((x, i) => !(i === 0 && x.startsWith('-'))),
523
+ stdbuf: (a) => a.slice(a.findIndex((x) => !x.startsWith('-'))),
524
+ caffeinate: (a) => a.slice(Math.max(0, a.findIndex((x) => !x.startsWith('-')))),
525
+ xargs: (a) => {
526
+ let i = 0;
527
+ while (i < a.length && a[i].startsWith('-'))
528
+ i += /^-[nIPLdsEa]$/.test(a[i]) ? 2 : 1;
529
+ return a.slice(i);
530
+ },
531
+ };
532
+ class Unparseable extends Error {
533
+ }
534
+ const SAFE_TARGETS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr']);
535
+ /** Splits the plain subset of shell into simple commands, or throws Unparseable. */
536
+ function parse(command, home) {
537
+ const segments = [];
538
+ let seg = { argv: [], afterPipe: false, redirects: [], text: '' };
539
+ let start = 0, word = '', inWord = false, redirect = null;
540
+ const endWord = () => {
541
+ if (!inWord)
542
+ return;
543
+ if (/^~(\/|$)/.test(word))
544
+ word = home + word.slice(1);
545
+ else if (word.startsWith('~'))
546
+ throw new Unparseable('~user paths are not read');
547
+ if (/[{}]/.test(word) && word !== '{}')
548
+ throw new Unparseable('brace expansion is not read');
549
+ if (redirect) {
550
+ seg.redirects.push({ out: redirect.out, target: word });
551
+ redirect = null;
552
+ }
553
+ else
554
+ seg.argv.push(word);
555
+ word = '';
556
+ inWord = false;
557
+ };
558
+ const endSegment = (end, pipe) => {
559
+ endWord();
560
+ if (redirect)
561
+ throw new Unparseable('a redirect has no target');
562
+ seg.text = command.slice(start, end).trim();
563
+ if (seg.argv.length)
564
+ segments.push(seg);
565
+ else if (pipe || seg.afterPipe || seg.redirects.length)
566
+ throw new Unparseable('an empty command');
567
+ seg = { argv: [], afterPipe: pipe, redirects: [], text: '' };
568
+ };
569
+ for (let i = 0; i < command.length; i++) {
570
+ const ch = command[i];
571
+ if (ch === "'") {
572
+ const end = command.indexOf("'", i + 1);
573
+ if (end < 0)
574
+ throw new Unparseable('an unclosed quote');
575
+ word += command.slice(i + 1, end);
576
+ inWord = true;
577
+ i = end;
578
+ }
579
+ else if (ch === '"') {
580
+ const end = command.indexOf('"', i + 1);
581
+ if (end < 0)
582
+ throw new Unparseable('an unclosed quote');
583
+ const inner = command.slice(i + 1, end);
584
+ if (/[$`\\]/.test(inner))
585
+ throw new Unparseable('expansion inside double quotes');
586
+ word += inner;
587
+ inWord = true;
588
+ i = end;
589
+ }
590
+ else if (ch === ' ' || ch === '\t')
591
+ endWord();
592
+ else if (ch === '\n' || ch === ';') {
593
+ endSegment(i, false);
594
+ start = i + 1;
595
+ }
596
+ else if (ch === '&' && command[i + 1] === '&') {
597
+ endSegment(i, false);
598
+ start = ++i + 1;
599
+ }
600
+ else if (ch === '|' && command[i + 1] === '|') {
601
+ endSegment(i, false);
602
+ start = ++i + 1;
603
+ }
604
+ else if (ch === '|') {
605
+ if (command[i + 1] === '&')
606
+ throw new Unparseable('|& is not read');
607
+ endSegment(i, true);
608
+ start = i + 1;
609
+ }
610
+ else if (ch === '>' || ch === '<' || (ch === '&' && command[i + 1] === '>')) {
611
+ // An fd number directly before the operator belongs to it: 2>&1, 2>/dev/null.
612
+ if (inWord && /^\d+$/.test(word)) {
613
+ word = '';
614
+ inWord = false;
615
+ }
616
+ else
617
+ endWord();
618
+ if (redirect)
619
+ throw new Unparseable('a redirect has no target');
620
+ if (ch === '&')
621
+ i++;
622
+ const out = command[i] === '>';
623
+ if (!out && command[i + 1] === '<')
624
+ throw new Unparseable('a heredoc or here-string');
625
+ if (command[i + 1] === '(' || (!out && command[i + 1] === '>'))
626
+ throw new Unparseable('process substitution');
627
+ if (out && command[i + 1] === '>')
628
+ i++;
629
+ if (out && command[i + 1] === '&') {
630
+ const m = /^&(\d+|-)/.exec(command.slice(i + 1));
631
+ if (!m)
632
+ throw new Unparseable('a redirect it cannot read');
633
+ i += m[0].length;
634
+ continue;
635
+ }
636
+ redirect = { out };
637
+ }
638
+ else if ('$`\\()&#'.includes(ch) && !(ch === '#' && inWord))
639
+ throw new Unparseable(ch === '$' || ch === '`'
640
+ ? 'command or variable substitution'
641
+ : ch === '&'
642
+ ? 'a background job'
643
+ : ch === '\\'
644
+ ? 'an escaped character'
645
+ : ch === '#'
646
+ ? 'a comment'
647
+ : 'a subshell');
648
+ else {
649
+ word += ch;
650
+ inWord = true;
651
+ }
652
+ }
653
+ endSegment(command.length, false);
654
+ return segments;
655
+ }
656
+ const yes = (category, reason, segment) => ({
657
+ dangerous: true,
658
+ category,
659
+ reason,
660
+ segment,
661
+ });
662
+ const CREDENTIAL_DIRS = [
663
+ '.ssh',
664
+ '.aws',
665
+ '.gnupg',
666
+ '.config/gh',
667
+ '.kube',
668
+ '.docker',
669
+ '.netrc',
670
+ 'Library/Keychains',
671
+ '.config/gcloud',
672
+ '.azure',
673
+ '.npmrc',
674
+ '.pypirc',
675
+ ];
676
+ /** Where a path really lands: symlinks in its existing part resolved, the rest kept as written. */
677
+ function real(p) {
678
+ for (let head = p, tail = '';;) {
679
+ try {
680
+ return tail ? join(realpathSync(head), tail) : realpathSync(head);
681
+ }
682
+ catch {
683
+ const up = dirname(head);
684
+ if (up === head)
685
+ return p;
686
+ tail = tail ? join(basename(head), tail) : basename(head);
687
+ head = up;
688
+ }
689
+ }
690
+ }
691
+ function credentialPath(abs, home) {
692
+ const name = basename(abs);
693
+ return (/^\.env(\.|$)/.test(name) ||
694
+ /\.(pem|key|p12|pfx)$/.test(name) ||
695
+ /^id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/.test(name) ||
696
+ CREDENTIAL_DIRS.some((d) => abs === home + sep + d || abs.startsWith(home + sep + d + sep)));
697
+ }
698
+ /** Whether a command (as the model wrote it) is dangerous to run without asking, and why. */
699
+ export function classifyCommand(command, ctx) {
700
+ return classify(command, ctx, 0, command);
701
+ }
702
+ function classify(command, ctx, depth, outer) {
703
+ let segments;
704
+ try {
705
+ segments = parse(command, ctx.home);
706
+ }
707
+ catch (error) {
708
+ if (!(error instanceof Unparseable))
709
+ throw error;
710
+ return yes('unparseable', `the check cannot read ${error.message}, so it cannot see what will run`, depth ? outer : command.trim());
711
+ }
712
+ for (const s of segments) {
713
+ const v = simple(s.argv, s, ctx, depth, depth ? outer : s.text);
714
+ if (v.dangerous)
715
+ return v;
716
+ }
717
+ return { dangerous: false };
718
+ }
719
+ function simple(argv, s, ctx, depth, text) {
720
+ const tmps = Array.isArray(ctx.tmp) ? ctx.tmp : [ctx.tmp];
721
+ // Operator ruling 2026-10-31: paths are checked where they really land, through symlinks.
722
+ const abs = (p) => real(resolve(ctx.root, p));
723
+ const roots = [ctx.root, ...tmps].map(real);
724
+ const inside = (p) => roots.some((r) => p === r || p.startsWith(r + sep)) || SAFE_TARGETS.has(p);
725
+ for (const r of s.redirects) {
726
+ const p = abs(r.target);
727
+ if (credentialPath(p, ctx.home) || credentialPath(p, real(ctx.home)))
728
+ return yes('credentials', `reads or writes a credential file (${r.target})`, text);
729
+ if (r.out && !inside(p))
730
+ return yes('outside-workspace', `writes ${r.target}, outside this workspace`, text);
731
+ }
732
+ let i = 0;
733
+ for (; i < argv.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]); i++)
734
+ if (/^(LD_|DYLD_|BASH_ENV=|ENV=|PROMPT_COMMAND=)/.test(argv[i]))
735
+ return yes('privilege', `${argv[i].split('=')[0]} makes every program load other code`, text);
736
+ argv = argv.slice(i);
737
+ if (!argv.length)
738
+ return { dangerous: false };
739
+ const cmd = basename(argv[0]), args = argv.slice(1);
740
+ if (/[^\x21-\x7e]/.test(argv[0]))
741
+ return yes('unparseable', 'the command name has hidden or non-ASCII characters', text);
742
+ if (cmd === 'eval' || cmd === 'source' || cmd === '.')
743
+ return yes('unparseable', `${cmd} runs text the check cannot read`, text);
744
+ if (s.afterPipe &&
745
+ (SHELLS.has(cmd) || (INTERPRETERS.has(cmd) && !positional(args).some((p) => p !== '-'))))
746
+ return yes('shell-pipe', `pipes text into ${cmd}, which runs it as code`, text);
747
+ for (const rule of DANGER_RULES) {
748
+ if (!rule.commands.includes(cmd) &&
749
+ !(rule.commands.includes('mkfs') && cmd.startsWith('mkfs.')))
750
+ continue;
751
+ if (rule.sub &&
752
+ !rule.sub.some((m) => typeof m === 'string' ? m === positional(args)[0] : m.test(positional(args)[0] ?? '')))
753
+ continue;
754
+ if (rule.test && !rule.test(args))
755
+ continue;
756
+ return yes(rule.category, `${cmd}: ${rule.why}`, text);
757
+ }
758
+ const inner = (next) => depth + 1 > MAX_DEPTH
759
+ ? yes('depth', `wrapped more than ${MAX_DEPTH} levels deep, so the check stops reading`, text)
760
+ : simple(next, { ...s, redirects: [] }, ctx, depth + 1, text);
761
+ if (SHELLS.has(cmd)) {
762
+ const c = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
763
+ if (c >= 0) {
764
+ if (depth + 1 > MAX_DEPTH)
765
+ return yes('depth', `wrapped more than ${MAX_DEPTH} levels deep, so the check stops reading`, text);
766
+ return classify(args[c + 1] ?? '', ctx, depth + 1, text);
767
+ }
768
+ }
769
+ if (cmd === 'find') {
770
+ const at = args.findIndex((a) => ['-exec', '-execdir', '-ok', '-okdir'].includes(a));
771
+ if (at >= 0) {
772
+ const rest = args.slice(at + 1), end = rest.findIndex((a) => a === ';' || a === '+');
773
+ return inner(end < 0 ? rest : rest.slice(0, end));
774
+ }
775
+ }
776
+ const wrapper = WRAPPERS[cmd];
777
+ if (wrapper) {
778
+ const next = wrapper(args);
779
+ if (next === null || !next.length)
780
+ return cmd === 'env'
781
+ ? yes('credentials', 'env prints the environment, which may hold secrets', text)
782
+ : { dangerous: false };
783
+ return inner(next);
784
+ }
785
+ const paths = positional(args).map(abs);
786
+ const cred = paths.find((p) => credentialPath(p, ctx.home) || credentialPath(p, real(ctx.home)));
787
+ if (cred)
788
+ return yes('credentials', `touches a credential file (${cred})`, text);
789
+ const writes = WRITERS.has(cmd) ||
790
+ (IN_PLACE.has(cmd) && args.some((a) => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place')));
791
+ if (writes) {
792
+ const out = paths.find((p) => !inside(p));
793
+ if (out)
794
+ return yes('outside-workspace', `${cmd} writes ${out}, outside this workspace`, text);
795
+ }
796
+ return { dangerous: false };
797
+ }