@getmarrow/mcp 3.9.103 → 3.9.106

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 (88) hide show
  1. package/README.md +26 -5
  2. package/dist/approval-reply.d.ts +2 -0
  3. package/dist/approval-reply.d.ts.map +1 -0
  4. package/dist/approval-reply.js +18 -0
  5. package/dist/approval-reply.js.map +1 -0
  6. package/dist/cli.js +1677 -1636
  7. package/dist/cli.js.map +1 -1
  8. package/dist/codex-plugin-pins.d.ts.map +1 -1
  9. package/dist/codex-plugin-pins.js +18 -0
  10. package/dist/codex-plugin-pins.js.map +1 -1
  11. package/dist/env.d.ts +8 -0
  12. package/dist/env.d.ts.map +1 -1
  13. package/dist/env.js +83 -4
  14. package/dist/env.js.map +1 -1
  15. package/dist/hook-context.d.ts.map +1 -1
  16. package/dist/hook-context.js +25 -0
  17. package/dist/hook-context.js.map +1 -1
  18. package/dist/hook-contract.d.ts +4 -3
  19. package/dist/hook-contract.d.ts.map +1 -1
  20. package/dist/hook-contract.js +36 -3
  21. package/dist/hook-contract.js.map +1 -1
  22. package/dist/hook-pre-action.d.ts +38 -7
  23. package/dist/hook-pre-action.d.ts.map +1 -1
  24. package/dist/hook-pre-action.js +254 -21
  25. package/dist/hook-pre-action.js.map +1 -1
  26. package/dist/hook-tool-policy.d.ts +73 -4
  27. package/dist/hook-tool-policy.d.ts.map +1 -1
  28. package/dist/hook-tool-policy.js +287 -18
  29. package/dist/hook-tool-policy.js.map +1 -1
  30. package/dist/hook.d.ts.map +1 -1
  31. package/dist/hook.js +5 -3
  32. package/dist/hook.js.map +1 -1
  33. package/dist/host-approval-state.d.ts +7 -0
  34. package/dist/host-approval-state.d.ts.map +1 -1
  35. package/dist/host-approval-state.js +15 -0
  36. package/dist/host-approval-state.js.map +1 -1
  37. package/dist/host-approval.d.ts +22 -1
  38. package/dist/host-approval.d.ts.map +1 -1
  39. package/dist/host-approval.js +65 -4
  40. package/dist/host-approval.js.map +1 -1
  41. package/dist/index.d.ts +5 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/keyless-server.d.ts +53 -0
  46. package/dist/keyless-server.d.ts.map +1 -0
  47. package/dist/keyless-server.js +230 -0
  48. package/dist/keyless-server.js.map +1 -0
  49. package/dist/normalized-action.d.ts +2 -0
  50. package/dist/normalized-action.d.ts.map +1 -1
  51. package/dist/normalized-action.js +65 -10
  52. package/dist/normalized-action.js.map +1 -1
  53. package/dist/own-server-identity.d.ts +22 -3
  54. package/dist/own-server-identity.d.ts.map +1 -1
  55. package/dist/own-server-identity.js +98 -27
  56. package/dist/own-server-identity.js.map +1 -1
  57. package/dist/own-tool.d.ts +14 -6
  58. package/dist/own-tool.d.ts.map +1 -1
  59. package/dist/own-tool.js +18 -7
  60. package/dist/own-tool.js.map +1 -1
  61. package/dist/seatbelt/env-dump.d.ts +45 -0
  62. package/dist/seatbelt/env-dump.d.ts.map +1 -0
  63. package/dist/seatbelt/env-dump.js +308 -0
  64. package/dist/seatbelt/env-dump.js.map +1 -0
  65. package/dist/seatbelt/fallback-copy.d.ts +16 -0
  66. package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
  67. package/dist/seatbelt/fallback-copy.js +68 -0
  68. package/dist/seatbelt/fallback-copy.js.map +1 -0
  69. package/dist/seatbelt/hook.d.ts +21 -2
  70. package/dist/seatbelt/hook.d.ts.map +1 -1
  71. package/dist/seatbelt/hook.js +45 -11
  72. package/dist/seatbelt/hook.js.map +1 -1
  73. package/dist/seatbelt/mapper.d.ts.map +1 -1
  74. package/dist/seatbelt/mapper.js +360 -27
  75. package/dist/seatbelt/mapper.js.map +1 -1
  76. package/dist/seatbelt/tables.d.ts +1 -1
  77. package/dist/seatbelt/tables.d.ts.map +1 -1
  78. package/dist/seatbelt/tables.js +9 -16
  79. package/dist/seatbelt/tables.js.map +1 -1
  80. package/dist/secret-paths.d.ts +17 -0
  81. package/dist/secret-paths.d.ts.map +1 -0
  82. package/dist/secret-paths.js +32 -0
  83. package/dist/secret-paths.js.map +1 -0
  84. package/dist/session-loop-guard.d.ts.map +1 -1
  85. package/dist/session-loop-guard.js +15 -5
  86. package/dist/session-loop-guard.js.map +1 -1
  87. package/package.json +2 -2
  88. package/server.json +2 -2
@@ -50,6 +50,7 @@ const tables_1 = require("./tables");
50
50
  const classifier_pin_1 = require("./classifier-pin");
51
51
  const longopts_1 = require("./longopts");
52
52
  const guards_1 = require("./guards");
53
+ const env_dump_1 = require("./env-dump");
53
54
  /* eslint-disable @typescript-eslint/no-require-imports */
54
55
  const shellParse = require('../../vendor/classifier/shell-parse.js');
55
56
  const baseline = require('../../vendor/classifier/policy-baseline.js');
