fullcourtdefense-cli 1.36.3 → 1.36.4

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.
@@ -309,6 +309,25 @@ export declare function inferToolContext(toolName: string, args: Record<string,
309
309
  operation: string;
310
310
  context: Record<string, string>;
311
311
  };
312
+ /**
313
+ * What kind of site a destination host is, for the few kinds that change how an
314
+ * upload should be read. A curated list, not a reputation feed: a host is in it
315
+ * only when its PURPOSE is the category (a paste site exists to publish text; a
316
+ * request catcher exists to capture whatever is sent to it). Deliberately absent:
317
+ * general cloud storage, code hosting and gists, which are everyday developer
318
+ * destinations and would turn the category into noise.
319
+ */
320
+ export type DestinationCategory = 'paste' | 'file_drop' | 'request_capture' | 'tunnel' | 'messaging' | 'news';
321
+ export declare const DESTINATION_CATEGORY_HOSTS: Readonly<Record<DestinationCategory, readonly string[]>>;
322
+ /** Categories whose purpose is to receive data from strangers: a first upload there is high risk. */
323
+ export declare const HIGH_RISK_DESTINATION_CATEGORIES: ReadonlySet<DestinationCategory>;
324
+ /** The listed host itself or any subdomain of it (`www.pastebin.com`, `x.m.pipedream.net`); never a look-alike (`notpastebin.com`). */
325
+ export declare function destinationCategory(host: string | undefined | null): DestinationCategory | undefined;
326
+ /** A host's category and whether that category exists to receive data from anyone — the shape the anomaly scorer takes. */
327
+ export declare function classifyDestinationHost(host: string): {
328
+ category: DestinationCategory;
329
+ dataDrop: boolean;
330
+ } | undefined;
312
331
  /** Exported for reproducible detector artifacts; policy decisions are not packaged. */
