fullcourtdefense-cli 1.36.2 → 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.
@@ -204,12 +204,28 @@ export declare function setShellAstMode(mode: ShellAstMode): void;
204
204
  export declare function getShellAstMode(): ShellAstMode;
205
205
  /** Shadow telemetry: the comparison recorded by the most recent shell scan (consumed once). */
206
206
  export declare function takeShellAstComparison(): ShellAstComparison | undefined;
207
+ /**
208
+ * PowerShell line, judged on its CODE only (`unquotedCode`): a cmdlet named
209
+ * inside a commit message or issue body does not change how the shell reads it.
210
+ */
211
+ export declare function isPowerShellLine(text: string): boolean;
207
212
  /**
208
213
  * Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
209
214
  * space. Detectors that ask "does this EXECUTE" must look here; the raw line
210
215
  * still carries fixture strings and docs.
211
216
  */
212
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
+ }>;
213
229
  export declare function stripInertDataSegments(text: string): string;
214
230
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
215
231
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
@@ -46,7 +46,9 @@ exports.setShellAstProvider = setShellAstProvider;
46
46
  exports.setShellAstMode = setShellAstMode;
47
47
  exports.getShellAstMode = getShellAstMode;
48
48
  exports.takeShellAstComparison = takeShellAstComparison;
49
+ exports.isPowerShellLine = isPowerShellLine;
49
50
  exports.unquotedCode = unquotedCode;
51
+ exports.posixHeredocs = posixHeredocs;
50
52
  exports.stripInertDataSegments = stripInertDataSegments;
51
53
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
52
54
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
@@ -1055,9 +1057,10 @@ function destructiveCommandDecision(value, commandContext) {
1055
1057
  if (astHit)
1056
1058
  return astHit;
1057
1059
  // The AST parsed the literal command only. A hit that exists only in a
1058
- // decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`) is
1059
- // outside what it saw, so it cannot clear it.
1060
- 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 || ''))
1061
1064
  return regexHit;
