@getmarrow/mcp 3.9.108 → 3.9.109

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 (93) hide show
  1. package/README.md +16 -1
  2. package/dist/cli.js +26 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/control-notice.d.ts +13 -0
  5. package/dist/control-notice.d.ts.map +1 -0
  6. package/dist/control-notice.js +76 -0
  7. package/dist/control-notice.js.map +1 -0
  8. package/dist/control-routes.d.ts +10 -0
  9. package/dist/control-routes.d.ts.map +1 -0
  10. package/dist/control-routes.js +81 -0
  11. package/dist/control-routes.js.map +1 -0
  12. package/dist/hook-context.d.ts.map +1 -1
  13. package/dist/hook-context.js +34 -11
  14. package/dist/hook-context.js.map +1 -1
  15. package/dist/hook-contract.d.ts +2 -2
  16. package/dist/hook-contract.js +1 -1
  17. package/dist/hook-pre-action.d.ts +2 -0
  18. package/dist/hook-pre-action.d.ts.map +1 -1
  19. package/dist/hook-pre-action.js +86 -20
  20. package/dist/hook-pre-action.js.map +1 -1
  21. package/dist/hook-session.d.ts.map +1 -1
  22. package/dist/hook-session.js +12 -7
  23. package/dist/hook-session.js.map +1 -1
  24. package/dist/hook-tool-policy.d.ts +1 -1
  25. package/dist/hook-tool-policy.d.ts.map +1 -1
  26. package/dist/hook.d.ts.map +1 -1
  27. package/dist/hook.js +19 -14
  28. package/dist/hook.js.map +1 -1
  29. package/dist/ipv4-first.d.ts +7 -0
  30. package/dist/ipv4-first.d.ts.map +1 -0
  31. package/dist/ipv4-first.js +60 -0
  32. package/dist/ipv4-first.js.map +1 -0
  33. package/dist/key-class.d.ts +16 -0
  34. package/dist/key-class.d.ts.map +1 -0
  35. package/dist/key-class.js +37 -0
  36. package/dist/key-class.js.map +1 -0
  37. package/dist/keyless-notice.d.ts +10 -0
  38. package/dist/keyless-notice.d.ts.map +1 -0
  39. package/dist/keyless-notice.js +47 -0
  40. package/dist/keyless-notice.js.map +1 -0
  41. package/dist/normalized-action.d.ts +4 -0
  42. package/dist/normalized-action.d.ts.map +1 -1
  43. package/dist/normalized-action.js +60 -27
  44. package/dist/normalized-action.js.map +1 -1
  45. package/dist/owner-bypass.d.ts +9 -0
  46. package/dist/owner-bypass.d.ts.map +1 -0
  47. package/dist/owner-bypass.js +79 -0
  48. package/dist/owner-bypass.js.map +1 -0
  49. package/dist/redact.d.ts +5 -0
  50. package/dist/redact.d.ts.map +1 -1
  51. package/dist/redact.js +61 -9
  52. package/dist/redact.js.map +1 -1
  53. package/dist/seatbelt/hook.d.ts +3 -1
  54. package/dist/seatbelt/hook.d.ts.map +1 -1
  55. package/dist/seatbelt/hook.js +7 -3
  56. package/dist/seatbelt/hook.js.map +1 -1
  57. package/dist/seatbelt/mapper.d.ts.map +1 -1
  58. package/dist/seatbelt/mapper.js +849 -12
  59. package/dist/seatbelt/mapper.js.map +1 -1
  60. package/dist/seatbelt/patch-text.d.ts +49 -0
  61. package/dist/seatbelt/patch-text.d.ts.map +1 -0
  62. package/dist/seatbelt/patch-text.js +376 -0
  63. package/dist/seatbelt/patch-text.js.map +1 -0
  64. package/dist/seatbelt/tables.d.ts +66 -0
  65. package/dist/seatbelt/tables.d.ts.map +1 -1
  66. package/dist/seatbelt/tables.js +467 -3
  67. package/dist/seatbelt/tables.js.map +1 -1
  68. package/dist/seatbelt/win-protected.d.ts +52 -0
  69. package/dist/seatbelt/win-protected.d.ts.map +1 -0
  70. package/dist/seatbelt/win-protected.js +1279 -0
  71. package/dist/seatbelt/win-protected.js.map +1 -0
  72. package/dist/self-protect.d.ts +18 -0
  73. package/dist/self-protect.d.ts.map +1 -0
  74. package/dist/self-protect.js +185 -0
  75. package/dist/self-protect.js.map +1 -0
  76. package/dist/session-id.d.ts +2 -0
  77. package/dist/session-id.d.ts.map +1 -0
  78. package/dist/session-id.js +32 -0
  79. package/dist/session-id.js.map +1 -0
  80. package/dist/session-loop-guard.d.ts +7 -0
  81. package/dist/session-loop-guard.d.ts.map +1 -1
  82. package/dist/session-loop-guard.js +168 -1
  83. package/dist/session-loop-guard.js.map +1 -1
  84. package/dist/session-marker.d.ts +7 -0
  85. package/dist/session-marker.d.ts.map +1 -0
  86. package/dist/session-marker.js +42 -0
  87. package/dist/session-marker.js.map +1 -0
  88. package/dist/win-private-state.d.ts +117 -0
  89. package/dist/win-private-state.d.ts.map +1 -0
  90. package/dist/win-private-state.js +544 -0
  91. package/dist/win-private-state.js.map +1 -0
  92. package/package.json +1 -1
  93. package/server.json +2 -2
@@ -50,6 +50,7 @@ const secret_paths_1 = require("../secret-paths");
50
50
  const tables_1 = require("./tables");
51
51
  const classifier_pin_1 = require("./classifier-pin");
52
52
  const env_names_1 = require("./env-names");
53
+ const patch_text_1 = require("./patch-text");
53
54
  const longopts_1 = require("./longopts");
54
55
  const guards_1 = require("./guards");
55
56
  const env_dump_1 = require("./env-dump");
