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.
- package/dist/actionPolicyEngine.d.ts +23 -0
- package/dist/actionPolicyEngine.js +58 -19
- package/dist/approvalPrompt.d.ts +15 -0
- package/dist/approvalPrompt.js +157 -20
- package/dist/askDialog.d.ts +2 -0
- package/dist/askDialog.js +15 -1
- package/dist/askHeader.d.ts +81 -0
- package/dist/askHeader.js +573 -0
- package/dist/blockExplanation.d.ts +9 -0
- package/dist/blockExplanation.js +14 -0
- package/dist/callerEnv.d.ts +1 -1
- package/dist/callerEnv.js +14 -0
- package/dist/cloudGrant.d.ts +46 -0
- package/dist/cloudGrant.js +420 -0
- package/dist/cloudGrantTools.d.ts +97 -0
- package/dist/cloudGrantTools.js +1112 -0
- package/dist/commandNarrative.d.ts +8 -0
- package/dist/commandNarrative.js +222 -49
- package/dist/commands/cmdGuard.js +1 -1
- package/dist/commands/daemon.js +14 -0
- package/dist/commands/deterministicGuard.d.ts +8 -2
- package/dist/commands/deterministicGuard.js +219 -55
- package/dist/commands/discover.js +37 -14
- package/dist/commands/discoverAgentFiles.d.ts +2 -0
- package/dist/commands/discoverAgentFiles.js +1 -0
- package/dist/commands/discoverPaths.d.ts +4 -0
- package/dist/commands/discoverPaths.js +19 -1
- package/dist/commands/discoverProjectRoots.d.ts +72 -0
- package/dist/commands/discoverProjectRoots.js +327 -0
- package/dist/commands/discoverProxy.d.ts +1 -1
- package/dist/commands/discoverProxy.js +1 -1
- package/dist/commands/discoverSecrets.d.ts +44 -0
- package/dist/commands/discoverSecrets.js +158 -14
- package/dist/commands/hook.js +10 -7
- package/dist/commands/mcpGateway.js +9 -7
- package/dist/commands/posixShellGuard.js +1 -1
- package/dist/commands/shellGuard.js +70 -25
- package/dist/commands/uninstallAll.d.ts +1 -7
- package/dist/commands/uninstallAll.js +6 -45
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -3
- package/dist/credentialGrant.d.ts +48 -0
- package/dist/credentialGrant.js +29 -18
- package/dist/credentialStore.js +1 -1
- package/dist/devConfirm.d.ts +44 -3
- package/dist/devConfirm.js +166 -30
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/grantTargets.d.ts +21 -0
- package/dist/grantTargets.js +235 -0
- package/dist/localSafetySnapshot.js +2 -0
- package/dist/msiProductCode.d.ts +10 -0
- package/dist/msiProductCode.js +124 -0
- package/dist/notify.js +1 -0
- package/dist/selfTest.d.ts +1 -1
- package/dist/selfTest.js +1 -1
- package/dist/selfUpdate.d.ts +51 -2
- package/dist/selfUpdate.js +217 -19
- package/dist/shellAst.d.ts +2 -0
- package/dist/shellAst.js +7 -1
- package/dist/shellAstLoader.js +12 -1
- package/dist/version.json +1 -1
- 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([
|
|
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 (
|
|
687
|
-
* see
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1398
|
-
cur = '';
|
|
1432
|
+
endStatement();
|
|
1399
1433
|
continue;
|
|
1400
1434
|
}
|
|
1401
1435
|
if (c === '&' && command[i + 1] === '&') {
|
|
1402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1575
|
-
out = out
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
};
|