@getmarrow/mcp 3.9.96 → 3.9.98

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.
@@ -6,7 +6,19 @@ exports.isOfficialMarrowMcpEvent = isOfficialMarrowMcpEvent;
6
6
  exports.isMcpHookTool = isMcpHookTool;
7
7
  exports.isProtectedShellMutation = isProtectedShellMutation;
8
8
  exports.hookToolCommand = hookToolCommand;
9
+ exports.parseShellSegments = parseShellSegments;
10
+ exports.normalizedToolPath = normalizedToolPath;
11
+ exports.isSecretPath = isSecretPath;
12
+ exports.isProtectedWriteTarget = isProtectedWriteTarget;
13
+ exports.isSecretVariableName = isSecretVariableName;
14
+ exports.shellSegmentVerdict = shellSegmentVerdict;
15
+ exports.toolTargetPaths = toolTargetPaths;
16
+ exports.isSecretMaterialAccess = isSecretMaterialAccess;
17
+ exports.toolClassificationText = toolClassificationText;
18
+ exports.isShellGovernedTool = isShellGovernedTool;
9
19
  exports.isReadOnlyToolEvent = isReadOnlyToolEvent;
20
+ const node_os_1 = require("node:os");
21
+ const node_path_1 = require("node:path");
10
22
  const READ_ONLY_TOOLS = new Set([
11
23
  'read',
12
24
  'read_file',
@@ -34,11 +46,56 @@ const READ_ONLY_TOOLS = new Set([
34
46
  'marrow_orient',
35
47
  'marrow_ask',
36
48
  ]);
49
+ // Session-local planning state: no external side effect, so words in a task
50
+ // subject such as "publish" never make the bookkeeping call itself protected.
51
+ const LOCAL_TASK_TOOLS = new Set(['taskcreate', 'taskupdate', 'taskoutput', 'todowrite', 'tasklist', 'taskget', 'todoread']);
52
+ // Tools that read file content; a secret target makes the read protected.
53
+ const CONTENT_READ_TOOLS = new Set(['read', 'read_file', 'notebookread', 'grep']);
54
+ // File-editing tools are judged by the file they write, never by the text they write.
55
+ const FILE_EDIT_TOOLS = new Set(['edit', 'write', 'multiedit', 'search_replace', 'notebookedit', 'write_to_file', 'replace_in_file']);
56
+ const SHELL_TOOLS = new Set(['bash', 'run_terminal_command', 'shell', 'execute_command', 'run_shell_command']);
37
57
  const READ_ONLY_BASH_COMMANDS = new Set([
38
58
  'read', 'grep', 'rg', 'ls', 'cat', 'find', 'tail', 'head', 'wc', 'file',
39
59
  'stat', 'which', 'type', 'echo', 'printf', 'pwd', 'date', 'env', 'printenv',
40
- 'whoami', 'uname',
60
+ 'whoami', 'uname', 'jq', 'cd', 'sort', 'cut', 'tr', 'nl', 'du', 'basename',
61
+ 'dirname', 'realpath', 'readlink', 'true',
41
62
  ]);
63
+ // Read-only programs whose arguments are names, text, or metadata targets rather
64
+ // than file content, so a secret-looking argument is not a secret read.
65
+ const NON_CONTENT_BASH_COMMANDS = new Set([
66
+ 'ls', 'find', 'stat', 'file', 'du', 'which', 'type', 'echo', 'printf', 'pwd',
67
+ 'date', 'whoami', 'uname', 'cd', 'basename', 'dirname', 'realpath', 'readlink',
68
+ 'true', 'tr', 'env', 'printenv',
69
+ ]);
70
+ const READ_ONLY_TOOLCHAIN_COMMANDS = [
71
+ /^(?:node|npm)\s+(?:-v|--version)$/i,
72
+ /^(?:npm|pnpm|yarn)\s+(?:test|audit(?!\s+fix)|run\s+(?:test|check|lint|typecheck|build))(?:\s|$)/i,
73
+ /^(?:node\s+--test|npx\s+(?:vitest|tsc\s+--noemit)|pytest|python(?:3)?\s+-m\s+(?:pytest|unittest)|cargo\s+(?:test|check)|go\s+test)(?:\s|$)/i,
74
+ /^(?:npm|pnpm)\s+(?:view|info|show|ls|list|outdated|explain|why)(?:\s|$)/i,
75
+ /^yarn\s+(?:info|list|why|outdated)(?:\s|$)/i,
76
+ ];
77
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'ls-files', 'ls-remote']);
78
+ const READ_ONLY_GH_COMMAND = /^(?:pr\s+(?:view|list|status|diff|checks)|issue\s+(?:view|list|status)|run\s+(?:view|list)|repo\s+view|release\s+(?:view|list)|workflow\s+(?:view|list))(?:\s|$)/i;
79
+ const STANDARD_PROGRAM_PATH = /^\/(?:usr\/)?(?:local\/)?s?bin\/([A-Za-z0-9._-]+)$/;
80
+ const SECRET_STORE_DIRECTORY = /^(?:\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.docker|\.password-store|\.secrets?)$/i;
81
+ // A plainly named secrets/credentials directory holds secret material, but source code may live in one too.
82
+ const NAMED_SECRET_DIRECTORY = /^(?:secrets?|credentials?)$/i;
83
+ const SECRET_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.dev\.vars|\.git-credentials|\.netrc|_netrc|\.npmrc|\.yarnrc\.ya?ml|\.pypirc|\.pgpass|\.my\.cnf|\.htpasswd|\.?credentials\.(?:json|ya?ml|toml|ini|csv|txt|xml)|secrets?\.(?:json|ya?ml|toml|env|txt)|id_(?:rsa|dsa|ecdsa|ed25519)(?:_[A-Za-z0-9]+)?|.+\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ppk))$/i;
84
+ const ENV_TEMPLATE_FILE = /(?:^|\.)env\.(?:example|sample|template|dist|defaults?)$/i;
85
+ const SECRET_KEYWORD_FILE = /(?:^|[._-])(?:secrets?|credentials?|tokens?|api[_-]?keys?|passwords?|private[_-]?keys?)(?:[._-]|$)/i;
86
+ const SOURCE_OR_DOC_FILE = /\.(?:[cm]?[jt]sx?|md|mdx|rst|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|sql|html|css|scss|vue|svelte|map|snap|lock)$/i;
87
+ const SECRET_VARIABLE_PART = new Set(['KEY', 'APIKEY', 'TOKEN', 'SECRET', 'PASSWORD', 'PASSWD', 'PAT', 'CREDENTIAL', 'CREDENTIALS', 'AUTH']);
88
+ const GOVERNANCE_CONTROL_FILE = /(?:^|\/)\.marrow\/control\.json$/;
89
+ const MAX_SHELL_ANALYSIS_BYTES = 8192;
90
+ // Characters whose shell meaning the splitter does not model exactly. Bash
91
+ // treats a lone carriage return, other control characters and Unicode spaces
92
+ // or line separators as word characters, so splitting on them could hide one
93
+ // command inside another; such input takes the conservative whole-command path.
94
+ const UNMODELED_SHELL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u00a0\u00ad\u061c\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f-\u206f\u3000\ufeff\ufff9-\ufffb]/;
95
+ // Programs whose options can execute, write, or change what runs; an expanded
96
+ // variable could supply such an option, so they are not read-only with one.
97
+ const OPTION_SENSITIVE_PROGRAMS = new Set(['find', 'rg', 'sort', 'git', 'gh', 'date', 'env', 'file', 'npm', 'pnpm', 'yarn', 'node', 'npx',
98
+ 'pytest', 'python', 'python3', 'cargo', 'go']);
42
99
  const MUTATION_TOOL_VERB = /(?:^|__|_)(?:create|update|delete|remove|write|edit|send|post|put|patch|execute|run|deploy|publish|merge|push|commit|revoke|rotate|charge|refund|cancel|approve)(?:_|$)/;