@@ -182,6 +183,11 @@ function evaluateEvent(event, env) {
182
183
  if (Array.isArray(rawInput))
183
184
  rawInput = { commands: rawInput.filter((v) => typeof v === 'string'), items: rawInput.filter((v) => v && typeof v === 'object') };
184
185
  const input = (rawInput && typeof rawInput === 'object' ? rawInput : {});
186
+ // A Claude Code built-in that runs a fixed local git command (exact label) is read as that command,
187
+ // as the keyed hook reads it (hook-tool-policy NATIVE_SHELL_EQUIVALENTS).
188
+ const nativeShell = NATIVE_SHELL_EQUIVALENTS.get(tool);
189
+ if (nativeShell !== undefined)
190
+ rawText = rawText === null ? nativeShell : `${rawText}\n${nativeShell}`;
185
191
  // One wall-clock ceiling for the whole event, counted from here (audit N6): the input walks below
186
192
  // run before any classification and share it.
187
193
  const deadline = deadlineFor(env);
@@ -197,9 +203,14 @@ function evaluateEvent(event, env) {
197
203
  }
198
204
  const evName = mcp ? mcp.name : name;
199
205
  const evInput = mcp ? mcp.args : input;
206
+ // Claude Code's Workflow (exact host label) runs its `script` as JavaScript that only starts
207
+ // sub-agents, whose own tools reach the hook: that one field is not shell text (as the keyed hook
208
+ // reads it, hook-tool-policy NON_SHELL_COMMAND_TOOLS). Every other field is read as for any tool.
209
+ const shellInput = !mcp && tool === 'Workflow' && typeof evInput.script === 'string'
210
+ ? Object.fromEntries(Object.entries(evInput).filter(([key]) => key !== 'script')) : evInput;
200
211
  // A tool that runs a shell command (audit F11): a known shell tool, or any tool whose input names a
201
212
  // command, script or argv field, at any depth.
202
- const invocation = shellInvocations(evName, evInput, deadline, rawText);
213
+ const invocation = shellInvocations(evName, shellInput, deadline, rawText);
203
214
  if (invocation) {
204
215
  const run = newRun(env, invocation.shell.join('\n'), deadline);
205
216
  run.shell = true;
@@ -377,6 +388,11 @@ function mcpPathChecks(label, input, ctx, run, allStrings = true) {
377
388
  checkProtectedWrite(litArg(p), ctx, run, 'mcp_write', false);
378
389
  }
379
390
  }
391
+ // Claude Code built-ins that run a fixed local git command (exact host label).
392
+ const NATIVE_SHELL_EQUIVALENTS = new Map([
393
+ ['EnterWorktree', 'git worktree add'],
394
+ ['ExitWorktree', 'git worktree remove'],
395
+ ]);
380
396
  // Tools that never run what their `command`/`script` field says (it names a skill, a slash command,
381
397
  // a sub-agent task), plus Marrow's own tools.
382
398
  const NON_SHELL_TOOLS = new Set(['slashcommand', 'skill', 'task', 'agent', 'todowrite', 'todoread', 'exitplanmode', 'enterplanmode',
@@ -978,6 +994,7 @@ function newRun(rawEnv, source, deadline) {
978
994
  protectedAnchors: anchors.map((a) => (fold ? a.toLowerCase() : a)),
979
995
  operandAnchors: operandAnchors.map((a) => (fold ? a.toLowerCase() : a)),
980
996
  secretRegexes: tables_1.SECRET_PATTERNS.map((p) => classifierModule.globToRegex(p, home)),
997
+ presentSecretRegexes: tables_1.PRESENT_SECRET_PATTERNS.map((p) => classifierModule.globToRegex(p, home)),
981
998
  branchRegexes: baseline.BASELINE_POLICY.protected_branches.map((p) => classifierModule.globToRegex(p, '/nonexistent-home')),
982
999
  topSource: source,
983
1000
  work: 0,
@@ -2916,6 +2933,8 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
2916
2933
  run.headMayChange = true;
2917
2934
  // Generic operand checks apply to every program (S4 reads, S0 writes by write-programs).
2918
2935
  genericOperandChecks(prog, args, ctx, run, x.info.subs);
2936
+ // Every command that prints the environment (a word-level table shared with the keyed hook).
2937
+ envDumpChecks(prog, rest, ctx, run, x);
2919
2938
  // NEW-2: a protected path as an operand of a program that is not a known reader.
2920
2939
  protectedOperandCheck(prog, rest, ctx, run);
2921
2940
  // Patch round 4: the command zip runs to test its archive (-TT, --unzip-command) is command text.
@@ -3014,6 +3033,10 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
3014
3033
  case 'awk':
3015
3034
  case 'gawk':
3016
3035
  case 'mawk': return awkProgram(rest, ctx, run, x);
3036
+ // nawk was read as an unknown program before: it keeps that argv scan (the break falls through to unknownProgram) besides the awk reading.
3037
+ case 'nawk':
3038
+ awkProgram(rest, ctx, run, x);
3039
+ break;
3017
3040
  case 'sed':
3018
3041
  case 'gsed': return sedProgram(rest, ctx, run);
3019
3042
  case 'ps': return psProgram(rest, run);
@@ -3292,7 +3315,7 @@ const NESTED_NOT_PROGRAMS = new Set(['set', 'export', 'declare', 'typeset', 'rea
3292
3315
  // Every command hidden in the argv of a program the mapper has no handler for (`strace -f npm
3293
3316
  // publish`, `cross-env NODE_ENV=production vercel --prod`, `tmux new -d 'npm publish'`). Used for
3294
3317
  // unknown programs and for known wrappers with open grammars (docker run IMAGE CMD…).
3295
- function scanArgvForCommands(rest, ctx, run, x, remote = false) {
3318
+ function scanArgvForCommands(rest, ctx, run, x, remote = false, execWrapper = false) {
3296
3319
  const fed = withFeed(ctx, x, run).stdinFeed;
3297
3320
  const base = remote ? childCtx(ctx, { cwds: [null], remote: true, vars: new Map(), aliases: new Map(), backstop: true, stdinFeed: fed }) : { ...ctx, backstop: true };
3298
3321
  const env = new Map(x.env);
@@ -3323,7 +3346,7 @@ function scanArgvForCommands(rest, ctx, run, x, remote = false) {
3323
3346
  continue;
3324
3347
  const tail = rest.slice(k);
3325
3348
  const b = progName(w, run);
3326
- if ((b === 'env' || b === 'printenv') && tail.length === 1)
3349
+ if ((b === 'env' || b === 'printenv') && tail.length === 1 && !execWrapper)
3327
3350
  continue;
3328
3351
  trials += 1;
3329
3352
  if (trials > tables_1.LIMITS.maxTailTrials) {
@@ -3545,7 +3568,7 @@ function unknownProgram(prog, args, ctx, run, x) {
3545
3568
  if (tables_1.INERT_PROGRAMS.has(prog) || ctx.backstop)
3546
3569
  return;
3547
3570
  const rest = args.slice(1);
3548
- scanArgvForCommands(rest, ctx, run, x);
3571
+ scanArgvForCommands(rest, ctx, run, x, false, env_dump_1.EXEC_WRAPPERS.has(prog));
3549
3572
  // stdin text: `echo 'npm publish' | at now`, `at now <<< 'npm publish'`, `batch <<EOF …`.
3550
3573
  const src = stdinSource(x, ctx, run);
3551
3574
  // Decoded text piped into a program that runs what it reads (at, batch, crontab, a function) or
@@ -3930,7 +3953,11 @@ function interpreterEnvDump(code) {
3930
3953
  if (rest.includes('\u0004SECRETENV\u0004'))
3931
3954
  return true;
3932
3955
  // What is left: any other mention of the environment object is a dump or a computed lookup.
3933
- return /\bos\.environ\b|\bprocess\.env\b|%ENV\b|\bENV\b(?!\s*(?:=|==)\s*['"])|\bENVIRON\b|\benviron\b\s*(?:[.)\],;:|]|$)|\$_ENV|\$_SERVER|\bSystem\.getenv\s*\(\s*\)|\bgetenv\s*\(\s*\)|\benvironment\.get_environment_variables|\bGet-ChildItem\s+env:/i.test(rest);
3956
+ if (/\bos\.environb?\b|\bprocess\.env\b|%ENV\b|\bENV\b(?!\s*(?:=|==)\s*['"])|\bENVIRON\b|\benvironb?\b\s*(?:[.)\],;:|]|$)|\$_ENV|\$_SERVER|\bSystem\.getenv\s*\(\s*\)|\bSystem\.get_env\s*\(\s*\)|\bgetenv\s*\(\s*(?:null)?\s*\)|\bphpinfo\s*\(|\benvironment\.get_environment_variables|\bGet-ChildItem\s+env:/i.test(rest))
3957
+ return true;
3958
+ // JavaScript reaches the environment object without spelling it out: `const { env } = process`, `process['en' + 'v']`, `with (process) env`, `Reflect.get(process, 'env')`.
3959
+ return (/\bprocess\b/.test(rest) && (/\benv\b/.test(rest) || /\bprocess\s*(?:\?\.)?\s*\[(?!\s*(['"])[A-Za-z_]\w*\1\s*\])/.test(rest)))
3960
+ || /\b(?:globalThis|global)\s*(?:\?\.)?\s*\[(?!\s*(['"])[A-Za-z_]\w*\1\s*\])/.test(rest);
3934
3961
  }
3935
3962
  function scanInterpreterCode(code, run) {
3936
3963
  if ((0, tables_1.interpreterTreeDelete)(code))
@@ -4243,7 +4270,9 @@ function longestRealpath(abs, run) {
4243
4270
  }
4244
4271
  return null;
4245
4272
  }
4246
- function isSecretPath(abs, run) {
4273
+ // `mustExist`: a `<name>.env` path (PRESENT_SECRET_PATTERNS) counts only when it is there to read; samples, git
4274
+ // pathspecs and git objects (history) leave it false.
4275
+ function isSecretPath(abs, run, mustExist = false) {
4247
4276
  const p = run.fold ? abs.toLowerCase() : abs;
4248
4277
  const base = path.posix.basename(p);
4249
4278
  if (/^\.env(?:\.|$)/.test(base) && tables_1.ENV_TEMPLATE_SUFFIX.test(base))
@@ -4254,8 +4283,11 @@ function isSecretPath(abs, run) {
4254
4283
  return true;
4255
4284
  }
4256
4285
  const hits = run.secretRegexes.some((re) => re.test(p) || (run.fold && re.test(abs)));
4257
- if (!hits)
4258
- return false;
4286
+ if (!hits) {
4287
+ if (!run.presentSecretRegexes.some((re) => re.test(p) || (run.fold && re.test(abs))))
4288
+ return false;
4289
+ return !mustExist || safeRealpath(run.env, abs) !== null;
4290
+ }
4259
4291
  if (/\/\.ssh\//.test(p) && tables_1.SSH_PUBLIC.test(base))
4260
4292
  return false;
4261
4293
  return true;
@@ -4391,25 +4423,35 @@ function sampleMatch(a, ctx, run, samples) {
4391
4423
  }
4392
4424
  function cwdsList(ctx) { return ctx.cwds.map((c) => c || '/__unknown_cwd__'); }
4393
4425
  // S4: a secret path as an operand. Returns true on a stop.
4394
- function checkSecretOperand(a, ctx, run, reason) {
4426
+ // `history`: git reads the path from its object store (a pathspec after `--`, a `REV:path` object), so it
4427
+ // need not exist in the work tree.
4428
+ // `pattern`: the pattern of a search (the word in `grep -rn <word> src`), which names no file to read.
4429
+ function checkSecretOperand(a, ctx, run, reason, history = false, pattern = false) {
4395
4430
  if (a.text === null && a.alts && a.alts.length) {
4396
4431
  for (const v of a.alts)
4397
- if (checkSecretOperand({ ...litArg(v), glob: a.glob, word: a.word }, ctx, run, reason))
4432
+ if (checkSecretOperand({ ...litArg(v), glob: a.glob, word: a.word }, ctx, run, reason, history, pattern))
4398
4433
  return true;
4399
4434
  return false;
4400
4435
  }
4401
4436
  for (const text of operandPaths(a)) {
4402
4437
  if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text))
4403
4438
  continue;
4439
+ // A name under an unknown working folder (the event gave no absolute cwd): a relative operand, `..` included,
4440
+ // or one under /proc/<pid>/cwd. It may be there to read, so a `<name>.env` operand counts as present and a
4441
+ // glob that can expand to a `*.env` name stops (fail closed). The pattern of a search names no file.
4442
+ const underUnknown = !pattern && ctx.cwds.some((c) => c === null) && (() => {
4443
+ const t = expandTilde(text, run);
4444
+ return t !== null && (!path.posix.isAbsolute(t) || /^\/proc\/[^/]+\/cwd(?:\/|$)/.test(t));
4445
+ })();
4404
4446
  // A glob is also listed (through links and names made in this command) and each match judged.
4405
4447
  if (a.text !== null && a.glob && /[*?[]/.test(text) && !a.nonAscii && run.env.fs.readdir) {
4406
4448
  const matches = expandGlob(text, ctx, run);
4407
4449
  if (matches !== null) {
4408
4450
  for (const mm of matches.slice(0, tables_1.LIMITS.maxGlobMatches))
4409
- if (checkSecretOperand({ ...litArg(mm), glob: false }, ctx, run, reason))
4451
+ if (checkSecretOperand({ ...litArg(mm), glob: false }, ctx, run, reason, history, pattern))
4410
4452
  return true;
4411
4453
  for (const k of linkNamesMatching(text, ctx, run))
4412
- if (checkSecretOperand({ ...litArg(k), glob: false }, ctx, run, reason))
4454
+ if (checkSecretOperand({ ...litArg(k), glob: false }, ctx, run, reason, history, pattern))
4413
4455
  return true;
4414
4456
  }
4415
4457
  }
@@ -4418,6 +4460,8 @@ function checkSecretOperand(a, ctx, run, reason) {
4418
4460
  if (/[A-Za-z0-9._~/-]/.test(probe.pattern.replace(new RegExp(DYN, 'g'), '')) && sampleMatch(probe, ctx, run, (0, tables_1.secretSamples)(run.home, cwdsList(ctx)))) {
4419
4461
  return hit(run, 'S4', `${reason}_secret_pattern`);
4420
4462
  }
4463
+ if (underUnknown && a.text !== null && a.glob && /[*?[]/.test(text) && globMayEndWith(text, '.env', run.fold))
4464
+ return hit(run, 'S4', `${reason}_secret_pattern`);
4421
4465
  if (a.text === null)
4422
4466
  continue;
4423
4467
  }
@@ -4428,13 +4472,135 @@ function checkSecretOperand(a, ctx, run, reason) {
4428
4472
  // A path through a link whose target the mapper cannot follow (audit N3).
4429
4473
  if (run.links.size > 0 && applyLinks(p, run) === '/__unknown_link__')
4430
4474
  return hit(run, 'S4', `${reason}_unknown_link`);
4475
+ const mustExist = !history && !(underUnknown || (!pattern && p.startsWith('/__unknown_cwd__/')));
4431
4476
  for (const r of realCandidates(p, run, true))
4432
- if (isSecretPath(r, run))
4477
+ if (isSecretPath(r, run, mustExist))
4433
4478
  return hit(run, 'S4', `${reason}_secret_path`);
4434
4479
  }
4435
4480
  }
4436
4481
  return false;
4437
4482
  }
4483
+ // Whether some name the glob matches (shell rules: `*` and `?` stay inside one path segment) ends with `suffix`:
4484
+ // a walk over (glob position, how much of the suffix the name ends with so far), trying the suffix characters
4485
+ // and a few others for every wildcard.
4486
+ function globMayEndWith(glob, suffix, ci) {
4487
+ const fold = (c) => (ci ? c.toLowerCase() : c);
4488
+ const toks = [];
4489
+ for (let i = 0; i < glob.length; i += 1) {
4490
+ const ch = glob[i];
4491
+ if (ch === '*') {
4492
+ if (toks[toks.length - 1]?.k !== 'star')
4493
+ toks.push({ k: 'star' });
4494
+ continue;
4495
+ }
4496
+ if (ch === '?') {
4497
+ toks.push({ k: 'any1' });
4498
+ continue;
4499
+ }
4500
+ if (ch === '[') {
4501
+ const end = glob.indexOf(']', i + 2);
4502
+ if (end > 0) {
4503
+ let body = glob.slice(i + 1, end);
4504
+ const neg = body.startsWith('!') || body.startsWith('^');
4505
+ if (neg)
4506
+ body = body.slice(1);
4507
+ const tok = { k: 'cls', neg, chars: [], ranges: [] };
4508
+ for (let q = 0; q < body.length; q += 1) {
4509
+ if (body[q + 1] === '-' && q + 2 < body.length) {
4510
+ tok.ranges.push([fold(body[q]), fold(body[q + 2])]);
4511
+ q += 2;
4512
+ }
4513
+ else
4514
+ tok.chars.push(fold(body[q]));
4515
+ }
4516
+ toks.push(tok);
4517
+ i = end;
4518
+ continue;
4519
+ }
4520
+ }
4521
+ toks.push({ k: 'lit', c: fold(ch) });
4522
+ }
4523
+ const want = fold(suffix);
4524
+ const tries = [...new Set([...want, 'x', 'A', '0', '_', '-', ...toks.flatMap((t) => (t.k === 'cls' ? [...t.chars, ...t.ranges.flat()] : []))])].filter((c) => c !== '/');
4525
+ const step = (k, c) => {
4526
+ const s = want.slice(0, k) + c;
4527
+ for (let n = Math.min(s.length, want.length); n > 0; n -= 1)
4528
+ if (s.endsWith(want.slice(0, n)))
4529
+ return n;
4530
+ return 0;
4531
+ };
4532
+ const accepts = (t, c) => {
4533
+ if (t.k === 'lit')
4534
+ return t.c === c;
4535
+ if (t.k === 'any1' || t.k === 'star')
4536
+ return c !== '/';
4537
+ const inSet = t.chars.includes(c) || t.ranges.some(([lo, hi]) => c >= lo && c <= hi);
4538
+ return t.neg ? !inSet && c !== '/' : inSet;
4539
+ };
4540
+ const seen = new Set();
4541
+ const queue = [[0, 0]];
4542
+ while (queue.length) {
4543
+ const [i, k] = queue.pop();
4544
+ const key = `${i}:${k}`;
4545
+ if (seen.has(key))
4546
+ continue;
4547
+ seen.add(key);
4548
+ if (i === toks.length) {
4549
+ if (k === want.length)
4550
+ return true;
4551
+ continue;
4552
+ }
4553
+ const t = toks[i];
4554
+ const chars = t.k === 'lit' ? [t.c] : tries;
4555
+ if (t.k === 'star')
4556
+ queue.push([i + 1, k]);
4557
+ for (const c of chars)
4558
+ if (accepts(t, c))
4559
+ queue.push([t.k === 'star' ? i : i + 1, step(k, c)]);
4560
+ }
4561
+ return false;
4562
+ }
4563
+ // The words a search reads as its pattern, not as a file: the value of -e/--regexp (also attached), and the
4564
+ // first operand when no -e/-f gave one. A form not listed here is read as a file (the strict reading).
4565
+ function searchPatternArgs(rest, end = rest.length) {
4566
+ const out = new Set();
4567
+ let given = false;
4568
+ let first = null;
4569
+ for (let i = 0; i < end; i += 1) {
4570
+ const t = rest[i].text;
4571
+ if (t === null) {
4572
+ if (first === null)
4573
+ first = rest[i];
4574
+ continue;
4575
+ }
4576
+ if (t === '--')
4577
+ break;
4578
+ if (t === '-e' || t === '--regexp') {
4579
+ given = true;
4580
+ if (rest[i + 1])
4581
+ out.add(rest[i + 1]);
4582
+ i += 1;
4583
+ continue;
4584
+ }
4585
+ if (/^(?:--regexp=|-e.)/.test(t)) {
4586
+ given = true;
4587
+ out.add(rest[i]);
4588
+ continue;
4589
+ }
4590
+ if (t === '-f' || t === '--file' || /^(?:--file=|-f.)/.test(t)) {
4591
+ given = true;
4592
+ if (t === '-f' || t === '--file')
4593
+ i += 1;
4594
+ continue;
4595
+ }
4596
+ if (first === null && !t.startsWith('-'))
4597
+ first = rest[i];
4598
+ }
4599
+ if (!given && first !== null)
4600
+ out.add(first);
4601
+ return out;
4602
+ }
4603
+ const SEARCH_PROGRAMS = new Set(['grep', 'egrep', 'fgrep', 'zgrep', 'rg', 'ag', 'ack']);
4438
4604
  // Path-ish parts of an operand: the value after `--opt=`, after `@` (curl -d @file) and after `:`
4439
4605
  // for git object paths (HEAD:.env).
4440
4606
  function operandPaths(a) {
@@ -4732,6 +4898,7 @@ function genericOperandChecks(prog, args, ctx, run, subs = []) {
4732
4898
  recursiveSecretDirRead(prog, rest, ctx, run);
4733
4899
  if (!tables_1.S4_EXEMPT_PROGRAMS.has(prog) && prog !== 'git') {
4734
4900
  let skipNext = false;
4901
+ const patterns = SEARCH_PROGRAMS.has(prog) ? searchPatternArgs(rest) : null;
4735
4902
  for (let i = 0; i < rest.length; i += 1) {
4736
4903
  const a = rest[i];
4737
4904
  if (skipNext) {
@@ -4745,7 +4912,7 @@ function genericOperandChecks(prog, args, ctx, run, subs = []) {
4745
4912
  }
4746
4913
  if (clock(run))
4747
4914
  return;
4748
- checkSecretOperand(a, ctx, run, 'operand');
4915
+ checkSecretOperand(a, ctx, run, 'operand', false, patterns !== null && patterns.has(a));
4749
4916
  }
4750
4917
  }
4751
4918
  // Copying or archiving a directory that holds credential files (cp -r ~/.ssh, tar ~) is a read too.
@@ -6670,6 +6837,7 @@ function xargsWrapper(prog, rest, ctx, run, x) {
6670
6837
  const sep = inner.findIndex((a) => a.text !== null && /^::::?\+?$/.test(a.text));
6671
6838
  const inputs = sep >= 0 ? inner.slice(sep + 1) : [];
6672
6839
  inner = sep >= 0 ? inner.slice(0, sep) : inner;
6840
+ xargsRunsBareEnv(inner, run);
6673
6841
  if (inner.length === 0) {
6674
6842
  // `parallel ::: 'cmd1' 'cmd2'` and `… | parallel`: every input line is a command line.
6675
6843
  if (sep >= 0) {
@@ -6692,6 +6860,7 @@ function xargsWrapper(prog, rest, ctx, run, x) {
6692
6860
  evalShell(/\{(?:[.\/#%]|\/\.|\d+)?\}/.test(inner.map((a) => a.text || '').join(' ')) ? text : `${text} ${marker}`, childCtx(ctx), run);
6693
6861
  return;
6694
6862
  }
6863
+ xargsRunsBareEnv(inner, run);
6695
6864
  if (inner.length === 0)
6696
6865
  inner = [litArg('echo')];
6697
6866
  // Input lines become extra operands: unknown targets (spec A6). The -I replacement string stands
@@ -6894,12 +7063,13 @@ function handleShell(prog, rest, ctx, run, x) {
6894
7063
  }
6895
7064
  function handleInterpreter(prog, rest, ctx, run, x) {
6896
7065
  const codeFlags = {
6897
- node: /^(?:-e|--eval|-p|--print|-pe)$/, nodejs: /^(?:-e|--eval|-p|--print)$/, bun: /^(?:-e|--eval|-p|--print)$/,
7066
+ node: /^(?:-e|--eval|-p|--print|-pe|-ep)$/, 'ts-node': /^(?:-e|--eval|-p|--print)$/, tsx: /^(?:-e|--eval|-p|--print)$/, nodejs: /^(?:-e|--eval|-p|--print)$/, bun: /^(?:-e|--eval|-p|--print)$/,
6898
7067
  deno: /^(?:eval)$/, php: /^-r$/, ruby: /^-[a-zA-Z]*e$/, perl: /^-[a-zA-Z]*[eE]$/, lua: /^-e$/, irb: /^-e$/,
6899
7068
  pwsh: /^-(?:c|command|e|ec|enc|encodedcommand)$/i, powershell: /^-(?:c|command|e|ec|enc|encodedcommand)$/i,
6900
7069
  };
6901
- const isPython = /^(?:python[23]?(?:\.\d+)?|ipython)$/.test(prog);
6902
- const codeRe = isPython ? /^-[a-zA-Z]*c$/ : (codeFlags[prog] || /^-e$/);
7070
+ const isPython = /^(?:python[23]?(?:\.\d+)?|ipython|pypy3?(?:\.\d+)?)$/.test(prog);
7071
+ // pypy keeps the `-e` it was read with before it was read as Python (a stop, never a pass), besides Python's `-c`.
7072
+ const codeRe = isPython ? (prog.startsWith('pypy') ? /^(?:-[a-zA-Z]*c|-e)$/ : /^-[a-zA-Z]*c$/) : (codeFlags[prog] || /^-e$/);
6903
7073
  for (let i = 0; i < rest.length; i += 1) {
6904
7074
  const a = rest[i];
6905
7075
  const t = a.text;
@@ -6914,6 +7084,12 @@ function handleInterpreter(prog, rest, ctx, run, x) {
6914
7084
  hit(run, 'Sx', 'dynamic_interpreter_code');
6915
7085
  return;
6916
7086
  }
7087
+ // The code attached to its flag: `node --eval=CODE`, `perl -e'CODE'`, `ruby -e'CODE'`, `php -r'CODE'`, `python3 -c'CODE'`.
7088
+ const attachedCode = /^(?:node|nodejs|bun)$/.test(prog) ? /^--(?:eval|print)=([\s\S]+)$/.exec(t)
7089
+ : isPython ? /^-[a-zA-Z]*c([\s\S]+)$/.exec(t) : prog === 'perl' ? /^-[a-zA-Z]*[eE]([\s\S]+)$/.exec(t) : prog === 'ruby' ? /^-[a-zA-Z]*e([\s\S]+)$/.exec(t)
7090
+ : prog === 'php' ? /^-r([\s\S]+)$/.exec(t) : null;
7091
+ if (attachedCode)
7092
+ scanInterpreterCode(attachedCode[1], run);
6917
7093
  if (codeRe.test(t) || (prog === 'deno' && t === 'eval')) {
6918
7094
  const code = rest[i + 1];
6919
7095
  if (!code)
@@ -7027,15 +7203,61 @@ function stdinInterpreter(prog, x, ctx, run) {
7027
7203
  }
7028
7204
  // BSD `ps e`, `ps auxe`, `ps eww` print every process's environment.
7029
7205
  function psProgram(rest, run) {
7206
+ const words = rest.map((a) => a.text);
7207
+ if ((0, env_dump_1.psPrintsEnvironment)(words))
7208
+ hit(run, 'S4', 'ps_environment');
7209
+ // What 3.9.103 stopped and no later version loosens: the keyed hook leaves this reading out.
7210
+ else if ((0, env_dump_1.psLooselyPrintsEnvironment)(words))
7211
+ hit(run, 'S4', 'ps_environment_loose');
7212
+ }
7213
+ // Commands that print many or all environment variables, found by their words alone (never by file contents):
7214
+ // `compgen -e`, `tmux show-environment`, `systemctl show-environment`, `php -i`, a jq filter that reads `env`,
7215
+ // `npm run env`, and any program given /proc/<pid>/environ.
7216
+ function envDumpChecks(prog, rest, ctx, run, x) {
7217
+ const words = rest.map((a) => a.text);
7218
+ if (prog === 'compgen' && (0, env_dump_1.compgenListsVariables)(words))
7219
+ hit(run, 'S4', 'env_dump_compgen');
7220
+ else if (prog === 'tmux' && ((0, env_dump_1.tmuxShowsEnvironment)(words) || (0, env_dump_1.tmuxRunsEnvironmentPrinter)(words)))
7221
+ hit(run, 'S4', 'env_dump_tmux');
7222
+ else if (prog === 'strace' && (0, env_dump_1.straceShowsEnvironment)(words))
7223
+ hit(run, 'S4', 'env_dump_strace');
7224
+ else if (prog === 'man' && (0, env_dump_1.manPagerPrintsEnvironment)(words))
7225
+ hit(run, 'S4', 'env_dump_env');
7226
+ else if (prog === 'systemctl' && (0, env_dump_1.systemctlShowsEnvironment)(words))
7227
+ hit(run, 'S4', 'env_dump_systemctl');
7228
+ else if (prog === 'php' && words.some((w) => w === '-i' || w === '--info'))
7229
+ hit(run, 'S4', 'env_dump_php_info');
7230
+ else if (prog === 'jq' && ((0, env_dump_1.jqFilters)(words).some(env_dump_1.jqFilterReadsEnvironment) || (x.cmd.heredoc !== null && words.some((w) => w !== null && STDIN_PATHS.has(w)) && (0, env_dump_1.jqFilterReadsEnvironment)(x.cmd.heredoc))))
7231
+ hit(run, 'S4', 'env_dump_jq');
7232
+ else if ((prog === 'npm' || prog === 'pnpm' || prog === 'yarn' || prog === 'bun') && (0, env_dump_1.packageRunsEnvScript)(words))
7233
+ hit(run, 'S4', 'env_dump_npm_run_env');
7234
+ for (const [name, values] of x.env) {
7235
+ if (env_dump_1.HELPER_ENV_VARS.has(name) && values !== null && values.some((v) => env_dump_1.ENV_PRINTER_COMMAND.test(v))) {
7236
+ hit(run, 'S4', 'env_dump_env');
7237
+ break;
7238
+ }
7239
+ }
7030
7240
  for (const a of rest) {
7031
- if (a.text === null)
7241
+ // A dynamic part of the path (`/proc/$(pgrep node)/environ`) stands for any pid.
7242
+ const text = a.text !== null ? a.text : a.pattern.split(DYN).join('x');
7243
+ if (!text.startsWith('/') && !text.includes('proc'))
7032
7244
  continue;
7033
- if (/^[a-zA-Z]*e[a-zA-Z]*$/.test(a.text) && !a.text.startsWith('-') && !/^\d+$/.test(a.text)) {
7034
- hit(run, 'S4', 'ps_environment');
7245
+ const candidates = (a.text !== null ? absCandidates(a.text, ctx, run) || [] : [text]).map((p) => (p.length > 1 ? p.replace(/\/+$/, '') : p));
7246
+ if (candidates.some((p) => env_dump_1.PROC_ENVIRON.test(p) || (0, env_dump_1.procGlobMatchesEnviron)(p) || (env_dump_1.RECURSIVE_READERS.has(prog) && env_dump_1.PROC_PROCESS_DIRECTORY.test(p)))) {
7247
+ hit(run, 'S4', 'proc_environ');
7035
7248
  return;
7036
7249
  }
7037
7250
  }
7038
7251
  }
7252
+ // `xargs env`, `parallel env ::: x`: the command is `env` or `printenv` with only options and assignments, so it prints the environment.
7253
+ function xargsRunsBareEnv(words, run) {
7254
+ const b = words[0] && words[0].text !== null ? progName(words[0], run) : null;
7255
+ if (b !== 'env' && b !== 'printenv')
7256
+ return;
7257
+ const bare = words.slice(1).every((a) => a.text !== null && (a.text.startsWith('-') || (b === 'env' && (/^[A-Za-z_]\w*=/.test(a.text) || /^\{[^}]*\}$/.test(a.text)))));
7258
+ if (bare)
7259
+ hit(run, 'S4', b === 'env' ? 'env_dump_env' : 'env_dump_printenv');
7260
+ }
7039
7261
  // trap 'cmd' SIGNAL…: the command runs when the signal arrives.
7040
7262
  // util-linux `script [-q] [-c COMMAND] [file]`: COMMAND runs in a pty with this command's stdin.
7041
7263
  function scriptSession(rest, ctx, run, x) {
@@ -7069,6 +7291,12 @@ function trapBuiltin(rest, ctx, run, x) {
7069
7291
  }
7070
7292
  // awk programs run commands through system() and pipes.
7071
7293
  const AWK_EXEC = /\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b|\bENVIRON\b|\bPROCINFO\b/;
7294
+ // A program that can start a command and names its own arguments (`system(ARGV[1])`, `for (i = 1; i < ARGC; i++) s = s ARGV[i]`) may run them: they are command text.
7295
+ const AWK_RUNS_ARGV = /\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b/;
7296
+ function awkArgvCommands(text, rest, ctx, run, x) {
7297
+ if (AWK_RUNS_ARGV.test(text) && /\b(?:ARGV|ARGC)\b/.test(text) && !ctx.backstop)
7298
+ scanArgvForCommands(rest, ctx, run, x);
7299
+ }
7072
7300
  function awkProgram(rest, ctx, run, x) {
7073
7301
  const vf = new Set(['-F', '-v', '-W', '--field-separator', '--assign', '-i', '-l', '--include', '--load']);
7074
7302
  let program = null;
@@ -7080,8 +7308,10 @@ function awkProgram(rest, ctx, run, x) {
7080
7308
  if (f && f.text !== null && (f.text === '-' || STDIN_PATHS.has(f.text))) {
7081
7309
  const src = stdinSource(x, ctx, run);
7082
7310
  for (const text of src.texts)
7083
- if (AWK_EXEC.test(text))
7311
+ if (AWK_EXEC.test(text)) {
7084
7312
  scanInterpreterCode(text, run);
7313
+ awkArgvCommands(text, rest, ctx, run, x);
7314
+ }
7085
7315
  if (src.unknown || (src.texts.length === 0 && src.files.length === 0)) {
7086
7316
  worseParse(run, 'dynamic');
7087
7317
  hit(run, 'Sx', 'dynamic_stdin_code');
@@ -7092,8 +7322,10 @@ function awkProgram(rest, ctx, run, x) {
7092
7322
  if (f && f.text !== null) {
7093
7323
  for (const abs of absCandidates(f.text, ctx, run) || []) {
7094
7324
  const content = safeRead(run.env, abs, tables_1.LIMITS.maxScriptBytes);
7095
- if (content !== null && AWK_EXEC.test(content))
7325
+ if (content !== null && AWK_EXEC.test(content)) {
7096
7326
  scanInterpreterCode(content, run);
7327
+ awkArgvCommands(content, rest, ctx, run, x);
7328
+ }
7097
7329
  }
7098
7330
  }
7099
7331
  i += 1;
@@ -7113,6 +7345,7 @@ function awkProgram(rest, ctx, run, x) {
7113
7345
  const code = program.text !== null ? program.text : argText(program);
7114
7346
  if (AWK_EXEC.test(code)) {
7115
7347
  scanInterpreterCode(code, run);
7348
+ awkArgvCommands(code, rest, ctx, run, x);
7116
7349
  // `awk '{system($0)}'` runs the lines it reads: the input is a command text (audit N1).
7117
7350
  if (/\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b/.test(code))
7118
7351
  stdinExec('sh', [], x, ctx, run);
@@ -8290,19 +8523,119 @@ function handleGit(rest, ctx, run, x, cOverrides, aliasDepth) {
8290
8523
  hit(run, 'S4', 'git_credential_get');
8291
8524
  return;
8292
8525
  }
8293
- if (!tables_1.GIT_S4_EXEMPT_SUBS.has(sub)) {
8294
- // Commit messages are prose, never paths.
8526
+ // `git log` prints names and messages, but with patch output it prints the content of the files it
8527
+ // names, as `git show` does: then its operands are read like those of `git show`.
8528
+ if (!tables_1.GIT_S4_EXEMPT_SUBS.has(sub) || (sub === 'log' && gitLogPrintsPatch(args))) {
8529
+ // Commit messages are prose, never paths. A pathspec after `--` or a `REV:path` object is read from history.
8530
+ // log, whatchanged and format-patch take no message: their -m and -F are flags, and the next word is read.
8531
+ const prose = !GIT_PATCH_LOG_SUBS.has(sub);
8532
+ const dashDash = args.findIndex((a) => a.text === '--');
8533
+ const patterns = sub === 'grep' ? searchPatternArgs(args, dashDash >= 0 ? dashDash : args.length) : null;
8295
8534
  for (let k = 0; k < args.length; k += 1) {
8296
8535
  const t = args[k].text;
8297
- if (t === '-m' || t === '--message' || t === '-F' || t === '--file' || (t !== null && /^-[a-zA-Z]*m$/.test(t))) {
8536
+ if (prose && (t === '-m' || t === '--message' || t === '-F' || t === '--file' || (t !== null && /^-[a-zA-Z]*m$/.test(t)))) {
8298
8537
  k += 1;
8299
8538
  continue;
8300
8539
  }
8301
- if (t !== null && /^(?:--message=|-m.)/.test(t))
8540
+ if (prose && t !== null && /^(?:--message=|-m.)/.test(t))
8302
8541
  continue;
8303
- checkSecretOperand(args[k], gctx, run, 'git_operand');
8542
+ const history = (dashDash >= 0 && k > dashDash) || t === null || /^[^/:]+:/.test(t);
8543
+ checkSecretOperand(args[k], gctx, run, 'git_operand', history, patterns !== null && patterns.has(args[k]));
8544
+ // `-L<range>:<file>` (or `-L <range>:<file>`) prints the history of <file>: every part after a colon is read.
8545
+ if (!prose && t !== null && /^-L/.test(t) && (dashDash < 0 || k < dashDash)) {
8546
+ const value = t === '-L' ? (args[k + 1] ? args[k + 1].text : null) : t.slice(2);
8547
+ if (value !== null) {
8548
+ for (let c = value.indexOf(':'); c >= 0; c = value.indexOf(':', c + 1)) {
8549
+ if (c + 1 < value.length)
8550
+ checkSecretOperand(litArg(value.slice(c + 1)), gctx, run, 'git_operand', true);
8551
+ }
8552
+ }
8553
+ }
8554
+ }
8555
+ }
8556
+ }
8557
+ // Subcommands whose output can be the patches of the files they name; none takes a commit message.
8558
+ const GIT_PATCH_LOG_SUBS = new Set(['log', 'whatchanged', 'format-patch']);
8559
+ // Long options of `git log` that print patches (each implies --patch, or prints changed lines: --check).
8560
+ const GIT_LOG_PATCH_LONG = new Set(['--patch', '--patch-with-stat', '--patch-with-raw', '--unified', '--binary', '--check', '--cc', '--dd',
8561
+ '--remerge-diff', '--combined']);
8562
+ // Words that never take the next word as their value (so a `-s` after one of them is an option).
8563
+ const GIT_LOG_FLAG_WORDS = new Set([...GIT_LOG_PATCH_LONG, '-p', '-u', '-s', '-c', '-m', '--no-patch', '--oneline', '--stat', '--all', '--follow',
8564
+ '--graph', '--reverse', '--first-parent', '--no-merges', '--merges', '--name-only', '--name-status', '--raw', '--numstat', '--shortstat',
8565
+ '--summary', '--decorate', '--full-diff', '--color', '--no-color', '--abbrev-commit', '--no-abbrev-commit', '--topo-order', '--date-order',
8566
+ '--no-renames', '--no-ext-diff', '--no-textconv', '--no-diff-merges']);
8567
+ // Whether `git log <args>` prints patches. On: -p, -u, --patch, -U<n>, --unified, -L, --binary, --check, -c,
8568
+ // --cc, --dd, --remerge-diff, --diff-merges=<format> other than off/none, --patch-with-stat/raw, a short cluster
8569
+ // with p, u, U or L (`-sp`), a word the mapper cannot read, and any long option not on the read list (git takes
8570
+ // no abbreviation here, so it cannot be a known one). Off: -s or --no-patch after the last of them, only where the
8571
+ // word cannot be the value of the word before it (`--author -s`) and only before --end-of-options. A word after
8572
+ // `--` is a pathspec (`git log -- -p`).
8573
+ function gitLogPrintsPatch(args) {
8574
+ let on = false;
8575
+ let offs = true;
8576
+ // A word that is not an option itself or holds its value (`--author=x`, `-n5`, `-Sx`) takes no next word.
8577
+ const mayTakeNext = (k) => {
8578
+ if (k < 0)
8579
+ return false;
8580
+ const p = args[k].text;
8581
+ if (p === null)
8582
+ return true;
8583
+ if (!p.startsWith('-') || p === '-' || p.includes('=') || GIT_LOG_FLAG_WORDS.has(p))
8584
+ return false;
8585
+ if (p.startsWith('--'))
8586
+ return true;
8587
+ const m = p.slice(1).match(/[SGOIlLnU]/);
8588
+ return m !== null && m.index === p.length - 2;
8589
+ };
8590
+ for (let k = 0; k < args.length; k += 1) {
8591
+ const t = args[k].text;
8592
+ if (t === '--')
8593
+ break;
8594
+ if (t === null) {
8595
+ on = true;
8596
+ continue;
8597
+ }
8598
+ if (t === '--end-of-options') {
8599
+ offs = false;
8600
+ continue;
8601
+ }
8602
+ const off = offs && !mayTakeNext(k - 1);
8603
+ if (t === '-c' || t === '--no-patch') {
8604
+ if (t === '-c')
8605
+ on = true;
8606
+ else if (off)
8607
+ on = false;
8608
+ continue;
8609
+ }
8610
+ if (t.startsWith('--')) {
8611
+ const eq = t.indexOf('=');
8612
+ const name = eq === -1 ? t : t.slice(0, eq);
8613
+ if (GIT_LOG_PATCH_LONG.has(name))
8614
+ on = true;
8615
+ else if (name === '--diff-merges') {
8616
+ if (!/^--diff-merges=(?:off|none)$/.test(t))
8617
+ on = true;
8618
+ }
8619
+ else if (t.length > 2 && (0, longopts_1.unknownLong)([args[k]], longopts_1.GIT_READ_LONG, 2, false) !== -1)
8620
+ on = true;
8621
+ continue;
8622
+ }
8623
+ // A short word or cluster, left to right: p, u, U<n> and L<range> print patches, s stops them; a letter
8624
+ // that takes a value ends the word (`-Sfoo`, `-n5`).
8625
+ if (/^-[A-Za-z]/.test(t)) {
8626
+ for (const ch of t.slice(1)) {
8627
+ if (ch === 'p' || ch === 'u' || ch === 'U' || ch === 'L')
8628
+ on = true;
8629
+ else if (ch === 's') {
8630
+ if (off)
8631
+ on = false;
8632
+ }
8633
+ if (/[^A-Za-z]|[SGOIlLnUMCBX]/.test(ch))
8634
+ break;
8635
+ }
8304
8636
  }
8305
8637
  }
8638
+ return on;
8306
8639
  }
8307
8640
  // Options that hand git a command to run: rebase --exec, filter-branch filters, bisect run, submodule
8308
8641
  // foreach, and the pack programs of fetch and push.