313
332
  export declare function inferBundledToolContext(toolName: string, args: Record<string, any>): {
314
333
  operation: string;
@@ -24,14 +24,18 @@ __export(actionPolicyEngine_exports, {
24
24
  ACTION_POLICY_GRANT_MAX_USES: () => ACTION_POLICY_GRANT_MAX_USES,
25
25
  CONTENT_FACT_FIELDS: () => CONTENT_FACT_FIELDS,
26
26
  DEFAULT_ACTION_POLICY_APPROVAL: () => DEFAULT_ACTION_POLICY_APPROVAL,
27
+ DESTINATION_CATEGORY_HOSTS: () => DESTINATION_CATEGORY_HOSTS,
27
28
  GCLOUD_DELETE_VERB_RE: () => GCLOUD_DELETE_VERB_RE,
28
29
  GCLOUD_READ_VERB_RE: () => GCLOUD_READ_VERB_RE,
29
30
  GCLOUD_WRITE_VERB_RE: () => GCLOUD_WRITE_VERB_RE,
31
+ HIGH_RISK_DESTINATION_CATEGORIES: () => HIGH_RISK_DESTINATION_CATEGORIES,
30
32
  MACHINE_KINDS: () => MACHINE_KINDS,
31
33
  WORKSTATION_ASK_APPROVAL: () => WORKSTATION_ASK_APPROVAL,
32
34
  approvalExceptionError: () => approvalExceptionError,
33
35
  canonicalAgentClient: () => canonicalAgentClient,
36
+ classifyDestinationHost: () => classifyDestinationHost,
34
37
  deriveToolOperations: () => deriveToolOperations,
38
+ destinationCategory: () => destinationCategory,
35
39
  detectSecretKind: () => detectSecretKind,
36
40
  effectiveApprovalScope: () => effectiveApprovalScope,
37
41
  evaluateActionPolicies: () => evaluateActionPolicies,
@@ -6888,6 +6892,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
6888
6892
  "destination.host",
6889
6893
  "destination.type",
6890
6894
  "destination.repository",
6895
+ "destination.category",
6891
6896
  "destinationHost",
6892
6897
  "host",
6893
6898
  "hostname",
@@ -8767,8 +8772,145 @@ function inferToolContext(toolName, args) {
8767
8772
  }
8768
8773
  applyRemoteControlFact(toolName, args, context);
8769
8774
  applyWebReadFacts(toolName, args, context);
8775
+ applyDestinationCategoryFact(context);
8770
8776
  return { operation, context };
8771
8777
  }
8778
+ var DESTINATION_CATEGORY_HOSTS = {
8779
+ paste: [
8780
+ "pastebin.com",
8781
+ "paste.ee",
8782
+ "hastebin.com",
8783
+ "rentry.co",
8784
+ "rentry.org",
8785
+ "dpaste.org",
8786
+ "dpaste.com",
8787
+ "paste.rs",
8788
+ "termbin.com",
8789
+ "controlc.com",
8790
+ "justpaste.it",
8791
+ "privatebin.net",
8792
+ "ix.io",
8793
+ "sprunge.us",
8794
+ "pastes.io",
8795
+ "paste.mozilla.org"
8796
+ ],
8797
+ file_drop: [
8798
+ "transfer.sh",
8799
+ "file.io",
8800
+ "0x0.st",
8801
+ "temp.sh",
8802
+ "bashupload.com",
8803
+ "gofile.io",
8804
+ "catbox.moe",
8805
+ "uguu.se",
8806
+ "tmpfiles.org",
8807
+ "filebin.net",
8808
+ "wetransfer.com",
8809
+ "we.tl",
8810
+ "sendspace.com",
8811
+ "mediafire.com",
8812
+ "mega.nz",
8813
+ "anonfiles.com",
8814
+ "oshi.at",
8815
+ "pixeldrain.com",
8816
+ "ufile.io",
8817
+ "keep.sh",
8818
+ "filetransfer.io"
8819
+ ],
8820
+ request_capture: [
8821
+ "webhook.site",
8822
+ "pipedream.net",
8823
+ "beeceptor.com",
8824
+ "hookbin.com",
8825
+ "requestcatcher.com",
8826
+ "interact.sh",
8827
+ "oast.fun",
8828
+ "oast.pro",
8829
+ "oast.live",
8830
+ "oast.site",
8831
+ "oast.online",
8832
+ "oast.me",
8833
+ "oastify.com",
8834
+ "burpcollaborator.net",
8835
+ "dnslog.cn",
8836
+ "ceye.io"
8837
+ ],
8838
+ tunnel: [
8839
+ "ngrok.io",
8840
+ "ngrok.app",
8841
+ "ngrok.dev",
8842
+ "ngrok-free.app",
8843
+ "ngrok-free.dev",
8844
+ "trycloudflare.com",
8845
+ "loca.lt",
8846
+ "localtunnel.me",
8847
+ "serveo.net",
8848
+ "localhost.run",
8849
+ "lhr.life",
8850
+ "pinggy.io",
8851
+ "pinggy.link",
8852
+ "bore.pub"
8853
+ ],
8854
+ messaging: ["discord.com", "discordapp.com", "api.telegram.org", "hooks.slack.com"],
8855
+ news: [
8856
+ "ynet.co.il",
8857
+ "ynetnews.com",
8858
+ "walla.co.il",
8859
+ "mako.co.il",
8860
+ "n12.co.il",
8861
+ "haaretz.co.il",
8862
+ "haaretz.com",
8863
+ "themarker.com",
8864
+ "maariv.co.il",
8865
+ "globes.co.il",
8866
+ "calcalist.co.il",
8867
+ "israelhayom.co.il",
8868
+ "kan.org.il",
8869
+ "timesofisrael.com",
8870
+ "jpost.com",
8871
+ "i24news.tv",
8872
+ "bbc.com",
8873
+ "bbc.co.uk",
8874
+ "cnn.com",
8875
+ "nytimes.com",
8876
+ "reuters.com",
8877
+ "apnews.com",
8878
+ "theguardian.com",
8879
+ "washingtonpost.com",
8880
+ "wsj.com",
8881
+ "bloomberg.com",
8882
+ "ft.com",
8883
+ "foxnews.com",
8884
+ "nbcnews.com",
8885
+ "cbsnews.com",
8886
+ "npr.org",
8887
+ "aljazeera.com"
8888
+ ]
8889
+ };
8890
+ var HIGH_RISK_DESTINATION_CATEGORIES = /* @__PURE__ */ new Set(["paste", "file_drop", "request_capture", "tunnel"]);
8891
+ var DESTINATION_CATEGORY_BY_HOST = new Map(
8892
+ Object.entries(DESTINATION_CATEGORY_HOSTS).flatMap(([category, hosts]) => hosts.map((host) => [host, category]))
8893
+ );
8894
+ function destinationCategory(host) {
8895
+ let candidate = String(host || "").trim().toLowerCase().replace(/\.$/, "");
8896
+ while (candidate) {
8897
+ const category = DESTINATION_CATEGORY_BY_HOST.get(candidate);
8898
+ if (category) return category;
8899
+ const dot = candidate.indexOf(".");
8900
+ if (dot < 0) return void 0;
8901
+ candidate = candidate.slice(dot + 1);
8902
+ }
8903
+ return void 0;
8904
+ }
8905
+ function classifyDestinationHost(host) {
8906
+ const category = destinationCategory(host);
8907
+ return category ? { category, dataDrop: HIGH_RISK_DESTINATION_CATEGORIES.has(category) } : void 0;
8908
+ }
8909
+ function applyDestinationCategoryFact(context) {
8910
+ delete context["destination.category"];
8911
+ const category = destinationCategory(context["destination.domain"]);
8912
+ if (category) context["destination.category"] = category;
8913
+ }
8772
8914
  function applyRemoteControlFact(toolName, args, context) {
8773
8915
  delete context["toolArgs.remoteControl"];
8774
8916
  const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
@@ -8920,6 +9062,7 @@ function inferBundledToolContext(toolName, args) {
8920
9062
  }
8921
9063
  applyRemoteControlFact(toolName, args, context);
8922
9064
  applyWebReadFacts(toolName, args, context);
9065
+ applyDestinationCategoryFact(context);
8923
9066
  return { operation, context };
8924
9067
  }
8925
9068
  var RESERVED_INPUT_FACTS = /* @__PURE__ */ new Set([
@@ -9021,14 +9164,18 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
9021
9164
  ACTION_POLICY_GRANT_MAX_USES,
9022
9165
  CONTENT_FACT_FIELDS,
9023
9166
  DEFAULT_ACTION_POLICY_APPROVAL,
9167
+ DESTINATION_CATEGORY_HOSTS,
9024
9168
  GCLOUD_DELETE_VERB_RE,
9025
9169
  GCLOUD_READ_VERB_RE,
9026
9170
  GCLOUD_WRITE_VERB_RE,
9171
+ HIGH_RISK_DESTINATION_CATEGORIES,
9027
9172
  MACHINE_KINDS,
9028
9173
  WORKSTATION_ASK_APPROVAL,
9029
9174
  approvalExceptionError,
9030
9175
  canonicalAgentClient,
9176
+ classifyDestinationHost,
9031
9177
  deriveToolOperations,
9178
+ destinationCategory,
9032
9179
  detectSecretKind,
9033
9180
  effectiveApprovalScope,
9034
9181
  evaluateActionPolicies,
@@ -21,6 +21,7 @@ exports.tokenDestinations = tokenDestinations;
21
21
  exports.capturedValueDestinations = capturedValueDestinations;
22
22
  exports.describeShellCommand = describeShellCommand;
23
23
  exports.describeShellCommandLines = describeShellCommandLines;
24
+ const deterministicGuard_1 = require("./commands/deterministicGuard");
24
25
  const MAX_STEPS = 14;
25
26
  const MAX_LINE = 220;
26
27
  /** Token printers whose output can be assigned; `issuer` = who accepts the token (identity tokens go to their audience). */
@@ -643,7 +644,7 @@ function describeOne(segmentIn, captured, fns = new Map()) {
643
644
  case 'python3':
644
645
  case 'tsx':
645
646
  case 'ts-node':
646
- return step(`Runs ${program} ${rest[1] ? `(${shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
647
+ return step(`Runs ${program} ${rest[1] ? `(${/[\\/]/.test(rest[1]) && !rest[1].startsWith('-') ? [shortPath(rest[1]), ...rest.slice(2)].join(' ').slice(0, 80) : shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
647
648
  case 'curl':
648
649
  case 'wget':
649
650
  case 'invoke-webrequest':
@@ -678,7 +679,10 @@ function describeOne(segmentIn, captured, fns = new Map()) {
678
679
  case 'mkdir':
679
680
  case 'new-item':
680
681
  case 'ni':
681
- case 'touch': return step(`Creates ${shortValue(rest.slice(1).filter(t => !t.startsWith('-')).join(', '), 60)}`);
682
+ case 'touch': {
683
+ const made = rest.slice(1).filter(t => !t.startsWith('-'));
684
+ return step(`Creates ${made.length === 1 ? shortPath(made[0]) : shortValue(made.join(', '), 60)}`);
685
+ }
682
686
  case 'cat':
683
687
  case 'get-content':
684
688
  case 'gc':
@@ -686,8 +690,22 @@ function describeOne(segmentIn, captured, fns = new Map()) {
686
690
  case 'head':
687
691
  case 'tail':
688
692
  case 'less':
689
- case 'more':
690
- return step(`Reads the file ${shortValue(rest.slice(1).filter(t => !t.startsWith('-'))[0] || '', 60)}${redirectNote}`);
693
+ case 'more': {
694
+ // `cat > f` / `cat 2>/dev/null f`: redirections and their targets are not files read.
695
+ const operands = [];
696
+ for (let k = 1; k < rest.length; k++) {
697
+ if (/^(?:\d*>{1,2}|&>|<)$/.test(rest[k])) {
698
+ k++;
699
+ continue;
700
+ }
701
+ if (/^(?:\d*>|&>|<)/.test(rest[k]) || rest[k].startsWith('-'))
702
+ continue;
703
+ operands.push(rest[k]);
704
+ }
705
+ if (!operands.length && redirectTo)
706
+ return step(`Writes its input to ${shortValue(redirectTo, 60)}`);
707
+ return step(`Reads the file ${shortValue(operands[0] || '', 60)}${redirectNote}`);
708
+ }
691
709
  case 'ls':
692
710
  case 'dir':
693
711
  case 'get-childitem':
@@ -948,13 +966,75 @@ function noteTokenDestinations(steps) {
948
966
  return { ...step, text: shortValue(`${step.text}, then sends it to ${described.join('; ')}`, MAX_LINE) };
949
967
  });
950
968
  }
969
+ const HEREDOC_OPENER = /(?:^|[^<])<<-?[ \t]*(?:'[^'\n]*'|"[^"\n]*"|\\?[^\s;&|<>()'"]+)/g;
970
+ /**
971
+ * A heredoc body is the input of the step that opens it, not steps of its own:
972
+ * drop every body (and its delimiter line) and hand each opener step its body.
973
+ */
974
+ function stepsWithHeredocs(command) {
975
+ const heredocs = (0, deterministicGuard_1.posixHeredocs)(command);
976
+ if (!heredocs.length)
977
+ return splitCommandSteps(command).map(segment => ({ segment, heredocs: [] }));
978
+ let code = '';
979
+ let at = 0;
980
+ for (const heredoc of heredocs) {
981
+ code += command.slice(at, heredoc.bodyStart);
982
+ at = Math.min(command.length, heredoc.end + 1);
983
+ }
984
+ code += command.slice(at);
985
+ let next = 0;
986
+ return splitCommandSteps(code).map(segment => {
987
+ const opened = segment.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => (/<</.test(m) ? m : '""')).match(HEREDOC_OPENER) || [];
988
+ const mine = heredocs.slice(next, next + opened.length);
989
+ next += opened.length;
990
+ return { segment, heredocs: mine };
991
+ });
992
+ }
993
+ /** A long path shortened from the front: the file name is what the reader needs. */
994
+ function shortPath(path, max = 60) {
995
+ if (path.length <= max)
996
+ return path;
997
+ const parts = path.split(/[\\/]/);
998
+ let out = parts.pop() || '';
999
+ while (parts.length && out.length + parts[parts.length - 1].length + 3 <= max)
1000
+ out = `${parts.pop()}/${out}`;
1001
+ return `…/${out}`.slice(0, max + 2);
1002
+ }
1003
+ function lineCount(body) {
1004
+ return body ? body.split('\n').length : 0;
1005
+ }
1006
+ /** What a step does with the heredoc bodies it opens. */
1007
+ function describeHeredocStep(segment, heredocs, fns) {
1008
+ const [heredoc] = heredocs;
1009
+ const lines = lineCount(heredoc.body);
1010
+ const size = `${lines}-line`;
1011
+ const masked = segment.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => m.replace(/[>|]/g, '_'));
1012
+ const target = /(?:^|[^>0-9-])>{1,2}\s*("[^"]*"|'[^']*'|[^\s&|;<]+)/.exec(masked);
1013
+ const file = target ? unquote(segment.slice(target.index + target[0].length - target[1].length, target.index + target[0].length)) : '';
1014
+ const words = tokens(segment).filter(t => !/^<</.test(t));
1015
+ const program = (words[0] || '').toLowerCase().replace(/^.*[\\/]/, '');
1016
+ const { kind, program: reader } = heredoc.receiver;
1017
+ const into = program === 'cat' ? file : program === 'tee' ? (file || words.slice(1).find(t => !t.startsWith('-')) || '') : '';
1018
+ // Writing the body to a file is this step; a later step that runs the file says so itself.
1019
+ if (into && !/\|/.test(masked)) {
1020
+ const what = kind === 'shell' ? `a ${size} shell script` : kind === 'language' ? `a ${size} ${reader} script` : `${lines} line${lines === 1 ? '' : 's'} of text`;
1021
+ return { segment, text: shortValue(`Writes ${what} to ${shortPath(into)}`, MAX_LINE), known: true };
1022
+ }
1023
+ if (kind === 'shell')
1024
+ return { segment, text: shortValue(`Runs a ${size} script with ${reader || program}`, MAX_LINE), known: true };
1025
+ if (kind === 'language')
1026
+ return { segment, text: shortValue(`Runs a ${size} ${reader} script`, MAX_LINE), known: true };
1027
+ const base = describeOne(segment.replace(HEREDOC_OPENER, m => m.replace(/<<[\s\S]*$/, '')), undefined, fns);
1028
+ return { ...base, segment, text: shortValue(`${base.text} (given ${lines} line${lines === 1 ? '' : 's'} of input text)`, MAX_LINE) };
1029
+ }
951
1030
  /** One plain-words line per step, in order. */
952
1031
  function describeShellCommand(command) {
953
- const steps = splitCommandSteps(command);
1032
+ const parts = stepsWithHeredocs(command);
1033
+ const steps = parts.map(part => part.segment);
954
1034
  const fns = localFunctions(command);
955
- const described = steps.map(segment => {
1035
+ const described = parts.map(({ segment, heredocs }) => {
956
1036
  try {
957
- return describeOne(segment, undefined, fns);
1037
+ return heredocs.length ? describeHeredocStep(segment, heredocs, fns) : describeOne(segment, undefined, fns);
958
1038
  }
959
1039
  catch {
960
1040
  return { segment, text: 'Runs a command the grammar does not recognise', known: false };
@@ -215,6 +215,17 @@ export declare function isPowerShellLine(text: string): boolean;
215
215
  * still carries fixture strings and docs.
216
216
  */
217
217
  export declare function unquotedCode(text: string): string;
218
+ /** POSIX heredocs of a command line, in order: body span, delimiter-line end, and receiver. */
219
+ export declare function posixHeredocs(text: string): Array<{
220
+ bodyStart: number;
221
+ bodyEnd: number;
222
+ end: number;
223
+ body: string;
224
+ receiver: {
225
+ kind: 'shell' | 'language' | 'data';
226
+ program: string;
227
+ };
228
+ }>;
218
229
  export declare function stripInertDataSegments(text: string): string;
219
230
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
220
231
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
@@ -48,6 +48,7 @@ exports.getShellAstMode = getShellAstMode;
48
48
  exports.takeShellAstComparison = takeShellAstComparison;
49
49
  exports.isPowerShellLine = isPowerShellLine;
50
50
  exports.unquotedCode = unquotedCode;
51
+ exports.posixHeredocs = posixHeredocs;
51
52
  exports.stripInertDataSegments = stripInertDataSegments;
52
53
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
53
54
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
@@ -1056,9 +1057,10 @@ function destructiveCommandDecision(value, commandContext) {
1056
1057
  if (astHit)
1057
1058
  return astHit;
1058
1059
  // The AST parsed the literal command only. A hit that exists only in a
1059
- // decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`) is
1060
- // outside what it saw, so it cannot clear it.
1061
- if (regexRm && !RM_SCOPE_ITEMS.has(destructiveCommandReason(value, { literalOnly: true })?.itemId || ''))
1060
+ // decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`) or in
1061
+ // a heredoc body a shell runs (`bash <<'EOF'`) is outside what it saw, so it
1062
+ // cannot clear it.
1063
+ if (regexRm && !RM_SCOPE_ITEMS.has(destructiveCommandReason(withoutActingHeredocBodies(value), { literalOnly: true })?.itemId || ''))
1062
1064
  return regexHit;
1063
1065
  if (regexRm) {
1064
1066
  // The AST cleared the rm-scope hit; other destructive rules still apply.
@@ -1519,7 +1521,7 @@ function isSensitivePathContext(toolName, candidate) {
1519
1521
  // remote executor earlier in the same statement) are kept.
1520
1522
  // 5. Secret-literal rules and honeypot/custom rules never use this view —
1521
1523
  // a token pasted into a commit message still leaks.
1522
- const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|(?<!\|)\|(?!\|)\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
1524
+ const DATA_TO_INTERPRETER = /\b(?:iex|icm|invoke-expression|invoke-command)\b|\[scriptblock\]::create\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|(?<!\|)\|(?!\|)\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
1523
1525
  // A LANGUAGE interpreter's `-c` / `-e` argument is CODE in another language, not
1524
1526
  // a shell command line. `node -e "console.log('rm -rf /')"` prints a string;
1525
1527
  // `python -c "print('DROP TABLE')"` prints a string. That code only reaches the
@@ -1544,33 +1546,42 @@ function interpreterFileArguments(text) {
1544
1546
  if (text.length > 32_000)
1545
1547
  return '';
1546
1548
  const paths = [];
1547
- let calls = 0;
1549
+ const budget = { calls: 0 };
1548
1550
  text.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1549
1551
  if (!LANG_INTERPRETER_CODE_BEFORE.test(text.slice(Math.max(0, offset - 80), offset)))
1550
1552
  return whole;
1551
- const code = dq !== undefined ? dq : (sq || '');
1552
- // Preserve offsets while masking string contents: a call printed inside a
1553
- // string is not an invocation; a sensitive path in another argument is data.
1554
- const instructions = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, literal => ' '.repeat(literal.length));
1555
- for (const call of instructions.matchAll(/\b(?:open|fopen|readFile(?:Sync)?|createReadStream|ReadAllText|ReadAllBytes)\s*\(/g)) {
1556
- if (++calls > 48)
1557
- break;
1558
- const start = (call.index || 0) + call[0].length;
1559
- let depth = 1;
1560
- for (let end = start; end < instructions.length; end++) {
1561
- const ch = instructions[end];
1562
- if ((ch === ')' && --depth === 0) || (ch === ',' && depth === 1)) {
1563
- paths.push(code.slice(start, end));
1564
- break;
1565
- }
1566
- if (ch === '(')
1567
- depth++;
1568
- }
1569
- }
1553
+ collectFileCallArguments(dq !== undefined ? dq : (sq || ''), paths, budget);
1570
1554
  return whole;
1571
1555
  });
1556
+ // Heredoc bodies a language interpreter runs (`python3 - <<'EOF'`, or
1557
+ // `cat > x.js <<'EOF' … node x.js`) are the same inline code.
1558
+ for (const heredoc of heredocView(text)?.heredocs || []) {
1559
+ if (heredoc.receiver.kind === 'language')
1560
+ collectFileCallArguments(heredoc.body, paths, budget);
1561
+ }
1572
1562
  return paths.join('\n');
1573
1563
  }
1564
+ const FILE_CALL_RE = /\b(?:open|fopen|readFile(?:Sync)?|createReadStream|ReadAllText|ReadAllBytes|readTextFile|file_get_contents|copyFile(?:Sync)?|copyfile|copy2?|Path|File\.read)\s*\(/g;
1565
+ function collectFileCallArguments(code, paths, budget) {
1566
+ // Preserve offsets while masking string contents: a call printed inside a
1567
+ // string is not an invocation; a sensitive path in another argument is data.
1568
+ const instructions = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, literal => ' '.repeat(literal.length));
1569
+ for (const call of instructions.matchAll(FILE_CALL_RE)) {
1570
+ if (++budget.calls > 48)
1571
+ break;
1572
+ const start = (call.index || 0) + call[0].length;
1573
+ let depth = 1;
1574
+ for (let end = start; end < instructions.length; end++) {
1575
+ const ch = instructions[end];
1576
+ if ((ch === ')' && --depth === 0) || (ch === ',' && depth === 1)) {
1577
+ paths.push(code.slice(start, end));
1578
+ break;
1579
+ }
1580
+ if (ch === '(')
1581
+ depth++;
1582
+ }
1583
+ }
1584
+ }
1574
1585
  // $( … ) always executes; backtick pairs only with ≥2 chars of content
1575
1586
  // (PowerShell escapes like `n / `t are single-char and inert).
1576
1587
  const INTERPOLATES = /\$\(|`[^`]{2,}`/;
@@ -1717,16 +1728,414 @@ function replaceQuotedSpans(src, fn) {
1717
1728
  }
1718
1729
  return out + src.slice(from);
1719
1730
  }
1731
+ // Stdin of these is executed as shell/SQL code; `done`/`fi`/`esac`/`}`/`)`
1732
+ // close a compound command whose body acts on what it reads (`while read c; do $c; done <<EOF`).
1733
+ const SHELL_STDIN_PROGRAMS = /^(?:sh|bash|zsh|dash|ksh|mksh|ash|csh|tcsh|fish|busybox|pwsh|powershell|cmd|ssh|wsl|chroot|su|eval|source|\.|xargs|parallel|at|batch|crontab|expect|tclsh|wish|osascript|script|psql|mysql|mariadb|sqlite3|sqlcmd|mongosh|mongo|redis-cli|cqlsh|clickhouse-client|duckdb|snowsql|done|fi|esac|\}|\))$/;
1734
+ const LANGUAGE_STDIN_PROGRAMS = /^(?:node|nodejs|deno|bun|python[0-9.]*|py|pypy[0-9.]*|perl|ruby|php|lua|luajit|tsx|ts-node|rscript|julia|groovy)$/;
1735
+ const CONTAINER_CLI_PROGRAMS = /^(?:docker|podman|nerdctl|kubectl|oc)$/;
1736
+ const RECEIVER_RANK = { data: 0, language: 1, shell: 2 };
1737
+ function programName(token) {
1738
+ return token.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(?:exe|cmd|bat)$/, '');
1739
+ }
1740
+ /** Words of one stage without assignments and redirections (`> f`, `2>&1`, `<<'EOF'`). */
1741
+ function stageWords(stage) {
1742
+ const words = [];
1743
+ const tokens = segmentTokens(stage.trim());
1744
+ for (let i = 0; i < tokens.length; i++) {
1745
+ const token = tokens[i];
1746
+ if (/^(?:\d*>{1,2}|&>{1,2}|<{1,3}-?|\d*<)$/.test(token)) {
1747
+ i++;
1748
+ continue;
1749
+ }
1750
+ if (/^(?:\d*>|&>|<)/.test(token))
1751
+ continue;
1752
+ if (!words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
1753
+ continue;
1754
+ words.push(token);
1755
+ }
1756
+ return words;
1757
+ }
1758
+ /** How one pipeline stage treats the bytes on its stdin. */
1759
+ function stdinReceiver(stage) {
1760
+ const words = stageWords(stage);
1761
+ for (let i = 0; i < words.length; i++) {
1762
+ const program = programName(words[i]);
1763
+ if (SHELL_STDIN_PROGRAMS.test(program))
1764
+ return { kind: 'shell', program };
1765
+ if (LANGUAGE_STDIN_PROGRAMS.test(program))
1766
+ return { kind: 'language', program };
1767
+ if (CONTAINER_CLI_PROGRAMS.test(program)) {
1768
+ return words.slice(i + 1).some(word => /^(?:exec|run|attach|debug)$/i.test(word)) ? { kind: 'shell', program } : { kind: 'data', program };
1769
+ }
1770
+ if (AST_TRANSPARENT_WRAPPERS.has(program)) {
1771
+ // `sudo -u root bash`, `env X=1 node`, `timeout 5 sh`: the reader is a later word, past the wrapper's own options.
1772
+ let reader = { kind: 'data', program };
1773
+ for (const word of words.slice(i + 1))
1774
+ reader = strongerReceiver(reader, stdinReceiver(word));
1775
+ return reader;
1776
+ }
1777
+ return { kind: 'data', program };
1778
+ }
1779
+ return { kind: 'data', program: '' };
1780
+ }
1781
+ function strongerReceiver(a, b) {
1782
+ return RECEIVER_RANK[b.kind] > RECEIVER_RANK[a.kind] ? b : a;
1783
+ }
1784
+ /** Files the opener stage writes its stdin to (`cat > f`, `tee f`). */
1785
+ function stageWriteTargets(stage) {
1786
+ const targets = redirectTargets(stage);
1787
+ const words = stageWords(stage);
1788
+ if (programName(words[0] || '') === 'tee')
1789
+ targets.push(...words.slice(1).filter(word => !word.startsWith('-')));
1790
+ return targets.map(target => programName(target)).filter(Boolean);
1791
+ }
1792
+ /** The strongest program among `statements` that runs one of `files` (`node x.js`, `bash x.sh`, `./x.sh`). */
1793
+ function laterRunnerOf(files, statements) {
1794
+ let runner = { kind: 'data', program: '' };
1795
+ if (!files.length)
1796
+ return runner;
1797
+ for (const stages of statements) {
1798
+ for (const stage of stages) {
1799
+ const words = stageWords(stage);
1800
+ if (!words.some(word => files.includes(programName(word))))
1801
+ continue;
1802
+ const direct = files.includes(programName(words[0] || ''));
1803
+ runner = strongerReceiver(runner, direct ? { kind: 'shell', program: programName(words[0]) } : stdinReceiver(stage));
1804
+ }
1805
+ }
1806
+ return runner;
1807
+ }
1808
+ /**
1809
+ * Every heredoc of a POSIX command line, with its exact body and its stages.
1810
+ * `undefined` when the line does not lex (an unclosed quote or substitution):
1811
+ * callers then keep every body as code.
1812
+ */
1813
+ function lexHeredocs(text) {
1814
+ const spans = [];
1815
+ const pending = [];
1816
+ const frame = (closer, stageStart) => ({ closer, quote: '', depth: 0, afterPipe: false, stageStart, stages: [], open: [] });
1817
+ const stack = [frame('', 0)];
1818
+ /** `i` at the first `(` of `((`: index of the closing `))` (arithmetic holds no heredoc; `<<` there is a shift). */
1819
+ const arithmeticEnd = (i) => {
1820
+ let depth = 0;
1821
+ for (let k = i; k < text.length; k++) {
1822
+ if (text[k] === '(')
1823
+ depth++;
1824
+ else if (text[k] === ')' && --depth === 0)
1825
+ return k;
1826
+ }
1827
+ return -1;
1828
+ };
1829
+ const codeSlice = (from, to) => {
1830
+ let out = '';
1831
+ let at = from;
1832
+ for (const span of spans) {
1833
+ if (span.end <= at || span.bodyStart >= to)
1834
+ continue;
1835
+ out += text.slice(at, Math.max(at, span.bodyStart));
1836
+ at = Math.max(at, span.end);
1837
+ }
1838
+ return out + text.slice(at, Math.max(at, to));
1839
+ };
1840
+ const endStage = (ctx, at) => { ctx.stages.push(codeSlice(ctx.stageStart, at)); ctx.stageStart = at + 1; };
1841
+ const endStatement = (ctx, at) => {
1842
+ endStage(ctx, at);
1843
+ for (const { span, stage } of ctx.open)
1844
+ span.stages = ctx.stages.slice(stage);
1845
+ ctx.stages = [];
1846
+ ctx.open = [];
1847
+ };
1848
+ const readBodies = (newline) => {
1849
+ let pos = newline + 1;
1850
+ for (const { span, delimiter, stripTabs } of pending.splice(0)) {
1851
+ span.bodyStart = pos;
1852
+ span.bodyEnd = span.end = text.length;
1853
+ while (pos < text.length) {
1854
+ const lineEnd = text.indexOf('\n', pos);
1855
+ const stop = lineEnd < 0 ? text.length : lineEnd;
1856
+ let line = text.slice(pos, stop).replace(/\r$/, '');
1857
+ if (stripTabs)
1858
+ line = line.replace(/^\t+/, '');
1859
+ if (line === delimiter) {
1860
+ span.bodyEnd = pos;
1861
+ span.end = stop;
1862
+ pos = stop + 1;
1863
+ break;
1864
+ }
1865
+ pos = stop + 1;
1866
+ }
1867
+ span.body = text.slice(span.bodyStart, span.bodyEnd < text.length ? Math.max(span.bodyStart, span.bodyEnd - 1) : text.length);
1868
+ spans.push(span);
1869
+ }
1870
+ return pos;
1871
+ };
1872
+ for (let i = 0; i < text.length; i++) {
1873
+ const ctx = stack[stack.length - 1];
1874
+ const c = text[i];
1875
+ const next = text[i + 1];
1876
+ if (ctx.quote === "'") {
1877
+ if (c === "'")
1878
+ ctx.quote = '';
1879
+ continue;
1880
+ }
1881
+ if (c === '\\') {
1882
+ i++;
1883
+ continue;
1884
+ }
1885
+ if (ctx.quote === '"') {
1886
+ if (c === '"')
1887
+ ctx.quote = '';
1888
+ else if (c === '$' && next === '(' && text[i + 2] === '(') {
1889
+ const e = arithmeticEnd(i + 1);
1890
+ if (e < 0)
1891
+ return undefined;
1892
+ i = e;
1893
+ }
1894
+ else if (c === '$' && next === '(') {
1895
+ stack.push(frame(')', i + 2));
1896
+ i++;
1897
+ }
1898
+ else if (c === '`')
1899
+ stack.push(frame('`', i + 1));
1900
+ continue;
1901
+ }
1902
+ if (c !== '|' && c !== '\n' && !/\s/.test(c))
1903
+ ctx.afterPipe = false;
1904
+ if (c === '#' && (i === 0 || /[\s;&|(]/.test(text[i - 1]))) {
1905
+ const nl = text.indexOf('\n', i);
1906
+ i = (nl < 0 ? text.length : nl) - 1;
1907
+ continue;
1908
+ }
1909
+ if (c === "'" || c === '"') {
1910
+ ctx.quote = c;
1911
+ continue;
1912
+ }
1913
+ if ((c === '$' && next === '(' && text[i + 2] === '(') || (c === '(' && next === '(')) {
1914
+ const e = arithmeticEnd(c === '$' ? i + 1 : i);
1915
+ if (e < 0)
1916
+ return undefined;
1917
+ i = e;
1918
+ continue;
1919
+ }
1920
+ if (c === '$' && next === '(') {
1921
+ stack.push(frame(')', i + 2));
1922
+ i++;
1923
+ continue;
1924
+ }
1925
+ if (c === '`') {
1926
+ if (ctx.closer === '`') {
1927
+ endStatement(ctx, i);
1928
+ stack.pop();
1929
+ }
1930
+ else
1931
+ stack.push(frame('`', i + 1));
1932
+ continue;
1933
+ }
1934
+ if (c === '(') {
1935
+ endStatement(ctx, i);
1936
+ ctx.depth++;
1937
+ continue;
1938
+ }
1939
+ if (c === ')') {
1940
+ endStatement(ctx, i);
1941
+ if (ctx.depth > 0)
1942
+ ctx.depth--;
1943
+ else if (ctx.closer === ')' && stack.length > 1)
1944
+ stack.pop();
1945
+ continue;
1946
+ }
1947
+ if (c === '{' && (next === undefined || /\s/.test(next))) {
1948
+ endStatement(ctx, i);
1949
+ continue;
1950
+ }
1951
+ if (c === ';' || (c === '&' && next !== '>' && text[i - 1] !== '>' && text[i - 1] !== '<')) {
1952
+ endStatement(ctx, i);
1953
+ if ((c === '&' && next === '&') || (c === ';' && next === ';'))
1954
+ i++;
1955
+ continue;
1956
+ }
1957
+ if (c === '|') {
1958
+ if (next === '|') {
1959
+ endStatement(ctx, i);
1960
+ i++;
1961
+ }
1962
+ else {
1963
+ endStage(ctx, i);
1964
+ ctx.afterPipe = true;
1965
+ if (next === '&')
1966
+ i++;
1967
+ }
1968
+ continue;
1969
+ }
1970
+ if (c === '\n') {
1971
+ // `cat <<'EOF' |` continues the pipeline on the line after the body.
1972
+ if (!ctx.afterPipe)
1973
+ endStatement(ctx, i);
1974
+ if (pending.length) {
1975
+ const after = readBodies(i);
1976
+ if (ctx.stageStart <= after)
1977
+ ctx.stageStart = after;
1978
+ i = after - 1;
1979
+ }
1980
+ continue;
1981
+ }
1982
+ if (c === '<' && next === '<' && text[i - 1] !== '<') {
1983
+ if (text[i + 2] === '<') {
1984
+ i += 2;
1985
+ continue;
1986
+ } // `<<<` here-string: quoted-span pass
1987
+ let j = i + 2;
1988
+ const stripTabs = text[j] === '-';
1989
+ if (stripTabs)
1990
+ j++;
1991
+ while (text[j] === ' ' || text[j] === '\t')
1992
+ j++;
1993
+ let delimiter = '';
1994
+ let literal = false;
1995
+ while (j < text.length && !/[\s;&|<>()]/.test(text[j])) {
1996
+ const q = text[j];
1997
+ if (q === "'" || q === '"') {
1998
+ const close = text.indexOf(q, j + 1);
1999
+ if (close < 0)
2000
+ return undefined;
2001
+ delimiter += text.slice(j + 1, close);
2002
+ literal = true;
2003
+ j = close + 1;
2004
+ }
2005
+ else if (q === '\\') {
2006
+ delimiter += text[j + 1] || '';
2007
+ literal = true;
2008
+ j += 2;
2009
+ }
2010
+ else {
2011
+ delimiter += q;
2012
+ j++;
2013
+ }
2014
+ }
2015
+ if (!delimiter) {
2016
+ i = j - 1;
2017
+ continue;
2018
+ }
2019
+ const span = { bodyStart: text.length, bodyEnd: text.length, end: text.length, body: '', literal, stages: [] };
2020
+ pending.push({ span, delimiter, stripTabs });
2021
+ ctx.open.push({ span, stage: ctx.stages.length });
2022
+ i = j - 1;
2023
+ continue;
2024
+ }
2025
+ }
2026
+ if (stack.length > 1 || stack[0].quote)
2027
+ return undefined;
2028
+ if (pending.length)
2029
+ readBodies(text.length - 1);
2030
+ endStatement(stack[0], text.length);
2031
+ return spans;
2032
+ }
2033
+ /** Heredocs of a POSIX line, each with the receiver that decides what its body is. */
2034
+ function heredocView(text) {
2035
+ if (!text.includes('<<') || text.length > 256_000)
2036
+ return undefined;
2037
+ const spans = lexHeredocs(text);
2038
+ if (!spans || !spans.length)
2039
+ return undefined;
2040
+ let skeleton = '';
2041
+ let at = 0;
2042
+ for (const span of spans) {
2043
+ skeleton += text.slice(at, span.bodyStart);
2044
+ at = span.end;
2045
+ }
2046
+ skeleton += text.slice(at);
2047
+ const statements = splitUnquotedPipelines(skeleton);
2048
+ const heredocs = spans.map(span => {
2049
+ let receiver = { kind: 'data', program: '' };
2050
+ for (const stage of span.stages)
2051
+ receiver = strongerReceiver(receiver, stdinReceiver(stage));
2052
+ receiver = strongerReceiver(receiver, laterRunnerOf(stageWriteTargets(span.stages[0] || ''), statements));
2053
+ return { ...span, receiver };
2054
+ });
2055
+ return { heredocs, skeleton };
2056
+ }
2057
+ /** POSIX heredocs of a command line, in order: body span, delimiter-line end, and receiver. */
2058
+ function posixHeredocs(text) {
2059
+ return (heredocView(text)?.heredocs || []).map(({ bodyStart, bodyEnd, end, body, receiver }) => ({ bodyStart, bodyEnd, end, body, receiver }));
2060
+ }
2061
+ /** Does this heredoc body run as code where it lands? */
2062
+ function heredocBodyActs(heredoc) {
2063
+ // Unquoted delimiter: `$( … )` / backticks in the body run before any receiver sees it.
2064
+ if (!heredoc.literal && INTERPOLATES.test(heredoc.body))
2065
+ return true;
2066
+ if (heredoc.receiver.kind === 'shell')
2067
+ return true;
2068
+ if (heredoc.receiver.kind === 'language') {
2069
+ return CODE_SPAWNS_PROCESS.test(heredoc.body)
2070
+ || (/^(?:perl|ruby)$/.test(heredoc.receiver.program) && /`[^`]*`|%x[\[{(]/.test(heredoc.body))
2071
+ || interpreterCodeRequestsMetadata(heredoc.body);
2072
+ }
2073
+ return false;
2074
+ }
2075
+ /**
2076
+ * Each body becomes `inert` when it is data, or `acting(index)` when it runs
2077
+ * (left as is without `acting`). Delimiter lines stay, so the result still
2078
+ * lexes. Returns the bodies that run so a caller can put them back verbatim.
2079
+ */
2080
+ function replaceHeredocBodies(text, view, inert, acting) {
2081
+ let out = '';
2082
+ let at = 0;
2083
+ const kept = [];
2084
+ for (const heredoc of view.heredocs) {
2085
+ const acts = heredocBodyActs(heredoc);
2086
+ if (acts && !acting)
2087
+ continue;
2088
+ out += text.slice(at, heredoc.bodyStart) + (acts ? acting(kept.push(heredoc.body) - 1) : inert);
2089
+ at = heredoc.bodyStart + heredoc.body.length;
2090
+ }
2091
+ return { text: out + text.slice(at), acting: kept };
2092
+ }
2093
+ /** The line as a bash parser sees it: bodies that RUN are opaque text there, so blank them. */
2094
+ function withoutActingHeredocBodies(text) {
2095
+ const view = heredocView(text);
2096
+ if (!view)
2097
+ return text;
2098
+ let out = '';
2099
+ let at = 0;
2100
+ for (const heredoc of view.heredocs) {
2101
+ if (!heredocBodyActs(heredoc))
2102
+ continue;
2103
+ out += text.slice(at, heredoc.bodyStart);
2104
+ at = heredoc.bodyStart + heredoc.body.length;
2105
+ }
2106
+ return out + text.slice(at);
2107
+ }
2108
+ const ACTING_BODY_MARK = (index) => `fcd_acting_heredoc_${index}_`;
2109
+ /** Receiver of a `<<<` here-string whose quoted word starts at `offset`. */
2110
+ function hereStringReceiver(src, offset) {
2111
+ const head = src.slice(src.lastIndexOf('\n', offset - 1) + 1, offset).replace(/<<<\s*$/, '');
2112
+ const statements = splitUnquotedPipelines(head);
2113
+ const stages = statements[statements.length - 1] || [];
2114
+ return stdinReceiver(stages[stages.length - 1] || '');
2115
+ }
1720
2116
  function stripInertDataSegments(text) {
1721
- if (DATA_TO_INTERPRETER.test(unquotedCode(text)))
2117
+ const heredocs = heredocView(text);
2118
+ // A data body (`cat > notes.md <<'EOF' … ssh … EOF`) cannot make the line an
2119
+ // interpreter feed; only the code around it, and bodies that run, can.
2120
+ const executable = heredocs ? replaceHeredocBodies(text, heredocs, '').text : text;
2121
+ if (DATA_TO_INTERPRETER.test(unquotedCode(executable)))
1722
2122
  return text;
2123
+ // A body that runs is code line for line: park it so the quoted-span pass
2124
+ // below cannot read `os.system('rm -rf ~')` as prose, then put it back.
2125
+ const parked = heredocs ? replaceHeredocBodies(text, heredocs, INERT_DATA, ACTING_BODY_MARK) : { text, acting: [] };
2126
+ let out = stripQuotedData(parked.text);
2127
+ parked.acting.forEach((body, index) => {
2128
+ const mark = ACTING_BODY_MARK(index);
2129
+ out = out.includes(mark) ? out.replace(mark, () => body) : `${out}\n${body}`;
2130
+ });
2131
+ return out;
2132
+ }
2133
+ /** Here-strings and quoted prose of a line whose heredoc bodies are already resolved. */
2134
+ function stripQuotedData(text) {
1723
2135
  let out = text;
1724
2136
  // PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
1725
2137
  out = replaceDelimited(out, "@'", "'@", () => INERT_DATA);
1726
2138
  out = replaceDelimited(out, '@"', '"@', m => (m.includes('$(') ? m : INERT_DATA));
1727
- // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
1728
- out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
1729
- out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
1730
2139
  // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
1731
2140
  // unless the span sits in a code position (invariants 2–4 above).
1732
2141
  const src = out;
@@ -1736,6 +2145,14 @@ function stripInertDataSegments(text) {
1736
2145
  if (!/\s/.test(body))
1737
2146
  return whole; // single token: argv, not prose
1738
2147
  const before = src.slice(Math.max(0, offset - 80), offset);
2148
+ // `bash <<< "…"` feeds the quoted word to stdin: it is what its reader makes of it.
2149
+ if (/<<<\s*$/.test(before)) {
2150
+ const receiver = hereStringReceiver(src, offset);
2151
+ if (receiver.kind === 'shell')
2152
+ return whole;
2153
+ if (receiver.kind === 'language')
2154
+ return CODE_SPAWNS_PROCESS.test(body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
2155
+ }
1739
2156
  // Language-interpreter code: data unless it spawns a process (see above).
1740
2157
  if (LANG_INTERPRETER_CODE_BEFORE.test(before))
1741
2158
  return interpreterCodeSpawnsProcess(before, body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.36.3"
2
+ "version": "1.36.4"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.36.3",
3
+ "version": "1.36.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {