fullcourtdefense-cli 1.36.0 → 1.36.2

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 (63) hide show
  1. package/dist/actionPolicyEngine.d.ts +23 -0
  2. package/dist/actionPolicyEngine.js +58 -19
  3. package/dist/approvalPrompt.d.ts +15 -0
  4. package/dist/approvalPrompt.js +157 -20
  5. package/dist/askDialog.d.ts +2 -0
  6. package/dist/askDialog.js +15 -1
  7. package/dist/askHeader.d.ts +81 -0
  8. package/dist/askHeader.js +573 -0
  9. package/dist/blockExplanation.d.ts +9 -0
  10. package/dist/blockExplanation.js +14 -0
  11. package/dist/callerEnv.d.ts +1 -1
  12. package/dist/callerEnv.js +14 -0
  13. package/dist/cloudGrant.d.ts +46 -0
  14. package/dist/cloudGrant.js +420 -0
  15. package/dist/cloudGrantTools.d.ts +97 -0
  16. package/dist/cloudGrantTools.js +1112 -0
  17. package/dist/commandNarrative.d.ts +8 -0
  18. package/dist/commandNarrative.js +222 -49
  19. package/dist/commands/cmdGuard.js +1 -1
  20. package/dist/commands/daemon.js +14 -0
  21. package/dist/commands/deterministicGuard.d.ts +8 -2
  22. package/dist/commands/deterministicGuard.js +219 -55
  23. package/dist/commands/discover.js +37 -14
  24. package/dist/commands/discoverAgentFiles.d.ts +2 -0
  25. package/dist/commands/discoverAgentFiles.js +1 -0
  26. package/dist/commands/discoverPaths.d.ts +4 -0
  27. package/dist/commands/discoverPaths.js +19 -1
  28. package/dist/commands/discoverProjectRoots.d.ts +72 -0
  29. package/dist/commands/discoverProjectRoots.js +327 -0
  30. package/dist/commands/discoverProxy.d.ts +1 -1
  31. package/dist/commands/discoverProxy.js +1 -1
  32. package/dist/commands/discoverSecrets.d.ts +44 -0
  33. package/dist/commands/discoverSecrets.js +158 -14
  34. package/dist/commands/hook.js +10 -7
  35. package/dist/commands/mcpGateway.js +9 -7
  36. package/dist/commands/posixShellGuard.js +1 -1
  37. package/dist/commands/shellGuard.js +70 -25
  38. package/dist/commands/uninstallAll.d.ts +1 -7
  39. package/dist/commands/uninstallAll.js +6 -45
  40. package/dist/config.d.ts +1 -1
  41. package/dist/config.js +3 -3
  42. package/dist/credentialGrant.d.ts +48 -0
  43. package/dist/credentialGrant.js +29 -18
  44. package/dist/credentialStore.js +1 -1
  45. package/dist/devConfirm.d.ts +44 -3
  46. package/dist/devConfirm.js +166 -30
  47. package/dist/distress.d.ts +2 -0
  48. package/dist/distress.js +2 -0
  49. package/dist/grantTargets.d.ts +21 -0
  50. package/dist/grantTargets.js +235 -0
  51. package/dist/localSafetySnapshot.js +2 -0
  52. package/dist/msiProductCode.d.ts +10 -0
  53. package/dist/msiProductCode.js +124 -0
  54. package/dist/notify.js +1 -0
  55. package/dist/selfTest.d.ts +1 -1
  56. package/dist/selfTest.js +1 -1
  57. package/dist/selfUpdate.d.ts +51 -2
  58. package/dist/selfUpdate.js +217 -19
  59. package/dist/shellAst.d.ts +2 -0
  60. package/dist/shellAst.js +7 -1
  61. package/dist/shellAstLoader.js +12 -1
  62. package/dist/version.json +1 -1
  63. package/package.json +6 -1
@@ -221,15 +221,6 @@ exports.DEFAULT_DISABLED_BUILT_INS = new Set([
221
221
  // IDE). Off by default — orgs opt in via the Local Safety console toggle,
222
222
  // which the runtime snapshot then applies on every machine.
223
223
  'contextual_secret',
224
- // Cloud token printers / secret fetchers used constantly by LEGITIMATE
225
- // deploy tooling and dev loops. Off by default; orgs with stricter posture
226
- // opt in via the console. (The rarely-legitimate forms — gh auth token,
227
- // git credential fill, keychain dumps — are ON by default.)
228
- 'gcloud_access_token_cmd',
229
- 'gcloud_secret_access_cmd',
230
- 'az_access_token_cmd',
231
- 'aws_secret_fetch_cmd',
232
- 'vault_read_cmd',
233
224
  ]);
