@getmarrow/mcp 3.9.102 → 3.9.103

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 (109) hide show
  1. package/README.md +49 -23
  2. package/dist/cli.js +41 -10
  3. package/dist/cli.js.map +1 -1
  4. package/dist/codex-plugin-pins.d.ts +19 -0
  5. package/dist/codex-plugin-pins.d.ts.map +1 -0
  6. package/dist/codex-plugin-pins.js +43 -0
  7. package/dist/codex-plugin-pins.js.map +1 -0
  8. package/dist/codex-plugins.d.ts +34 -0
  9. package/dist/codex-plugins.d.ts.map +1 -0
  10. package/dist/codex-plugins.js +240 -0
  11. package/dist/codex-plugins.js.map +1 -0
  12. package/dist/hook-contract.d.ts +2 -2
  13. package/dist/hook-contract.d.ts.map +1 -1
  14. package/dist/hook-contract.js +8 -3
  15. package/dist/hook-contract.js.map +1 -1
  16. package/dist/hook-pre-action.d.ts +48 -2
  17. package/dist/hook-pre-action.d.ts.map +1 -1
  18. package/dist/hook-pre-action.js +195 -22
  19. package/dist/hook-pre-action.js.map +1 -1
  20. package/dist/hook.d.ts +1 -1
  21. package/dist/hook.d.ts.map +1 -1
  22. package/dist/hook.js +12 -6
  23. package/dist/hook.js.map +1 -1
  24. package/dist/host-session.d.ts +11 -0
  25. package/dist/host-session.d.ts.map +1 -1
  26. package/dist/host-session.js +49 -0
  27. package/dist/host-session.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +15 -10
  30. package/dist/index.js.map +1 -1
  31. package/dist/normalized-action.d.ts +29 -6
  32. package/dist/normalized-action.d.ts.map +1 -1
  33. package/dist/normalized-action.js +492 -102
  34. package/dist/normalized-action.js.map +1 -1
  35. package/dist/own-server-identity.d.ts +123 -0
  36. package/dist/own-server-identity.d.ts.map +1 -0
  37. package/dist/own-server-identity.js +1052 -0
  38. package/dist/own-server-identity.js.map +1 -0
  39. package/dist/own-tool.d.ts +7 -3
  40. package/dist/own-tool.d.ts.map +1 -1
  41. package/dist/own-tool.js +7 -3
  42. package/dist/own-tool.js.map +1 -1
  43. package/dist/redact.d.ts +23 -0
  44. package/dist/redact.d.ts.map +1 -1
  45. package/dist/redact.js +181 -8
  46. package/dist/redact.js.map +1 -1
  47. package/dist/seatbelt/classifier-pin.d.ts +15 -0
  48. package/dist/seatbelt/classifier-pin.d.ts.map +1 -0
  49. package/dist/seatbelt/classifier-pin.js +26 -0
  50. package/dist/seatbelt/classifier-pin.js.map +1 -0
  51. package/dist/seatbelt/guards.d.ts +10 -0
  52. package/dist/seatbelt/guards.d.ts.map +1 -0
  53. package/dist/seatbelt/guards.js +509 -0
  54. package/dist/seatbelt/guards.js.map +1 -0
  55. package/dist/seatbelt/hook.d.ts +72 -0
  56. package/dist/seatbelt/hook.d.ts.map +1 -0
  57. package/dist/seatbelt/hook.js +362 -0
  58. package/dist/seatbelt/hook.js.map +1 -0
  59. package/dist/seatbelt/longopts.d.ts +68 -0
  60. package/dist/seatbelt/longopts.d.ts.map +1 -0
  61. package/dist/seatbelt/longopts.js +377 -0
  62. package/dist/seatbelt/longopts.js.map +1 -0
  63. package/dist/seatbelt/mapper.d.ts +83 -0
  64. package/dist/seatbelt/mapper.d.ts.map +1 -0
  65. package/dist/seatbelt/mapper.js +10248 -0
  66. package/dist/seatbelt/mapper.js.map +1 -0
  67. package/dist/seatbelt/record.d.ts +94 -0
  68. package/dist/seatbelt/record.d.ts.map +1 -0
  69. package/dist/seatbelt/record.js +776 -0
  70. package/dist/seatbelt/record.js.map +1 -0
  71. package/dist/seatbelt/tables.d.ts +82 -0
  72. package/dist/seatbelt/tables.d.ts.map +1 -0
  73. package/dist/seatbelt/tables.js +359 -0
  74. package/dist/seatbelt/tables.js.map +1 -0
  75. package/dist/seatbelt-deploy.d.ts +100 -0
  76. package/dist/seatbelt-deploy.d.ts.map +1 -0
  77. package/dist/seatbelt-deploy.js +393 -0
  78. package/dist/seatbelt-deploy.js.map +1 -0
  79. package/dist/seatbelt-fs.d.ts +30 -0
  80. package/dist/seatbelt-fs.d.ts.map +1 -0
  81. package/dist/seatbelt-fs.js +94 -0
  82. package/dist/seatbelt-fs.js.map +1 -0
  83. package/dist/seatbelt-git.d.ts +212 -0
  84. package/dist/seatbelt-git.d.ts.map +1 -0
  85. package/dist/seatbelt-git.js +844 -0
  86. package/dist/seatbelt-git.js.map +1 -0
  87. package/dist/seatbelt-mode.d.ts +77 -0
  88. package/dist/seatbelt-mode.d.ts.map +1 -0
  89. package/dist/seatbelt-mode.js +496 -0
  90. package/dist/seatbelt-mode.js.map +1 -0
  91. package/dist/seatbelt-path.d.ts +123 -0
  92. package/dist/seatbelt-path.d.ts.map +1 -0
  93. package/dist/seatbelt-path.js +482 -0
  94. package/dist/seatbelt-path.js.map +1 -0
  95. package/dist/seatbelt-scripts.d.ts +109 -0
  96. package/dist/seatbelt-scripts.d.ts.map +1 -0
  97. package/dist/seatbelt-scripts.js +1060 -0
  98. package/dist/seatbelt-scripts.js.map +1 -0
  99. package/dist/tool-catalog.js +2 -2
  100. package/dist/tool-catalog.js.map +1 -1
  101. package/dist/types.d.ts +2 -1
  102. package/dist/types.d.ts.map +1 -1
  103. package/package.json +2 -1
  104. package/server.json +3 -3
  105. package/vendor/classifier/MANIFEST.json +25 -0
  106. package/vendor/classifier/classifier.js +1356 -0
  107. package/vendor/classifier/policy-baseline.js +187 -0
  108. package/vendor/classifier/redact.js +59 -0
  109. package/vendor/classifier/shell-parse.js +455 -0
