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