43
100
  const READ_ONLY_TOOL_VERB = /(?:^|__|_)(?:get|list|read|search|find|fetch|status|inspect|query)(?:_|$)/;
44
101
  const PROTECTED_SHELL_MUTATION_FAMILIES = [
@@ -139,14 +196,558 @@ function hookToolCommand(event) {
139
196
  return '';
140
197
  }
141
198
  }
142
- function hasWriteLikeShellSyntax(command) {
143
- return /(^|[^>])>(?!>)|>>|\b(?:tee|touch|mkdir|rm|mv|cp|install|uninstall|publish|deploy|release)\b|\bsed\s+-i\b|\bperl\s+-i\b/i.test(command)
144
- || /\b(?:curl|wget|nc|ncat|netcat|scp|rsync|ssh|ftp|tftp)\b/i.test(command)
145
- || /\bgit\s+(?:push|merge|commit|rebase|reset|checkout|switch|tag)\b/i.test(command)
146
- || isProtectedShellMutation(command);
199
+ /**
200
+ * Splits a shell command into simple commands at |, ||, &&, ;, & and newlines,
201
+ * honoring quotes. Returns null for syntax whose effect cannot be judged from
202
+ * the words alone (command or process substitution, subshells, heredocs,
203
+ * complex parameter expansion); callers then fall back to whole-command rules.
204
+ */
205
+ function parseShellSegments(command) {
206
+ if (command.length > MAX_SHELL_ANALYSIS_BYTES || UNMODELED_SHELL_CHARACTER.test(command))
207
+ return null;
208
+ const segments = [];
209
+ let segment = { words: [], outputs: [], inputs: [], expansions: [] };
210
+ let word = '';
211
+ let inWord = false;
212
+ let redirect = null;
213
+ const finishWord = () => {
214
+ if (!inWord)
215
+ return;
216
+ if (redirect === 'out' || redirect === 'in')
217
+ (redirect === 'out' ? segment.outputs : segment.inputs).push(word);
218
+ else if (redirect) {
219
+ // >&2 and <&0 duplicate descriptors; any other >&target names a file.
220
+ if (!/^(?:\d+|-|\d+-)$/.test(word))
221
+ (redirect === 'dup_out' ? segment.outputs : segment.inputs).push(word);
222
+ }
223
+ else
224
+ segment.words.push(word);
225
+ redirect = null;
226
+ word = '';
227
+ inWord = false;
228
+ };
229
+ const finishSegment = () => {
230
+ finishWord();
231
+ if (redirect)
232
+ return false;
233
+ if (segment.words.length || segment.outputs.length || segment.inputs.length)
234
+ segments.push(segment);
235
+ segment = { words: [], outputs: [], inputs: [], expansions: [] };
236
+ return true;
237
+ };
238
+ // Reads $NAME, ${NAME} or a special parameter at index; returns the consumed length, or -1 when unsupported.
239
+ const expansion = (index) => {
240
+ const rest = command.slice(index);
241
+ // $( runs a command; $'...' and $"..." decode escapes the words would hide.
242
+ if (rest.startsWith('$(') || rest.startsWith("$'") || rest.startsWith('$"'))
243
+ return -1;
244
+ const braced = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rest);
245
+ if (braced) {
246
+ segment.expansions.push(braced[1]);
247
+ word += braced[0];
248
+ inWord = true;
249
+ return braced[0].length;
250
+ }
251
+ if (rest.startsWith('${'))
252
+ return -1;
253
+ const named = /^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(rest);
254
+ if (named) {
255
+ segment.expansions.push(named[1]);
256
+ word += named[0];
257
+ inWord = true;
258
+ return named[0].length;
259
+ }
260
+ word += '$';
261
+ inWord = true;
262
+ return 1;
263
+ };
264
+ let index = 0;
265
+ while (index < command.length) {
266
+ const char = command[index];
267
+ const next = command[index + 1];
268
+ if (char === "'") {
269
+ const end = command.indexOf("'", index + 1);
270
+ if (end < 0)
271
+ return null;
272
+ word += command.slice(index + 1, end);
273
+ inWord = true;
274
+ index = end + 1;
275
+ continue;
276
+ }
277
+ if (char === '"') {
278
+ inWord = true;
279
+ index += 1;
280
+ let closed = false;
281
+ while (index < command.length) {
282
+ const inner = command[index];
283
+ if (inner === '"') {
284
+ closed = true;
285
+ index += 1;
286
+ break;
287
+ }
288
+ if (inner === '`')
289
+ return null;
290
+ if (inner === '\\' && index + 1 < command.length && '$`"\\\n'.includes(command[index + 1])) {
291
+ if (command[index + 1] !== '\n')
292
+ word += command[index + 1];
293
+ index += 2;
294
+ continue;
295
+ }
296
+ if (inner === '$') {
297
+ const consumed = expansion(index);
298
+ if (consumed < 0)
299
+ return null;
300
+ index += consumed;
301
+ continue;
302
+ }
303
+ word += inner;
304
+ index += 1;
305
+ }
306
+ if (!closed)
307
+ return null;
308
+ continue;
309
+ }
310
+ if (char === '\\') {
311
+ if (next === undefined)
312
+ return null;
313
+ if (next !== '\n') {
314
+ word += next;
315
+ inWord = true;
316
+ }
317
+ index += 2;
318
+ continue;
319
+ }
320
+ // Braces expand or group commands, so the words would not be what runs.
321
+ if (char === '`' || char === '(' || char === ')' || char === '{')
322
+ return null;
323
+ if (char === '$') {
324
+ const consumed = expansion(index);
325
+ if (consumed < 0)
326
+ return null;
327
+ index += consumed;
328
+ continue;
329
+ }
330
+ if (char === ' ' || char === '\t') {
331
+ finishWord();
332
+ index += 1;
333
+ continue;
334
+ }
335
+ if (char === '\n' || char === ';') {
336
+ if (!finishSegment())
337
+ return null;
338
+ index += 1;
339
+ continue;
340
+ }
341
+ if (char === '|') {
342
+ if (!finishSegment())
343
+ return null;
344
+ index += next === '|' || next === '&' ? 2 : 1;
345
+ continue;
346
+ }
347
+ if (char === '&') {
348
+ if (next === '>') {
349
+ finishWord();
350
+ if (redirect)
351
+ return null;
352
+ redirect = 'out';
353
+ index += command[index + 2] === '>' ? 3 : 2;
354
+ continue;
355
+ }
356
+ if (!finishSegment())
357
+ return null;
358
+ index += next === '&' ? 2 : 1;
359
+ continue;
360
+ }
361
+ if (char === '>' || char === '<') {
362
+ // A digits-only word directly before the operator is a descriptor (2>).
363
+ if (inWord && /^\d+$/.test(word)) {
364
+ word = '';
365
+ inWord = false;
366
+ }
367
+ else
368
+ finishWord();
369
+ if (redirect)
370
+ return null;
371
+ if (char === '<' && next === '<')
372
+ return null;
373
+ if (next === '(')
374
+ return null;
375
+ let cursor = index + 1;
376
+ if (char === '>' && (command[cursor] === '>' || command[cursor] === '|'))
377
+ cursor += 1;
378
+ if (command[cursor] === '&') {
379
+ redirect = char === '>' ? 'dup_out' : 'dup_in';
380
+ cursor += 1;
381
+ }
382
+ else if (char === '<' && command[cursor] === '>') {
383
+ redirect = 'out';
384
+ cursor += 1;
385
+ }
386
+ else {
387
+ redirect = char === '>' ? 'out' : 'in';
388
+ }
389
+ index = cursor;
390
+ continue;
391
+ }
392
+ if (char === '#' && !inWord) {
393
+ while (index < command.length && command[index] !== '\n')
394
+ index += 1;
395
+ continue;
396
+ }
397
+ word += char;
398
+ inWord = true;
399
+ index += 1;
400
+ }
401
+ if (!finishSegment())
402
+ return null;
403
+ return segments;
404
+ }
405
+ function pathCandidates(value) {
406
+ const candidates = [value];
407
+ const assigned = value.indexOf('=');
408
+ if (assigned >= 0)
409
+ candidates.push(value.slice(assigned + 1));
410
+ const revision = value.lastIndexOf(':');
411
+ if (revision >= 0)
412
+ candidates.push(value.slice(revision + 1));
413
+ return candidates.filter(Boolean);
414
+ }
415
+ function homeDirectory() {
416
+ return process.env.HOME || (0, node_os_1.homedir)();
417
+ }
418
+ function expandHome(value) {
419
+ if (value === '~' || value.startsWith('~/'))
420
+ return `${homeDirectory()}${value.slice(1)}`;
421
+ return value.replace(/^\$(?:HOME|\{HOME\})(?=\/|$)/, homeDirectory());
422
+ }
423
+ /** Resolves ~, $HOME, ., .. and repeated slashes against the directory the command runs in. */
424
+ function normalizedToolPath(value, base = process.cwd()) {
425
+ return node_path_1.posix.resolve(base.replace(/\\/g, '/'), expandHome(value.trim().replace(/\\/g, '/')));
426
+ }
427
+ function secretPath(value, base) {
428
+ const cleaned = value.trim().replace(/\\/g, '/');
429
+ if (!cleaned)
430
+ return false;
431
+ const rawName = cleaned.replace(/\/+$/, '').split('/').pop() || '';
432
+ // A bare word such as "token" is usually a search term or argument, not a file.
433
+ const pathLike = cleaned.includes('/') || cleaned.startsWith('~') || rawName.startsWith('.') || /\.[A-Za-z0-9]+$/.test(rawName);
434
+ const path = normalizedToolPath(cleaned, base);
435
+ const parts = path.split('/').filter(Boolean);
436
+ const name = parts[parts.length - 1] || '';
437
+ const directories = pathLike ? parts : parts.slice(0, -1);
438
+ if (directories.some((part) => SECRET_STORE_DIRECTORY.test(part)))
439
+ return true;
440
+ if (!SOURCE_OR_DOC_FILE.test(name) && directories.some((part) => NAMED_SECRET_DIRECTORY.test(part)))
441
+ return true;
442
+ if (/^\/proc\/[^/]+\/environ$/.test(path) || /^\/etc\/g?shadow$/.test(path))
443
+ return true;
444
+ // Claude Code local settings can carry agent keys in their env block.
445
+ if (name === 'settings.local.json' && parts[parts.length - 2] === '.claude')
446
+ return true;
447
+ if (!pathLike)
448
+ return SECRET_FILE.test(name) && !ENV_TEMPLATE_FILE.test(name);
449
+ if (ENV_TEMPLATE_FILE.test(name))
450
+ return false;
451
+ if (SECRET_FILE.test(name))
452
+ return true;
453
+ return SECRET_KEYWORD_FILE.test(name) && !SOURCE_OR_DOC_FILE.test(name);
454
+ }
455
+ // A glob such as .env* or *.pem names secret files without spelling one out.
456
+ function globVariants(value) {
457
+ if (!/[*?[]/.test(value))
458
+ return [value];
459
+ const firstInClass = value.replace(/\[!?\^?([^\]])[^\]]*\]/g, '$1');
460
+ return [value, firstInClass.replace(/[*?]/g, ''), firstInClass.replace(/[*?]/g, 'x')];
461
+ }
462
+ /** True for a path that holds secret or credential material. */
463
+ function isSecretPath(value, base = process.cwd()) {
464
+ return pathCandidates(String(value || '')).flatMap(globVariants).some((candidate) => secretPath(candidate, base));
465
+ }
466
+ /** True for a secret file or Marrow's own local control state. */
467
+ function isProtectedWriteTarget(value, base = process.cwd()) {
468
+ return pathCandidates(String(value || '')).flatMap(globVariants).some((candidate) => secretPath(candidate, base)
469
+ || GOVERNANCE_CONTROL_FILE.test(normalizedToolPath(candidate, base)));
470
+ }
471
+ /** True for an environment variable name that conventionally holds a secret. */
472
+ function isSecretVariableName(value) {
473
+ return String(value || '').toUpperCase().split('_').some((part) => SECRET_VARIABLE_PART.has(part));
474
+ }
475
+ function programName(value) {
476
+ if (!value.includes('/'))
477
+ return value.toLowerCase();
478
+ const standard = STANDARD_PROGRAM_PATH.exec(value);
479
+ return standard ? standard[1].toLowerCase() : null;
480
+ }
481
+ // Positional file arguments for content readers, skipping the pattern or filter
482
+ // operand of grep, rg and jq, and option values that are not files.
483
+ function contentReadTargets(program, args) {
484
+ const targets = [];
485
+ const patternFirst = ['grep', 'egrep', 'fgrep', 'rg', 'jq'].includes(program);
486
+ const valueOptions = program === 'jq'
487
+ ? new Set(['--indent', '--tab'])
488
+ : new Set(['-A', '-B', '-C', '-m', '-d', '-D', '-g', '-t', '-T', '-j', '-M', '--max-count', '--context', '--glob', '--type', '--type-not', '--threads', '--max-columns']);
489
+ let patternSeen = !patternFirst;
490
+ for (let index = 0; index < args.length; index += 1) {
491
+ const arg = args[index];
492
+ if (program === 'jq' && (arg === '--arg' || arg === '--argjson')) {
493
+ index += 2;
494
+ continue;
495
+ }
496
+ if (program === 'jq' && (arg === '--slurpfile' || arg === '--rawfile')) {
497
+ if (args[index + 2] !== undefined)
498
+ targets.push(args[index + 2]);
499
+ index += 2;
500
+ continue;
501
+ }
502
+ if (['-e', '--regexp', '-f', '--file', '--from-file'].includes(arg)) {
503
+ if (arg !== '-e' && arg !== '--regexp' && args[index + 1] !== undefined)
504
+ targets.push(args[index + 1]);
505
+ patternSeen = true;
506
+ index += 1;
507
+ continue;
508
+ }
509
+ if (valueOptions.has(arg)) {
510
+ index += 1;
511
+ continue;
512
+ }
513
+ if (arg.startsWith('-')) {
514
+ if (arg.includes('='))
515
+ targets.push(arg);
516
+ continue;
517
+ }
518
+ if (!patternSeen) {
519
+ patternSeen = true;
520
+ continue;
521
+ }
522
+ targets.push(arg);
523
+ }
524
+ return targets;
525
+ }
526
+ // GNU getopt and git accept any unambiguous prefix of a long option.
527
+ function longOptionPrefix(arg, names) {
528
+ if (!arg.startsWith('--'))
529
+ return false;
530
+ const name = arg.slice(2).split('=')[0];
531
+ return name.length > 0 && names.some((candidate) => candidate.startsWith(name));
532
+ }
533
+ const GIT_BRANCH_LIST_FLAGS = new Set(['--list', '-l', '-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '--show-current',
534
+ '-i', '--ignore-case', '--color', '--no-color', '--column', '--no-column', '--omit-empty']);
535
+ const GIT_BRANCH_VALUE_FLAGS = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format']);
536
+ // `git branch` only lists when every argument is a list option or a --list pattern.
537
+ function gitBranchListsOnly(args) {
538
+ const listing = args.includes('--list') || args.includes('-l');
539
+ for (let index = 0; index < args.length; index += 1) {
540
+ const arg = args[index];
541
+ if (GIT_BRANCH_LIST_FLAGS.has(arg))
542
+ continue;
543
+ if (GIT_BRANCH_VALUE_FLAGS.has(arg)) {
544
+ index += 1;
545
+ continue;
546
+ }
547
+ if ([...GIT_BRANCH_VALUE_FLAGS].some((flag) => arg.startsWith(`${flag}=`)))
548
+ continue;
549
+ if (!arg.startsWith('-') && listing)
550
+ continue;
551
+ return false;
552
+ }
553
+ return true;
554
+ }
555
+ function readOnlyProgramInvocation(program, args) {
556
+ if (program === 'git') {
557
+ let index = 0;
558
+ while (index < args.length) {
559
+ if (args[index] === '-C' && args[index + 1] !== undefined)
560
+ index += 2;
561
+ else if (args[index] === '--no-pager')
562
+ index += 1;
563
+ else
564
+ break;
565
+ }
566
+ const subcommand = args[index] || '';
567
+ const rest = args.slice(index + 1);
568
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand))
569
+ return false;
570
+ if (subcommand === 'branch')
571
+ return gitBranchListsOnly(rest);
572
+ if (subcommand === 'ls-remote')
573
+ return !rest.some((arg) => longOptionPrefix(arg, ['upload-pack', 'exec']));
574
+ return !rest.some((arg) => longOptionPrefix(arg, ['output', 'ext-diff']));
575
+ }
576
+ if (program === 'gh')
577
+ return READ_ONLY_GH_COMMAND.test(args.join(' '));
578
+ if (READ_ONLY_TOOLCHAIN_COMMANDS.some((pattern) => pattern.test([program, ...args].join(' '))))
579
+ return true;
580
+ if (!READ_ONLY_BASH_COMMANDS.has(program))
581
+ return false;
582
+ if (program === 'find')
583
+ return !args.some((arg) => /^-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/.test(arg));
584
+ if (program === 'rg')
585
+ return !args.some((arg) => longOptionPrefix(arg, ['pre', 'hostname-bin']));
586
+ if (program === 'sort')
587
+ return !args.some((arg) => longOptionPrefix(arg, ['output', 'compress-program']) || /^-[A-Za-z]*o/.test(arg));
588
+ if (program === 'env')
589
+ return args.every((arg) => arg === '-0' || arg === '--null');
590
+ if (program === 'date')
591
+ return !args.some((arg) => /^-[A-Za-z]*s/.test(arg) || longOptionPrefix(arg, ['set']));
592
+ if (program === 'file')
593
+ return !args.some((arg) => /^-[A-Za-z]*C/.test(arg) || longOptionPrefix(arg, ['compile']));
594
+ return true;
595
+ }
596
+ /** Classifies one simple command: a read-only inspection, a secret access, or anything else. */
597
+ function shellSegmentVerdict(segment, cwd = process.cwd()) {
598
+ // After a cd Marrow cannot follow, relative paths are unknown, so nothing that follows is read-only.
599
+ const base = cwd ?? process.cwd();
600
+ const secret = (value) => isSecretPath(value, base);
601
+ const protectedTarget = (value) => isProtectedWriteTarget(value, base);
602
+ const writes = segment.outputs.filter((target) => !['/dev/null', '/dev/stdout', '/dev/stderr'].includes(target));
603
+ if (segment.inputs.some(secret) || writes.some(protectedTarget))
604
+ return 'secret';
605
+ if (!segment.words.length)
606
+ return writes.length || cwd === null ? 'other' : 'read';
607
+ // A leading assignment can redirect a pager or helper into arbitrary code.
608
+ const program = /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment.words[0]) ? null : programName(segment.words[0]);
609
+ const args = segment.words.slice(1);
610
+ if (program === 'gh' && /^auth\s+(?:token\b|status\b.*(?:--show-token|-t\b))/.test(args.join(' ')))
611
+ return 'secret';
612
+ const expandedOption = program !== null && OPTION_SENSITIVE_PROGRAMS.has(program) && segment.expansions.length > 0;
613
+ if (program && !expandedOption && readOnlyProgramInvocation(program, args)) {
614
+ // Printing a secret variable copies it into the agent transcript.
615
+ if (segment.expansions.some(isSecretVariableName))
616
+ return 'secret';
617
+ if (program === 'printenv' && args.some(isSecretVariableName))
618
+ return 'secret';
619
+ // A bare env or printenv prints every secret the process holds.
620
+ if ((program === 'env' || program === 'printenv') && args.every((arg) => arg.startsWith('-')))
621
+ return 'secret';
622
+ if (program === 'jq' && args.some((arg) => /\$ENV\b|(?:^|[^A-Za-z0-9_$.])env\b/.test(arg)))
623
+ return 'secret';
624
+ if (program === 'cd' && args.some(secret))
625
+ return 'secret';
626
+ if (!NON_CONTENT_BASH_COMMANDS.has(program)) {
627
+ const targets = ['grep', 'egrep', 'fgrep', 'rg', 'jq'].includes(program) ? contentReadTargets(program, args) : args.filter((arg) => !arg.startsWith('-') || arg.includes('='));
628
+ if (targets.some(secret))
629
+ return 'secret';
630
+ }
631
+ return writes.length || cwd === null ? 'other' : 'read';
632
+ }
633
+ return segment.words.some(protectedTarget) ? 'secret' : 'other';
634
+ }
635
+ // The directory later simple commands run in after this one, or null when it cannot be known.
636
+ function nextWorkingDirectory(segment, cwd) {
637
+ if (cwd === null || programName(segment.words[0] || '') !== 'cd')
638
+ return cwd;
639
+ const targets = segment.words.slice(1).filter((arg) => arg !== '--');
640
+ if (!targets.length)
641
+ return homeDirectory();
642
+ if (targets.length > 1 || targets[0] === '-' || targets[0].startsWith('-') || segment.expansions.some((name) => name !== 'HOME'))
643
+ return null;
644
+ return normalizedToolPath(targets[0], cwd);
645
+ }
646
+ function shellAnalysis(command) {
647
+ const segments = parseShellSegments(command);
648
+ if (!segments)
649
+ return null;
650
+ let cwd = process.cwd();
651
+ const verdicts = segments.map((segment) => {
652
+ const verdict = shellSegmentVerdict(segment, cwd);
653
+ cwd = nextWorkingDirectory(segment, cwd);
654
+ return verdict;
655
+ });
656
+ return { segments, verdicts };
657
+ }
658
+ function stringLeaves(value, depth = 0, output = []) {
659
+ if (output.length >= 64 || depth > 4)
660
+ return output;
661
+ if (typeof value === 'string')
662
+ output.push(value);
663
+ else if (Array.isArray(value))
664
+ value.forEach((item) => stringLeaves(item, depth + 1, output));
665
+ else if (value && typeof value === 'object')
666
+ Object.values(value).forEach((item) => stringLeaves(item, depth + 1, output));
667
+ return output;
147
668
  }