@@ -0,0 +1,1356 @@
1
+ 'use strict';
2
+
3
+ // Local action classifier, contract marrow.classifier.v1. It decides only one thing on its own:
4
+ // whether an action is ROUTINE (safe to allow locally with a receipt). Every other action is
5
+ // RISKY or UNKNOWN and goes to the server gate, which is authoritative. It classifies the parsed
6
+ // program and argv, never raw text, and it defaults anything it cannot resolve to not-routine
7
+ // (ADV-06). The rule tables come from the signed policy bundle (policy-baseline.js shape).
8
+
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { parseShell } = require('./shell-parse');
13
+ const { redactArgv, redactText, safeToken } = require('./redact');
14
+
15
+ const RANK = { routine: 0, unknown: 1, risky: 2 };
16
+ const MAX_DEPTH = 3;
17
+ const MAX_ALTERNATIVES = 16;
18
+ const SCRIPT_MAX_BYTES = 64 * 1024;
19
+ const PACKAGE_JSON_MAX_BYTES = 256 * 1024;
20
+ const SYSTEM_BIN = /^\/(?:usr\/(?:local\/)?)?s?bin\/|^\/opt\/homebrew\/bin\/|^\/snap\/bin\//;
21
+ const OUTPUT_REDIRECTS = new Set(['>', '>>', '>|', '&>', '&>>', '<>']);
22
+ const MAX_TRACKED_COMMANDS = 32;
23
+ const INSTALL_SUBS = new Set(['install', 'i', 'ci', 'add', 'rebuild', 'install-test', 'it', 'cit', 'clean-install', 'update', 'up', 'upgrade', 'import', 'pack']);
24
+ const INSTALL_LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepublish', 'preprepare', 'prepare', 'postprepare', 'dependencies'];
25
+ const PACK_LIFECYCLE = ['prepack', 'prepare', 'postpack'];
26
+ const MAX_COMMAND_CHARS = 1000;
27
+ const MAX_PATH_CHARS = 4096;
28
+ // Environment names that change what a later program runs or loads (PATH hijack, preload,
29
+ // interpreter options, npm/git config, Marrow's own settings).
30
+ // Only the variables that change which code runs or which server/trust store is used. Ordinary
31
+ // settings (NODE_ENV, CI, DEBUG, GIT_AUTHOR_NAME, PYTHONUNBUFFERED, RUST_LOG, TZ...) stay routine.
32
+ const SENSITIVE_ENV = new RegExp('^(?:' + [
33
+ 'PATH', 'IFS', 'ENV', 'BASH_ENV', 'SHELLOPTS', 'BASHOPTS', 'PS4', 'PROMPT_COMMAND', 'CDPATH', 'HOME', 'ZDOTDIR', 'SHELL',
34
+ 'LD_[A-Z_]*', 'DYLD_[A-Z_]*',
35
+ 'NODE_OPTIONS', 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', 'NODE_TLS_REJECT_UNAUTHORIZED', 'NODE_REPL_EXTERNAL_MODULE',
36
+ 'NPM_CONFIG_[A-Z_]*', 'npm_config_[A-Za-z_]*', 'YARN_[A-Z_]*', 'PNPM_[A-Z_]*', 'BUN_[A-Z_]*',
37
+ 'PYTHONPATH', 'PYTHONSTARTUP', 'PYTHONHOME', 'PYTHONBREAKPOINT', 'PYTHONUSERBASE', 'PYTHONEXECUTABLE',
38
+ 'PIP_INDEX_URL', 'PIP_EXTRA_INDEX_URL', 'PIP_TRUSTED_HOST', 'PIP_CONFIG_FILE', 'UV_INDEX_URL', 'UV_EXTRA_INDEX_URL',
39
+ 'RUBYOPT', 'RUBYLIB', 'GEM_HOME', 'GEM_PATH', 'BUNDLE_GEMFILE', 'PERL5OPT', 'PERL5LIB', 'PERLLIB', 'PERL5DB',
40
+ 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXEC_PATH', 'GIT_CONFIG[A-Z_]*', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_ASKPASS', 'GIT_EDITOR',
41
+ 'GIT_SEQUENCE_EDITOR', 'GIT_PAGER', 'GIT_TEMPLATE_DIR', 'GIT_EXTERNAL_DIFF', 'GIT_PROXY_COMMAND', 'GIT_ALLOW_PROTOCOL',
42
+ 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE',
43
+ 'SSH_[A-Z_]*', 'PAGER', 'EDITOR', 'VISUAL', 'BROWSER', 'XDG_[A-Z_]*', 'SSL_[A-Z_]*', 'CURL_[A-Z_]*',
44
+ 'REQUESTS_CA_BUNDLE', 'HTTPS?_PROXY', 'https?_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy', 'MARROW_[A-Z_]*',
45
+ ].join('|') + ')$');
46
+ const INTERNAL_HOST = /^(?:localhost|.*\.localhost|.*\.internal|metadata|metadata\.google\.internal|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0|\[?::1\]?|\[?f[cd][0-9a-f]{2}:.*|\[?fe80:.*)$/i;
47
+ const DEVICE_TARGETS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty', '/dev/fd/1', '/dev/fd/2']);
48
+
49
+ function globToRegex(pattern, home) {
50
+ let p = String(pattern);
51
+ let anchored = true;
52
+ if (p.startsWith('~/')) p = `${home.replace(/\/$/, '')}/${p.slice(2)}`;
53
+ else if (p === '~') p = home;
54
+ if (p.startsWith('**/')) { anchored = false; p = p.slice(3); }
55
+ let out = '';
56
+ for (let i = 0; i < p.length; i += 1) {
57
+ const ch = p[i];
58
+ if (ch === '*' && p[i + 1] === '*') {
59
+ if (p[i + 2] === '/') { out += '(?:.*/)?'; i += 2; }
60
+ else if (out.endsWith('/') && i + 2 === p.length) { out = `${out.slice(0, -1)}(?:/.*)?`; i += 1; }
61
+ else { out += '.*'; i += 1; }
62
+ } else if (ch === '*') out += '[^/]*';
63
+ else if (ch === '?') out += '[^/]';
64
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
65
+ }
66
+ return new RegExp(`${anchored ? '^' : '(?:^|/)'}${out}$`);
67
+ }
68
+
69
+ function compileMatcher(patterns, home) {
70
+ const regexes = (patterns || []).map((pattern) => globToRegex(pattern, home));
71
+ return (absPath) => regexes.some((regex) => regex.test(absPath));
72
+ }
73
+
74
+ function splitVerbs(name) {
75
+ return String(name)
76
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
77
+ .toLowerCase()
78
+ .split(/[^a-z0-9]+/)
79
+ .filter(Boolean);
80
+ }
81
+
82
+ class Result {
83
+ constructor() {
84
+ this.class = 'routine';
85
+ this.reasons = [];
86
+ this.programs = [];
87
+ this.paths = [];
88
+ this.hosts = [];
89
+ this.commands = [];
90
+ // True when the action had more than the tracked commands/arguments/characters. A truncated
91
+ // action is never covered by a server lease (the server did not see all of it).
92
+ this.truncated = false;
93
+ }
94
+ raise(cls, reason) {
95
+ if (RANK[cls] > RANK[this.class]) this.class = cls;
96
+ if (reason && cls !== 'routine') {
97
+ const token = safeToken(reason, 80);
98
+ if (!this.reasons.includes(token) && this.reasons.length < 24) this.reasons.push(token);
99
+ }
100
+ return this;
101
+ }
102
+ merge(other) {
103
+ if (!other) return this;
104
+ this.raise(other.class);
105
+ for (const reason of other.reasons) if (!this.reasons.includes(reason) && this.reasons.length < 24) this.reasons.push(reason);
106
+ for (const key of ['programs', 'paths', 'hosts', 'commands']) {
107
+ for (const value of other[key]) this.add(key, value);
108
+ }
109
+ if (other.truncated) this.truncated = true;
110
+ return this;
111
+ }
112
+ add(key, value) {
113
+ if (!value || this[key].includes(value)) return;
114
+ if (this[key].length >= MAX_TRACKED_COMMANDS) { this.truncated = true; return; }
115
+ this[key].push(value);
116
+ }
117
+ note(key, value) {
118
+ if (!value) return;
119
+ this.add(key, key === 'programs' || key === 'hosts' ? safeToken(value, 64) : value);
120
+ }
121
+ }
122
+
123
+ function createClassifier(policy, options = {}) {
124
+ const home = options.home || os.homedir();
125
+ const tmpRoots = [os.tmpdir(), '/tmp', '/var/tmp'].map((p) => p.replace(/\/$/, ''));
126
+ const tables = policy;
127
+ const programs = tables.programs || {};
128
+ const isProtected = compileMatcher(tables.protected_paths, home);
129
+ const isSecretRaw = compileMatcher(tables.secret_paths, home);
130
+ const isSecretException = compileMatcher(tables.secret_path_exceptions, home);
131
+ const isReview = compileMatcher(tables.review_paths, home);
132
+ const isProtectedBranch = compileMatcher(tables.protected_branches, '/nonexistent-home');
133
+ const urlMutation = new RegExp(tables.url_mutation_pattern || '(?!)', 'i');
134
+ const interpreterDanger = new RegExp(tables.interpreter_danger_pattern || '(?!)', 'i');
135
+ const sqlMutation = new RegExp(tables.sql_mutation_pattern || '(?!)', 'i');
136
+ const mutationVerbs = new Set(tables.mutation_verbs || []);
137
+ const toolSets = {
138
+ readOnly: new Set((tables.tools && tables.tools.read_only) || []),
139
+ edit: new Set((tables.tools && tables.tools.edit) || []),
140
+ shell: new Set((tables.tools && tables.tools.shell) || []),
141
+ fetch: new Set((tables.tools && tables.tools.fetch) || []),
142
+ };
143
+ const marrowPrefix = (tables.tools && tables.tools.marrow_mcp_prefix) || 'mcp__marrow__marrow_';
144
+ const wrappers = new Set(tables.wrappers || []);
145
+ const privilegeWrappers = new Set(tables.privilege_wrappers || []);
146
+ const shells = new Set(tables.shells || []);
147
+ const interpreters = new Set(tables.interpreters || []);
148
+ const routinePythonModules = new Set(tables.routine_python_modules || []);
149
+ const routineNpx = new Set(tables.routine_npx || []);
150
+ const subFlagsWithValue = tables.sub_flags_with_value || {};
151
+ const daemonPids = new Set((options.protectedPids || []).map(String));
152
+
153
+ const isSecret = (abs) => isSecretRaw(abs) && !isSecretException(abs);
154
+
155
+ // ---------- context helpers ----------
156
+ function newContext(cwd) {
157
+ const resolved = typeof cwd === 'string' && path.isAbsolute(cwd) ? path.normalize(cwd) : null;
158
+ // `written` is shared by every copy of the context for one action: a script or package.json
159
+ // written earlier in the same command line is never classified by its current contents.
160
+ return { cwd: resolved, cwdUnknown: !resolved, vars: new Map([['HOME', home]]), depth: 0, written: new Set() };
161
+ }
162
+
163
+ // Returns every literal value a word can take (a loop variable has several), or null when a
164
+ // part cannot be resolved statically.
165
+ function resolveWordAlternatives(word, ctx) {
166
+ if (!word) return null;
167
+ if (!word.dynamic) return [word.text];
168
+ if (!word.slots || word.slots.length === 0) return null;
169
+ const parts = word.text.split('\u0000');
170
+ let values = [parts[0]];
171
+ for (let k = 0; k < word.slots.length; k += 1) {
172
+ const slot = word.slots[k];
173
+ if (!slot || slot.type !== 'var' || !ctx.vars.has(slot.name)) return null;
174
+ const bound = ctx.vars.get(slot.name);
175
+ const options = Array.isArray(bound) ? bound : [bound];
176
+ const next = [];
177
+ for (const prefix of values) for (const option of options) next.push(`${prefix}${option}${parts[k + 1] || ''}`);
178
+ if (next.length > MAX_ALTERNATIVES) return null;
179
+ values = next;
180
+ }
181
+ return values;
182
+ }
183
+
184
+ function resolveWordText(word, ctx) {
185
+ const values = resolveWordAlternatives(word, ctx);
186
+ return values && values.length === 1 ? values[0] : null;
187
+ }
188
+
189
+ function expandTilde(text) {
190
+ if (text === '~') return home;
191
+ if (text.startsWith('~/')) return path.join(home, text.slice(2));
192
+ return text;
193
+ }
194
+
195
+ function absPath(text, ctx) {
196
+ const expanded = expandTilde(text);
197
+ if (path.isAbsolute(expanded)) return path.normalize(expanded);
198
+ if (ctx.cwdUnknown || !ctx.cwd) return null;
199
+ return path.resolve(ctx.cwd, expanded);
200
+ }
201
+
202
+ function inWorkspace(abs, ctx) {
203
+ if (!abs) return false;
204
+ const roots = [...tmpRoots];
205
+ if (ctx.cwd && !ctx.cwdUnknown && ctx.cwd !== '/' && ctx.cwd !== home) roots.push(ctx.cwd);
206
+ for (const root of [...roots]) { const real = realOf(root); if (real !== root && real !== '/' && real !== home) roots.push(real); }
207
+ return roots.some((root) => abs === root || abs.startsWith(`${root}/`));
208
+ }
209
+
210
+ // A target whose subtree includes the home directory, the filesystem root or the workspace
211
+ // itself (for example `~`, `/`, `.`, `..`, `./*`).
212
+ // A glob is broad only when it matches every entry of a directory that covers the home
213
+ // directory, the root or the workspace (`*`, `./*`, `~/*`, `.*`, `/tmp/*` with the workspace in
214
+ // /tmp). A glob with a literal part (`*.log`, `build-*`) deletes only matching entries and is
215
+ // judged like any other path.
216
+ function isBroadTarget(raw, ctx) {
217
+ const abs = absPath(raw, ctx);
218
+ const covers = (dir) => dir && (dir === '/' || dir === home || home.startsWith(`${dir}/`) || (ctx.cwd && (dir === ctx.cwd || ctx.cwd.startsWith(`${dir}/`))));
219
+ if (['/', '~', '*', '.', '..', './', '../', '/*', '~/*', './*', '../*', '.*', './.*'].includes(raw)) return true;
220
+ if (/[*?[]/.test(raw)) {
221
+ const name = path.basename(raw);
222
+ const matchesAll = name.replace(/\[[^\]]*\]|[*?.{},]/g, '') === '';
223
+ if (!matchesAll) return false; // `*.log`, `*/build`: only matching entries
224
+ const parts = raw.split('/');
225
+ const firstGlob = parts.findIndex((part) => /[*?[]/.test(part));
226
+ const fixed = parts.slice(0, firstGlob).join('/') || (raw.startsWith('/') ? '/' : '.');
227
+ return covers(absPath(fixed, ctx));
228
+ }
229
+ return covers(abs);
230
+ }
231
+
232
+ function relLabel(abs, ctx) {
233
+ if (!abs) return '[unresolved]';
234
+ if (ctx.cwd && abs.startsWith(`${ctx.cwd}/`)) return redactText(path.relative(ctx.cwd, abs), 120);
235
+ if (abs.startsWith(`${home}/`)) return redactText(`~/${path.relative(home, abs)}`, 120);
236
+ return redactText(abs, 120);
237
+ }
238
+
239
+ // The real location behind symlinks, for the path itself or its nearest existing ancestor. A
240
+ // workspace file that is a symlink to ~/.bashrc is judged as ~/.bashrc.
241
+ function realOf(abs) {
242
+ let current = abs;
243
+ const rest = [];
244
+ for (let level = 0; level < 64; level += 1) {
245
+ try {
246
+ return path.join(fs.realpathSync(current), ...rest.reverse());
247
+ } catch {
248
+ const parent = path.dirname(current);
249
+ if (parent === current) return abs;
250
+ rest.push(path.basename(current));
251
+ current = parent;
252
+ }
253
+ }
254
+ return abs;
255
+ }
256
+
257
+ function checkReadPath(text, ctx, result, reason = 'secret_read') {
258
+ if (text.length > MAX_PATH_CHARS) { result.raise('unknown', 'oversize_path'); return; }
259
+ const abs = absPath(text, ctx);
260
+ if (!abs) return;
261
+ if (/^\/dev\/(?:tcp|udp)\//.test(abs)) { result.raise('unknown', 'dev_tcp_network'); return; }
262
+ const real = realOf(abs);
263
+ if (isSecret(abs) || isSecret(real)) { result.raise('risky', reason); result.note('paths', relLabel(abs, ctx)); }
264
+ }
265
+
266
+ function checkWritePath(text, ctx, result, { recursiveDelete = false } = {}) {
267
+ if (text == null) { result.raise('unknown', 'dynamic_write_target'); return; }
268
+ if (DEVICE_TARGETS.has(text)) return;
269
+ if (text.length > MAX_PATH_CHARS) { result.raise('unknown', 'oversize_path'); return; }
270
+ const abs = absPath(text, ctx);
271
+ if (!abs) { result.raise('unknown', 'write_target_cwd_unknown'); return; }
272
+ if (/^\/dev\/(?:tcp|udp)\//.test(abs)) { result.raise('unknown', 'dev_tcp_network'); return; }
273
+ const real = realOf(abs);
274
+ if (ctx.written) { ctx.written.add(abs); ctx.written.add(real); }
275
+ result.note('paths', relLabel(abs, ctx));
276
+ if (isProtected(abs) || isProtected(real)) { result.raise('risky', real !== abs && !isProtected(abs) ? 'symlink_to_protected_path' : 'protected_path_write'); return; }
277
+ // Writing a credential store in the home directory (~/.aws, ~/.ssh...) is risky; creating or
278
+ // editing a project .env file inside the workspace goes to the server gate instead.
279
+ if (isSecret(abs) || isSecret(real)) { result.raise(inWorkspace(abs, ctx) && inWorkspace(real, ctx) ? 'unknown' : 'risky', 'secret_path_write'); return; }
280
+ if (isReview(abs) || isReview(real)) { result.raise('unknown', 'review_path_write'); return; }
281
+ if (inWorkspace(abs, ctx) && inWorkspace(real, ctx)) return;
282
+ // Outside the workspace: a non-recursive write to an ordinary (non-dot) path under the home
283
+ // directory, such as ~/agents/<role>/results/report.md, is routine. Dotfiles and dot-dirs,
284
+ // ~/Library, ~/Applications, ~/bin and ~/snap hold configuration or executables and stay
285
+ // gated; recursive deletes outside the workspace stay risky.
286
+ if (!recursiveDelete && isHomeDataPath(abs) && isHomeDataPath(real)) return;
287
+ result.raise(recursiveDelete ? 'risky' : 'unknown', 'write_outside_workspace');
288
+ }
289
+
290
+ function isHomeDataPath(abs) {
291
+ if (!abs || !abs.startsWith(`${home}/`)) return false;
292
+ const top = abs.slice(home.length + 1).split('/')[0];
293
+ return Boolean(top) && !top.startsWith('.') && !['Library', 'Applications', 'bin', 'snap'].includes(top);
294
+ }
295
+
296
+ // ---------- tools ----------
297
+ function toolInputPaths(input) {
298
+ const out = [];
299
+ for (const key of ['file_path', 'path', 'notebook_path', 'filePath', 'target_file', 'filename', 'file']) {
300
+ if (input && typeof input[key] === 'string') out.push(input[key]);
301
+ }
302
+ if (input && Array.isArray(input.edits)) {
303
+ for (const edit of input.edits) if (edit && typeof edit.file_path === 'string') out.push(edit.file_path);
304
+ }
305
+ return out;
306
+ }
307
+
308
+ function patchPaths(text) {
309
+ const out = [];
310
+ const re = /^\*\*\* (?:Update|Add|Delete) File: (.+)$|^\*\*\* Move to: (.+)$|^(?:\+\+\+|---) (?:[ab]\/)?(.+)$/gm;
311
+ let match;
312
+ while ((match = re.exec(String(text || ''))) !== null) {
313
+ const value = (match[1] || match[2] || match[3] || '').trim();
314
+ if (value && value !== '/dev/null' && out.length < 64) out.push(value);
315
+ }
316
+ return out;
317
+ }
318
+
319
+ function classifyUrl(url, result) {
320
+ let parsed = null;
321
+ try { parsed = new URL(url); } catch { parsed = null; }
322
+ if (parsed) result.note('hosts', parsed.host.toLowerCase());
323
+ if (!parsed) { result.raise('unknown', 'unparseable_url'); return; }
324
+ // Loopback, private and link-local hosts include cloud metadata endpoints that hand out
325
+ // credentials; a GET there is not routine.
326
+ // A plain request to the local dev server (localhost / 127.0.0.1 / ::1) is routine; cloud
327
+ // metadata, link-local and private-network hosts are not (write methods and bodies are judged
328
+ // separately by the http handler).
329
+ const loopback = /^(?:localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(parsed.hostname);
330
+ if (!loopback && (INTERNAL_HOST.test(parsed.hostname) || /^\d+$/.test(parsed.hostname))) result.raise('unknown', 'internal_host');
331
+ const target = `${parsed.pathname}${parsed.search}`;
332
+ if (urlMutation.test(target)) result.raise('unknown', 'url_mutation_hint');
333
+ }
334
+
335
+ function classifyTool(event) {
336
+ const result = new Result();
337
+ const rawName = String(event.tool_name || '');
338
+ const name = rawName.replace(/^functions\./, '');
339
+ const lower = name.toLowerCase();
340
+ const input = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
341
+ const ctx = newContext(event.cwd);
342
+ result.tool = { name: redactText(rawName, 96), kind: 'other' };
343
+
344
+ if (!name) return result.raise('unknown', 'missing_tool_name');
345
+ if (lower.startsWith(marrowPrefix) && /^[a-z0-9_]+$/.test(lower.slice(marrowPrefix.length))) {
346
+ result.tool.kind = 'marrow';
347
+ return result;
348
+ }
349
+ if (lower.startsWith('mcp__') || lower.startsWith('mcp:')) {
350
+ result.tool.kind = 'mcp';
351
+ const verbs = splitVerbs(name.split(/__|:/).slice(2).join('_') || name);
352
+ if (verbs.some((verb) => mutationVerbs.has(verb))) return result.raise('risky', 'mcp_mutation_verb');
353
+ return result.raise('unknown', 'mcp_tool');
354
+ }
355
+ if (toolSets.shell.has(lower)) {
356
+ result.tool.kind = 'shell';
357
+ const command = input.command !== undefined ? input.command : input.cmd;
358
+ if (Array.isArray(command)) return result.merge(classifyArgvArray(command.map(String), ctx, input.workdir));
359
+ if (typeof command !== 'string' || !command.trim()) return result.raise('unknown', 'missing_command');
360
+ if (typeof input.workdir === 'string' && path.isAbsolute(input.workdir)) { ctx.cwd = input.workdir; ctx.cwdUnknown = false; }
361
+ return result.merge(classifyShellSource(command, ctx, 0));
362
+ }
363
+ if (toolSets.edit.has(lower)) {
364
+ result.tool.kind = 'edit';
365
+ const targets = lower === 'apply_patch'
366
+ ? patchPaths(typeof input === 'string' ? input : input.input || input.patch || input.command || '')
367
+ : toolInputPaths(input);
368
+ if (lower === 'apply_patch' && targets.length === 0 && typeof event.tool_input === 'string') targets.push(...patchPaths(event.tool_input));
369
+ if (targets.length === 0) return result.raise('unknown', 'edit_without_path');
370
+ for (const target of targets) checkWritePath(target, ctx, result);
371
+ return result;
372
+ }
373
+ if (toolSets.readOnly.has(lower)) {
374
+ result.tool.kind = 'read';
375
+ for (const target of toolInputPaths(input)) checkReadPath(target, ctx, result);
376
+ if (typeof input.pattern === 'string' && /\.(?:aws|ssh)\b|\.env\b|id_rsa|id_ed25519|credentials/.test(input.pattern) && lower === 'glob') result.raise('unknown', 'secret_glob');
377
+ return result;
378
+ }
379
+ if (toolSets.fetch.has(lower)) {
380
+ result.tool.kind = 'fetch';
381
+ if (typeof input.url === 'string') classifyUrl(input.url, result);
382
+ else result.raise('unknown', 'fetch_without_url');
383
+ return result;
384
+ }
385
+ const verbs = splitVerbs(name);
386
+ if (verbs.some((verb) => mutationVerbs.has(verb))) return result.raise('risky', 'tool_mutation_verb');
387
+ return result.raise('unknown', 'unlisted_tool');
388
+ }
389
+
390
+ // Codex can send an exact argv (`["bash","-lc","git status"]`).
391
+ function classifyArgvArray(argv, ctx, workdir) {
392
+ if (typeof workdir === 'string' && path.isAbsolute(workdir)) { ctx.cwd = workdir; ctx.cwdUnknown = false; }
393
+ const words = argv.map((text) => ({ text, dynamic: false, quoted: true, slots: [] }));
394
+ const command = { words, assignments: [], redirects: [], heredoc: null, pipedInput: false };
395
+ return classifySimple(command, ctx, 0);
396
+ }
397
+
398
+ // ---------- shell ----------
399
+ function classifyShellSource(source, ctx, depth) {
400
+ const result = new Result();
401
+ if (depth > MAX_DEPTH) return result.raise('unknown', 'nesting_depth');
402
+ const parsed = parseShell(source, { depth });
403
+ if (!parsed.ok) return result.raise('unknown', `unparseable:${parsed.error}`);
404
+ for (const reason of parsed.reasons) {
405
+ if (['case_statement', 'coproc_statement', 'unterminated_heredoc', 'dangling_redirect'].includes(reason)) result.raise('unknown', reason);
406
+ }
407
+ // Each substitution is classified on its own. Its output becomes a dynamic word, which every
408
+ // handler treats conservatively (dynamic program -> unknown, dynamic write/delete/read target
409
+ // -> unknown or risky), so a routine substitution used as, say, a commit message stays routine.
410
+ for (const inner of parsed.substitutions) {
411
+ result.merge(classifyShellSource(inner, { ...ctx, vars: new Map(ctx.vars) }, depth + 1));
412
+ }
413
+ for (const command of parsed.commands) {
414
+ if (command.forLoop) {
415
+ const { name, items } = command.forLoop;
416
+ const values = [];
417
+ let resolvable = Array.isArray(items) && typeof name === 'string';
418
+ for (const item of items || []) {
419
+ const alternatives = resolveWordAlternatives(item, ctx);
420
+ if (!alternatives) { resolvable = false; break; }
421
+ values.push(...alternatives);
422
+ }
423
+ if (resolvable && values.length > 0 && values.length <= MAX_ALTERNATIVES) ctx.vars.set(name, values);
424
+ else if (typeof name === 'string') ctx.vars.delete(name);
425
+ continue;
426
+ }
427
+ for (const assignment of command.assignments) {
428
+ const eq = assignment.text.indexOf('=');
429
+ const name = assignment.text.slice(0, eq).replace(/\+$/, '');
430
+ const valueWord = { ...assignment, text: assignment.text.slice(eq + 1) };
431
+ const value = resolveWordText(valueWord, ctx);
432
+ if (command.words.length === 0) {
433
+ if (value === null) ctx.vars.delete(name);
434
+ else ctx.vars.set(name, value);
435
+ }
436
+ if (SENSITIVE_ENV.test(name)) result.raise('unknown', 'sensitive_env_assignment');
437
+ }
438
+ result.merge(classifySimple(command, ctx, depth));
439
+ }
440
+ return result;
441
+ }
442
+
443
+ function classifySimple(command, ctx, depth) {
444
+ const result = new Result();
445
+ for (const redirect of command.redirects) {
446
+ if (redirect.op === '>&' || redirect.op === '<&') {
447
+ const text = redirect.target ? resolveWordText(redirect.target, ctx) : null;
448
+ if (text !== null && /^(?:[0-9]+|-)$/.test(text)) continue;
449
+ checkWritePath(text, ctx, result);
450
+ continue;
451
+ }
452
+ if (OUTPUT_REDIRECTS.has(redirect.op)) {
453
+ checkWritePath(redirect.target ? resolveWordText(redirect.target, ctx) : null, ctx, result);
454
+ } else if (redirect.op === '<' || redirect.op === '<<<') {
455
+ const text = redirect.target ? resolveWordText(redirect.target, ctx) : null;
456
+ if (redirect.op === '<' && text) checkReadPath(text, ctx, result);
457
+ }
458
+ }
459
+ if (command.words.length === 0) return result;
460
+ const perWord = command.words.map((word) => resolveWordAlternatives(word, ctx));
461
+ let combos = [[]];
462
+ for (let k = 0; k < perWord.length; k += 1) {
463
+ const options = perWord[k] || [null];
464
+ const next = [];
465
+ for (const combo of combos) for (const option of options) next.push([...combo, { word: command.words[k], text: option }]);
466
+ if (next.length > MAX_ALTERNATIVES) return result.raise('unknown', 'too_many_alternatives');
467
+ combos = next;
468
+ }
469
+ for (const args of combos) result.merge(classifyWords(args, command, ctx, depth, false));
470
+ return result;
471
+ }
472
+
473
+ // `args` is [{word, text|null}]; `extraDynamic` marks argv that gains unseen arguments (xargs).
474
+ function classifyWords(args, command, ctx, depth, extraDynamic) {
475
+ const result = new Result();
476
+ let i = 0;
477
+ for (;;) {
478
+ if (i >= args.length) return result;
479
+ const first = args[i];
480
+ if (first.text === null) return result.raise('unknown', 'dynamic_program');
481
+ const program = first.text;
482
+ if (program === '') return result.raise('unknown', 'empty_program');
483
+ const base = programBase(program);
484
+ if (privilegeWrappers.has(base)) {
485
+ result.raise('risky', 'privilege_escalation');
486
+ result.note('programs', base);
487
+ i = skipWrapperOptions(base, args, i + 1);
488
+ continue;
489
+ }
490
+ if (wrappers.has(base)) {
491
+ result.note('programs', base);
492
+ if (base === 'env' && args.slice(i + 1).some((a) => a.text === '-S' || (a.text || '').startsWith('--split-string'))) return result.raise('unknown', 'env_split_string');
493
+ if (base === 'env' && args.slice(i + 1).some((a) => a.text === null || (/^[A-Za-z_][A-Za-z0-9_]*=/.test(a.text) && SENSITIVE_ENV.test(a.text.slice(0, a.text.indexOf('=')))))) result.raise('unknown', 'sensitive_env_assignment');
494
+ if (base === 'watch') {
495
+ const rest = args.slice(skipWrapperOptions(base, args, i + 1));
496
+ if (rest.some((a) => a.text === null)) return result.raise('unknown', 'dynamic_watch');
497
+ return result.merge(classifyShellSource(rest.map((a) => a.text).join(' '), ctx, depth + 1));
498
+ }
499
+ if (base === 'retry') return result.raise('unknown', 'retry_wrapper');
500
+ if (base === 'xargs') extraDynamic = true;
501
+ if (base === 'command' && args[i + 1] && /^-[vV]$/.test(args[i + 1].text || '')) return result;
502
+ if (base === 'env' && args.length === i + 1) return result;
503
+ i = skipWrapperOptions(base, args, i + 1);
504
+ if (i >= args.length) return result;
505
+ continue;
506
+ }
507
+ result.note('programs', base);
508
+ const argvTexts = args.slice(i).map((a) => (a.text === null ? '[dynamic]' : a.text));
509
+ if (argvTexts.length > 32 || argvTexts.reduce((n, t) => n + t.length + 1, 0) > MAX_COMMAND_CHARS) result.truncated = true;
510
+ result.add('commands', redactArgv(argvTexts, { maxArgs: 32, maxLength: 400 }).join(' ').slice(0, MAX_COMMAND_CHARS));
511
+ if (program.includes('/') && !SYSTEM_BIN.test(program)) {
512
+ return result.merge(classifyScriptExecution(program, args.slice(i + 1), command, ctx, depth));
513
+ }
514
+ return result.merge(classifyProgram(base, args.slice(i + 1), command, ctx, depth, extraDynamic));
515
+ }
516
+ }
517
+
518
+ function programBase(program) {
519
+ const base = path.basename(program);
520
+ return base.replace(/\.exe$/i, '');
521
+ }
522
+
523
+ function skipWrapperOptions(base, args, index) {
524
+ let i = index;
525
+ const valueFlags = {
526
+ env: ['-u', '--unset', '-C', '--chdir'],
527
+ timeout: ['-s', '--signal', '-k', '--kill-after'],
528
+ nice: ['-n', '--adjustment'],
529
+ ionice: ['-c', '-n', '-p', '--class', '--classdata'],
530
+ stdbuf: ['-i', '-o', '-e'],
531
+ xargs: ['-n', '-I', '-P', '-d', '-L', '-a', '-E', '-s', '--max-args', '--max-procs', '--delimiter', '--arg-file', '-i'],
532
+ sudo: ['-u', '-g', '-h', '-p', '-C', '-U', '-r', '-t', '--user', '--group', '--host', '--prompt'],
533
+ doas: ['-u', '-C'],
534
+ su: ['-c', '-s', '-g'],
535
+ exec: ['-a'],
536
+ watch: ['-n', '--interval', '-d', '--differences'],
537
+ chrt: [],
538
+ taskset: [],
539
+ }[base] || [];
540
+ while (i < args.length) {
541
+ const text = args[i].text;
542
+ if (text === null) return i;
543
+ if (text === '--') return i + 1;
544
+ if (base === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(text)) { i += 1; continue; }
545
+ if (!text.startsWith('-') || text === '-') break;
546
+ if (valueFlags.includes(text)) { i += 2; continue; }
547
+ i += 1;
548
+ }
549
+ if (base === 'timeout' && i < args.length && /^[0-9.]+[smhd]?$/.test(args[i].text || '')) i += 1;
550
+ if ((base === 'chrt' || base === 'taskset') && i < args.length) i += 1;
551
+ return i;
552
+ }
553
+
554
+ function positionals(args, valueFlags = []) {
555
+ const out = [];
556
+ for (let i = 0; i < args.length; i += 1) {
557
+ const text = args[i].text;
558
+ if (text === null) { out.push(args[i]); continue; }
559
+ if (text === '--') { out.push(...args.slice(i + 1)); break; }
560
+ if (text.startsWith('-') && text !== '-') {
561
+ if (valueFlags.includes(text)) i += 1;
562
+ continue;
563
+ }
564
+ out.push(args[i]);
565
+ }
566
+ return out;
567
+ }
568
+
569
+ function hasFlag(args, ...flags) {
570
+ return args.some((a) => a.text !== null && flags.some((flag) => (flag.length === 2 && flag[0] === '-' && flag[1] !== '-'
571
+ ? a.text === flag || (/^-[A-Za-z]+$/.test(a.text) && a.text.includes(flag[1]))
572
+ : a.text === flag || a.text.startsWith(`${flag}=`))));
573
+ }
574
+
575
+ function flagValue(args, ...flags) {
576
+ for (let i = 0; i < args.length; i += 1) {
577
+ const text = args[i].text;
578
+ if (text === null) continue;
579
+ for (const flag of flags) {
580
+ if (text === flag) return args[i + 1] ? args[i + 1].text : null;
581
+ if (text.startsWith(`${flag}=`)) return text.slice(flag.length + 1);
582
+ }
583
+ }
584
+ return undefined;
585
+ }
586
+
587
+ function classifyProgram(base, args, command, ctx, depth, extraDynamic) {
588
+ const result = new Result();
589
+ const rule = programs[base];
590
+ if (shells.has(base) && rule === undefined) return result.merge(classifyShellProgram(base, args, command, ctx, depth));
591
+ if (interpreters.has(base) && (rule === undefined || (rule && rule.handler === 'interpreter'))) return result.merge(classifyInterpreter(base, args, command, ctx));
592
+ if (rule === undefined) return result.raise('unknown', `unlisted_program:${base.slice(0, 40)}`);
593
+ if (rule === 'routine') {
594
+ if (extraDynamic) return result;
595
+ return result;
596
+ }
597
+ if (rule === 'unknown') return result.raise('unknown', `opaque_program:${base}`);
598
+ if (rule === 'risky') return result.raise('risky', `risky_program:${base}`);
599
+ if (rule.read) return classifyReadProgram(base, args, ctx, result);
600
+ if (rule.write) {
601
+ const pos = positionals(args, ['-m', '--mode', '-o', '--owner', '-g', '--group', '-t', '--target-directory', '-S', '--suffix']);
602
+ // cp/mv/ln/install read their sources and write the last argument; the rest (mkdir, touch,
603
+ // tee...) only write. A secret file copied anywhere is a secret read.
604
+ const copies = ['cp', 'mv', 'ln', 'install'].includes(base);
605
+ pos.forEach((arg, index) => {
606
+ if (copies && index < pos.length - 1 && arg.text !== null) checkReadPath(arg.text, ctx, result);
607
+ checkWritePath(arg.text, ctx, result);
608
+ });
609
+ if (extraDynamic) result.raise('unknown', 'xargs_targets');
610
+ return result;
611
+ }
612
+ if (rule.sub) return classifySubcommand(base, rule, args, result);
613
+ const handler = HANDLERS[rule.handler];
614
+ if (!handler) return result.raise('unknown', `unhandled:${base}`);
615
+ return result.merge(handler(base, args, command, ctx, depth, extraDynamic));
616
+ }
617
+
618
+ // `grep -c NAME ~/.marrow/env` (count, list or quiet output; the pattern a plain variable name)
619
+ // reveals only whether a name is present - the owner's own rule for checking credentials. Any
620
+ // other pattern could probe the value one character at a time, so it stays a secret read.
621
+ function isNameOnlyGrep(base, args) {
622
+ if (!['grep', 'egrep', 'fgrep'].includes(base)) return false;
623
+ const flags = args.filter((a) => a.text !== null && a.text.startsWith('-')).map((a) => a.text);
624
+ if (flags.some((f) => !/^-(?:[cqlLsFwx]+|-count|-quiet|-silent|-files-with-matches|-files-without-match|-fixed-strings|-word-regexp|-no-messages)$/.test(f))) return false;
625
+ if (!flags.some((f) => /^-[a-zA-Z]*[cqlL]|^--(?:count|quiet|silent|files-with-matches|files-without-match)$/.test(f))) return false;
626
+ const positional = args.filter((a) => a.text === null || !a.text.startsWith('-'));
627
+ return positional.length >= 2 && positional[0].text !== null && /^\^?[A-Za-z_][A-Za-z0-9_]*=?$/.test(positional[0].text);
628
+ }
629
+
630
+ function classifyReadProgram(base, args, ctx, result) {
631
+ if (isNameOnlyGrep(base, args)) return result;
632
+ // A recursive read rooted at the home directory or above reads every credential file in it.
633
+ const recursive = (['grep', 'egrep', 'fgrep'].includes(base) && hasFlag(args, '-r', '-R', '--recursive', '--dereference-recursive'))
634
+ || (['rg', 'ag', 'ack'].includes(base) && hasFlag(args, '--hidden', '-u', '-uu', '-uuu', '--no-ignore'))
635
+ || (base === 'zip' && hasFlag(args, '-r', '--recurse-paths'));
636
+ if (recursive) {
637
+ const targets = args.filter((a) => a.text !== null && !a.text.startsWith('-'));
638
+ if (targets.some((a) => isBroadTarget(a.text, ctx) && absPath(a.text, ctx) !== ctx.cwd) || (ctx.cwd === home)) result.raise('unknown', 'recursive_read_of_home');
639
+ }
640
+ const patternFirst = ['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'jq', 'yq'].includes(base)
641
+ && !hasFlag(args, '-e', '-f', '--regexp', '--file');
642
+ let skippedPattern = !patternFirst;
643
+ for (const arg of args) {
644
+ if (arg.text === null) {
645
+ if (!skippedPattern) { skippedPattern = true; continue; }
646
+ result.raise('unknown', 'dynamic_read_target');
647
+ continue;
648
+ }
649
+ const value = arg.text.startsWith('-') ? (arg.text.includes('=') ? arg.text.slice(arg.text.indexOf('=') + 1) : null) : arg.text;
650
+ if (!arg.text.startsWith('-') && !skippedPattern) { skippedPattern = true; continue; }
651
+ if (value) checkReadPath(value, ctx, result);
652
+ }
653
+ return result;
654
+ }
655
+
656
+ function classifySubcommand(base, rule, args, result) {
657
+ const valueFlags = subFlagsWithValue[rule.skip || base] || [];
658
+ let selector = null;
659
+ if (rule.flags) selector = args[0] ? args[0].text : null;
660
+ else {
661
+ const first = positionals(args, valueFlags)[0];
662
+ selector = first ? first.text : undefined;
663
+ }
664
+ if (selector === undefined) return result;
665
+ if (selector === null) return result.raise('unknown', `dynamic_subcommand:${base}`);
666
+ const cls = Object.prototype.hasOwnProperty.call(rule.sub, selector) ? rule.sub[selector] : rule.default || 'unknown';
667
+ return result.raise(cls, `${base}:${String(selector).slice(0, 32)}`);
668
+ }
669
+
670
+ function classifyScriptExecution(program, args, command, ctx, depth) {
671
+ const result = new Result();
672
+ const abs = absPath(program, ctx);
673
+ if (!abs) return result.raise('unknown', 'script_cwd_unknown');
674
+ result.note('paths', relLabel(abs, ctx));
675
+ return result.merge(classifyScriptFile(abs, ctx, depth));
676
+ }
677
+
678
+ function readSmallFile(abs, max) {
679
+ try {
680
+ const stat = fs.statSync(abs);
681
+ if (!stat.isFile() || stat.size > max) return null;
682
+ return fs.readFileSync(abs, 'utf8');
683
+ } catch {
684
+ return null;
685
+ }
686
+ }
687
+
688
+ function classifyScriptFile(abs, ctx, depth) {
689
+ const result = new Result();
690
+ if (ctx.written && (ctx.written.has(abs) || ctx.written.has(realOf(abs)))) return result.raise('unknown', 'script_written_in_same_command');
691
+ if (isProtected(abs) && !abs.startsWith(`${home}/.marrow/agentd/`)) result.raise('unknown', 'protected_path_exec');
692
+ const content = readSmallFile(abs, SCRIPT_MAX_BYTES);
693
+ if (content === null) return result.raise('unknown', 'script_unreadable');
694
+ if (content.includes('\u0000')) return result.raise('unknown', 'binary_executable');
695
+ const shebang = content.startsWith('#!') ? content.slice(2, content.indexOf('\n') >>> 0).trim() : '';
696
+ const interp = shebang ? programBase(shebang.split(/\s+/)[0] === '/usr/bin/env' || shebang.startsWith('/usr/bin/env ')
697
+ ? shebang.split(/\s+/).filter((s) => !s.startsWith('-'))[1] || '' : shebang.split(/\s+/)[0]) : '';
698
+ if (!shebang || shells.has(interp)) {
699
+ const scriptCtx = { ...ctx, vars: new Map(ctx.vars) };
700
+ return result.merge(classifyShellSource(content, scriptCtx, depth + 1));
701
+ }
702
+ if (interpreterDanger.test(content)) return result.raise('risky', 'script_interpreter_danger');
703
+ return result.raise('unknown', `script_interpreter:${interp.slice(0, 24)}`);
704
+ }
705
+
706
+ function classifyShellProgram(base, args, command, ctx, depth) {
707
+ const result = new Result();
708
+ for (let i = 0; i < args.length; i += 1) {
709
+ const text = args[i].text;
710
+ if (text === null) return result.raise('unknown', 'dynamic_shell_arg');
711
+ if (text === '-c' || (/^-[a-zA-Z]*c[a-zA-Z]*$/.test(text) && !text.startsWith('--'))) {
712
+ const body = args[i + 1];
713
+ if (!body || body.text === null) return result.raise('unknown', 'dynamic_shell_body');
714
+ return result.merge(classifyShellSource(body.text, { ...ctx, vars: new Map(ctx.vars) }, depth + 1));
715
+ }
716
+ if (text === '-s' || text === '--') break;
717
+ if (text.startsWith('-') || text.startsWith('+')) continue;
718
+ const abs = absPath(text, ctx);
719
+ if (!abs) return result.raise('unknown', 'script_cwd_unknown');
720
+ result.note('paths', relLabel(abs, ctx));
721
+ return result.merge(classifyScriptFile(abs, ctx, depth));
722
+ }
723
+ if (command.heredoc !== null && command.heredoc !== undefined) {
724
+ if (command.heredocDynamic) return result.raise('unknown', 'dynamic_heredoc');
725
+ return result.merge(classifyShellSource(command.heredoc, { ...ctx, vars: new Map(ctx.vars) }, depth + 1));
726
+ }
727
+ return result.raise('unknown', 'shell_reads_stdin');
728
+ }
729
+
730
+ function classifyInterpreter(base, args, command, ctx) {
731
+ const result = new Result();
732
+ if (args.length === 0) {
733
+ if (command.heredoc) return result.raise(interpreterDanger.test(command.heredoc) ? 'risky' : 'unknown', 'interpreter_stdin');
734
+ return result.raise('unknown', 'interpreter_stdin');
735
+ }
736
+ if (args.every((a) => a.text !== null && /^(?:--version|-v|-V|--help|-h)$/.test(a.text))) return result;
737
+ for (let i = 0; i < args.length; i += 1) {
738
+ const text = args[i].text;
739
+ if (text === null) return result.raise('unknown', 'dynamic_interpreter_arg');
740
+ if (['-c', '-e', '--eval', '-p', '--print', '-E', '-r', '-x'].includes(text) || /^-[A-Za-z]*[ce]$/.test(text)) {
741
+ const body = args[i + 1] ? args[i + 1].text : null;
742
+ if (body === null) return result.raise('unknown', 'dynamic_inline_code');
743
+ return result.raise(interpreterDanger.test(body) ? 'risky' : 'unknown', 'inline_code');
744
+ }
745
+ if (text === '-m' && base.startsWith('python')) {
746
+ const module = args[i + 1] ? args[i + 1].text : null;
747
+ if (module && routinePythonModules.has(module)) {
748
+ if (module === 'pip' && args[i + 2] && ['uninstall'].includes(args[i + 2].text)) return result.raise('unknown', 'pip_uninstall');
749
+ return result;
750
+ }
751
+ return result.raise('unknown', `python_module:${String(module).slice(0, 32)}`);
752
+ }
753
+ if ((base === 'node' || base === 'nodejs') && ['--test', '--check', '-c'].includes(text)) {
754
+ if (text === '--test') return result;
755
+ return result;
756
+ }
757
+ if (text.startsWith('-')) continue;
758
+ const abs = absPath(text, ctx);
759
+ if (abs) result.note('paths', relLabel(abs, ctx));
760
+ const content = abs ? readSmallFile(abs, SCRIPT_MAX_BYTES) : null;
761
+ if (content && interpreterDanger.test(content)) return result.raise('risky', 'script_interpreter_danger');
762
+ return result.raise('unknown', 'interpreter_script');
763
+ }
764
+ return result.raise('unknown', 'interpreter');
765
+ }
766
+
767
+ // ---------- program handlers ----------
768
+ const HANDLERS = {
769
+ export(base, args) {
770
+ const result = new Result();
771
+ for (const arg of args) {
772
+ if (arg.text === null) { result.raise('unknown', 'dynamic_export'); continue; }
773
+ if (/^-[a-zA-Z]*f/.test(arg.text)) result.raise('unknown', 'export_function');
774
+ const name = arg.text.split('=')[0].replace(/\+$/, '');
775
+ if (SENSITIVE_ENV.test(name)) result.raise('unknown', 'sensitive_env_assignment');
776
+ }
777
+ return result;
778
+ },
779
+ pip(base, args, command, ctx) {
780
+ const result = new Result();
781
+ const pos = positionals(args, ['-r', '--requirement', '-c', '--constraint', '-i', '--index-url', '--extra-index-url', '-t', '--target', '--prefix', '--root']);
782
+ const sub = pos[0] ? pos[0].text : undefined;
783
+ if (sub === undefined || ['list', 'show', 'freeze', 'check', 'help', '--version'].includes(sub)) return result;
784
+ if (sub === null) return result.raise('unknown', 'dynamic_pip_arg');
785
+ if (sub !== 'install') return result.raise('unknown', `pip:${String(sub).slice(0, 24)}`);
786
+ // Installing a local path, an editable project, a VCS URL or a requirements file can run
787
+ // code the agent wrote (setup.py, build backends).
788
+ if (hasFlag(args, '-e', '--editable', '--index-url', '-i', '--extra-index-url')) return result.raise('unknown', 'pip_install_local_or_index');
789
+ for (let i = 0; i < args.length; i += 1) {
790
+ const text = args[i].text;
791
+ if (text !== '-r' && text !== '--requirement' && !(text || '').startsWith('--requirement=')) continue;
792
+ const file = text.startsWith('--requirement=') ? text.slice(14) : (args[i + 1] ? args[i + 1].text : null);
793
+ if (!requirementsArePlain(file, ctx)) return result.raise('unknown', 'pip_requirements_not_plain');
794
+ }
795
+ for (const arg of pos.slice(1)) {
796
+ if (arg.text === null || /[/\\]|^\.|^(?:git|hg|svn|bzr)\+|\.(?:whl|zip|tar\.gz|tgz)$|@/.test(arg.text)) return result.raise('unknown', 'pip_install_local_or_url');
797
+ }
798
+ return result;
799
+ },
800
+ cd(base, args, command, ctx) {
801
+ const result = new Result();
802
+ const target = positionals(args)[0];
803
+ if (!target) { ctx.cwd = home; ctx.cwdUnknown = false; return result; }
804
+ if (target.text === null || target.text === '-') { ctx.cwdUnknown = true; return result; }
805
+ const abs = absPath(target.text, ctx);
806
+ if (!abs) { ctx.cwdUnknown = true; return result; }
807
+ ctx.cwd = abs;
808
+ ctx.cwdUnknown = false;
809
+ return result;
810
+ },
811
+ rg(base, args, command, ctx) {
812
+ const result = new Result();
813
+ if (hasFlag(args, '--pre')) return result.raise('unknown', 'rg_pre');
814
+ return classifyReadProgram(base, args, ctx, result);
815
+ },
816
+ find(base, args, command, ctx, depth) {
817
+ const result = new Result();
818
+ for (let i = 0; i < args.length; i += 1) {
819
+ const text = args[i].text;
820
+ if (text === null) continue;
821
+ if (['-exec', '-execdir', '-ok', '-okdir'].includes(text)) {
822
+ const inner = [];
823
+ let j = i + 1;
824
+ while (j < args.length && args[j].text !== ';' && args[j].text !== '+') {
825
+ inner.push(args[j].text === '{}' ? { word: args[j].word, text: null } : args[j]);
826
+ j += 1;
827
+ }
828
+ result.raise('unknown', 'find_exec');
829
+ result.merge(classifyWords(inner.map((a, k) => (k === 0 ? a : a)), { redirects: [], heredoc: null }, ctx, depth + 1, true));
830
+ i = j;
831
+ continue;
832
+ }
833
+ if (text === '-delete') result.raise('unknown', 'find_delete');
834
+ if (/^-f(?:print|printf|ls)/.test(text)) result.raise('unknown', 'find_write');
835
+ if (!text.startsWith('-') && !['(', ')', '!', ','].includes(text)) checkReadPath(text, ctx, result);
836
+ }
837
+ return result;
838
+ },
839
+ fd(base, args, command, ctx) {
840
+ const result = new Result();
841
+ if (hasFlag(args, '-x', '-X', '--exec', '--exec-batch')) return result.raise('unknown', 'fd_exec');
842
+ return classifyReadProgram(base, args, ctx, result);
843
+ },
844
+ awk(base, args, command, ctx) {
845
+ const result = new Result();
846
+ if (hasFlag(args, '-f', '--file')) return result.raise('unknown', 'awk_program_file');
847
+ const pos = positionals(args, ['-F', '-v', '--field-separator', '--assign']);
848
+ const program = pos[0];
849
+ if (!program) return result.raise('unknown', 'awk_without_program');
850
+ if (program.text === null) return result.raise('unknown', 'dynamic_awk_program');
851
+ if (/system\s*\(|\|\s*"|\|&|print[^;}]*>|getline\s*<|"\s*\|\s*getline/.test(program.text)) {
852
+ return result.raise(interpreterDanger.test(program.text) ? 'risky' : 'unknown', 'awk_side_effect');
853
+ }
854
+ for (const arg of pos.slice(1)) {
855
+ if (arg.text === null) result.raise('unknown', 'dynamic_read_target');
856
+ else checkReadPath(arg.text, ctx, result);
857
+ }
858
+ return result;
859
+ },
860
+ sed(base, args, command, ctx) {
861
+ const result = new Result();
862
+ const inPlace = args.some((a) => a.text !== null && (/^-[a-zA-Z]*i/.test(a.text) || a.text.startsWith('--in-place')));
863
+ const explicitScript = hasFlag(args, '-e', '--expression', '-f', '--file');
864
+ const pos = positionals(args, ['-e', '--expression', '-f', '--file', '-l']);
865
+ const scripts = [];
866
+ for (let i = 0; i < args.length; i += 1) if (['-e', '--expression'].includes(args[i].text) && args[i + 1]) scripts.push(args[i + 1].text);
867
+ const files = explicitScript ? pos : pos.slice(1);
868
+ if (!explicitScript && pos[0]) scripts.push(pos[0].text);
869
+ if (hasFlag(args, '-f', '--file')) result.raise('unknown', 'sed_script_file');
870
+ for (const script of scripts) {
871
+ if (script === null) { result.raise('unknown', 'dynamic_sed_script'); continue; }
872
+ if (/(^|[;}\n])\s*[0-9,$/!]*\s*e\b|\bw\s+\S|\bW\s+\S/.test(script)) result.raise('unknown', 'sed_exec_or_write');
873
+ // GNU sed's `e` flag on s/// executes the pattern space as a command.
874
+ if (/[/|#,:@!_]\s*[gpIiMm0-9]*e[gpIiMm0-9]*\s*(?:$|[;}\n])/.test(script)) result.raise('unknown', 'sed_exec_flag');
875
+ }
876
+ for (const file of files) {
877
+ if (inPlace) checkWritePath(file.text, ctx, result);
878
+ else if (file.text === null) result.raise('unknown', 'dynamic_read_target');
879
+ else checkReadPath(file.text, ctx, result);
880
+ }
881
+ return result;
882
+ },
883
+ rm(base, args, command, ctx, depth, extraDynamic) {
884
+ const result = new Result();
885
+ const recursive = hasFlag(args, '-r', '-R', '--recursive');
886
+ const force = hasFlag(args, '-f', '--force');
887
+ const targets = positionals(args);
888
+ if (extraDynamic) result.raise(recursive || force ? 'risky' : 'unknown', 'rm_dynamic_targets');
889
+ for (const target of targets) {
890
+ if (target.text === null) { result.raise(recursive ? 'risky' : 'unknown', 'rm_dynamic_target'); continue; }
891
+ const raw = target.text;
892
+ const abs = absPath(raw, ctx);
893
+ if (isBroadTarget(raw, ctx)) { result.raise('risky', 'rm_broad_target'); result.note('paths', relLabel(abs, ctx)); continue; }
894
+ // Deleting a repository's .git loses every unpushed commit.
895
+ if (abs && (path.basename(abs) === '.git' || abs.includes('/.git/'))) { result.raise('risky', 'rm_git_dir'); continue; }
896
+ checkWritePath(raw, ctx, result, { recursiveDelete: recursive });
897
+ }
898
+ return result;
899
+ },
900
+ chmod(base, args, command, ctx) {
901
+ const result = new Result();
902
+ const recursive = hasFlag(args, '-R', '--recursive');
903
+ const pos = positionals(args, ['--reference']);
904
+ for (const target of pos.slice(1)) {
905
+ if (recursive && target.text !== null && isBroadTarget(target.text, ctx)) { result.raise('risky', `${base}_recursive_broad`); continue; }
906
+ checkWritePath(target.text, ctx, result, { recursiveDelete: recursive });
907
+ }
908
+ return result;
909
+ },
910
+ archive(base, args, command, ctx) {
911
+ const result = new Result();
912
+ const dest = flagValue(args, '-C', '--directory', '-d');
913
+ if (dest === null) return result.raise('unknown', 'dynamic_extract_target');
914
+ if (dest !== undefined) checkWritePath(dest, ctx, result);
915
+ for (const arg of positionals(args, ['-C', '--directory', '-d', '-f', '--file'])) {
916
+ if (arg.text !== null) checkReadPath(arg.text, ctx, result);
917
+ }
918
+ const archiveFile = flagValue(args, '-f', '--file');
919
+ if (typeof archiveFile === 'string' && /^-?[a-zA-Z]*c/.test((args[0] && args[0].text) || '')) checkWritePath(archiveFile, ctx, result);
920
+ return result;
921
+ },
922
+ git(base, args, command, ctx) {
923
+ const result = new Result();
924
+ let i = 0;
925
+ let gitCwd = ctx.cwd;
926
+ let gitCwdUnknown = ctx.cwdUnknown;
927
+ while (i < args.length) {
928
+ const text = args[i].text;
929
+ if (text === null) return result.raise('unknown', 'dynamic_git_arg');
930
+ if (text === '-C') {
931
+ const dir = args[i + 1] ? args[i + 1].text : null;
932
+ const abs = dir === null ? null : absPath(dir, { cwd: gitCwd, cwdUnknown: gitCwdUnknown });
933
+ gitCwd = abs; gitCwdUnknown = !abs; i += 2; continue;
934
+ }
935
+ if (text === '-c') {
936
+ const setting = args[i + 1] ? args[i + 1].text : null;
937
+ if (setting === null || /^(?:core\.(?:hookspath|sshcommand|pager|editor|fsmonitor|gitproxy)|alias\.|credential\.|url\.|protocol\.|http\.)/i.test(setting)) result.raise('unknown', 'git_sensitive_config_override');
938
+ i += 2; continue;
939
+ }
940
+ if (text.startsWith('--git-dir') || text.startsWith('--work-tree')) { result.raise('unknown', 'git_dir_override'); i += text.includes('=') ? 1 : 2; continue; }
941
+ if (text.startsWith('-')) { i += 1; continue; }
942
+ break;
943
+ }
944
+ if (i >= args.length) return result;
945
+ const sub = args[i].text;
946
+ const rest = args.slice(i + 1);
947
+ const gitCtx = { ...ctx, cwd: gitCwd, cwdUnknown: gitCwdUnknown };
948
+ const has = (...flags) => hasFlag(rest, ...flags);
949
+ const pos = positionals(rest);
950
+ const routineSubs = new Set(['status', 'diff', 'log', 'show', 'blame', 'fetch', 'pull', 'add', 'commit', 'switch', 'rev-parse', 'ls-files', 'ls-tree', 'cat-file', 'describe', 'shortlog', 'grep', 'reflog', 'bisect', 'format-patch', 'mv', 'rm', 'init', 'clone', 'cherry-pick', 'revert', 'merge', 'rebase', 'apply', 'am', 'difftool', 'range-diff', 'show-ref', 'for-each-ref', 'count-objects', 'fsck', 'verify-commit', 'whatchanged', 'help', 'version', 'merge-base', 'name-rev', 'rev-list', 'check-ignore', 'check-attr', 'var', 'stage', 'show-branch', 'mergetool', 'restore-staged', 'sparse-checkout', 'maintenance']);
951
+ switch (sub) {
952
+ case 'push': return result.merge(classifyGitPush(rest, gitCtx));
953
+ case 'branch': return has('-D', '--force', '-f', '-M', '-C') ? result.raise('unknown', 'git_branch_force') : result;
954
+ case 'checkout': return (has('-f', '--force') || pos.some((a) => a.text === '.') || rest.some((a) => a.text === '--')) ? result.raise('unknown', 'git_checkout_discard') : result;
955
+ case 'restore': return pos.some((a) => a.text === '.' || a.text === ':/') ? result.raise('unknown', 'git_restore_all') : result;
956
+ case 'reset': return has('--hard', '--merge', '--keep') ? result.raise('unknown', 'git_reset_hard') : result;
957
+ case 'clean': return has('-n', '--dry-run') ? result : result.raise('unknown', 'git_clean');
958
+ case 'stash': return pos[0] && ['drop', 'clear'].includes(pos[0].text) ? result.raise('unknown', 'git_stash_drop') : result;
959
+ case 'tag': return has('-d', '--delete', '-f', '--force') ? result.raise('unknown', 'git_tag_delete') : result;
960
+ case 'remote': return pos[0] && ['add', 'remove', 'rm', 'set-url', 'rename', 'prune', 'set-head', 'set-branches'].includes(pos[0].text) ? result.raise('unknown', 'git_remote_change') : result;
961
+ case 'config': {
962
+ if (has('--get', '--get-all', '--list', '-l', '--show-origin', '--get-regexp', '--show-scope') || pos.length <= 1) return result;
963
+ const key = pos[0] && pos[0].text ? pos[0].text.toLowerCase() : '';
964
+ if (/^(?:core\.(?:hookspath|sshcommand|fsmonitor)|alias\.|credential\.|url\.)/.test(key)) return result.raise('risky', 'git_config_sensitive');
965
+ return result.raise('unknown', 'git_config_set');
966
+ }
967
+ // Without --force git refuses to remove a worktree with changes, so plain remove is safe.
968
+ case 'worktree': return pos[0] && ((pos[0].text === 'remove' && has('-f', '--force')) || pos[0].text === 'move') ? result.raise('unknown', 'git_worktree_remove') : result;
969
+ case 'submodule': return pos[0] && pos[0].text === 'foreach' ? result.raise('unknown', 'git_submodule_foreach') : result;
970
+ case 'filter-branch': case 'filter-repo': return result.raise('risky', 'git_history_rewrite');
971
+ case 'gc': case 'prune': case 'repack': case 'update-ref': case 'replace': case 'notes': case 'lfs': case 'send-email': case 'daemon': case 'svn': case 'p4':
972
+ return result.raise('unknown', `git:${sub}`);
973
+ default:
974
+ if (routineSubs.has(sub)) return result;
975
+ return result.raise('unknown', `git:${String(sub).slice(0, 32)}`);
976
+ }
977
+ },
978
+ gh(base, args) {
979
+ const result = new Result();
980
+ const pos = positionals(args, subFlagsWithValue.gh || []);
981
+ if (pos.length === 0) return result;
982
+ if (pos.some((a, k) => k < 2 && a.text === null)) return result.raise('unknown', 'dynamic_gh_arg');
983
+ const [group, action] = [pos[0].text, pos[1] ? pos[1].text : null];
984
+ const maps = {
985
+ pr: { routine: ['view', 'list', 'status', 'diff', 'checks', 'create', 'comment', 'edit', 'ready', 'checkout', 'develop'], risky: ['merge'] },
986
+ issue: { routine: ['view', 'list', 'status', 'create', 'comment', 'edit', 'develop'], risky: ['delete', 'transfer'] },
987
+ run: { routine: ['view', 'list', 'watch', 'download'], risky: [] },
988
+ workflow: { routine: ['list', 'view'], risky: ['run', 'enable', 'disable'] },
989
+ repo: { routine: ['view', 'list', 'clone', 'fork', 'set-default'], risky: ['delete', 'archive', 'rename', 'edit', 'unarchive'] },
990
+ release: { routine: ['list', 'view', 'download'], risky: ['create', 'delete', 'upload', 'edit', 'delete-asset'] },
991
+ secret: { routine: ['list'], risky: ['set', 'delete', 'remove'] },
992
+ variable: { routine: ['list', 'get'], risky: ['set', 'delete', 'remove'] },
993
+ auth: { routine: ['status'], risky: ['token', 'login', 'logout', 'refresh', 'setup-git', 'switch'] },
994
+ 'ssh-key': { routine: ['list'], risky: ['add', 'delete'] },
995
+ 'gpg-key': { routine: ['list'], risky: ['add', 'delete'] },
996
+ gist: { routine: ['view', 'list'], risky: [] },
997
+ extension: { routine: ['list'], risky: [] },
998
+ cache: { routine: ['list'], risky: [] },
999
+ config: { routine: ['get', 'list'], risky: [] },
1000
+ label: { routine: ['list'], risky: ['delete'] },
1001
+ };
1002
+ if (['search', 'browse', 'status', 'help', 'version', 'completion'].includes(group)) return result;
1003
+ if (group === 'pr' && action === 'review' && hasFlag(args, '--approve')) return result.raise('unknown', 'gh_pr_approve');
1004
+ if (group === 'pr' && action === 'review') return result;
1005
+ if (group === 'api') {
1006
+ const method = String(flagValue(args, '-X', '--method') || 'GET').toUpperCase();
1007
+ if (method === 'DELETE') return result.raise('risky', 'gh_api_delete');
1008
+ if (method !== 'GET' || hasFlag(args, '-f', '-F', '--field', '--raw-field', '--input')) return result.raise('unknown', 'gh_api_write');
1009
+ const endpoint = pos[1] ? pos[1].text : null;
1010
+ if (endpoint === null || urlMutation.test(endpoint) || /\/dispatches\b|\/merges?\b/.test(endpoint)) return result.raise('unknown', 'gh_api_mutation_hint');
1011
+ return result;
1012
+ }
1013
+ const map = maps[group];
1014
+ if (!map) return result.raise('unknown', `gh:${String(group).slice(0, 24)}`);
1015
+ if (action === null) return result.raise('unknown', 'dynamic_gh_arg');
1016
+ if (!action || map.routine.includes(action)) return result;
1017
+ if (map.risky.includes(action)) return result.raise('risky', `gh:${group}:${action}`);
1018
+ return result.raise('unknown', `gh:${group}:${String(action).slice(0, 24)}`);
1019
+ },
1020
+ pkg(base, args, command, ctx, depth) {
1021
+ const result = new Result();
1022
+ const pos = positionals(args, subFlagsWithValue[base] || []);
1023
+ if (pos.length === 0) return base === 'yarn' ? result : result;
1024
+ if (pos[0].text === null) return result.raise('unknown', 'dynamic_pkg_subcommand');
1025
+ const sub = pos[0].text;
1026
+ const routineSubs = new Set(['install', 'i', 'ci', 'add', 'remove', 'rm', 'uninstall', 'un', 'ls', 'list', 'll', 'la', 'outdated', 'view', 'info', 'show', 'why', 'explain', 'audit', 'pack', 'doctor', 'help', 'prune', 'dedupe', 'rebuild', 'init', 'fund', 'bin', 'root', 'prefix', 'search', 'update', 'up', 'upgrade', 'whoami', 'licenses', 'exec-env', 'store', 'import', 'install-test', 'it', 'cit', 'clean-install']);
1027
+ const riskySubs = new Set(['publish', 'unpublish', 'deprecate', 'dist-tag', 'owner', 'access', 'team', 'token', 'adduser', 'login', 'logout', 'org', 'profile', 'hook', 'star', 'unstar', 'npm', 'trust']);
1028
+ if (riskySubs.has(sub)) return result.raise('risky', `${base}:${sub}`);
1029
+ if (sub === 'config' || sub === 'c' || sub === 'set') {
1030
+ const action = pos[1] ? pos[1].text : null;
1031
+ return ['get', 'list', 'ls'].includes(action) ? result : result.raise('risky', `${base}:config_set`);
1032
+ }
1033
+ if (['exec', 'x', 'dlx', 'create', 'link', 'ln', 'version', 'set-script', 'patch', 'edit', 'repo', 'docs'].includes(sub)) return result.raise('unknown', `${base}:${sub}`);
1034
+ if (['test', 't', 'tst', 'start', 'stop', 'restart'].includes(sub)) return result.merge(classifyPackageScript(sub === 't' || sub === 'tst' ? 'test' : sub, ctx, depth));
1035
+ if (sub === 'run' || sub === 'run-script' || sub === 'rum' || sub === 'urn') {
1036
+ const name = pos[1] ? pos[1].text : undefined;
1037
+ if (name === undefined) return result;
1038
+ if (name === null) return result.raise('unknown', 'dynamic_script_name');
1039
+ return result.merge(classifyPackageScript(name, ctx, depth));
1040
+ }
1041
+ // Installing, packing or rebuilding runs the ROOT package's lifecycle scripts (for example
1042
+ // "postinstall"), which the agent can write; classify them by content.
1043
+ if (INSTALL_SUBS.has(sub)) return result.merge(classifyLifecycleScripts(ctx, depth, sub === 'pack' ? PACK_LIFECYCLE : INSTALL_LIFECYCLE));
1044
+ if (routineSubs.has(sub)) return result;
1045
+ if (base === 'yarn' || base === 'pnpm') return result.merge(classifyPackageScript(sub, ctx, depth));
1046
+ return result.raise('unknown', `${base}:${String(sub).slice(0, 24)}`);
1047
+ },
1048
+ bun(base, args, command, ctx, depth) {
1049
+ const result = new Result();
1050
+ const pos = positionals(args);
1051
+ if (pos.length === 0) return result;
1052
+ const sub = pos[0].text;
1053
+ if (sub === null) return result.raise('unknown', 'dynamic_bun_arg');
1054
+ if (['install', 'i', 'add', 'update'].includes(sub)) return result.merge(classifyLifecycleScripts(ctx, depth, INSTALL_LIFECYCLE));
1055
+ if (['remove', 'outdated', 'pm', 'test', 'build'].includes(sub)) return result;
1056
+ if (sub === 'publish') return result.raise('risky', 'bun:publish');
1057
+ if (sub === 'x') return result.merge(HANDLERS.npx('bunx', args.slice(1), command, ctx, depth));
1058
+ if (sub === 'run') {
1059
+ const name = pos[1] ? pos[1].text : undefined;
1060
+ if (!name) return result.raise('unknown', 'bun_run');
1061
+ if (/\.[cm]?[jt]sx?$/.test(name)) return result.raise('unknown', 'bun_run_file');
1062
+ return result.merge(classifyPackageScript(name, ctx, depth));
1063
+ }
1064
+ return result.raise('unknown', 'bun_file_or_script');
1065
+ },
1066
+ npx(base, args, command, ctx, depth) {
1067
+ const result = new Result();
1068
+ let spec;
1069
+ let rest = [];
1070
+ for (let i = 0; i < args.length; i += 1) {
1071
+ const text = args[i].text;
1072
+ if (text === null) return result.raise('unknown', 'dynamic_npx_arg');
1073
+ if (text === '-c' || text === '--call') {
1074
+ const body = args[i + 1] ? args[i + 1].text : null;
1075
+ if (body === null) return result.raise('unknown', 'dynamic_npx_call');
1076
+ return result.merge(classifyShellSource(body, { ...ctx, vars: new Map(ctx.vars) }, depth + 1));
1077
+ }
1078
+ if (text === '-p' || text === '--package') {
1079
+ const pkg = args[i + 1] ? args[i + 1].text : '';
1080
+ if (/@getmarrow\//.test(pkg || '')) result.note('programs', 'marrow-package');
1081
+ i += 1; continue;
1082
+ }
1083
+ if (text.startsWith('--package=')) continue;
1084
+ if (text.startsWith('-')) continue;
1085
+ spec = text;
1086
+ rest = args.slice(i + 1);
1087
+ break;
1088
+ }
1089
+ if (!spec) return result.raise('unknown', 'npx_without_spec');
1090
+ const name = spec.replace(/^(@[^/]+\/[^@]+|[^@]+)@.*$/, '$1');
1091
+ if (/^@getmarrow\/install$|^marrow-install$/.test(name) || /^marrow-mcp$/.test(name)) {
1092
+ const action = positionals(rest)[0];
1093
+ const verb = action ? action.text : '';
1094
+ if (['control', 'uninstall', 'agentd', 'disable'].includes(verb) || verb === null) return result.raise('risky', 'marrow_control_change');
1095
+ if (['doctor', 'status', 'detect'].includes(verb)) return result;
1096
+ return result.raise('unknown', 'marrow_cli');
1097
+ }
1098
+ if (routineNpx.has(name)) return result;
1099
+ return result.raise('unknown', `npx:${String(name).slice(0, 32)}`);
1100
+ },
1101
+ interpreter(base, args, command, ctx) {
1102
+ return classifyInterpreter(base, args, command, ctx);
1103
+ },
1104
+ source(base, args, command, ctx, depth) {
1105
+ const result = new Result();
1106
+ const target = positionals(args)[0];
1107
+ if (!target || target.text === null) return result.raise('unknown', 'dynamic_source');
1108
+ const abs = absPath(target.text, ctx);
1109
+ if (!abs) return result.raise('unknown', 'script_cwd_unknown');
1110
+ return result.merge(classifyScriptFile(abs, ctx, depth));
1111
+ },
1112
+ docker(base, args) {
1113
+ const result = new Result();
1114
+ const pos = positionals(args, subFlagsWithValue.docker || []);
1115
+ if (pos.length === 0) return result;
1116
+ if (pos.some((a, k) => k < 2 && a.text === null)) return result.raise('unknown', 'dynamic_docker_arg');
1117
+ const [sub, sub2] = [pos[0].text, pos[1] ? pos[1].text : ''];
1118
+ const routine = ['ps', 'images', 'logs', 'inspect', 'version', 'info', 'build', 'pull', 'stats', 'top', 'port', 'diff', 'history', 'search', 'events', 'buildx'];
1119
+ const risky = ['rm', 'rmi', 'push', 'login', 'logout', 'prune', 'swarm', 'service', 'stack', 'secret', 'config', 'node', 'plugin', 'trust', 'manifest'];
1120
+ if (sub === 'compose') {
1121
+ if (['ps', 'logs', 'config', 'build', 'pull', 'ls', 'top', 'images', 'version'].includes(sub2)) return result;
1122
+ if (sub2 === 'down') return hasFlag(args, '-v', '--volumes', '--rmi') ? result.raise('risky', 'docker_compose_down_volumes') : result.raise('unknown', 'docker_compose_down');
1123
+ if (['rm', 'push', 'kill'].includes(sub2)) return result.raise('risky', `docker_compose_${sub2}`);
1124
+ return result.raise('unknown', `docker_compose_${String(sub2).slice(0, 16)}`);
1125
+ }
1126
+ if (['network', 'volume', 'image', 'container', 'system', 'builder', 'context'].includes(sub)) {
1127
+ if (['ls', 'list', 'inspect', 'df', 'show', 'history'].includes(sub2)) return result;
1128
+ if (['rm', 'remove', 'prune', 'delete'].includes(sub2)) return result.raise('risky', `docker_${sub}_${sub2}`);
1129
+ return result.raise('unknown', `docker_${sub}_${String(sub2).slice(0, 16)}`);
1130
+ }
1131
+ if (routine.includes(sub)) return result;
1132
+ if (risky.includes(sub)) return result.raise('risky', `docker_${sub}`);
1133
+ return result.raise('unknown', `docker_${String(sub).slice(0, 16)}`);
1134
+ },
1135
+ sql(base, args, command) {
1136
+ const result = new Result();
1137
+ const text = [...args.map((a) => a.text || ''), command.heredoc || ''].join(' ');
1138
+ if (sqlMutation.test(text)) return result.raise('risky', 'sql_mutation');
1139
+ return result.raise('unknown', `database_client:${base}`);
1140
+ },
1141
+ http(base, args, command, ctx) {
1142
+ const result = new Result();
1143
+ const methodFlag = flagValue(args, '-X', '--request', '--method');
1144
+ if (methodFlag === null) return result.raise('unknown', 'dynamic_http_method');
1145
+ if (methodFlag && !['GET', 'HEAD', 'OPTIONS'].includes(String(methodFlag).toUpperCase())) result.raise('unknown', 'http_write_method');
1146
+ if (methodFlag && String(methodFlag).toUpperCase() === 'DELETE') result.raise('risky', 'http_delete');
1147
+ const dataFlags = ['-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '-F', '--form', '--form-string', '-T', '--upload-file', '--json', '--post-data', '--post-file', '--body-data', '--body-file'];
1148
+ for (let i = 0; i < args.length; i += 1) {
1149
+ const text = args[i].text;
1150
+ if (text === null) { result.raise('unknown', 'dynamic_http_arg'); continue; }
1151
+ const flag = dataFlags.find((f) => text === f || text.startsWith(`${f}=`));
1152
+ if (flag) {
1153
+ result.raise('unknown', 'http_body');
1154
+ const value = text === flag ? (args[i + 1] ? args[i + 1].text : null) : text.slice(flag.length + 1);
1155
+ if (value === null) result.raise('unknown', 'dynamic_http_body');
1156
+ else if (/(^|=)@/.test(value)) checkReadPath(value.replace(/^.*?@/, ''), ctx, result, 'secret_upload');
1157
+ else if (flag === '--post-file' || flag === '--body-file' || flag === '-T' || flag === '--upload-file') checkReadPath(value, ctx, result, 'secret_upload');
1158
+ if (text === flag) i += 1;
1159
+ continue;
1160
+ }
1161
+ const outputFlags = base === 'wget' ? ['-O', '--output-document'] : ['-o', '--output'];
1162
+ if (outputFlags.includes(text)) {
1163
+ const target = args[i + 1] ? args[i + 1].text : null;
1164
+ if (target !== '-') checkWritePath(target, ctx, result);
1165
+ i += 1;
1166
+ continue;
1167
+ }
1168
+ if (['-K', '--config', '--next', '-:'].includes(text)) result.raise('unknown', 'http_config');
1169
+ if (/^https?:\/\//i.test(text) || /^[a-z0-9.-]+\.[a-z]{2,}(?:[:/]|$)/i.test(text)) classifyUrl(/^https?:/i.test(text) ? text : `https://${text}`, result);
1170
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(text) && (base === 'http' || base === 'https' || base === 'xh')) result.raise(text === 'DELETE' ? 'risky' : 'unknown', 'http_write_method');
1171
+ }
1172
+ return result;
1173
+ },
1174
+ rsync(base, args, command, ctx) {
1175
+ const result = new Result();
1176
+ const pos = positionals(args, ['-e', '--rsh', '--exclude', '--include', '--filter', '-f']);
1177
+ if (pos.some((a) => a.text === null)) return result.raise('unknown', 'dynamic_rsync_arg');
1178
+ if (pos.some((a) => /^[^/][^:]*:/.test(a.text) && !/^[A-Za-z]:\\/.test(a.text))) return result.raise('risky', 'rsync_remote');
1179
+ if (hasFlag(args, '--delete', '--delete-before', '--delete-after', '--delete-during', '--remove-source-files')) result.raise('unknown', 'rsync_delete');
1180
+ if (pos.length) checkWritePath(pos[pos.length - 1].text, ctx, result);
1181
+ for (const arg of pos.slice(0, -1)) checkReadPath(arg.text, ctx, result);
1182
+ return result;
1183
+ },
1184
+ systemctl(base, args) {
1185
+ const result = new Result();
1186
+ const pos = positionals(args, ['-H', '--host', '-M', '--machine', '-t', '--type', '--state', '-p', '--property']);
1187
+ const sub = pos[0] ? pos[0].text : undefined;
1188
+ if (sub === undefined) return result;
1189
+ if (['status', 'show', 'cat', 'list-units', 'list-unit-files', 'is-active', 'is-enabled', 'is-failed', 'list-timers', 'list-sockets', 'list-dependencies', 'help'].includes(sub)) return result;
1190
+ return result.raise('risky', `systemctl:${String(sub).slice(0, 24)}`);
1191
+ },
1192
+ kill(base, args) {
1193
+ const result = new Result();
1194
+ const text = args.map((a) => a.text || '').join(' ');
1195
+ if (/marrow/i.test(text) || args.some((a) => a.text !== null && daemonPids.has(a.text))) return result.raise('risky', 'kill_marrow');
1196
+ if (args.some((a) => a.text === null)) return result.raise('unknown', 'dynamic_kill_target');
1197
+ if (args.some((a) => a.text === '-1' || a.text === '1')) return result.raise('risky', 'kill_all');
1198
+ return result.raise('unknown', `${base}`);
1199
+ },
1200
+ };
1201
+
1202
+ function classifyGitPush(rest, ctx) {
1203
+ const result = new Result();
1204
+ const riskyFlags = ['--force', '-f', '--force-with-lease', '--force-if-includes', '--mirror', '--delete', '-d', '--prune', '--tags', '--follow-tags', '--all'];
1205
+ if (hasFlag(rest, ...riskyFlags)) return result.raise('risky', 'git_push_force_or_bulk');
1206
+ if (hasFlag(rest, '--no-verify')) result.raise('unknown', 'git_push_no_verify');
1207
+ const pos = positionals(rest, ['-o', '--push-option', '--repo', '--receive-pack', '--exec']);
1208
+ if (pos.some((a) => a.text === null)) return result.raise('unknown', 'dynamic_git_push_arg');
1209
+ const refspecs = pos.slice(1).map((a) => a.text);
1210
+ const current = () => currentBranch(ctx);
1211
+ const targets = [];
1212
+ if (refspecs.length === 0) targets.push(current());
1213
+ for (const spec of refspecs) {
1214
+ if (spec.startsWith('+')) return result.raise('risky', 'git_push_forced_refspec');
1215
+ if (spec.startsWith(':')) return result.raise('risky', 'git_push_delete_refspec');
1216
+ const [src, dst] = spec.includes(':') ? spec.split(':', 2) : [spec, spec];
1217
+ let target = dst || src;
1218
+ if (target === 'HEAD' || target === '@') target = current();
1219
+ if (target && /^refs\/tags\/|^v?\d+\.\d+/.test(target)) return result.raise('risky', 'git_push_tag');
1220
+ targets.push(target);
1221
+ }
1222
+ for (const target of targets) {
1223
+ if (!target) { result.raise('unknown', 'git_push_branch_unknown'); continue; }
1224
+ const branch = target.replace(/^refs\/heads\//, '');
1225
+ if (isProtectedBranch(branch) || isProtectedBranch(`/${branch}`)) return result.raise('risky', 'git_push_protected_branch');
1226
+ }
1227
+ return result;
1228
+ }
1229
+
1230
+ function currentBranch(ctx) {
1231
+ if (!ctx.cwd || ctx.cwdUnknown) return null;
1232
+ let dir = ctx.cwd;
1233
+ for (let level = 0; level < 24; level += 1) {
1234
+ const gitPath = path.join(dir, '.git');
1235
+ try {
1236
+ const stat = fs.statSync(gitPath);
1237
+ let gitDir = gitPath;
1238
+ if (stat.isFile()) {
1239
+ const pointer = readSmallFile(gitPath, 4096) || '';
1240
+ const match = pointer.match(/^gitdir:\s*(.+)$/m);
1241
+ if (!match) return null;
1242
+ gitDir = path.resolve(dir, match[1].trim());
1243
+ }
1244
+ const head = readSmallFile(path.join(gitDir, 'HEAD'), 4096) || '';
1245
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/m);
1246
+ return ref ? ref[1].trim() : null;
1247
+ } catch {
1248
+ const parent = path.dirname(dir);
1249
+ if (parent === dir) return null;
1250
+ dir = parent;
1251
+ }
1252
+ }
1253
+ return null;
1254
+ }
1255
+
1256
+ function findPackageJson(ctx) {
1257
+ let dir = ctx.cwd;
1258
+ for (let level = 0; level < 12; level += 1) {
1259
+ if (ctx.written && ctx.written.has(path.join(dir, 'package.json'))) return { error: 'package_json_written_in_same_command' };
1260
+ const raw = readSmallFile(path.join(dir, 'package.json'), PACKAGE_JSON_MAX_BYTES);
1261
+ if (raw !== null) {
1262
+ try { return { pkg: JSON.parse(raw), dir }; } catch { return { error: 'package_json_invalid' }; }
1263
+ }
1264
+ const parent = path.dirname(dir);
1265
+ if (parent === dir) break;
1266
+ dir = parent;
1267
+ }
1268
+ return { pkg: null, dir: null };
1269
+ }
1270
+
1271
+ // A requirements file is routine only if every line is a plain package spec from the index
1272
+ // (no -e, local paths, URLs, nested -r or index overrides).
1273
+ function requirementsArePlain(file, ctx) {
1274
+ if (file === null || file === undefined) return false;
1275
+ const abs = absPath(file, ctx);
1276
+ if (!abs || (ctx.written && ctx.written.has(abs))) return false;
1277
+ const content = readSmallFile(abs, SCRIPT_MAX_BYTES);
1278
+ if (content === null) return false;
1279
+ const spec = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\[[A-Za-z0-9,._-]+\])?\s*(?:(?:===|==|>=|<=|~=|!=|<|>)\s*[A-Za-z0-9.*+!_-]+\s*,?\s*)*(?:;[^#]*)?(?:\s+--hash=[a-z0-9]+:[a-f0-9]+)*$/;
1280
+ return content.split(/\r?\n/).every((line) => {
1281
+ const trimmed = line.replace(/(^|\s)#.*$/, '').trim();
1282
+ return trimmed === '' || spec.test(trimmed);
1283
+ });
1284
+ }
1285
+
1286
+ function classifyLifecycleScripts(ctx, depth, names) {
1287
+ const result = new Result();
1288
+ if (!ctx.cwd || ctx.cwdUnknown) return result.raise('unknown', 'package_script_cwd_unknown');
1289
+ if (depth >= (tables.routine_package_scripts_depth || 3)) return result.raise('unknown', 'package_script_depth');
1290
+ const found = findPackageJson(ctx);
1291
+ if (found.error) return result.raise('unknown', found.error);
1292
+ if (!found.pkg || !found.pkg.scripts || typeof found.pkg.scripts !== 'object') return result;
1293
+ for (const name of names) {
1294
+ if (typeof found.pkg.scripts[name] !== 'string') continue;
1295
+ const inner = classifyShellSource(found.pkg.scripts[name], { ...ctx, cwd: found.dir, cwdUnknown: false, vars: new Map(ctx.vars) }, depth + 1);
1296
+ result.merge(inner);
1297
+ if (inner.class !== 'routine') result.raise(inner.class, `lifecycle_script:${name}`);
1298
+ }
1299
+ return result;
1300
+ }
1301
+
1302
+ function classifyPackageScript(name, ctx, depth) {
1303
+ const result = new Result();
1304
+ if (depth >= (tables.routine_package_scripts_depth || 3)) return result.raise('unknown', 'package_script_depth');
1305
+ if (!ctx.cwd || ctx.cwdUnknown) return result.raise('unknown', 'package_script_cwd_unknown');
1306
+ for (let d = ctx.cwd, k = 0; k < 12; k += 1) {
1307
+ if (ctx.written && ctx.written.has(path.join(d, 'package.json'))) return result.raise('unknown', 'package_json_written_in_same_command');
1308
+ const parent = path.dirname(d);
1309
+ if (parent === d) break;
1310
+ d = parent;
1311
+ }
1312
+ let dir = ctx.cwd;
1313
+ let pkg = null;
1314
+ let pkgDir = null;
1315
+ for (let level = 0; level < 12 && !pkg; level += 1) {
1316
+ const raw = readSmallFile(path.join(dir, 'package.json'), PACKAGE_JSON_MAX_BYTES);
1317
+ if (raw !== null) {
1318
+ try { pkg = JSON.parse(raw); pkgDir = dir; } catch { return result.raise('unknown', 'package_json_invalid'); }
1319
+ break;
1320
+ }
1321
+ const parent = path.dirname(dir);
1322
+ if (parent === dir) break;
1323
+ dir = parent;
1324
+ }
1325
+ if (!pkg || !pkg.scripts || typeof pkg.scripts !== 'object') return result.raise('unknown', 'package_script_missing');
1326
+ const body = pkg.scripts[name];
1327
+ if (typeof body !== 'string') return result.raise('unknown', 'package_script_missing');
1328
+ const scriptCtx = { ...ctx, cwd: pkgDir, cwdUnknown: false, vars: new Map(ctx.vars) };
1329
+ for (const hook of [`pre${name}`, name, `post${name}`]) {
1330
+ if (typeof pkg.scripts[hook] !== 'string') continue;
1331
+ const inner = classifyShellSource(pkg.scripts[hook], { ...scriptCtx, vars: new Map(scriptCtx.vars) }, depth + 1);
1332
+ result.merge(inner);
1333
+ if (inner.class !== 'routine') result.raise(inner.class, `package_script:${hook.slice(0, 32)}`);
1334
+ }
1335
+ return result;
1336
+ }
1337
+
1338
+ // ---------- public ----------
1339
+ function classify(event) {
1340
+ const result = classifyTool(event);
1341
+ return {
1342
+ class: result.class,
1343
+ reasons: result.reasons,
1344
+ tool: result.tool,
1345
+ programs: result.programs,
1346
+ paths: result.paths,
1347
+ hosts: result.hosts,
1348
+ commands: result.commands,
1349
+ truncated: result.truncated,
1350
+ };
1351
+ }
1352
+
1353
+ return { classify, classifyShell: (source, cwd) => classifyShellSource(source, newContext(cwd), 0) };
1354
+ }
1355
+
1356
+ module.exports = { createClassifier, globToRegex, RANK };