@@ -119,7 +120,10 @@ function evaluateSeatbeltShell(command, env) {
119
120
  try {
120
121
  const run = newRun(env, command);
121
122
  run.shell = true;
122
- evalShell(String(command == null ? '' : command), newCtx(run), run);
123
+ if (env.platform === 'win32')
124
+ psTop(String(command == null ? '' : command), run);
125
+ else
126
+ evalShell(String(command == null ? '' : command), newCtx(run), run);
123
127
  return finish(run);
124
128
  }
125
129
  catch {
@@ -142,7 +146,16 @@ function failClosedBare() {
142
146
  }
143
147
  function evaluateSeatbelt(event, env) {
144
148
  try {
145
- return evaluateEvent(event, env);
149
+ const startedAt = Date.now();
150
+ const decision = evaluateEvent(event, env);
151
+ // RC-1 (3.9.106): on win32 only, the Windows S0/S4 stops are added (src/seatbelt/win-protected.ts). They only
152
+ // add hits; every other platform returns the decision unchanged. A throw there is the stop below.
153
+ if (env.platform !== 'win32')
154
+ return decision;
155
+ return require('./win-protected').withWindowsProtected(decision, event, env, {
156
+ shellInvocations, mcpCall, pathInputs, walkPathInputs, patchPaths, protectedExtra: SEATBELT_PROTECTED_EXTRA, operandOmits: OPERAND_RULE_OMITS,
157
+ tools: { read: READ_TOOLS, grep: GREP_TOOLS, write: WRITE_TOOLS, delete: DELETE_TOOLS, list: LIST_TOOLS, inert: INERT_TOOLS, nonShell: NON_SHELL_TOOLS, nativeShell: NATIVE_SHELL_EQUIVALENTS },
158
+ }, startedAt);
146
159
  }
147
160
  catch {
148
161
  try {
@@ -222,11 +235,16 @@ function evaluateEvent(event, env) {
222
235
  exhaust(run, 'budget_time');
223
236
  run.lineSource = [...new Set(invocation.plain)].join('\n');
224
237
  const lastGroup = invocation.groups[invocation.groups.length - 1];
238
+ // A3: PowerShell / cmd text is the PowerShell tool's, and on native Windows every shell tool's (the host shell may be either).
239
+ const psTopMode = env.platform === 'win32' || evName === 'powershell' || evName === 'cmd';
225
240
  invocation.shell.forEach((text, k) => {
226
241
  if (clock(run))
227
242
  return;
228
243
  run.topLast = invocation.groups[k] === lastGroup;
229
- evalShell(text, newCtx(run), run);
244
+ if (psTopMode)
245
+ psTop(text, run);
246
+ else
247
+ evalShell(text, newCtx(run), run);
230
248
  });
231
249
  run.topLast = true;
232
250
  for (const code of invocation.code) {
@@ -1010,6 +1028,8 @@ function newRun(rawEnv, source, deadline) {
1010
1028
  scriptExpansions: 0,
1011
1029
  deadline: deadline !== undefined ? deadline : deadlineFor(env),
1012
1030
  exhausted: false,
1031
+ psSteps: 0,
1032
+ psOver: false,
1013
1033
  links: new Map(),
1014
1034
  linksUnknown: false,
1015
1035
  headMayChange: false,
@@ -1035,7 +1055,7 @@ function newCtx(run) {
1035
1055
  const cwd = typeof run.env.cwd === 'string' && path.posix.isAbsolute(run.env.cwd) ? path.posix.normalize(run.env.cwd) : null;
1036
1056
  return {
1037
1057
  cwds: [cwd], vars: new Map([['HOME', [run.home]]]), exported: new Set(['HOME']), aliases: new Map(), gitAliases: new Map(),
1038
- gitConfig: new Map(), written: new Set(), writtenFrom: new Map(), depth: 0, remote: false, kubeContext: undefined, tfWorkspace: undefined, pulumiStack: undefined,
1058
+ gitConfig: new Map(), written: new Set(), writtenFrom: new Map(), checkedOut: new Set(), depth: 0, remote: false, kubeContext: undefined, tfWorkspace: undefined, pulumiStack: undefined,
1039
1059
  decodedVars: new Set(), candidates: new Map(), fds: new Map(), ifs: undefined, backstop: false, arrays: new Set(),
1040
1060
  };
1041
1061
  }
@@ -1145,13 +1165,17 @@ function finish(run) {
1145
1165
  if (run.fsState.errored)
1146
1166
  hit(run, 'Sx', 'fs_error');
1147
1167
  const hits = [...run.hits].sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category));
1168
+ // A3: within the first category, the A3 reasons lead, and then the decision's rule is `<category>.<reason>`.
1169
+ const a3 = hits.length ? hits.findIndex((h) => h.category === hits[0].category && tables_1.A3_REASONS.has(h.reason)) : -1;
1170
+ if (a3 > 0)
1171
+ hits.unshift(...hits.splice(a3, 1));
1148
1172
  const category = hits.length ? hits[0].category : 'none';
1149
1173
  if (run.parse !== 'ok')
1150
1174
  run.flags.add(`parse_${run.parse}`);
1151
1175
  return {
1152
1176
  decision: hits.length ? 'stop' : 'pass',
1153
1177
  category,
1154
- rule: RULE_IDS[category],
1178
+ rule: a3 >= 0 ? `${category}.${hits[0].reason}` : RULE_IDS[category],
1155
1179
  reasons: hits.map((h) => h.reason),
1156
1180
  hits,
1157
1181
  flags: [...run.flags].sort(),
@@ -2198,9 +2222,22 @@ function evalShell(src, ctx, run) {
2198
2222
  tripwire(src.slice(0, tables_1.LIMITS.maxCommandBytes), run);
2199
2223
  return;
2200
2224
  }
2225
+ // A3: shell text that looks like PowerShell with script blocks (`Get-ChildItem | % { ri $_ -r }`): its blocks are statements too.
2226
+ if (ctx.ps === undefined && tables_1.PS_SNIFF.test(src)) {
2227
+ const flat = (0, tables_1.psFlatten)((0, tables_1.psClean)(src));
2228
+ if (flat !== src)
2229
+ psProbe(flat, childCtx(ctx, { ps: 1 }), run);
2230
+ psLexical((0, tables_1.psNormalize)(src), (0, tables_1.psClean)(src), ctx, run, false);
2231
+ }
2232
+ // A3: a .NET / COM delete of a whole tree (the cheap word test first; the text is only scanned when it says "delete").
2233
+ if (/delete|directory\]\s*::/i.test(src) && (0, tables_1.dotNetTreeDelete)((0, tables_1.psClean)(src), ctx.ps !== undefined))
2234
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
2201
2235
  const parsed = safeParse(src, { depth: ctx.depth });
2202
2236
  if (!parsed.ok) {
2203
2237
  run.headMayChange = true;
2238
+ // A3: PowerShell syntax bash cannot read: the delete verb, `iex` or `& $x` in it is still a stop.
2239
+ if (ctx.ps === undefined)
2240
+ psUnparsed(src, run);
2204
2241
  const big = parsed.error === 'too_large' || parsed.error === 'too_many_commands' || parsed.error === 'parse_cost';
2205
2242
  worseParse(run, big ? 'too_large' : 'unparsed');
2206
2243
  tripwire(src.slice(0, tables_1.LIMITS.maxCommandBytes), run);
@@ -2907,6 +2944,13 @@ function dispatch(args, ctx, run, x) {
2907
2944
  const prog = progName(args[0], run);
2908
2945
  if (prog === null)
2909
2946
  return dynamicProgram(args, ctx, run, x);
2947
+ // A3b: on native Windows the program is also read by its Windows name (`C:\Program Files\Git\cmd\git.exe`, `GIT.EXE`):
2948
+ // a second reading next to the unchanged one, so it can only add stops.
2949
+ if (run.env.platform === 'win32' && args[0].text !== null) {
2950
+ const winProg = (0, tables_1.windowsProgramName)(args[0].text);
2951
+ if (winProg !== prog && winProg !== '')
2952
+ dispatchProgram(winProg, args, args.slice(1), false, ctx, run, x);
2953
+ }
2910
2954
  // A glob in the command word names whatever matches it at run time (`/bin/s[h]`, `np[m]`): read each
2911
2955
  // match as the program, and stop when it matches nothing the mapper can list (audit N1).
2912
2956
  if (args[0].glob && args[0].text !== null && args[0].text !== '[' && args[0].text !== '[[' && /[*?[]/.test(args[0].text)) {
@@ -2933,6 +2977,8 @@ function dispatch(args, ctx, run, x) {
2933
2977
  return dispatchProgram(prog, args, rest, pathLike, ctx, run, x);
2934
2978
  }
2935
2979
  function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
2980
+ // A3: delete verbs, pwsh / cmd / iex / Start-Process text. Adds hits only; the program is then handled as before.
2981
+ windowsShell(args, rest, ctx, run, x);
2936
2982
  // Task runners and builders run project code the mapper does not read (audit T1/T3).
2937
2983
  if (tables_1.HEAD_RUNNERS.has(prog) || tables_1.HEAD_CHANGERS.has(prog))
2938
2984
  run.headMayChange = true;
@@ -2954,6 +3000,11 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
2954
3000
  zipCommandTexts(rest, ctx, run);
2955
3001
  // I3: programs that change something outside this machine (held only while Marrow is unreachable).
2956
3002
  outageRiskTags(prog, rest, run);
3003
+ // WK-5: the dashed spellings of `git apply` and `git am`. They were unknown programs on master, which read a file redirected into them as a script and marked the head as
3004
+ // changeable: that scan still runs after this. They are no `case` label and no KNOWN_COMMANDS member on purpose: the keyless normalizer keeps its own copy of that table
3005
+ // (src/normalized-action.ts, test/n5-full-scope.test.js rule P) and test/seatbelt-patch-r2.test.js wants every case label in the table.
3006
+ if (prog === 'git-apply' || prog === 'git-am')
3007
+ patchFamilyWrites(prog === 'git-am' ? 'am' : 'apply', rest, ctx, run, x, new Map());
2957
3008
  switch (prog) {
2958
3009
  case 'cd':
2959
3010
  case 'pushd': return changeDir(rest, ctx, run, x.cmd.subshell);
@@ -3085,6 +3136,8 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
3085
3136
  }
3086
3137
  return;
3087
3138
  }
3139
+ // WK-5: the files these write are named inside the patch text.
3140
+ case 'patch': return patchFamilyWrites('patch', rest, ctx, run, x);
3088
3141
  default: break;
3089
3142
  }
3090
3143
  // An awk by a name the table does not have (`original-awk`, `goawk`, `my-awk`): read as awk, and then as the unknown program it also is (as `nawk` is).
@@ -3302,6 +3355,9 @@ function dynamicProgram(args, ctx, run, x) {
3302
3355
  function isPureSubstitution(a) {
3303
3356
  return a.text === null && a.pattern === DYN && !!a.word && a.word.slots.length === 1 && a.word.slots[0].type === 'sub';
3304
3357
  }
3358
+ // The dash spellings of `git apply` and `git am` have a handler (the switch in dispatch) but are not in KNOWN_COMMANDS: the keyless normalizer keeps its own
3359
+ // copy of that table (src/normalized-action.ts) and test/n5-full-scope.test.js rule P needs the two to agree, so the names stay out of it. A wrapper's tail still reads them.
3360
+ const PATCH_DASH_PROGRAMS = new Set(['git-apply', 'git-am']);
3305
3361
  // A word that starts a command the mapper has a handler for (or a script path).
3306
3362
  function isCommandWord(a, run) {
3307
3363
  if (a.text === null)
@@ -3309,8 +3365,13 @@ function isCommandWord(a, run) {
3309
3365
  const base = progName(a, run) || '';
3310
3366
  if (NESTED_NOT_PROGRAMS.has(base))
3311
3367
  return false;
3312
- if (tables_1.KNOWN_COMMANDS.has(base) || /^python3\.\d+$/.test(base) || /^mkfs(?:\.|$)/.test(base))
3368
+ if (tables_1.KNOWN_COMMANDS.has(base) || PATCH_DASH_PROGRAMS.has(base) || /^python3\.\d+$/.test(base) || /^mkfs(?:\.|$)/.test(base))
3313
3369
  return true;
3370
+ if (run.env.platform === 'win32') {
3371
+ const win = (0, tables_1.windowsProgramName)(a.text);
3372
+ if (win !== base && (tables_1.KNOWN_COMMANDS.has(win) || PATCH_DASH_PROGRAMS.has(win)) && !NESTED_NOT_PROGRAMS.has(win))
3373
+ return true;
3374
+ }
3314
3375
  if (a.text.includes('/')) {
3315
3376
  const name = base.toLowerCase();
3316
3377
  if (tables_1.NON_SCRIPT_NAMES.has(name))
@@ -3583,7 +3644,7 @@ function unknownProgram(prog, args, ctx, run, x) {
3583
3644
  if (tables_1.INERT_PROGRAMS.has(prog) || ctx.backstop)
3584
3645
  return;
3585
3646
  const rest = args.slice(1);
3586
- scanArgvForCommands(rest, ctx, run, x, false, env_dump_1.EXEC_WRAPPERS.has(prog));
3647
+ scanArgvForCommands(rest, tables_1.PS_OUTPUT_CMDLETS.has(prog.toLowerCase()) || (ctx.ps !== undefined && tables_1.PS_BLOCK_WORDS.has(prog.toLowerCase())) ? { ...ctx, a3Quiet: true } : ctx, run, x, false, env_dump_1.EXEC_WRAPPERS.has(prog));
3587
3648
  // stdin text: `echo 'npm publish' | at now`, `at now <<< 'npm publish'`, `batch <<EOF …`.
3588
3649
  const src = stdinSource(x, ctx, run);
3589
3650
  // Decoded text piped into a program that runs what it reads (at, batch, crontab, a function) or
@@ -3741,12 +3802,15 @@ function fetcherCommand(cmd) {
3741
3802
  // A shell or interpreter that reads its program from stdin: a heredoc, a here-string (audit F7), or a
3742
3803
  // pipeline. The Q4 pass (`curl … | sh`, flag remote_exec) covers exactly a fetcher that is the stage
3743
3804
  // directly upstream of the shell (audit F8). Any other stage is classified by what it emits.
3744
- function stdinExec(prog, rest, x, ctx, run, viaCode = false) {
3745
- const asShell = tables_1.SHELLS.has(prog) || prog === 'sh';
3805
+ function stdinExec(prog, rest, x, ctx, run, viaCode = false, ps = false) {
3806
+ // ps (A3): the program is pwsh / powershell / cmd: its stdin text and script file are PowerShell / cmd text.
3807
+ const asShell = ps || tables_1.SHELLS.has(prog) || prog === 'sh';
3746
3808
  // `viaCode`: interpreter code that reads its own stdin and runs it (`python3 -c 'exec(sys.stdin.read())'`):
3747
3809
  // the text may be shell text for os.system as well as code.
3748
3810
  const code = (text) => {
3749
- if (asShell)
3811
+ if (ps)
3812
+ psNested(text, ctx, run);
3813
+ else if (asShell)
3750
3814
  evalShell(text, childCtx(ctx), run);
3751
3815
  else {
3752
3816
  scanInterpreterCode(text, run);
@@ -3757,6 +3821,10 @@ function stdinExec(prog, rest, x, ctx, run, viaCode = false) {
3757
3821
  // A file the program reads as its program text (`bash -s < f`, `python3 < f`, or a file that a pipe
3758
3822
  // into a group carries): a shell script is evaluated, interpreter code is scanned.
3759
3823
  const fileCode = (f) => {
3824
+ if (ps) {
3825
+ powershellScript(f, ctx, run);
3826
+ return;
3827
+ }
3760
3828
  if (asShell) {
3761
3829
  scriptFile(f, rest, ctx, run, x);
3762
3830
  return;
@@ -4737,11 +4805,51 @@ function checkProtectedWrite(a, ctx, run, reason, tree) {
4737
4805
  }
4738
4806
  return false;
4739
4807
  }
4740
- function markWritten(a, ctx) {
4808
+ function markWritten(a, ctx, into = ctx.written) {
4741
4809
  if (a.text === null)
4742
4810
  return;
4743
4811
  for (const c of ctx.cwds)
4744
- ctx.written.add(path.posix.resolve(c || '/__unknown_cwd__', a.text));
4812
+ into.add(path.posix.resolve(c || '/__unknown_cwd__', a.text));
4813
+ }
4814
+ // WK-5 patch 2: what `git checkout` and `git restore` rewrite on this line, for the patch readers only (the script,
4815
+ // package.json and Makefile readers use `ctx.written` and keep their answers). One plain file name adds its path, and
4816
+ // the reader tests a patch file by prefix, so a folder named here (`patches`, `.`) covers every file under it. A word
4817
+ // that is not one plain name makes the whole line a rewrite of anything (CHECKED_OUT_ANY, never an absolute path): a
4818
+ // variable or substitution, a glob (quoted too: git reads a pathspec as a glob), an empty word, pathspec magic (`:/`,
4819
+ // `:(glob)…`), a name that cannot be made absolute, or `--pathspec-from-file` (any abbreviation git reads), whose list
4820
+ // the mapper does not read.
4821
+ const CHECKED_OUT_ANY = '*';
4822
+ function markCheckedOut(args, ctx, run) {
4823
+ // After a bare `--` every word is a path, even one that starts with a dash (`git checkout main -- -x.diff`).
4824
+ let paths = false;
4825
+ for (const a of args) {
4826
+ const lead = a.text === null ? a.pattern : a.text;
4827
+ if (!paths && lead === '--') {
4828
+ paths = true;
4829
+ continue;
4830
+ }
4831
+ if (!paths && lead.startsWith('-')) {
4832
+ const long = lead.split('=')[0];
4833
+ if (long.length > 2 && '--pathspec-from-file'.startsWith(long))
4834
+ ctx.checkedOut.add(CHECKED_OUT_ANY);
4835
+ continue;
4836
+ }
4837
+ const abs = a.text === null || a.glob || a.text === '' || a.text.startsWith(':') || /[*?[\\{]/.test(a.text) ? null : absCandidates(a.text, ctx, run);
4838
+ if (abs === null) {
4839
+ ctx.checkedOut.add(CHECKED_OUT_ANY);
4840
+ continue;
4841
+ }
4842
+ markWritten(a, ctx, ctx.checkedOut);
4843
+ for (const p of abs)
4844
+ ctx.checkedOut.add(p);
4845
+ }
4846
+ }
4847
+ // True when a patch file at `p` may have been rewritten by a `git checkout` or `git restore` earlier on this line.
4848
+ function checkedOutCovers(ctx, p) {
4849
+ if (ctx.checkedOut.has(CHECKED_OUT_ANY))
4850
+ return true;
4851
+ const base = path.posix.basename(p);
4852
+ return [...ctx.checkedOut].some((w) => path.posix.basename(w) === base || isAncestorOrSelf(w, p));
4745
4853
  }
4746
4854
  // ---------------------------------------------------------------------------------------------
4747
4855
  // S5 workspace boundary
@@ -6185,6 +6293,197 @@ function targetDirFlag(rest) {
6185
6293
  return null;
6186
6294
  }
6187
6295
  // ---------------------------------------------------------------------------------------------
6296
+ // WK-5: `patch`, `git apply` and `git am` write the files their patch text names.
6297
+ //
6298
+ // The names are inside the patch file, not in the command, so the mapper reads a literal patch file (the bounded,
6299
+ // no-symlink reader the script checks use) and checks every name like the destination of tee, cp and mv. What it
6300
+ // cannot know for certain is an unknown write: S0, reason `<label>_unknown_<why>`, never a pass. That covers patch
6301
+ // text on stdin (heredoc, here-string, pipe, process substitution, a group or shell that feeds it), a dynamic or glob
6302
+ // word, an unreadable, missing, non-regular, oversize (LIMITS.maxPatchBytes) or just-written file, a header the
6303
+ // reader doubts, an option the parser does not list, and a working folder or git work tree the mapper cannot name.
6304
+ // A dry run (`patch --dry-run`, `git apply --check/--stat/--numstat/--summary`) writes nothing and keeps the old
6305
+ // answer, but only when every option is known, none repeats, negates or turns the apply back on, and the call has
6306
+ // no extra output file.
6307
+ //
6308
+ // Residual (same as the script-file reads): a patch file rewritten earlier in the same command line by a program the
6309
+ // mapper does not model (an interpreter, an unknown tool) is read as it is on disk now.
6310
+ function patchFamilyWrites(kind, args, ctx, run, x, overrides) {
6311
+ // Files on another machine (`ssh host 'patch …'`) are not local files: the answer stays what it was (like protectedOperandCheck).
6312
+ if (ctx.remote)
6313
+ return;
6314
+ const label = kind === 'patch' ? 'patch_write' : kind === 'apply' ? 'git_apply_write' : 'git_am_write';
6315
+ const unknown = (why) => { hit(run, 'S0', `${label}_unknown_${why}`); };
6316
+ const ownStdin = x.cmd.heredoc !== null || x.cmd.redirects.some((r) => r.op.startsWith('<')) || x.info.upstream.length > 0 || Boolean(x.info.lost) || Boolean(ctx.stdinFeed);
6317
+ const readsFile = (text) => (absCandidates(text, ctx, run) || []).some((p) => readCached(run, p, tables_1.LIMITS.maxPatchBytes) !== null);
6318
+ // Reached through the tail-position scan of an unknown wrapper, a bare `patch` word is data (`poetry version patch`,
6319
+ // `semver -i patch 1.2.3`): with no option, no stdin source and no operand that is a file, it is not a patch call.
6320
+ if (kind === 'patch' && ctx.backstop && !ownStdin && args.every((a) => a.text !== null && !a.glob && !a.text.startsWith('-') && !readsFile(a.text)))
6321
+ return;
6322
+ if (args.some((a) => a.text === null || a.glob)) {
6323
+ unknown('dynamic_word');
6324
+ return;
6325
+ }
6326
+ const call = (0, patch_text_1.parsePatchCall)(kind, args.map((a) => a.text));
6327
+ if (!call.ok) {
6328
+ unknown(call.why);
6329
+ return;
6330
+ }
6331
+ if (call.info || call.readOnly)
6332
+ return;
6333
+ if (ctx.cwds.length === 0 || ctx.cwds.some((c) => c === null) || ctx.cwdStrict === true) {
6334
+ unknown('cwd');
6335
+ return;
6336
+ }
6337
+ if (kind !== 'patch') {
6338
+ // Another repository, work tree or configuration (`--work-tree`, GIT_WORK_TREE, core.worktree): the files land elsewhere.
6339
+ const moved = (overrides !== undefined && overrides.has(CONFIG_UNKNOWN))
6340
+ || ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE'].some((name) => envValue(x, ctx, name) !== undefined);
6341
+ if (moved) {
6342
+ unknown('git_repository');
6343
+ return;
6344
+ }
6345
+ for (const c of ctx.cwds) {
6346
+ const configured = gitAll(run, ctx, c, 'core.worktree', overrides);
6347
+ if (configured === null || configured.length) {
6348
+ unknown('git_worktree');
6349
+ return;
6350
+ }
6351
+ }
6352
+ }
6353
+ const dir = call.dirs.length === 1 && call.dirs[0] !== '' ? call.dirs[0] : null;
6354
+ // `..` in the folder is a form the reader does not follow (git apply refuses it; patch writes outside the tree).
6355
+ if (dir !== null && dir.split('/').includes('..')) {
6356
+ unknown('dotdot');
6357
+ return;
6358
+ }
6359
+ // Where the patch comes from. patch: -i FILE, or its second operand, or stdin; its first operand is a file it writes.
6360
+ // git: every operand is a patch file (`-` is stdin), none at all is stdin.
6361
+ let patchFiles = [];
6362
+ let fromStdin = false;
6363
+ let originals = [];
6364
+ if (kind === 'patch') {
6365
+ if (call.inputs.length === 1) {
6366
+ originals = call.operands;
6367
+ if (call.inputs[0] === '-')
6368
+ fromStdin = true;
6369
+ else
6370
+ patchFiles = [call.inputs[0]];
6371
+ }
6372
+ else if (call.operands.length > 2) {
6373
+ unknown('operands');
6374
+ return;
6375
+ }
6376
+ else if (call.operands.length === 2) {
6377
+ originals = [call.operands[0]];
6378
+ if (call.operands[1] === '-')
6379
+ fromStdin = true;
6380
+ else
6381
+ patchFiles = [call.operands[1]];
6382
+ }
6383
+ else {
6384
+ originals = call.operands;
6385
+ fromStdin = true;
6386
+ }
6387
+ // GNU patch changes directory first, then opens -i FILE: which folder a relative file is read from is not certain.
6388
+ if (dir !== null && patchFiles.some((f) => !f.startsWith('/') && !f.startsWith('~'))) {
6389
+ unknown('dir_relative_input');
6390
+ return;
6391
+ }
6392
+ }
6393
+ else {
6394
+ patchFiles = call.operands.filter((o) => o !== '-');
6395
+ fromStdin = call.operands.length === 0 || call.operands.includes('-');
6396
+ }
6397
+ const files = patchFiles.map((f) => litArg(f));
6398
+ if (fromStdin) {
6399
+ // Exactly one plain `< file` on this command, and nothing else feeding stdin.
6400
+ const ins = x.cmd.redirects.filter((r) => r.op.startsWith('<'));
6401
+ const one = ins.length === 1 && ins[0].op === '<' && (ins[0].fd === null || ins[0].fd === '0') && ins[0].target && !ins[0].target.substitution ? ins[0].target : null;
6402
+ const target = one ? resolveWord(one, ctx, run) : null;
6403
+ if (x.cmd.heredoc !== null || x.info.upstream.length > 0 || x.info.lost || ctx.stdinFeed || target === null || target.text === null || target.glob) {
6404
+ unknown('stdin');
6405
+ return;
6406
+ }
6407
+ files.push(litArg(target.text));
6408
+ }
6409
+ const texts = [];
6410
+ for (const f of files) {
6411
+ const abs = absCandidates(f.text, ctx, run);
6412
+ if (!abs || abs.length === 0 || abs.some((p) => p.startsWith('/__unknown_cwd__'))) {
6413
+ unknown('path');
6414
+ return;
6415
+ }
6416
+ for (const p of abs) {
6417
+ // A file this command line writes first (or a name `ln -s` made) is not the file the mapper can read now.
6418
+ const base = path.posix.basename(p);
6419
+ if (ctx.written.has(p) || [...ctx.written].some((w) => path.posix.basename(w) === base) || checkedOutCovers(ctx, p)) {
6420
+ unknown('written_here');
6421
+ return;
6422
+ }
6423
+ if (run.links.size > 0 && applyLinks(p, run) !== p) {
6424
+ unknown('link');
6425
+ return;
6426
+ }
6427
+ const text = readCached(run, p, tables_1.LIMITS.maxPatchBytes);
6428
+ if (text === null) {
6429
+ unknown('unreadable');
6430
+ return;
6431
+ }
6432
+ texts.push(text);
6433
+ }
6434
+ }
6435
+ const writes = new Set([...call.extraWrites, ...originals]);
6436
+ for (const text of texts) {
6437
+ const names = (0, patch_text_1.readPatchNames)(text, kind === 'am');
6438
+ if (names.doubt !== null) {
6439
+ unknown(`header_${names.doubt}`);
6440
+ return;
6441
+ }
6442
+ for (const name of (0, patch_text_1.targetNames)(names.names, kind, call.strip))
6443
+ writes.add(name);
6444
+ }
6445
+ // -d / --directory: the name is also read under that folder.
6446
+ const all = new Set();
6447
+ for (const w of writes) {
6448
+ all.add(w);
6449
+ if (dir !== null)
6450
+ all.add(`${dir}/${w}`);
6451
+ }
6452
+ if (all.size > patch_text_1.MAX_NAMES * 4) {
6453
+ unknown('too_many_targets');
6454
+ return;
6455
+ }
6456
+ // `git am` always, and `git apply` for a git diff run in a sub-folder, name files from the top of the work tree, not from
6457
+ // the working folder (apply keeps the ones under that folder). A home-anchored protected path (`~/.local/bin/**`) is then
6458
+ // read under the top as well as under the folder, for every folder the call may run in. A top that cannot be found is
6459
+ // an unknown write.
6460
+ const tops = [];
6461
+ if (kind !== 'patch') {
6462
+ for (const c of ctx.cwds) {
6463
+ let top;
6464
+ try {
6465
+ top = run.env.git.toplevel(c);
6466
+ }
6467
+ catch {
6468
+ unknown('git_toplevel');
6469
+ return;
6470
+ }
6471
+ if (top !== null && path.posix.isAbsolute(top) && top !== c && !tops.includes(top))
6472
+ tops.push(top);
6473
+ }
6474
+ }
6475
+ for (const w of all) {
6476
+ if (clock(run))
6477
+ return;
6478
+ const arg = litArg(w);
6479
+ checkProtectedWrite(arg, ctx, run, label, false);
6480
+ if (!w.startsWith('/') && !w.startsWith('~'))
6481
+ for (const top of tops)
6482
+ checkProtectedWrite(litArg(path.posix.join(top, w)), ctx, run, label, false);
6483
+ markWritten(arg, ctx);
6484
+ }
6485
+ }
6486
+ // ---------------------------------------------------------------------------------------------
6188
6487
  // builtins
6189
6488
  // `cd /proc/$$`, `cd /proc/$(pgrep node)/task/*`, `cd $PID` from `/proc`: a target that is a process or thread id (a dynamic part or a glob) under
6190
6489
  // `/proc` is also read as `self`, so a later `cat environ` names the process file. The unknown folder stays possible, as before.
@@ -9436,7 +9735,13 @@ function handleGit(rest, ctx, run, x, cOverrides, aliasDepth) {
9436
9735
  for (const a of args)
9437
9736
  if (a.text === null || !a.text.startsWith('-'))
9438
9737
  checkProtectedWrite(a, gctx, run, `git_${sub}`, sub === 'rm' || sub === 'mv');
9738
+ // WK-5: checkout and restore rewrite the files they name (a ref among the words only adds a name nothing reads).
9739
+ if (sub === 'checkout' || sub === 'restore')
9740
+ markCheckedOut(args, gctx, run);
9439
9741
  }
9742
+ // WK-5: `git apply` and `git am` write the files their patch text names.
9743
+ if (sub === 'apply' || sub === 'am')
9744
+ patchFamilyWrites(sub, args, gctx, run, x, overrides);
9440
9745
  // `git mv link y` moves the symlink like `mv` does (audit N3).
9441
9746
  if (sub === 'mv')
9442
9747
  carryLinks('mv', args, gctx, run);
@@ -11752,4 +12057,536 @@ function marrowExtract(text) {
11752
12057
  out[k] = v;
11753
12058
  return JSON.stringify(Object.keys(out).sort().map((k) => [k, out[k]]));
11754
12059
  }
12060
+ // ---------------------------------------------------------------------------------------------
12061
+ // A3: PowerShell and cmd text (Windows hotfix). Every function here only ADDS hits: the unchanged readings of the
12062
+ // same text still run, so no existing stop can go away.
12063
+ const DYN_ALL = new RegExp(DYN, 'g');
12064
+ const DYN_PLACEHOLDER = '${__marrow_dynamic}';
12065
+ /** An argument as text: the literal, or the literal parts with a placeholder for each unknown piece. */
12066
+ function dynText(a) { return a.text !== null ? a.text : a.pattern.replace(DYN_ALL, DYN_PLACEHOLDER); }
12067
+ /** The event's own shell text on native Windows or from the PowerShell / cmd tool: the usual reading, as PowerShell / cmd text. */
12068
+ function psTop(text, run) {
12069
+ const ctx = newCtx(run);
12070
+ ctx.ps = 1;
12071
+ evalShell(text, ctx, run);
12072
+ psExtras(text, ctx, run);
12073
+ }
12074
+ /** Text a PowerShell or cmd command line runs: pwsh -c, cmd /c, iex, Start-Process, a decoded -EncodedCommand, a script file. */
12075
+ function psNested(text, ctx, run) {
12076
+ if (clock(run))
12077
+ return;
12078
+ const level = (ctx.ps || 0) + 1;
12079
+ if (level > tables_1.LIMITS.maxPsDepth) {
12080
+ worseParse(run, 'too_large');
12081
+ run.flags.add('depth_limit');
12082
+ hit(run, 'Sx', 'budget_depth');
12083
+ return;
12084
+ }
12085
+ const c = childCtx(ctx, { ps: level });
12086
+ evalShell(text, c, run);
12087
+ psExtras(text, c, run);
12088
+ }
12089
+ /** Code text of a pwsh -Command / cmd /c: a command line that is nothing but unknown pieces cannot be read. */
12090
+ function psCommandText(text, ctx, run) {
12091
+ if (text.trim() === '')
12092
+ return;
12093
+ if (text.split(DYN_PLACEHOLDER).join('').trim() === '') {
12094
+ worseParse(run, 'dynamic');
12095
+ hit(run, 'Sx', 'dynamic_powershell_code');
12096
+ return;
12097
+ }
12098
+ psNested(text, ctx, run);
12099
+ }
12100
+ // The other readings of PowerShell / cmd text, next to the one evalShell already did: the text without its escape
12101
+ // characters and (on native Windows) with its backslashes kept, both as complete readings; the text with its block braces
12102
+ // turned into line breaks as a probe (only the A3 hits count, so `{ $_.Name }` does not become a dynamic command); and the
12103
+ // stops for text bash cannot read, for `& $x`, and for tree deletes through .NET.
12104
+ function psExtras(text, c, run) {
12105
+ if (run.exhausted || Buffer.byteLength(text) > tables_1.LIMITS.maxCommandBytes)
12106
+ return;
12107
+ const cleaned = (0, tables_1.psClean)(text);
12108
+ const flat = (0, tables_1.psFlatten)(cleaned);
12109
+ // PowerShell keeps a backslash as a plain character, even before a quote: the readings with every backslash doubled show
12110
+ // the text as PowerShell reads it (`"C:\"` is a whole string, `C:\tools\git.exe` one program word).
12111
+ const keep = (t) => t.replace(/\\/g, '\\\\');
12112
+ const faithful = new Set([keep(text), keep(cleaned)]);
12113
+ const full = new Set(faithful);
12114
+ full.add(cleaned);
12115
+ full.delete(text);
12116
+ for (const reading of full) {
12117
+ if (clock(run))
12118
+ return;
12119
+ evalShell(reading, childCtx(c), run);
12120
+ }
12121
+ if (flat !== cleaned && flat !== text)
12122
+ psProbe(flat, c, run);
12123
+ psLexical((0, tables_1.psNormalize)(text), cleaned, c, run, true);
12124
+ if (tables_1.PS_DYNAMIC_CALL.test(cleaned) || (0, tables_1.psDynamicCode)(cleaned)) {
12125
+ worseParse(run, 'dynamic');
12126
+ hit(run, 'Sx', 'dynamic_powershell_code');
12127
+ }
12128
+ if ((0, tables_1.psDefaultParameters)(cleaned) || PS_ALIAS_DRIVE.test(cleaned))
12129
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12130
+ }
12131
+ // An alias made through the Alias: drive (`Set-Item Alias:z Remove-Item`, `New-Item -Path Alias:z`, `${alias:z} = ...`) hides a delete verb like Set-Alias does.
12132
+ const PS_ALIAS_DRIVE = /(?:\b(?:set-item|new-item|copy-item|rename-item|move-item|set-content|si|ni|cpi|rni|mi|sc)\b[^\n;|]{0,200}\balias:|\$\{alias:)/i;
12133
+ // The always-on lexical check of PowerShell / cmd text (rules i to iv and vi), independent of how bash parses it: a PowerShell
12134
+ // reading and a cmd reading of the statements, with quotes as data. A delete verb is judged where a command starts (the first
12135
+ // word of a statement, or any word of an if / for / foreach / call / start statement). Text the tokenizer does not read at all
12136
+ // (an unterminated quote, a here-string, a block comment) stops when it holds a delete verb or `iex` anywhere.
12137
+ function psLexical(raw, cleaned, c, run, inPs) {
12138
+ if (run.psOver)
12139
+ return;
12140
+ let readable = true;
12141
+ for (const cmdMode of [false, true]) {
12142
+ if (clock(run))
12143
+ return;
12144
+ // The tokenizer reads the escape characters (backtick, caret) itself, so an escaped quote keeps its meaning.
12145
+ const { statements, ok } = (0, tables_1.psStatements)(raw, cmdMode);
12146
+ if (!ok)
12147
+ readable = false;
12148
+ for (const st of statements) {
12149
+ const w = st.words;
12150
+ // Work is counted per statement and per word, and a statement of more words than one command may have stops like it does.
12151
+ if (psSpend(run, 1 + w.length, w.length > tables_1.LIMITS.maxWords))
12152
+ return;
12153
+ const open = tables_1.PS_KEYWORDS.has(w[0].toLowerCase());
12154
+ // An argument list written as an array (`@('/c', 'rd /s /q x')`) is a statement of its own that starts with the switch.
12155
+ if (w.length > 1 && /^[/-][a-z]/i.test(w[0])) {
12156
+ const launched = /^\//.test(w[0]) ? cmdSwitchText(w.map(litArg)) : ((0, tables_1.psOption)(w[0])?.kind === 'command' ? w.slice(1).join(' ') : null);
12157
+ if (launched !== null && launched.trim() !== '')
12158
+ psCommandText(launched, c, run);
12159
+ }
12160
+ // Both answers that look at the words after a program word are read from one pass over the statement, not one per word.
12161
+ let deleteStops = null;
12162
+ let switchAt = null;
12163
+ for (let p = 0; p < (open ? w.length : 1); p += 1) {
12164
+ if ((p & 63) === 63 && clock(run))
12165
+ return;
12166
+ const name = (0, tables_1.windowsProgramName)(w[p]);
12167
+ const prev = p > 0 ? w[p - 1].toLowerCase() : '';
12168
+ // cmd: a command word that is a variable (`%X%`, `!X!`, a loop variable `%a`) can be anything. After if / for ... the
12169
+ // command starts after the condition, so a variable that is followed by an option (`/s`, `-r`) counts too.
12170
+ if (cmdMode && CMD_DYNAMIC_WORD.test(w[p]) && (p === 0 || prev === 'call' || prev === 'do' || prev === 'then' || prev === 'else' || prev === 'start'
12171
+ || (open && w[p + 1] !== undefined && /^[/-]/.test(w[p + 1])))) {
12172
+ worseParse(run, 'dynamic');
12173
+ hit(run, 'Sx', 'dynamic_powershell_code');
12174
+ }
12175
+ if (tables_1.WIN_DELETE_VERBS.has(name) || (inPs && tables_1.WIN_SHELL_DELETE_VERBS.has(name))) {
12176
+ if (deleteStops === null)
12177
+ deleteStops = (0, tables_1.windowsDeleteStopsFrom)(w, st.piped);
12178
+ if (deleteStops(p))
12179
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12180
+ }
12181
+ else if (name === 'iex' || name === 'invoke-expression') {
12182
+ const code = w[p + 1];
12183
+ if (code === undefined || st.piped || code.includes('$')) {
12184
+ worseParse(run, 'dynamic');
12185
+ hit(run, 'Sx', 'dynamic_powershell_code');
12186
+ }
12187
+ else
12188
+ psNested(code, c, run);
12189
+ }
12190
+ else if (name === 'cmd') {
12191
+ if (switchAt === null)
12192
+ switchAt = cmdSwitchIndexes(w);
12193
+ const at = switchAt[p + 1];
12194
+ // The command line is the rest of the statement from the switch on: its words are work too.
12195
+ if (at >= 0 && !psSpend(run, w.length - at, false))
12196
+ cmdLine(cmdSwitchLine(w, at), c, run);
12197
+ if (run.psOver)
12198
+ return;
12199
+ }
12200
+ }
12201
+ }
12202
+ }
12203
+ if (!readable && inPs && tables_1.WIN_DELETE_WORDS.test(cleaned))
12204
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12205
+ if (!readable && inPs && tables_1.PS_EVAL_WORDS.test(cleaned)) {
12206
+ worseParse(run, 'dynamic');
12207
+ hit(run, 'Sx', 'dynamic_powershell_code');
12208
+ }
12209
+ }
12210
+ // The work budget of psLexical, for the whole run: one step per statement and per word, 64 times the word budget of one
12211
+ // command. Over it, or over the word budget in one statement, psLexical stops reading and the run records the same stop
12212
+ // as any other word budget (Sx.budget_words). The rest of the evaluation goes on, so the other readings keep their own hits.
12213
+ const PS_LEX_STEPS = tables_1.LIMITS.maxWords * 64;
12214
+ function psSpend(run, steps, tooWide) {
12215
+ if (run.psOver)
12216
+ return true;
12217
+ run.psSteps += steps;
12218
+ if (!tooWide && run.psSteps <= PS_LEX_STEPS)
12219
+ return false;
12220
+ run.psOver = true;
12221
+ worseParse(run, 'too_large');
12222
+ hit(run, 'Sx', 'budget_words');
12223
+ return true;
12224
+ }
12225
+ // A reading whose only hits are A3 hits (and the budget stops): everything else it finds is dropped.
12226
+ function psProbe(text, c, run) {
12227
+ const saved = { hits: run.hits, parse: run.parse, flags: run.flags, risk: run.risk, head: run.headMayChange };
12228
+ run.hits = [];
12229
+ run.flags = new Set(saved.flags);
12230
+ run.risk = new Set(saved.risk);
12231
+ try {
12232
+ evalShell(text, childCtx(c), run);
12233
+ }
12234
+ finally {
12235
+ const kept = run.hits.filter((h) => tables_1.A3_REASONS.has(h.reason) || h.reason.startsWith('budget_'));
12236
+ run.hits = saved.hits;
12237
+ run.parse = saved.parse;
12238
+ run.flags = saved.flags;
12239
+ run.risk = saved.risk;
12240
+ run.headMayChange = saved.head;
12241
+ if (run.exhausted || run.psOver)
12242
+ worseParse(run, 'too_large');
12243
+ for (const h of kept)
12244
+ hit(run, h.category, h.reason);
12245
+ }
12246
+ }
12247
+ // Plain shell text bash cannot read, outside PowerShell / cmd text: the cmdlet name, a script block called with `&` / `.`
12248
+ // around a delete verb, `iex`, and `& $x` are still stops.
12249
+ function psUnparsed(src, run) {
12250
+ const t = (0, tables_1.psClean)(src);
12251
+ if (tables_1.WIN_CMDLET_WORD.test(t) || (tables_1.PS_BLOCK_CALL.test(t) && tables_1.WIN_DELETE_WORDS.test(t)))
12252
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12253
+ if (tables_1.PS_EVAL_WORDS.test(t) || tables_1.PS_DYNAMIC_AMP.test(t))
12254
+ hit(run, 'Sx', 'dynamic_powershell_code');
12255
+ }
12256
+ // The arguments of a delete verb as text; `-Confirm:$false` is a literal switch value, not a variable.
12257
+ function deleteArgs(rest) {
12258
+ return rest.map((a) => {
12259
+ if (a.text !== null)
12260
+ return a.text;
12261
+ const m = /^(-[A-Za-z]+:)\u0001$/.exec(a.pattern);
12262
+ return m && a.word && a.word.variables.length === 1 && /^(?:true|false)$/i.test(a.word.variables[0]) ? `${m[1]}$${a.word.variables[0]}` : null;
12263
+ });
12264
+ }
12265
+ // The program of this command by its Windows name: delete verbs, cmd, pwsh / powershell, iex, Start-Process and the rest.
12266
+ function windowsShell(args, rest, ctx, run, x) {
12267
+ const first = args[0].text;
12268
+ if (first === null || ctx.a3Quiet === true)
12269
+ return;
12270
+ const name = (0, tables_1.windowsProgramName)(first);
12271
+ const win = run.env.platform === 'win32';
12272
+ if (tables_1.WIN_DELETE_VERBS.has(name) || ((ctx.ps !== undefined || win) && tables_1.WIN_SHELL_DELETE_VERBS.has(name))) {
12273
+ if ((0, tables_1.windowsDeleteStops)(deleteArgs(rest), x.cmd.pipedInput || x.info.upstream.length > 0))
12274
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12275
+ return;
12276
+ }
12277
+ switch (name) {
12278
+ case 'cmd': return handleCmd(rest, ctx, run, x);
12279
+ case 'pwsh':
12280
+ case 'powershell': return handlePowerShell(rest, ctx, run, x);
12281
+ case 'iex':
12282
+ case 'invoke-expression': return handleInvokeExpression(rest, ctx, run, x);
12283
+ case 'start-process':
12284
+ case 'saps': return handleStartProcess(rest, ctx, run);
12285
+ case 'invoke-command':
12286
+ case 'icm':
12287
+ psCommandText(rest.map(dynText).join(' '), ctx, run);
12288
+ return;
12289
+ case 'set-alias':
12290
+ case 'new-alias':
12291
+ case 'sal':
12292
+ case 'nal':
12293
+ case 'import-alias': {
12294
+ // An alias can hide the verb from every later command (and `Set-Alias z Remove-Item` is the way to do it): any alias in
12295
+ // PowerShell text or on native Windows is a stop, and so is an alias for a delete verb anywhere.
12296
+ let hides = ctx.ps !== undefined || win;
12297
+ for (const a of rest) {
12298
+ const aliased = a.text === null ? '' : (0, tables_1.windowsProgramName)(a.text);
12299
+ if (tables_1.WIN_DELETE_VERBS.has(aliased) || tables_1.WIN_SHELL_DELETE_VERBS.has(aliased))
12300
+ hides = true;
12301
+ }
12302
+ if (hides)
12303
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12304
+ return;
12305
+ }
12306
+ // Windows-native destructive commands, each with a POSIX analog that stops (rsync --delete, mkfs, find -delete).
12307
+ case 'robocopy':
12308
+ // `robocopy <empty folder> <target> /mir` empties the target tree; /mov and /move delete what they copy.
12309
+ if (rest.some((a) => a.text !== null && /^\/(?:mir|purge|mov|move)$/i.test(a.text)))
12310
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12311
+ return;
12312
+ case 'format-volume':
12313
+ case 'clear-disk':
12314
+ case 'initialize-disk':
12315
+ case 'remove-partition':
12316
+ case 'diskpart':
12317
+ case 'clear-recyclebin':
12318
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12319
+ return;
12320
+ case 'format':
12321
+ if (rest.some((a) => a.text !== null && /^[a-z]:\\?$/i.test(a.text)))
12322
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12323
+ return;
12324
+ case 'vssadmin':
12325
+ if (rest.some((a) => a.text !== null && /^delete$/i.test(a.text)))
12326
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12327
+ return;
12328
+ case 'wsl':
12329
+ if (rest.some((a) => a.text !== null && /^--unregister/i.test(a.text)))
12330
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12331
+ return;
12332
+ case 'wmic':
12333
+ if (rest.some((a) => a.text !== null && /^shadowcopy$/i.test(a.text)) && rest.some((a) => a.text !== null && /^delete$/i.test(a.text)))
12334
+ hit(run, 'S5', 'windows_shell_delete_unparsed');
12335
+ execWrapper(rest, ctx, run);
12336
+ return;
12337
+ // Programs whose argument strings are command lines.
12338
+ case 'invoke-wmimethod':
12339
+ case 'invoke-cimmethod':
12340
+ case 'schtasks':
12341
+ case 'forfiles':
12342
+ case 'conhost':
12343
+ case 'runas':
12344
+ case 'mshta':
12345
+ case 'cscript':
12346
+ case 'wscript':
12347
+ case 'psexec':
12348
+ case 'psexec64':
12349
+ execWrapper(rest, ctx, run);
12350
+ return;
12351
+ case 'start':
12352
+ if (ctx.ps !== undefined || win)
12353
+ execWrapper(rest, ctx, run);
12354
+ return;
12355
+ default: return;
12356
+ }
12357
+ }
12358
+ // The command line after cmd's /c, /k or /r switch, or null when there is none. Switches may be run together (/d/s/c) and the
12359
+ // text may follow the switch without a blank (/c"dir"); the words after it are the command line, joined as cmd joins them.
12360
+ function cmdSwitchText(rest) {
12361
+ for (let i = 0; i < rest.length; i += 1) {
12362
+ const attached = cmdSwitchAttached(rest[i].text);
12363
+ if (attached === null)
12364
+ continue;
12365
+ return [attached, ...rest.slice(i + 1).map(dynText)].filter((part) => part !== '').join(' ');
12366
+ }
12367
+ return null;
12368
+ }
12369
+ // The text a cmd switch word carries after its switch letter (`/c"dir"` gives `"dir"`, `/d/s/c` gives ``), or null when the word is no /c, /k or /r switch.
12370
+ function cmdSwitchAttached(t) {
12371
+ if (t === null || t[0] !== '/')
12372
+ return null;
12373
+ const parts = t.slice(1).split('/');
12374
+ const j = parts.findIndex((part) => /^[ckr]/i.test(part));
12375
+ if (j < 0)
12376
+ return null;
12377
+ return parts[j].slice(1) + (j < parts.length - 1 ? `/${parts.slice(j + 1).join('/')}` : '');
12378
+ }
12379
+ // For every position, the index of the first cmd switch word at or after it (-1 = none; one extra slot past the end): the
12380
+ // answer of cmdSwitchText for every word of a statement, from one pass.
12381
+ function cmdSwitchIndexes(words) {
12382
+ const next = new Int32Array(words.length + 1);
12383
+ next[words.length] = -1;
12384
+ for (let i = words.length - 1; i >= 0; i -= 1)
12385
+ next[i] = cmdSwitchAttached(words[i]) !== null ? i : next[i + 1];
12386
+ return next;
12387
+ }
12388
+ // The command line of a statement whose word `at` is a cmd switch: what cmdSwitchText gives for the words from there.
12389
+ function cmdSwitchLine(words, at) {
12390
+ return [cmdSwitchAttached(words[at]), ...words.slice(at + 1)].filter((part) => part !== '').join(' ');
12391
+ }
12392
+ // A cmd command line, read as it is and with one pair of outer quotes taken off.
12393
+ function cmdLine(text, ctx, run) {
12394
+ psCommandText(text, ctx, run);
12395
+ if (text.length >= 2 && text.startsWith('"') && text.endsWith('"'))
12396
+ psCommandText(text.slice(1, -1), ctx, run);
12397
+ }
12398
+ function handleCmd(rest, ctx, run, x) {
12399
+ const line = cmdSwitchText(rest);
12400
+ if (line !== null) {
12401
+ cmdLine(line, ctx, run);
12402
+ return;
12403
+ }
12404
+ // A switch word that is not a literal might be /c; without /c cmd reads its commands from stdin.
12405
+ if (rest.some((a) => a.text === null)) {
12406
+ worseParse(run, 'dynamic');
12407
+ hit(run, 'Sx', 'dynamic_powershell_code');
12408
+ return;
12409
+ }
12410
+ stdinExec('sh', [], x, ctx, run, false, true);
12411
+ }
12412
+ // Programs whose argument strings are command lines (wmic process call create "cmd /c …", schtasks /tr "…", forfiles /c "…",
12413
+ // conhost cmd /c …, start /b "" cmd /c …): each argument with blanks is a command line, and so is the rest of the arguments
12414
+ // from any word that names a delete verb, a shell or PowerShell. Brace, `=` and `;` forms (@{CommandLine='…'}) are read too.
12415
+ function execWrapper(rest, ctx, run) {
12416
+ const texts = rest.map(dynText);
12417
+ let trials = 0;
12418
+ for (let k = 0; k < rest.length; k += 1) {
12419
+ if (clock(run))
12420
+ return;
12421
+ const w = texts[k];
12422
+ if (/\s/.test(w) && w.length > 2) {
12423
+ psCommandText(w, ctx, run);
12424
+ const flat = w.replace(/[{}=;@]/g, ' ');
12425
+ if (flat !== w)
12426
+ psCommandText(flat, ctx, run);
12427
+ }
12428
+ const t = rest[k].text;
12429
+ const name = t === null ? '' : (0, tables_1.windowsProgramName)(t);
12430
+ if (tables_1.WIN_DELETE_VERBS.has(name) || tables_1.WIN_SHELL_DELETE_VERBS.has(name) || ['cmd', 'pwsh', 'powershell', 'iex', 'invoke-expression', 'start-process', 'saps', 'robocopy'].includes(name)) {
12431
+ trials += 1;
12432
+ if (trials > tables_1.LIMITS.maxTailTrials) {
12433
+ exhaust(run, 'budget_argv_scan');
12434
+ return;
12435
+ }
12436
+ psCommandText(texts.slice(k).join(' '), ctx, run);
12437
+ }
12438
+ }
12439
+ }
12440
+ const CMD_DYNAMIC_WORD = /%[^%\s]*%|![^!\s]*!|^%{1,2}~?[a-z]$/i;
12441
+ const PS_FILE_SUFFIX = /\.(?:ps1|psm1|bat|cmd)$/i;
12442
+ // pwsh / powershell: -Command (and its prefixes) takes the rest of the line as code, -EncodedCommand a base64 UTF-16LE
12443
+ // text, -File a script, and a first word that is no option is read both as code and as a script path.
12444
+ function handlePowerShell(rest, ctx, run, x) {
12445
+ // With no code, file or encoded text, and with `-` (or `-Command -`), PowerShell reads its commands from stdin.
12446
+ const stdin = () => stdinExec('sh', [], x, ctx, run, false, true);
12447
+ let ran = false;
12448
+ for (let i = 0; i < rest.length; i += 1) {
12449
+ const a = rest[i];
12450
+ if (a.text === null) {
12451
+ worseParse(run, 'dynamic');
12452
+ hit(run, 'Sx', 'dynamic_powershell_code');
12453
+ return;
12454
+ }
12455
+ if (a.text === '-') {
12456
+ stdin();
12457
+ return;
12458
+ }
12459
+ const opt = (0, tables_1.psOption)(a.text);
12460
+ if (opt === null) {
12461
+ psCommandText(rest.slice(i).map(dynText).join(' '), ctx, run);
12462
+ if (!/\s/.test(a.text) && (PS_FILE_SUFFIX.test(a.text) || /[\\/]/.test(a.text)))
12463
+ powershellScript(a, ctx, run);
12464
+ return;
12465
+ }
12466
+ const value = opt.attached !== null ? litArg(opt.attached) : rest[i + 1];
12467
+ const eats = opt.attached === null ? 1 : 0;
12468
+ if (opt.kind === 'command') {
12469
+ const tail = (opt.attached !== null ? [litArg(opt.attached)] : []).concat(rest.slice(i + 1));
12470
+ if (tail.length === 0 || (tail.length === 1 && tail[0].text === '-')) {
12471
+ stdin();
12472
+ return;
12473
+ }
12474
+ psCommandText(tail.map(dynText).join(' '), ctx, run);
12475
+ return;
12476
+ }
12477
+ if (opt.kind === 'encoded' || (opt.name === 'ep' && value !== undefined && (value.text === null || !tables_1.PS_EXECUTION_POLICIES.test(value.text)))) {
12478
+ const decoded = value !== undefined && value.text !== null ? (0, tables_1.decodeEncodedCommand)(value.text) : null;
12479
+ if (decoded === null)
12480
+ hit(run, 'Sx', 'encoded_powershell_unreadable');
12481
+ else
12482
+ psNested(decoded, ctx, run);
12483
+ ran = true;
12484
+ i += eats;
12485
+ continue;
12486
+ }
12487
+ if (opt.kind === 'file') {
12488
+ powershellScript(value, ctx, run);
12489
+ return;
12490
+ }
12491
+ if (opt.kind === 'value' && value !== undefined && !(value.text !== null && (0, tables_1.psOption)(value.text) !== null))
12492
+ i += eats;
12493
+ }
12494
+ if (!ran)
12495
+ stdin();
12496
+ }
12497
+ // A script PowerShell runs (-File, a path as first word). Its text is read as PowerShell text; one that cannot be read
12498
+ // (missing, too big, not a literal path, or a Windows path the mapper cannot open) is a stop.
12499
+ function powershellScript(a, ctx, run) {
12500
+ if (!a || a.text === null || !expandScript(run)) {
12501
+ if (!run.exhausted)
12502
+ hit(run, 'Sx', 'powershell_script_unreadable');
12503
+ return;
12504
+ }
12505
+ const found = (absCandidates(a.text, ctx, run) || []).filter((p) => !p.startsWith('/__unknown_cwd__')).map((p) => readCached(run, p, tables_1.LIMITS.maxScriptBytes));
12506
+ if (found.length === 0 || found.some((c) => c === null)) {
12507
+ hit(run, 'Sx', 'powershell_script_unreadable');
12508
+ return;
12509
+ }
12510
+ for (const c of found)
12511
+ psNested(c, ctx, run);
12512
+ }
12513
+ // iex / Invoke-Expression: a literal argument is PowerShell text; anything else (a variable, a pipe, no argument) is code
12514
+ // nobody can read.
12515
+ function handleInvokeExpression(rest, ctx, run, x) {
12516
+ const args = rest.filter((a) => !(a.text !== null && (0, tables_1.psOption)(a.text)?.kind === 'command'));
12517
+ const code = args[0];
12518
+ if (code === undefined || x.cmd.pipedInput || x.info.upstream.length > 0) {
12519
+ worseParse(run, 'dynamic');
12520
+ hit(run, 'Sx', 'dynamic_powershell_code');
12521
+ return;
12522
+ }
12523
+ if (code.text !== null) {
12524
+ psNested(code.text, ctx, run);
12525
+ return;
12526
+ }
12527
+ if (code.alts) {
12528
+ for (const v of code.alts)
12529
+ psNested(v, ctx, run);
12530
+ return;
12531
+ }
12532
+ worseParse(run, 'dynamic');
12533
+ hit(run, 'Sx', 'dynamic_powershell_code');
12534
+ }
12535
+ const START_PROCESS_OPTIONS = ['filepath', 'argumentlist', 'workingdirectory', 'loaduserprofile', 'nonewwindow', 'passthru', 'redirectstandarderror',
12536
+ 'redirectstandardinput', 'redirectstandardoutput', 'usenewenvironment', 'verb', 'wait', 'windowstyle', 'credential', 'inputobject'];
12537
+ const START_PROCESS_VALUE_OPTIONS = new Set(['workingdirectory', 'redirectstandarderror', 'redirectstandardinput', 'redirectstandardoutput', 'verb',
12538
+ 'windowstyle', 'credential', 'inputobject']);
12539
+ function startOption(word) {
12540
+ const m = /^-([a-z]+)(?::([\s\S]*))?$/i.exec(word);
12541
+ if (!m)
12542
+ return null;
12543
+ const name = m[1].toLowerCase();
12544
+ const full = START_PROCESS_OPTIONS.find((k) => k.startsWith(name));
12545
+ return full ? { name: full, attached: m[2] !== undefined ? m[2] : null } : null;
12546
+ }
12547
+ // Start-Process / saps / start: `<program> <ArgumentList>` is read as one command line. A program that is not a literal
12548
+ // is code nobody can read. Commas of -ArgumentList 'a','b' (which bash joined into one word) read as spaces.
12549
+ function handleStartProcess(rest, ctx, run) {
12550
+ let program;
12551
+ const argv = [];
12552
+ let listed = false;
12553
+ for (let i = 0; i < rest.length; i += 1) {
12554
+ const a = rest[i];
12555
+ const opt = a.text !== null ? startOption(a.text) : null;
12556
+ if (opt) {
12557
+ if (opt.name === 'filepath')
12558
+ program = opt.attached !== null ? litArg(opt.attached) : rest[++i];
12559
+ else if (opt.name === 'argumentlist') {
12560
+ listed = true;
12561
+ if (opt.attached !== null)
12562
+ argv.push(litArg(opt.attached));
12563
+ else if (i + 1 < rest.length)
12564
+ argv.push(rest[++i]);
12565
+ while (i + 1 < rest.length && !(rest[i + 1].text !== null && startOption(rest[i + 1].text)))
12566
+ argv.push(rest[++i]);
12567
+ }
12568
+ else if (START_PROCESS_VALUE_OPTIONS.has(opt.name) && opt.attached === null)
12569
+ i += 1;
12570
+ continue;
12571
+ }
12572
+ if (program === undefined)
12573
+ program = a;
12574
+ else if (!listed)
12575
+ argv.push(a);
12576
+ }
12577
+ if (!program)
12578
+ return;
12579
+ if (program.text === null) {
12580
+ worseParse(run, 'dynamic');
12581
+ hit(run, 'Sx', 'dynamic_powershell_code');
12582
+ return;
12583
+ }
12584
+ const name = (0, tables_1.windowsProgramName)(program.text);
12585
+ if ((name === 'cmd' || name === 'pwsh' || name === 'powershell') && argv.some((a) => a.text === null)) {
12586
+ worseParse(run, 'dynamic');
12587
+ hit(run, 'Sx', 'dynamic_powershell_code');
12588
+ return;
12589
+ }
12590
+ psNested(`${shellQuote(program.text)} ${argv.map(dynText).join(' ').replace(/,/g, ' ')}`.trim(), ctx, run);
12591
+ }
11755
12592
  //# sourceMappingURL=mapper.js.map