148
- function hasCompoundShellSyntax(command) {
149
- return /[|;&`\n\r]|\$\(|\$\{/.test(command);
669
+ /** Files a file-reading or file-editing tool targets, including apply_patch file headers. */
670
+ function toolTargetPaths(event) {
671
+ const paths = [];
672
+ const input = asRecord(event.tool_input);
673
+ if (input) {
674
+ for (const key of ['file_path', 'path', 'target_file', 'filename', 'notebook_path', 'glob']) {
675
+ const value = stringValue(input[key]);
676
+ if (value)
677
+ paths.push(value);
678
+ }
679
+ }
680
+ for (const leaf of stringLeaves(event.tool_input)) {
681
+ if (!leaf.includes('*** Begin Patch'))
682
+ continue;
683
+ for (const match of leaf.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm))
684
+ paths.push(match[1].trim());
685
+ }
686
+ return [...new Set(paths)];
687
+ }
688
+ // The shell text exactly as the host will run it; hookToolCommand trims, and a
689
+ // trimmed control or Unicode space character must still reach the parser check.
690
+ function shellCommandText(event) {
691
+ if (typeof event.tool_input === 'string')
692
+ return event.tool_input;
693
+ const input = asRecord(event.tool_input);
694
+ for (const key of ['command', 'cmd']) {
695
+ if (typeof input?.[key] === 'string' && String(input[key]).trim())
696
+ return String(input[key]);
697
+ }
698
+ return hookToolCommand(event);
699
+ }
700
+ /**
701
+ * True when a tool reads secret or credential material, or writes a secret file
702
+ * or Marrow's local control state. Unparseable shell falls back to a token scan.
703
+ */
704
+ function isSecretMaterialAccess(event) {
705
+ const tool = normalizeHookToolName(event.tool_name);
706
+ if (CONTENT_READ_TOOLS.has(tool))
707
+ return toolTargetPaths(event).some((path) => isSecretPath(path));
708
+ if (FILE_EDIT_TOOLS.has(tool))
709
+ return toolTargetPaths(event).some((path) => isProtectedWriteTarget(path));
710
+ if (!SHELL_TOOLS.has(tool))
711
+ return false;
712
+ const command = shellCommandText(event);
713
+ const analysis = shellAnalysis(command);
714
+ if (analysis)
715
+ return analysis.verdicts.includes('secret');
716
+ return command.split(/[\s'"`;&|<>(){}]+/).some((token) => isProtectedWriteTarget(token));
717
+ }
718
+ /**
719
+ * The text whose words decide a tool's action type, surfaces and keyword
720
+ * protection. Read-only shell segments, task bookkeeping and the content an
721
+ * editing tool writes are excluded: naming a word is not performing it.
722
+ */
723
+ function toolClassificationText(event) {
724
+ const tool = normalizeHookToolName(event.tool_name);
725
+ if (LOCAL_TASK_TOOLS.has(tool))
726
+ return tool;
727
+ if (FILE_EDIT_TOOLS.has(tool))
728
+ return [tool, ...toolTargetPaths(event)].join(' ');
729
+ const command = hookToolCommand(event);
730
+ if (SHELL_TOOLS.has(tool)) {
731
+ const analysis = shellAnalysis(shellCommandText(event));
732
+ if (analysis) {
733
+ return [tool, ...analysis.segments
734
+ .filter((_, index) => analysis.verdicts[index] !== 'read')
735
+ .map((segment) => [...segment.words, ...segment.outputs].join(' '))].join(' ; ');
736
+ }
737
+ }
738
+ let serialized = '';
739
+ try {
740
+ serialized = JSON.stringify(event.tool_input || {});
741
+ }
742
+ catch {
743
+ serialized = '';
744
+ }
745
+ return `${tool} ${command} ${serialized}`;
746
+ }
747
+ /** Editing and task tools are judged by their target, not by shell-like text in their input. */
748
+ function isShellGovernedTool(event) {
749
+ const tool = normalizeHookToolName(event.tool_name);
750
+ return !FILE_EDIT_TOOLS.has(tool) && !LOCAL_TASK_TOOLS.has(tool);
150
751
  }
151
752
  function pathOnlyInput(value) {
152
753
  const input = asRecord(value);
@@ -161,28 +762,24 @@ function isReadOnlyToolEvent(event) {
161
762
  const tool = normalizeHookToolName(event.tool_name);
162
763
  if (!tool)
163
764
  return false;
164
- if (READ_ONLY_TOOLS.has(tool))
765
+ if (LOCAL_TASK_TOOLS.has(tool))
165
766
  return true;
767
+ if (READ_ONLY_TOOLS.has(tool))
768
+ return !isSecretMaterialAccess(event);
166
769
  if (['edit', 'write', 'multiedit', 'search_replace', 'run_terminal_command', 'spawn_subagent'].includes(tool))
167
770
  return false;
168
771
  if (MUTATION_TOOL_VERB.test(tool))
169
772
  return false;
170
773
  if (READ_ONLY_TOOL_VERB.test(tool))
171
774
  return true;
172
- const command = hookToolCommand(event).replace(/\s+/g, ' ').trim();
173
- if ((tool === 'bash' || tool === 'run_terminal_command') && command && !hasCompoundShellSyntax(command) && !hasWriteLikeShellSyntax(command)) {
174
- if (/^(?:node|npm)\s+(?:-v|--version)$/i.test(command))
175
- return true;
176
- if (/^(?:npm|pnpm|yarn)\s+(?:test|audit|run\s+(?:test|check|lint|typecheck|build))(?:\s|$)/i.test(command))
177
- return true;
178
- if (/^(?:node\s+--test|npx\s+(?:vitest|tsc\s+--noemit)|pytest|python(?:3)?\s+-m\s+(?:pytest|unittest)|cargo\s+(?:test|check)|go\s+test)(?:\s|$)/i.test(command))
179
- return true;
180
- if (/^git\s+(?:status|diff|show|log|branch|rev-parse|ls-files|ls-remote)(?:\s|$)/i.test(command))
181
- return true;
182
- const firstToken = command.split(/[\s|;&]+/, 1)[0]?.toLowerCase();
183
- if (firstToken && READ_ONLY_BASH_COMMANDS.has(firstToken))
184
- return true;
775
+ const command = hookToolCommand(event);
776
+ if (tool === 'bash' && command) {
777
+ // A compound command is read-only only when every simple command in it is.
778
+ const analysis = shellAnalysis(shellCommandText(event));
779
+ return Boolean(analysis?.segments.length && analysis.verdicts.every((verdict) => verdict === 'read'));
185
780
  }
186
- return !['edit', 'write', 'multiedit', 'search_replace'].includes(tool) && pathOnlyInput(event.tool_input);
781
+ return !['edit', 'write', 'multiedit', 'search_replace'].includes(tool)
782
+ && pathOnlyInput(event.tool_input)
783
+ && !toolTargetPaths(event).some((path) => isSecretPath(path));
187
784
  }
188
785
  //# sourceMappingURL=hook-tool-policy.js.map