1062
1065
  if (regexRm) {
1063
1066
  // The AST cleared the rm-scope hit; other destructive rules still apply.
@@ -1510,13 +1513,15 @@ function isSensitivePathContext(toolName, candidate) {
1510
1513
  // (`wsl -e bash -c ""r""m"…`) that per-span reasoning cannot survive.
1511
1514
  // 2. A double-quoted span containing $( … ) or real backtick command
1512
1515
  // substitution is kept — substitution executes BEFORE the outer command.
1513
- // A single-char backtick pair (`n, `t) is a PowerShell ESCAPE, not code.
1516
+ // A single-char backtick pair (`n, `t) is a PowerShell ESCAPE, not code;
1517
+ // on a PowerShell line (`isPowerShellLine`) so is every `$ `" `` `n in
1518
+ // a body that uses the backtick only that way. `""` is an escaped quote.
1514
1519
  // 3. Single-token quoted spans (rm -rf "/") are argv, not prose — kept.
1515
1520
  // 4. Quoted spans in a CODE POSITION (interpreter -c/-Command/-e flags, or a
1516
1521
  // remote executor earlier in the same statement) are kept.
1517
1522
  // 5. Secret-literal rules and honeypot/custom rules never use this view —
1518
1523
  // a token pasted into a commit message still leaks.
1519
- 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;
1520
1525
  // A LANGUAGE interpreter's `-c` / `-e` argument is CODE in another language, not
1521
1526
  // a shell command line. `node -e "console.log('rm -rf /')"` prints a string;
1522
1527
  // `python -c "print('DROP TABLE')"` prints a string. That code only reaches the
@@ -1541,36 +1546,70 @@ function interpreterFileArguments(text) {
1541
1546
  if (text.length > 32_000)
1542
1547
  return '';
1543
1548
  const paths = [];
1544
- let calls = 0;
1549
+ const budget = { calls: 0 };
1545
1550
  text.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1546
1551
  if (!LANG_INTERPRETER_CODE_BEFORE.test(text.slice(Math.max(0, offset - 80), offset)))
1547
1552
  return whole;
1548
- const code = dq !== undefined ? dq : (sq || '');
1549
- // Preserve offsets while masking string contents: a call printed inside a
1550
- // string is not an invocation; a sensitive path in another argument is data.
1551
- const instructions = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, literal => ' '.repeat(literal.length));
1552
- for (const call of instructions.matchAll(/\b(?:open|fopen|readFile(?:Sync)?|createReadStream|ReadAllText|ReadAllBytes)\s*\(/g)) {
1553
- if (++calls > 48)
1554
- break;
1555
- const start = (call.index || 0) + call[0].length;
1556
- let depth = 1;
1557
- for (let end = start; end < instructions.length; end++) {
1558
- const ch = instructions[end];
1559
- if ((ch === ')' && --depth === 0) || (ch === ',' && depth === 1)) {
1560
- paths.push(code.slice(start, end));
1561
- break;
1562
- }
1563
- if (ch === '(')
1564
- depth++;
1565
- }
1566
- }
1553
+ collectFileCallArguments(dq !== undefined ? dq : (sq || ''), paths, budget);
1567
1554
  return whole;
1568
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
+ }
1569
1562
  return paths.join('\n');
1570
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
+ }
1571
1585
  // $( … ) always executes; backtick pairs only with ≥2 chars of content
1572
1586
  // (PowerShell escapes like `n / `t are single-char and inert).
1573
1587
  const INTERPOLATES = /\$\(|`[^`]{2,}`/;
1588
+ // Same signals as shellDialectOf (shellQuoteMask.ts / shellAst.ts looksLikePowerShell).
1589
+ const POWERSHELL_LINE = /\$env:|-ErrorAction\b|\bWrite-(?:Output|Host)\b|\b(?:Get|Set|New|Remove|Start|Stop|Invoke|Select|Where|ForEach|Test|Add|Format|Out|ConvertTo|ConvertFrom|Import|Export|Measure|Sort|Group|Restart|Register|Unregister|Enable|Disable|Copy|Move|Rename|Resolve|Split|Join|Read|Wait|Clear)-[A-Z][A-Za-z]+\b|@\{|@'|@"|\[(?:System\.|Security\.|IO\.|math\])|\$_\.|\$\w+\s*=\s*(?:Get|New|Read)-|\|\s*%\s|\|\s*\?\s/;
1590
+ const POWERSHELL_ONLY_SYNTAX = /(?:^|[\s;{(|])\$[A-Za-z_][\w:]*\s*[+-]?=(?!=)|`"|(?:^|[\s;{])(?:if|elseif|foreach|while|switch)\s*\((?!\()|\}\s*(?:else|elseif|catch|finally)\b|\|\s*(?:where|foreach|%|\?)\s*\{/i;
1591
+ /**
1592
+ * PowerShell line, judged on its CODE only (`unquotedCode`): a cmdlet named
1593
+ * inside a commit message or issue body does not change how the shell reads it.
1594
+ */
1595
+ function isPowerShellLine(text) {
1596
+ const code = unquotedCode(text);
1597
+ return POWERSHELL_LINE.test(code) || POWERSHELL_ONLY_SYNTAX.test(code);
1598
+ }
1599
+ // Every backtick is a canonical PowerShell escape (`$ `" `' `` `n `t `r `0 …).
1600
+ const POWERSHELL_ESCAPES_ONLY = /^(?:[^`]|`[$"'`0abefnrtuv])*$/;
1601
+ /**
1602
+ * Does a double-quoted body run code before its command? `$( … )` always does.
1603
+ * A backtick pair is POSIX command substitution, except on a PowerShell line
1604
+ * whose body uses the backtick only as PowerShell's escape character there.
1605
+ */
1606
+ function doubleQuotedInterpolates(body, powershell) {
1607
+ if (body.includes('$('))
1608
+ return true;
1609
+ if (!/`[^`]{2,}`/.test(body))
1610
+ return false;
1611
+ return !(powershell && POWERSHELL_ESCAPES_ONLY.test(body));
1612
+ }
1574
1613
  const INERT_DATA = ' fcd_inert_data ';
1575
1614
  // Code position immediately before a quoted span: interpreter code flags or a
1576
1615
  // remote/nested executor earlier in the same statement (no ; & | between).
@@ -1596,6 +1635,11 @@ function unquotedCode(text) {
1596
1635
  i += 2;
1597
1636
  continue;
1598
1637
  }
1638
+ // `""` inside "…" is PowerShell's escaped quote; POSIX reads close+reopen, text either way.
1639
+ if (q === '"' && text[i] === '"' && text[i + 1] === '"') {
1640
+ i += 2;
1641
+ continue;
1642
+ }
1599
1643
  if (text[i] === q)
1600
1644
  break;
1601
1645
  i += 1;
@@ -1629,12 +1673,12 @@ function replaceDelimited(text, open, close, fn) {
1629
1673
  }
1630
1674
  const LINE_TERMINATOR = new Set(['\n', '\r', '\u2028', '\u2029']);
1631
1675
  /**
1632
- * `src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, fn)` in one
1676
+ * `src.replace(/"((?:[^"\\`]|""|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, fn)` in one
1633
1677
  * pass, same matches and callback arguments (a PowerShell backtick escapes any
1634
- * character inside double quotes). After an unclosed quote the regex retried
1635
- * every later quote of that kind; each one before the dead end is the tail of an
1636
- * escape pair and walks the same path to the same dead end (quadratic on a body
1637
- * of `\"`).
1678
+ * character inside double quotes; `""` is an escaped quote there). After an
1679
+ * unclosed quote the regex retried every later quote of that kind; each one
1680
+ * before the dead end is the tail of an escape pair and walks the same path to
1681
+ * the same dead end (quadratic on a body of `\"`).
1638
1682
  */
1639
1683
  function replaceQuotedSpans(src, fn) {
1640
1684
  const deadUntil = { '"': -1, "'": -1 };
@@ -1651,6 +1695,10 @@ function replaceQuotedSpans(src, fn) {
1651
1695
  let closed = -1;
1652
1696
  while (j < src.length) {
1653
1697
  const c = src[j];
1698
+ if (c === '"' && quote === '"' && src[j + 1] === '"') {
1699
+ j += 2;
1700
+ continue;
1701
+ }
1654
1702
  if (c === quote) {
1655
1703
  closed = j;
1656
1704
  break;
@@ -1680,30 +1728,437 @@ function replaceQuotedSpans(src, fn) {
1680
1728
  }
1681
1729
  return out + src.slice(from);
1682
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
+ }
1683
2116
  function stripInertDataSegments(text) {
1684
- 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)))
1685
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) {
1686
2135
  let out = text;
1687
2136
  // PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
1688
2137
  out = replaceDelimited(out, "@'", "'@", () => INERT_DATA);
1689
2138
  out = replaceDelimited(out, '@"', '"@', m => (m.includes('$(') ? m : INERT_DATA));
1690
- // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
1691
- out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
1692
- out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
1693
2139
  // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
1694
2140
  // unless the span sits in a code position (invariants 2–4 above).
1695
2141
  const src = out;
2142
+ const powershell = isPowerShellLine(src);
1696
2143
  out = replaceQuotedSpans(src, (whole, dq, sq, offset) => {
1697
2144
  const body = dq !== undefined ? dq : (sq || '');
1698
2145
  if (!/\s/.test(body))
1699
2146
  return whole; // single token: argv, not prose
1700
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
+ }
1701
2156
  // Language-interpreter code: data unless it spawns a process (see above).
1702
2157
  if (LANG_INTERPRETER_CODE_BEFORE.test(before))
1703
2158
  return interpreterCodeSpawnsProcess(before, body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
1704
2159
  if (CODE_CONTEXT_BEFORE.test(before))
1705
2160
  return whole; // code position: executes
1706
- if (dq !== undefined && INTERPOLATES.test(body))
2161
+ if (dq !== undefined && doubleQuotedInterpolates(body, powershell))
1707
2162
  return whole; // $( )/`cmd` executes first
1708
2163
  return INERT_DATA;
1709
2164
  });
@@ -165,6 +165,7 @@ async function healthCheck(apiUrl, config) {
165
165
  }
166
166
  console.log('');
167
167
  const identity = getMachineIdentity();
168
+ console.log(`machine id: ${identity.machineId.slice(0, 8)} (source: ${identity.idSource || 'unknown'}${identity.idConfirmed ? ', backend-confirmed' : ', not yet confirmed by the backend'})`);
168
169
  const report = await runDeepSelfTest({
169
170
  creds,
170
171
  developerName: identity.developerName,
@@ -20,6 +20,9 @@ async function loginCommand(args, config) {
20
20
  throw new Error('A fleet enrollment token is required. Pass --token <token> or set FCD_ENROLL_TOKEN.\n'
21
21
  + 'An org admin issues one from the AI Fleet console (Settings → Fleet enrollment token).');
22
22
  }
23
+ // Enrollment binds the fleet record to this id for good: an unconfirmed
24
+ // fallback pin gets one native re-check first, throttle or not.
25
+ (0, machineIdentity_1.reconcileMachineIdentity)('force');
23
26
  const identity = (0, machineIdentity_1.getMachineIdentity)();
24
27
  const shieldName = (0, machineIdentity_1.suggestedShieldName)(identity);
25
28
  const nickname = (args.nickname || process.env.FCD_MACHINE_NICKNAME || '').trim().slice(0, 80) || undefined;
@@ -51,6 +54,7 @@ async function loginCommand(args, config) {
51
54
  throw new Error(data.error || `Enrollment failed (HTTP ${resp.status})`);
52
55
  }
53
56
  const result = data.data;
57
+ (0, machineIdentity_1.confirmMachineIdentity)(identity.machineId, 'enrollment');
54
58
  // Clear any leftover runtime cache (previous enrollment, failed/partial
55
59
  // install) so a stale enforcing bundle can never keep blocking on a freshly
56
60
  // enrolled machine. The next hook/gateway call fetches the real bundle.