234
225
  function evidence(value) {
235
226
  const compact = value.replace(/\s+/g, ' ').trim();
@@ -446,8 +437,18 @@ function isEnabled(itemId, options) {
446
437
  * the snapshot carries no explicit action for the item — an org override to
447
438
  * 'block' ships explicitly in the snapshot and wins. Keeps offline machines
448
439
  * and stale snapshots on the warn-first rollout instead of hard-blocking.
440
+ * The cloud token printers / secret fetchers are used constantly by
441
+ * legitimate deploy tooling and dev loops: recorded, never stopped, unless an
442
+ * org sets them to Ask / Block / Off (#1837).
449
443
  */
450
- const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
444
+ const DEFAULT_WARN_BUILT_INS = new Set([
445
+ 'env_workspace',
446
+ 'gcloud_access_token_cmd',
447
+ 'gcloud_secret_access_cmd',
448
+ 'az_access_token_cmd',
449
+ 'aws_secret_fetch_cmd',
450
+ 'vault_read_cmd',
451
+ ]);
451
452
  /**
452
453
  * Items that ASK by default when the snapshot carries no action for them — a
453
454
  * snapshot built before the item existed (older backend) must not turn a new
@@ -683,8 +684,8 @@ function envFileClass(token) {
683
684
  * useless against them because the secret never comes from a watched file
684
685
  * ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
685
686
  * rarely-legitimate-for-agents forms are on by default; cloud token printers
686
- * that legitimate deploy tooling uses constantly are strict (off by default,
687
- * see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
687
+ * that legitimate deploy tooling uses constantly are strict (warn by default,
688
+ * see DEFAULT_WARN_BUILT_INS). Ids match local-safety-catalog.ts.
688
689
  */
689
690
  exports.CREDENTIAL_COMMAND_RULES = [
690
691
  { itemId: 'gh_auth_token_cmd', categoryId: 'credential_commands', severity: 'critical', commands: ['gh'],
@@ -798,7 +799,9 @@ const RC_EXT = String.raw `(?:\.cmd|\.exe|\.ps1)?`;
798
799
  // Statement start, then transparent wrappers (`sudo`, `env X=1`, `cmd /c`, `bash -c "`, `pwsh -Command "`).
799
800
  const RC_START = String.raw `(?:^|[;&|(\n])\s*`;
800
801
  const RC_WRAPPERS = String.raw `(?:(?:(?:sudo|doas)(?:\s+-[a-z]+)*|env|nohup|time|exec|command|call|start|[a-z_][a-z0-9_]*=[^\s;&|]*|(?:ba|z|da|k)?sh\s+-l?c|cmd(?:\.exe)?\s+/[ck]|(?:powershell|pwsh)(?:\.exe)?(?:\s+-[a-z]+)*?\s+-c(?:ommand)?)\s+["']?)*`;
801
- const RC_PATH = String.raw `(?:[^\s;&|"']*[\\/])?`;
802
+ // No `(` in the path: `(` is itself a statement start above, and a path class that
803
+ // spans it re-scanned a whole `(((…` run from every `(` (quadratic, 8 KB ~0.2 s per rule).
804
+ const RC_PATH = String.raw `(?:[^\s;&|"'(]*[\\/])?`;
802
805
  const RC_LAUNCHER = String.raw `(?:(?:npx|pnpx|bunx|uvx)${RC_EXT}|npm${RC_EXT}\s+(?:exec|x)|pnpm${RC_EXT}\s+(?:dlx|exec)|yarn${RC_EXT}\s+dlx|bun${RC_EXT}\s+x|uv${RC_EXT}\s+tool\s+run|pipx${RC_EXT}\s+run)`;
803
806
  // Launcher options (`-y`, `--package=x`, `-p x`, `--from x`); a value never starts with `-`.
804
807
  const RC_LAUNCHER_OPTIONS = String.raw `(?:\s+-{1,2}[a-z][\w-]*(?:=[^\s;&|]*)?(?:\s+[^\s;&|-][^\s;&|]*)?)*?`;
@@ -914,7 +917,7 @@ function canonicalRemoteLaunch(match) {
914
917
  // wipes the CONTAINER's workdir — routine build/image cleanup on a
915
918
  // docker-heavy fleet — not the developer's machine, so it must never warn or
916
919
  // stall. Root targets (`/`, `/*`) stay blocked even inside containers (the
917
- // Patria case: an agent nuking a running container's filesystem).
920
+ // Contoso case: an agent nuking a running container's filesystem).
918
921
  const CONTAINER_EXEC_BEFORE = /\b(?:docker|podman|nerdctl|kubectl)(?:\.exe)?\s+(?:compose\s+)?(?:exec|run)\b[^;&|]*$/i;
919
922
  let shellAstProvider;
920
923
  let shellAstMode = 'shadow';
@@ -947,10 +950,10 @@ const AST_TRANSPARENT_WRAPPERS = new Set(['sudo', 'doas', 'pkexec', 'run0', 'gos
947
950
  * of `bash -c` / `docker run … sh -c` are included; inside a container a bare
948
951
  * `*` is the container workdir (cleanup), the root `/` is still the root.
949
952
  */
950
- function astDestructiveRm(facts, inContainer = false) {
953
+ function astDestructiveRm(facts, inContainer = false, text = '') {
951
954
  for (const command of facts.commands) {
952
955
  if (command.wrapper?.inner) {
953
- const hit = astDestructiveRm(command.wrapper.inner, inContainer || command.wrapper.kind === 'container');
956
+ const hit = astDestructiveRm(command.wrapper.inner, inContainer || command.wrapper.kind === 'container', text);
954
957
  if (hit)
955
958
  return hit;
956
959
  }
@@ -1014,7 +1017,8 @@ function astDestructiveRm(facts, inContainer = false) {
1014
1017
  if (/^(?:\*|\.\/+\*)$/.test(t)) {
1015
1018
  if (inContainer || command.cwdScope === 'scoped')
1016
1019
  continue;
1017
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of the current directory tree (rm -rf *)' };
1020
+ // Wording from the `cd` before the removal, as on the regex path.
1021
+ return bareStarRemoval(text.slice(0, Math.max(0, text.toLowerCase().lastIndexOf('rm '))));
1018
1022
  }
1019
1023
  }
1020
1024
  }
@@ -1028,9 +1032,12 @@ const RM_SCOPE_ITEMS = new Set(['rm_rf_root', 'rm_rf_home']);
1028
1032
  * a regex false positive or adding a structurally proven hit the regex missed —
1029
1033
  * while every other destructive rule keeps its regex verdict.
1030
1034
  */
1031
- function destructiveCommandDecision(value) {
1035
+ function destructiveCommandDecision(value, commandContext) {
1032
1036
  const regexHit = destructiveCommandReason(value);
1033
- if (shellAstMode === 'off' || !shellAstProvider)
1037
+ // Text outside a command position (file content, a search query, a path) is
1038
+ // gated out of destructive findings by scanBuiltInRules, so a parse cannot
1039
+ // change its verdict; the bash grammar is super-linear on long non-shell text.
1040
+ if (shellAstMode === 'off' || !shellAstProvider || !commandContext)
1034
1041
  return regexHit;
1035
1042
  let facts;
1036
1043
  try {
@@ -1040,7 +1047,7 @@ function destructiveCommandDecision(value) {
1040
1047
  facts = undefined;
1041
1048
  }
1042
1049
  const complete = shellAstFactsFullyComplete(facts);
1043
- const astHit = complete && facts ? astDestructiveRm(facts) : undefined;
1050
+ const astHit = complete && facts ? astDestructiveRm(facts, false, value) : undefined;
1044
1051
  const regexRm = regexHit && RM_SCOPE_ITEMS.has(regexHit.itemId) ? regexHit.itemId : undefined;
1045
1052
  lastShellAstComparison = { mode: shellAstMode, complete, regex: regexRm, ast: astHit?.itemId, agree: !complete || regexRm === astHit?.itemId };
1046
1053
  if (shellAstMode !== 'primary' || !complete)
@@ -1061,6 +1068,34 @@ function destructiveCommandDecision(value) {
1061
1068
  // Every `cd <target>` statement start (cwd persists across `&&`, `;`, `|`,
1062
1069
  // newlines and `then`/`do`); the caller keeps the LAST one before the `rm`.
1063
1070
  const CD_STATEMENT = /(?:^|[;&|\n(]|\bthen\b|\bdo\b)\s*cd\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi;
1071
+ /** The directory the LAST `cd` before this point changed into (dequoted, `/` separators); undefined when none applies. */
1072
+ function lastCdTarget(before) {
1073
+ let last = null;
1074
+ CD_STATEMENT.lastIndex = 0;
1075
+ for (let m = CD_STATEMENT.exec(before); m; m = CD_STATEMENT.exec(before))
1076
+ last = m;
1077
+ if (!last)
1078
+ return undefined;
1079
+ // A `)` after the cd closes a subshell: `(cd x && …); rm -rf *` runs the rm
1080
+ // back in the ORIGINAL directory, which the cd never changed.
1081
+ if (before.slice(last.index + last[0].length).includes(')'))
1082
+ return undefined;
1083
+ const target = last[1].replace(/^["']|["']$/g, '').trim().replace(/\\/g, '/');
1084
+ return target || undefined;
1085
+ }
1086
+ /**
1087
+ * The finding for a bare `rm -rf *` that is not scoped: it wipes the CURRENT
1088
+ * folder — the filesystem root or the home folder only when a `cd` said so.
1089
+ * Wording only; the item (and so the verdict) is `rm_rf_root` in every case.
1090
+ */
1091
+ function bareStarRemoval(before) {
1092
+ const target = (lastCdTarget(before) || '').toLowerCase();
1093
+ if (/^(?:\/+\*?|[a-z]:\/*\*?)$/.test(target))
1094
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
1095
+ if (/^(?:~\/*\*?|\$home\/?|\$\{home\}\/?|%userprofile%\/?|\$env:userprofile\/?)$/.test(target))
1096
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of the home directory' };
1097
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of everything in the current folder (rm -rf *)' };
1098
+ }
1064
1099
  /**
1065
1100
  * Bare `rm -rf *` wipes whatever the CURRENT directory is. When the same
1066
1101
  * script has already `cd`'d into a scoped directory — a literal subpath such
@@ -1072,17 +1107,7 @@ const CD_STATEMENT = /(?:^|[;&|\n(]|\bthen\b|\bdo\b)\s*cd\s+(?:--\s+)?("[^"]*"|'
1072
1107
  * conservative reading: the agent's cwd is usually the project.
1073
1108
  */
1074
1109
  function cdScopedBeforeBareStar(before) {
1075
- let last = null;
1076
- CD_STATEMENT.lastIndex = 0;
1077
- for (let m = CD_STATEMENT.exec(before); m; m = CD_STATEMENT.exec(before))
1078
- last = m;
1079
- if (!last)
1080
- return false;
1081
- // A `)` after the cd closes a subshell: `(cd x && …); rm -rf *` runs the rm
1082
- // back in the ORIGINAL directory, which the cd never changed.
1083
- if (before.slice(last.index + last[0].length).includes(')'))
1084
- return false;
1085
- const target = last[1].replace(/^["']|["']$/g, '').trim().replace(/\\/g, '/');
1110
+ const target = lastCdTarget(before);
1086
1111
  if (!target)
1087
1112
  return false;
1088
1113
  const lower = target.toLowerCase();
@@ -1176,9 +1201,8 @@ function destructiveCommandReason(value, opts = {}) {
1176
1201
  const bareStars = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/g;
1177
1202
  for (let bareStar = bareStars.exec(lower); bareStar; bareStar = bareStars.exec(lower)) {
1178
1203
  const before = lower.slice(0, bareStar.index);
1179
- if (!CONTAINER_EXEC_BEFORE.test(before) && !cdScopedBeforeBareStar(before)) {
1180
- return { itemId: 'rm_rf_root', reason: 'recursive force delete of the current directory tree (rm -rf *)' };
1181
- }
1204
+ if (!CONTAINER_EXEC_BEFORE.test(before) && !cdScopedBeforeBareStar(before))
1205
+ return bareStarRemoval(before);
1182
1206
  }
1183
1207
  }
1184
1208
  // Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
@@ -1322,8 +1346,16 @@ function isCommandContext(toolName, candidate) {
1322
1346
  || COMMAND_KEYS.has(lastKey) || COMMAND_KEYS.has(candidate.keyPath);
1323
1347
  }
1324
1348
  function isSqlContext(toolName, candidate) {
1349
+ if (SQL_TOOL_HINT.test(toolName))
1350
+ return true;
1325
1351
  const lastKey = candidate.keyPath.split('.').pop() || '';
1326
- return SQL_TOOL_HINT.test(toolName) || SQL_KEYS.has(lastKey) || SQL_KEYS.has(candidate.keyPath);
1352
+ // A search / list tool's `query` is the text it looks for (`list_issues {
1353
+ // query: "… drop schema …" }`), not a statement it runs; `sql` still runs.
1354
+ const nameWords = toolName.toLowerCase().replace(/[_\-.]+/g, ' ');
1355
+ if (lastKey === 'query' && /\b(?:search|grep|find|lookup|locate|list)\b/.test(nameWords)
1356
+ && !/\b(?:delete|remove|drop|write|update|insert|replace|create)\b/.test(nameWords))
1357
+ return false;
1358
+ return SQL_KEYS.has(lastKey) || SQL_KEYS.has(candidate.keyPath);
1327
1359
  }
1328
1360
  function isWebLookupTarget(toolName, candidate) {
1329
1361
  return WEB_LOOKUP_TOOL_HINT.test(toolName) && candidate.keyPath.split('.').pop() === 'ref_id';
@@ -1368,10 +1400,13 @@ function isPowerShellBacktickEscape(text, i, quote) {
1368
1400
  return true;
1369
1401
  return quote === null && /["'$\s]/.test(text[i + 1]);
1370
1402
  }
1371
- function splitUnquotedCommandSegments(command) {
1403
+ /** Unquoted statements (`;`, newline, `&&`, `||`), each split into its single-`|` pipeline stages. */
1404
+ function splitUnquotedPipelines(command) {
1372
1405
  const out = [];
1406
+ let stages = [];
1373
1407
  let cur = '';
1374
1408
  let quote = null;
1409
+ const endStatement = () => { stages.push(cur); out.push(stages); stages = []; cur = ''; };
1375
1410
  for (let i = 0; i < command.length; i++) {
1376
1411
  const c = command[i];
1377
1412
  if (isPowerShellBacktickEscape(command, i, quote)) {
@@ -1394,29 +1429,35 @@ function splitUnquotedCommandSegments(command) {
1394
1429
  continue;
1395
1430
  }
1396
1431
  if (c === '\n' || c === ';') {
1397
- out.push(cur);
1398
- cur = '';
1432
+ endStatement();
1399
1433
  continue;
1400
1434
  }
1401
1435
  if (c === '&' && command[i + 1] === '&') {
1402
- out.push(cur);
1403
- cur = '';
1436
+ endStatement();
1404
1437
  i += 1;
1405
1438
  continue;
1406
1439
  }
1407
1440
  if (c === '|') {
1408
- if (command[i + 1] === '|')
1441
+ if (command[i + 1] === '|') {
1442
+ endStatement();
1409
1443
  i += 1;
1410
- out.push(cur);
1444
+ continue;
1445
+ }
1446
+ stages.push(cur);
1411
1447
  cur = '';
1412
1448
  continue;
1413
1449
  }
1414
1450
  cur += c;
1415
1451
  }
1416
1452
  if (cur)
1417
- out.push(cur);
1453
+ stages.push(cur);
1454
+ if (stages.length)
1455
+ out.push(stages);
1418
1456
  return out;
1419
1457
  }
1458
+ function splitUnquotedCommandSegments(command) {
1459
+ return [].concat(...splitUnquotedPipelines(command));
1460
+ }
1420
1461
  /** True when every segment that mentions a metadata endpoint is a searcher, not a client. */
1421
1462
  function commandOnlySearchesMetadata(command) {
1422
1463
  if (!containsMetadataEndpoint(command))
@@ -1475,7 +1516,7 @@ function isSensitivePathContext(toolName, candidate) {
1475
1516
  // remote executor earlier in the same statement) are kept.
1476
1517
  // 5. Secret-literal rules and honeypot/custom rules never use this view —
1477
1518
  // a token pasted into a commit message still leaks.
1478
- 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;
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;
1479
1520
  // A LANGUAGE interpreter's `-c` / `-e` argument is CODE in another language, not
1480
1521
  // a shell command line. `node -e "console.log('rm -rf /')"` prints a string;
1481
1522
  // `python -c "print('DROP TABLE')"` prints a string. That code only reaches the
@@ -1566,20 +1607,93 @@ function unquotedCode(text) {
1566
1607
  }
1567
1608
  return out;
1568
1609
  }
1610
+ /**
1611
+ * `text.replace(/OPEN[\s\S]*?CLOSE/g, fn)` in one pass: the lazy regex re-scans
1612
+ * to the end from every OPEN once no CLOSE follows (quadratic on a long body of
1613
+ * unclosed `@'`); once no CLOSE remains, no later OPEN can match either.
1614
+ */
1615
+ function replaceDelimited(text, open, close, fn) {
1616
+ let out = '';
1617
+ let from = 0;
1618
+ for (;;) {
1619
+ const start = text.indexOf(open, from);
1620
+ if (start < 0)
1621
+ break;
1622
+ const end = text.indexOf(close, start + open.length);
1623
+ if (end < 0)
1624
+ break;
1625
+ out += text.slice(from, start) + fn(text.slice(start, end + close.length));
1626
+ from = end + close.length;
1627
+ }
1628
+ return out + text.slice(from);
1629
+ }
1630
+ const LINE_TERMINATOR = new Set(['\n', '\r', '\u2028', '\u2029']);
1631
+ /**
1632
+ * `src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, fn)` in one
1633
+ * 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 `\"`).
1638
+ */
1639
+ function replaceQuotedSpans(src, fn) {
1640
+ const deadUntil = { '"': -1, "'": -1 };
1641
+ let out = '';
1642
+ let from = 0;
1643
+ let i = 0;
1644
+ while (i < src.length) {
1645
+ const quote = src[i];
1646
+ if ((quote !== '"' && quote !== "'") || i < deadUntil[quote]) {
1647
+ i++;
1648
+ continue;
1649
+ }
1650
+ let j = i + 1;
1651
+ let closed = -1;
1652
+ while (j < src.length) {
1653
+ const c = src[j];
1654
+ if (c === quote) {
1655
+ closed = j;
1656
+ break;
1657
+ }
1658
+ if (c === '`' && quote === '"') {
1659
+ if (j + 1 >= src.length)
1660
+ break;
1661
+ j += 2;
1662
+ continue;
1663
+ }
1664
+ if (c === '\\') {
1665
+ if (j + 1 >= src.length || LINE_TERMINATOR.has(src[j + 1]))
1666
+ break;
1667
+ j += 2;
1668
+ continue;
1669
+ }
1670
+ j++;
1671
+ }
1672
+ if (closed < 0) {
1673
+ deadUntil[quote] = j;
1674
+ i++;
1675
+ continue;
1676
+ }
1677
+ const body = src.slice(i + 1, closed);
1678
+ out += src.slice(from, i) + fn(src.slice(i, closed + 1), quote === '"' ? body : undefined, quote === "'" ? body : undefined, i);
1679
+ from = i = closed + 1;
1680
+ }
1681
+ return out + src.slice(from);
1682
+ }
1569
1683
  function stripInertDataSegments(text) {
1570
1684
  if (DATA_TO_INTERPRETER.test(unquotedCode(text)))
1571
1685
  return text;
1572
1686
  let out = text;
1573
1687
  // PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
1574
- out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
1575
- out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
1688
+ out = replaceDelimited(out, "@'", "'@", () => INERT_DATA);
1689
+ out = replaceDelimited(out, '@"', '"@', m => (m.includes('$(') ? m : INERT_DATA));
1576
1690
  // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
1577
1691
  out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
1578
1692
  out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
1579
1693
  // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
1580
1694
  // unless the span sits in a code position (invariants 2–4 above).
1581
1695
  const src = out;
1582
- out = src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1696
+ out = replaceQuotedSpans(src, (whole, dq, sq, offset) => {
1583
1697
  const body = dq !== undefined ? dq : (sq || '');
1584
1698
  if (!/\s/.test(body))
1585
1699
  return whole; // single token: argv, not prose
@@ -1881,7 +1995,7 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1881
1995
  if (finding)
1882
1996
  return { ...finding, remoteControl: remoteBridge.facts };
1883
1997
  }
1884
- const destructive = destructiveCommandDecision(value);
1998
+ const destructive = destructiveCommandDecision(value, commandContext);
1885
1999
  if (destructive) {
1886
2000
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
1887
2001
  }
@@ -1942,7 +2056,22 @@ function redirectTargets(segment) {
1942
2056
  out.push(match[2]);
1943
2057
  return out;
1944
2058
  }
1945
- function isFcdUninstall(program, rest) {
2059
+ const MSI_PRODUCT_CODE_RE = /^\{?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}?/i;
2060
+ /** `msiexec /x <target>` whose target is a FullCourtDefense package path/name or the recorded FCD ProductCode. */
2061
+ function msiexecRemovesFcd(argText, productCodes) {
2062
+ const at = argText.match(/(?:^|[\s,'"])[/-](?:x|uninstall)(?=[\s,'"{]|$)/i);
2063
+ if (!at || at.index === undefined)
2064
+ return false;
2065
+ const target = argText.slice(at.index + at[0].length).replace(/^[\s,'"]+/, '').split(/[\s,'"]+[/-][a-z]/i)[0];
2066
+ if (/fullcourtdefense/i.test(target))
2067
+ return true;
2068
+ const guid = target.match(MSI_PRODUCT_CODE_RE);
2069
+ if (!guid)
2070
+ return false;
2071
+ const code = guid[1].toLowerCase();
2072
+ return productCodes.some(recorded => (recorded.match(MSI_PRODUCT_CODE_RE)?.[1] || '').toLowerCase() === code);
2073
+ }
2074
+ function isFcdUninstall(program, rest, productCodes = []) {
1946
2075
  let prog = program;
1947
2076
  let args = rest;
1948
2077
  if (prog === 'npx' || prog === 'pnpx' || prog === 'bunx') {
@@ -1956,9 +2085,11 @@ function isFcdUninstall(program, rest) {
1956
2085
  return args.some(token => /^(?:uninstall|remove|rm|r|un|unlink)$/i.test(token))
1957
2086
  && args.some(token => /^fullcourtdefense-cli(?:@|$)/i.test(token));
1958
2087
  }
1959
- if (prog === 'msiexec') {
1960
- const at = args.findIndex(token => /^[/-](?:x|uninstall)$/i.test(token));
1961
- return at >= 0 && /fullcourtdefense/i.test(args[at + 1] || '');
2088
+ if (prog === 'msiexec')
2089
+ return msiexecRemovesFcd(args.join(' '), productCodes);
2090
+ if (prog === 'start-process' || prog === 'saps' || prog === 'start') {
2091
+ const at = args.findIndex(token => /^(?:.*[\\/])?msiexec(?:\.exe)?$/i.test(token));
2092
+ return at >= 0 && msiexecRemovesFcd(args.slice(at + 1).join(' '), productCodes);
1962
2093
  }
1963
2094
  if (prog === 'schtasks') {
1964
2095
  if (!args.some(token => /^[/-](?:delete|disable)$/i.test(token)))
@@ -2025,18 +2156,51 @@ function optionValues(args, named, skipNamed) {
2025
2156
  }
2026
2157
  return out;
2027
2158
  }
2159
+ const PROCESS_SOURCE_PROGRAMS = new Set(['get-process', 'gps', 'ps', 'get-ciminstance', 'gcim', 'get-wmiobject', 'gwmi', 'pgrep', 'get-package']);
2160
+ const PROCESS_STOP_PROGRAMS = new Set(['stop-process', 'spps', 'kill', 'uninstall-package']);
2161
+ const EACH_STOP_RE = /\b(?:stop-process|spps|taskkill|kill|uninstall-package)\b|\.(?:kill|terminate)\s*\(/i;
2162
+ /** A filter that EXCLUDES FullCourtDefense (`-notlike '*FullCourtDefense*'`, `grep -v fullcourtdefense`) selects other processes. */
2163
+ const NEGATED_FCD_FILTER_RE = /-(?:notlike|notmatch|ne|notcontains|notin)\s+['"]?[^\s'"}|]*fullcourtdefense[^\s'"}|]*['"]?|\b(?:grep|findstr)\s+(?:-[a-z]*\s+)*-[a-z]*[v][a-z]*\s+(?:-[a-z]+\s+)*['"]?[^\s'"|]*fullcourtdefense[^\s'"|]*['"]?|\/v\s+['"]?[^\s'"|]*fullcourtdefense[^\s'"|]*['"]?/gi;
2164
+ function stageSelectsFcd(stage) {
2165
+ return /fullcourtdefense/i.test(stage.replace(NEGATED_FCD_FILTER_RE, ' '));
2166
+ }
2167
+ /**
2168
+ * Pipeline view: a stop/uninstall stage fed by a process (or package) source
2169
+ * whose upstream stages select FullCourtDefense by name or path, e.g.
2170
+ * `Get-Process | Where-Object { $_.Path -like '*FullCourtDefense*' } | Stop-Process`.
2171
+ * The per-segment view sees a bare `Stop-Process` there.
2172
+ */
2173
+ function fcdPipelineStop(stages) {
2174
+ if (stages.length < 2)
2175
+ return false;
2176
+ const programs = stages.map(stage => segmentProgram(segmentTokens(stage.trim())));
2177
+ if (!PROCESS_SOURCE_PROGRAMS.has(programs[0].program))
2178
+ return false;
2179
+ for (let i = 1; i < stages.length; i++) {
2180
+ const { program, rest } = programs[i];
2181
+ const stops = PROCESS_STOP_PROGRAMS.has(program)
2182
+ || (program === 'invoke-cimmethod' && rest.some(token => /^(?:terminate|uninstall)$/i.test(token)))
2183
+ || ((program === 'foreach-object' || program === '%' || program === 'foreach' || program === 'xargs') && EACH_STOP_RE.test(stages[i]));
2184
+ if (stops && stages.slice(0, i).some(stageSelectsFcd))
2185
+ return true;
2186
+ }
2187
+ return false;
2188
+ }
2028
2189
  /**
2029
2190
  * Shell view: per unquoted segment, the leading program decides. Quoted prose
2030
2191
  * (commit messages, issue bodies, echo text) is removed before redirections
2031
2192
  * and off-switch keys are judged, so writing ABOUT the setting never matches.
2032
2193
  */
2033
- function hookConfigShellChange(command) {
2194
+ function hookConfigShellChange(command, productCodes = []) {
2195
+ if (splitUnquotedPipelines(command).some(fcdPipelineStop)) {
2196
+ return { itemId: 'fcd_uninstalled_by_agent', reason: 'agent stopping FullCourtDefense processes' };
2197
+ }
2034
2198
  for (const rawSegment of splitUnquotedCommandSegments(command)) {
2035
2199
  const raw = rawSegment.trim();
2036
2200
  if (!raw)
2037
2201
  continue;
2038
2202
  const { program, rest } = segmentProgram(segmentTokens(raw));
2039
- if (isFcdUninstall(program, rest)) {
2203
+ if (isFcdUninstall(program, rest, productCodes)) {
2040
2204
  return { itemId: 'fcd_uninstalled_by_agent', reason: 'agent removing FullCourtDefense from this machine' };
2041
2205
  }
2042
2206
  const stripped = stripInertDataSegments(raw);
@@ -2094,7 +2258,7 @@ function agentGuardTamperFinding(toolName, candidates, options) {
2094
2258
  for (const candidate of candidates) {
2095
2259
  if (!isCommandContext(toolName, candidate))
2096
2260
  continue;
2097
- const hit = hookConfigShellChange(candidate.value);
2261
+ const hit = hookConfigShellChange(candidate.value, options?.fcdProductCodes);
2098
2262
  const found = hit && finding(hit, candidate.value);
2099
2263
  if (found)
2100
2264
  return found;
@@ -45,6 +45,7 @@ const discoverProxy_1 = require("./discoverProxy");
45
45
  const discoverSchedule_1 = require("./discoverSchedule");
46
46
  const mcpGateway_1 = require("./mcpGateway");
47
47
  const discoverPaths_1 = require("./discoverPaths");
48
+ const discoverProjectRoots_1 = require("./discoverProjectRoots");
48
49
  const runtimeConfig_1 = require("../runtimeConfig");
49
50
  const knownMcpServers_1 = require("./knownMcpServers");
50
51
  const discoverAgentFiles_1 = require("./discoverAgentFiles");
@@ -130,15 +131,6 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
130
131
  function candidateConfigPaths(cwd, extra) {
131
132
  return (0, discoverPaths_1.discoverScanTargets)(cwd, extra);
132
133
  }
133
- /** True when a path is an operating-system folder (daemon/scheduled-task cwd), never a real project. */
134
- function isOsSystemFolder(dir) {
135
- const normalized = path.resolve(dir).toLowerCase();
136
- if (process.platform === 'win32') {
137
- const windir = (process.env.SystemRoot || 'C:\\Windows').toLowerCase();
138
- return normalized === windir || normalized.startsWith(`${windir}${path.sep}`) || /^[a-z]:\\$/.test(normalized);
139
- }
140
- return normalized === '/' || ['/usr', '/bin', '/sbin', '/etc', '/var'].some(root => normalized === root || normalized.startsWith(`${root}/`));
141
- }
142
134
  /** Dot-dirs that hold a client's project config; the real project root is their parent. */
143
135
  const CONFIG_DOT_DIRS = new Set(['.cursor', '.claude', '.codex', '.vscode', '.gemini', '.kiro']);
144
136
  /** Map discovered project config file paths back to their owning project folders. */
@@ -844,7 +836,7 @@ async function discoverCommand(args, config) {
844
836
  // meaningless and confusing in the dashboard scan-scope report — fall back to
845
837
  // the user's home, which is where all the AI-dev roots we scan live anyway.
846
838
  const rawCwd = process.cwd();
847
- const cwd = isOsSystemFolder(rawCwd) ? os.homedir() : rawCwd;
839
+ const cwd = (0, discoverProjectRoots_1.isOsSystemFolder)(rawCwd) ? os.homedir() : rawCwd;
848
840
  const runMcp = surfaces.has('mcp');
849
841
  const scanned = [];
850
842
  let found = [];
@@ -865,10 +857,18 @@ async function discoverCommand(args, config) {
865
857
  .filter(root => !isDisabledRoot(root));
866
858
  const sweep = scanRoots.length > 0
867
859
  ? (0, discoverPaths_1.scanRootsForProjectConfigs)(scanRoots, { excludedRoots: normalizedDisabledRoots })
868
- : { candidates: [], scannedDirs: 0, excludedDirs: 0, truncated: false, maxDepth: 4, maxDirs: 25000 };
860
+ : { candidates: [], scannedDirs: 0, excludedDirs: 0, truncated: false, truncatedReason: undefined, maxDepth: 4, maxDirs: 25000 };
869
861
  // Admin roots are scanned for env/secrets even when no MCP config lives there.
870
862
  const sweepProjectRoots = (0, discoverPaths_1.compactScanRoots)([...deriveProjectRoots(sweep.candidates), ...adminScanRoots])
871
863
  .filter(root => !isDisabledRoot(root));
864
+ // Folders the AI clients themselves list as projects. Kept separate from the
865
+ // compacted roots on purpose: each is walked from its own top level, so a
866
+ // project nested deeper than the home walk's depth limit is still covered.
867
+ const aiClientRoots = surfaces.has('secrets') || surfaces.has('posture') || surfaces.has('agent-files')
868
+ ? (0, discoverProjectRoots_1.collectAiClientProjectRoots)({ disabledRoots: normalizedDisabledRoots, allowedNetworkRoots: adminScanRoots })
869
+ : { roots: [], stats: { candidates: 0, skippedMissing: 0, skippedUnsafe: 0, skippedNetwork: 0, skippedDisabled: 0, capped: 0 } };
870
+ const aiClientRootPaths = aiClientRoots.roots.map(root => root.path);
871
+ const postureProjectRoots = (0, discoverPaths_1.uniqueNormalizedPaths)([...sweepProjectRoots, ...aiClientRootPaths]);
872
872
  const knownConfigCandidates = candidateConfigPaths(cwd, args.extraPath)
873
873
  .filter(candidate => !isDisabledRoot(candidate.path));
874
874
  const secretConfigFiles = [...knownConfigCandidates, ...sweep.candidates]
@@ -917,10 +917,10 @@ async function discoverCommand(args, config) {
917
917
  }
918
918
  }
919
919
  const secrets = surfaces.has('secrets') || surfaces.has('posture')
920
- ? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: normalizedDisabledRoots, configFiles: secretConfigFiles })
920
+ ? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: postureProjectRoots, disabledRoots: normalizedDisabledRoots, configFiles: secretConfigFiles })
921
921
  : undefined;
922
922
  const agentFileScan = surfaces.has('agent-files') || surfaces.has('posture')
923
- ? (0, discoverAgentFiles_1.scanAgentFilesWithMetadata)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: normalizedDisabledRoots })
923
+ ? (0, discoverAgentFiles_1.scanAgentFilesWithMetadata)({ cwd, extraRoots: postureProjectRoots, disabledRoots: normalizedDisabledRoots })
924
924
  : undefined;
925
925
  const agentFiles = agentFileScan?.findings;
926
926
  // Fail-open: an unreadable IDE folder drops the extension inventory (the
@@ -960,6 +960,13 @@ async function discoverCommand(args, config) {
960
960
  ];
961
961
  const compactedScannedPaths = (0, discoverPaths_1.compactScanRoots)(scannedRootCandidates.map(entry => entry.path));
962
962
  const scannedRoots = compactedScannedPaths.map(root => scannedRootCandidates.find(entry => path.resolve(entry.path).toLowerCase() === path.resolve(root).toLowerCase()));
963
+ const listedRootKeys = new Set(scannedRoots.map(root => (0, discoverPaths_1.normalizedPath)(root.path)));
964
+ for (const root of aiClientRoots.roots) {
965
+ if (listedRootKeys.has((0, discoverPaths_1.normalizedPath)(root.path)))
966
+ continue;
967
+ listedRootKeys.add((0, discoverPaths_1.normalizedPath)(root.path));
968
+ scannedRoots.push({ path: root.path, source: root.source });
969
+ }
963
970
  // Shallow folder tree for the console folder picker (names only, no contents).
964
971
  const folderCatalog = surfaces.has('posture') || surfaces.has('secrets')
965
972
  ? (0, discoverPaths_1.buildFolderCatalog)({ extraRoots: adminScanRoots })
@@ -986,6 +993,9 @@ async function discoverCommand(args, config) {
986
993
  `Credential stores and shell history under ${home}`,
987
994
  `Environment files under ${cwd}, ${path.join(home, 'dev')}, ${path.join(home, 'repos')}, ${path.join(home, 'projects')}, ${path.join(home, 'Documents')}, and ${path.join(home, 'code')}`,
988
995
  ...adminScanRoots.map(root => `Admin-selected folder: ${root} (bounded descendant scan; exclusions and limits below)`),
996
+ ...(aiClientRoots.roots.length > 0
997
+ ? [`Project folders listed by the AI clients on this machine (Claude Code, Cursor, VS Code, Codex): ${aiClientRoots.roots.length}, each scanned from its own top level within the same limits`]
998
+ : []),
989
999
  ],
990
1000
  excluded: [
991
1001
  'Unrelated project folders outside the listed roots',
@@ -998,6 +1008,7 @@ async function discoverCommand(args, config) {
998
1008
  limits: {
999
1009
  projectMaxDepth: sweep.maxDepth,
1000
1010
  projectMaxDirectories: sweep.maxDirs,
1011
+ secretMaxDepth: secrets?.limits?.maxDepth,
1001
1012
  secretMaxFileBytes: secrets?.limits?.maxFileBytes,
1002
1013
  secretMaxCandidateFiles: secrets?.limits?.maxEnvFiles,
1003
1014
  secretMaxFindings: secrets?.limits?.maxFindings,
@@ -1010,8 +1021,12 @@ async function discoverCommand(args, config) {
1010
1021
  stats: {
1011
1022
  projectDirectoriesScanned: sweep.scannedDirs,
1012
1023
  projectConfigsFound: sweep.candidates.length,
1013
- projectRootsFound: sweepProjectRoots.length,
1024
+ projectRootsFound: postureProjectRoots.length,
1025
+ aiClientProjectRootsFound: aiClientRoots.roots.length,
1026
+ aiClientProjectRootsSkipped: aiClientRoots.stats.skippedMissing + aiClientRoots.stats.skippedUnsafe
1027
+ + aiClientRoots.stats.skippedNetwork + aiClientRoots.stats.skippedDisabled + aiClientRoots.stats.capped,
1014
1028
  rootsScanned: scannedRoots.length,
1029
+ cloudPlaceholdersSkipped: secrets?.stats?.cloudPlaceholdersSkipped ?? 0,
1015
1030
  secretCandidateFilesScanned: secrets?.scannedFiles ?? 0,
1016
1031
  secretFindingsFound: secrets?.findings.length ?? 0,
1017
1032
  secretFindingsUploaded: secrets?.findings.length ?? 0,
@@ -1026,6 +1041,14 @@ async function discoverCommand(args, config) {
1026
1041
  agentFiles: agentFileScan?.truncated ?? false,
1027
1042
  ideExtensions: ideExtensions?.truncated ?? false,
1028
1043
  },
1044
+ // Which limit stopped each list (`depth` vs `count`); `truncated` above stays for old consoles.
1045
+ truncatedReasons: Object.fromEntries(Object.entries({
1046
+ projectDirectories: sweep.truncatedReason,
1047
+ secretCandidates: secrets?.truncatedReasons?.candidateFiles,
1048
+ secretFindings: secrets?.truncated?.findings ? 'count' : undefined,
1049
+ agentFiles: agentFileScan?.truncatedReason,
1050
+ ideExtensions: ideExtensions?.truncated ? 'count' : undefined,
1051
+ }).filter(([, reason]) => reason !== undefined)),
1029
1052
  },
1030
1053
  };
1031
1054
  async function maybeUpload() {
@@ -1,3 +1,4 @@
1
+ import { type ScanTruncationReason } from './discoverPaths';
1
2
  export type AgentFileKind = 'rules' | 'skills' | 'instructions' | 'hooks' | 'memory' | 'other';
2
3
  export interface AgentFileFinding {
3
4
  id: string;
@@ -22,6 +23,7 @@ export interface AgentFilesScanResult {
22
23
  excludedPaths: number;
23
24
  };
24
25
  truncated: boolean;
26
+ truncatedReason?: ScanTruncationReason;
25
27
  }
26
28
  export type ClaudeHooksOffSwitch = 'disableAllHooks' | 'allowManagedHooksOnly';
27
29
  /**
@@ -444,6 +444,7 @@ function scanAgentFilesWithMetadata(options = {}) {
444
444
  limits: { maxFiles: MAX_FILES, maxFileBytes: MAX_FILE_BYTES, maxDepth: 4 },
445
445
  stats: { scannedFiles: out.length, excludedPaths: stats.excludedPaths },
446
446
  truncated: stats.depthLimited || out.length >= MAX_FILES,
447
+ truncatedReason: (0, discoverPaths_1.truncationReason)(stats.depthLimited, out.length >= MAX_FILES),
447
448
  };
448
449
  }
449
450
  /** Backward-compatible findings-only API used by existing callers/tests. */
@@ -13,6 +13,9 @@ export declare function normalizedPath(value: string): string;
13
13
  /** True for the excluded root itself and every descendant (segment-safe). */
14
14
  export declare function pathIsWithin(candidate: string, root: string): boolean;
15
15
  export declare function isExcludedPath(candidate: string, excludedRoots?: string[]): boolean;
16
+ /** Why a bounded walk stopped early: its depth limit, its file/folder cap, or both. */
17
+ export type ScanTruncationReason = 'depth' | 'count' | 'depth_and_count';
18
+ export declare function truncationReason(depthLimited: boolean, countLimited: boolean): ScanTruncationReason | undefined;
16
19
  export declare function uniqueNormalizedPaths(values: string[]): string[];
17
20
  /**
18
21
  * Keep only the shallowest non-overlapping scan roots. If C:\dev is present,
@@ -50,6 +53,7 @@ export declare function scanRootsForProjectConfigs(roots: string[], options?: {
50
53
  scannedDirs: number;
51
54
  excludedDirs: number;
52
55
  truncated: boolean;
56
+ truncatedReason?: ScanTruncationReason;
53
57
  maxDepth: number;
54
58
  maxDirs: number;
55
59
  };