fullcourtdefense-cli 1.35.9 → 1.36.0
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 +1 -0
- package/dist/actionPolicyEngine.js +21 -0
- package/dist/askDialog.js +1 -2
- package/dist/callerEnv.d.ts +10 -0
- package/dist/callerEnv.js +18 -1
- package/dist/commandNarrative.d.ts +8 -0
- package/dist/commandNarrative.js +69 -10
- package/dist/commands/agentCi.js +3 -1
- package/dist/commands/cmdGuard.js +2 -0
- package/dist/commands/daemon.js +48 -3
- package/dist/commands/deterministicGuard.d.ts +62 -2
- package/dist/commands/deterministicGuard.js +583 -30
- package/dist/commands/discover.js +36 -2
- package/dist/commands/discoverAgentFiles.d.ts +11 -0
- package/dist/commands/discoverAgentFiles.js +107 -0
- package/dist/commands/discoverExtensions.d.ts +64 -0
- package/dist/commands/discoverExtensions.js +386 -0
- package/dist/commands/hook.d.ts +28 -4
- package/dist/commands/hook.js +76 -19
- package/dist/commands/knownMcpServers.d.ts +8 -2
- package/dist/commands/knownMcpServers.js +33 -2
- package/dist/commands/local-scan.js +1 -0
- package/dist/commands/mcpGateway.js +273 -54
- package/dist/commands/protectedRun.d.ts +1 -1
- package/dist/commands/protectedRun.js +3 -3
- package/dist/commands/shellGuard.js +16 -1
- package/dist/commands/watchdog.js +19 -3
- package/dist/credentialGrant.d.ts +43 -0
- package/dist/credentialGrant.js +504 -0
- package/dist/daemonForensics.d.ts +27 -0
- package/dist/daemonForensics.js +47 -0
- package/dist/detectorBaseline.d.ts +2 -2
- package/dist/detectorBaseline.js +2 -2
- package/dist/devConfirm.d.ts +55 -1
- package/dist/devConfirm.js +291 -112
- package/dist/envDiagnostics.js +4 -2
- package/dist/hookSlim.js +4 -3
- package/dist/ideExtensionRisk.d.ts +110 -0
- package/dist/ideExtensionRisk.js +172 -0
- package/dist/machineActionVerify.d.ts +34 -8
- package/dist/machineActionVerify.js +76 -16
- package/dist/mcpProtocol.d.ts +124 -0
- package/dist/mcpProtocol.js +302 -0
- package/dist/mcpToolReview.d.ts +2 -1
- package/dist/mcpToolReview.js +6 -4
- package/dist/notify.d.ts +7 -0
- package/dist/notify.js +61 -0
- package/dist/selfUpdate.d.ts +59 -0
- package/dist/selfUpdate.js +138 -1
- package/dist/shellAstLoader.js +2 -0
- package/dist/shellQuoteMask.d.ts +25 -0
- package/dist/shellQuoteMask.js +319 -0
- package/dist/telemetry.d.ts +5 -0
- package/dist/telemetry.js +1 -0
- package/dist/verdictIpc.js +16 -0
- package/dist/verdictIpcClient.d.ts +32 -12
- package/dist/verdictIpcClient.js +55 -26
- package/dist/version.json +1 -1
- package/installer/macos/guardian/ai.fullcourtdefense.guardian.plist +19 -0
- package/installer/macos/guardian/fcd-guardian.sh +590 -0
- package/installer/macos/guardian/install-guardian.sh +32 -0
- package/installer/macos/guardian/uninstall-guardian.sh +12 -0
- package/installer/windows/README.md +88 -0
- package/installer/windows/rescue/FullCourtDefense-Rescue.ps1 +229 -0
- package/package.json +12 -2
|
@@ -33,8 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
|
|
36
|
+
exports.REMOTE_CONTROL_COMMAND_RULES = exports.REMOTE_CONTROL_BRIDGES = exports.REMOTE_CONTROL_CATALOG_VERSION = exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
|
|
37
37
|
exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
|
|
38
|
+
exports.remoteControlBridgePattern = remoteControlBridgePattern;
|
|
39
|
+
exports.remoteControlBridgeInCommand = remoteControlBridgeInCommand;
|
|
40
|
+
exports.classifyRemoteMcpLaunch = classifyRemoteMcpLaunch;
|
|
41
|
+
exports.sanitizeRemoteControlFacts = sanitizeRemoteControlFacts;
|
|
42
|
+
exports.remoteControlFactFor = remoteControlFactFor;
|
|
43
|
+
exports.remoteControlGrantKey = remoteControlGrantKey;
|
|
44
|
+
exports.remoteControlLaunches = remoteControlLaunches;
|
|
38
45
|
exports.setShellAstProvider = setShellAstProvider;
|
|
39
46
|
exports.setShellAstMode = setShellAstMode;
|
|
40
47
|
exports.getShellAstMode = getShellAstMode;
|
|
@@ -441,6 +448,12 @@ function isEnabled(itemId, options) {
|
|
|
441
448
|
* and stale snapshots on the warn-first rollout instead of hard-blocking.
|
|
442
449
|
*/
|
|
443
450
|
const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
|
|
451
|
+
/**
|
|
452
|
+
* Items that ASK by default when the snapshot carries no action for them — a
|
|
453
|
+
* snapshot built before the item existed (older backend) must not turn a new
|
|
454
|
+
* ask-first rule into a hard block. An explicit org 'block' still wins.
|
|
455
|
+
*/
|
|
456
|
+
const DEFAULT_ASK_BUILT_INS = new Set(['agent_hooks_switched_off', 'agent_hook_config_removed', 'fcd_uninstalled_by_agent', 'remote_control_bridge']);
|
|
444
457
|
function actionFor(itemId, options) {
|
|
445
458
|
const action = toLocalSafetyRuleAction(options?.itemActions?.[itemId]);
|
|
446
459
|
if (action)
|
|
@@ -451,6 +464,8 @@ function actionFor(itemId, options) {
|
|
|
451
464
|
// keeps the strict "absent item = block" contract below.
|
|
452
465
|
if (options?.snapshotPresent === false)
|
|
453
466
|
return 'warn';
|
|
467
|
+
if (DEFAULT_ASK_BUILT_INS.has(itemId))
|
|
468
|
+
return 'ask';
|
|
454
469
|
return DEFAULT_WARN_BUILT_INS.has(itemId) ? 'warn' : 'block';
|
|
455
470
|
}
|
|
456
471
|
function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
|
|
@@ -575,36 +590,36 @@ function containsMetadataEndpoint(value) {
|
|
|
575
590
|
function containsSensitiveCredentialPath(value) {
|
|
576
591
|
const text = normalizeForPath(value);
|
|
577
592
|
const checks = [
|
|
578
|
-
{ itemId: 'ssh_private_key', label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"])/ },
|
|
579
|
-
{ itemId: 'ssh_config', label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"])/ },
|
|
580
|
-
{ itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
|
|
581
|
-
{ itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
|
|
582
|
-
{ itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
|
|
593
|
+
{ itemId: 'ssh_private_key', label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"`);|&<>])/ },
|
|
594
|
+
{ itemId: 'ssh_config', label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"`);|&<>])/ },
|
|
595
|
+
{ itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"`);|&<>])/ },
|
|
596
|
+
{ itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"`);|&<>])/ },
|
|
597
|
+
{ itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"`);|&<>])/ },
|
|
583
598
|
// GitHub CLI stores its OAuth token in hosts.yml — ~/.config/gh/ on Unix,
|
|
584
599
|
// AppData/Roaming/GitHub CLI/ on Windows (normalized+lowercased upstream).
|
|
585
|
-
{ itemId: 'github_cli_token', label: 'GitHub CLI token', re: /(?:^|\/)(?:gh|github cli)\/hosts\.yml(?:$|[/?#\s'"])/ },
|
|
586
|
-
{ itemId: 'git_credentials', label: 'Git plaintext credentials', re: /(?:^|\/)\.git-credentials(?:$|[/?#\s'"])/ },
|
|
587
|
-
{ itemId: 'gcloud_credentials', label: 'gcloud credential store', re: /(?:^|\/)gcloud\/(?:credentials\.db|access_tokens\.db|legacy_credentials)(?:$|[/?#\s'"])/ },
|
|
600
|
+
{ itemId: 'github_cli_token', label: 'GitHub CLI token', re: /(?:^|\/)(?:gh|github cli)\/hosts\.yml(?:$|[/?#\s'"`);|&<>])/ },
|
|
601
|
+
{ itemId: 'git_credentials', label: 'Git plaintext credentials', re: /(?:^|\/)\.git-credentials(?:$|[/?#\s'"`);|&<>])/ },
|
|
602
|
+
{ itemId: 'gcloud_credentials', label: 'gcloud credential store', re: /(?:^|\/)gcloud\/(?:credentials\.db|access_tokens\.db|legacy_credentials)(?:$|[/?#\s'"`);|&<>])/ },
|
|
588
603
|
{ itemId: 'azure_tokens', label: 'Azure token cache', re: /(?:^|\/)\.azure\/(?:msal_token_cache|accesstokens\.json)/ },
|
|
589
|
-
{ itemId: 'gnupg_private_keys', label: 'GnuPG private keys', re: /(?:^|\/)\.gnupg\/(?:private-keys-v1\.d|secring\.gpg)(?:$|[/?#\s'"])/ },
|
|
590
|
-
{ itemId: 'pgpass', label: 'PostgreSQL password file', re: /(?:^|\/)(?:\.pgpass|pgpass\.conf)(?:$|[/?#\s'"])/ },
|
|
591
|
-
{ itemId: 'mysql_cnf', label: 'MySQL credentials file', re: /(?:^|\/)\.my\.cnf(?:$|[/?#\s'"])/ },
|
|
592
|
-
{ itemId: 'terraform_credentials', label: 'Terraform credentials', re: /credentials\.tfrc\.json(?:$|[/?#\s'"])/ },
|
|
593
|
-
{ itemId: 'huggingface_token', label: 'Hugging Face token', re: /(?:^|\/)\.?huggingface\/token(?:$|[/?#\s'"])/ },
|
|
594
|
-
{ itemId: 'cargo_credentials', label: 'Cargo registry credentials', re: /(?:^|\/)\.cargo\/credentials(?:\.toml)?(?:$|[/?#\s'"])/ },
|
|
595
|
-
{ itemId: 'gem_credentials', label: 'RubyGems credentials', re: /(?:^|\/)\.gem\/credentials(?:$|[/?#\s'"])/ },
|
|
596
|
-
{ itemId: 'composer_auth', label: 'Composer auth tokens', re: /(?:^|\/)\.composer\/auth\.json(?:$|[/?#\s'"])/ },
|
|
597
|
-
{ itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
|
|
598
|
-
{ itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
|
|
599
|
-
{ itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
|
|
600
|
-
{ itemId: 'gcp_adc', label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"])/ },
|
|
601
|
-
{ itemId: 'linux_shadow', label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"])/ },
|
|
602
|
-
{ itemId: 'powershell_history', label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"])/ },
|
|
604
|
+
{ itemId: 'gnupg_private_keys', label: 'GnuPG private keys', re: /(?:^|\/)\.gnupg\/(?:private-keys-v1\.d|secring\.gpg)(?:$|[/?#\s'"`);|&<>])/ },
|
|
605
|
+
{ itemId: 'pgpass', label: 'PostgreSQL password file', re: /(?:^|\/)(?:\.pgpass|pgpass\.conf)(?:$|[/?#\s'"`);|&<>])/ },
|
|
606
|
+
{ itemId: 'mysql_cnf', label: 'MySQL credentials file', re: /(?:^|\/)\.my\.cnf(?:$|[/?#\s'"`);|&<>])/ },
|
|
607
|
+
{ itemId: 'terraform_credentials', label: 'Terraform credentials', re: /credentials\.tfrc\.json(?:$|[/?#\s'"`);|&<>])/ },
|
|
608
|
+
{ itemId: 'huggingface_token', label: 'Hugging Face token', re: /(?:^|\/)\.?huggingface\/token(?:$|[/?#\s'"`);|&<>])/ },
|
|
609
|
+
{ itemId: 'cargo_credentials', label: 'Cargo registry credentials', re: /(?:^|\/)\.cargo\/credentials(?:\.toml)?(?:$|[/?#\s'"`);|&<>])/ },
|
|
610
|
+
{ itemId: 'gem_credentials', label: 'RubyGems credentials', re: /(?:^|\/)\.gem\/credentials(?:$|[/?#\s'"`);|&<>])/ },
|
|
611
|
+
{ itemId: 'composer_auth', label: 'Composer auth tokens', re: /(?:^|\/)\.composer\/auth\.json(?:$|[/?#\s'"`);|&<>])/ },
|
|
612
|
+
{ itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"`);|&<>])/ },
|
|
613
|
+
{ itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"`);|&<>])/ },
|
|
614
|
+
{ itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"`);|&<>])/ },
|
|
615
|
+
{ itemId: 'gcp_adc', label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"`);|&<>])/ },
|
|
616
|
+
{ itemId: 'linux_shadow', label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"`);|&<>])/ },
|
|
617
|
+
{ itemId: 'powershell_history', label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"`);|&<>])/ },
|
|
603
618
|
// Anchored to real browser-profile paths (Chromium: "User Data/<profile>/Network/Cookies",
|
|
604
619
|
// "<profile>/Login Data"; Firefox: key4.db / logins.json / cookies.sqlite). Bare English
|
|
605
620
|
// words like "cookies" in prose, code, or a "/cookies" web route must NOT match — the
|
|
606
621
|
// unanchored version over-blocked ordinary web-dev work.
|
|
607
|
-
{ itemId: 'browser_credentials', label: 'browser credential store', re: /(?:^|\/)(?:(?:default|profile [0-9]+|network)\/cookies|login data|cookies\.sqlite|key4\.db|logins\.json)(?:$|[/?#\s'"])/ },
|
|
622
|
+
{ itemId: 'browser_credentials', label: 'browser credential store', re: /(?:^|\/)(?:(?:default|profile [0-9]+|network)\/cookies|login data|cookies\.sqlite|key4\.db|logins\.json)(?:$|[/?#\s'"`);|&<>])/ },
|
|
608
623
|
];
|
|
609
624
|
for (const check of checks) {
|
|
610
625
|
if (check.re.test(text))
|
|
@@ -718,6 +733,181 @@ function credentialCommandReason(value, options) {
|
|
|
718
733
|
return undefined;
|
|
719
734
|
});
|
|
720
735
|
}
|
|
736
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
737
|
+
// Remote-control AI bridges: tools that hand THIS machine's terminal / files /
|
|
738
|
+
// MCP tools to an AI running somewhere else (a remote MCP device, a local MCP
|
|
739
|
+
// server re-published on a network port). The catalog is the one place these
|
|
740
|
+
// are named; every surface — IDE hooks, the PowerShell / cmd / bash guards,
|
|
741
|
+
// MCP config discovery, the action-policy engine fact — matches through the
|
|
742
|
+
// patterns generated below. A package matches only in its REMOTE mode, chosen
|
|
743
|
+
// by the argument after the package (`desktop-commander remote`) or by the
|
|
744
|
+
// serving flag (`supergateway --stdio`); the same package in local stdio mode is
|
|
745
|
+
// an ordinary local MCP server.
|
|
746
|
+
//
|
|
747
|
+
// `remote_connector` entries (mcp-remote, supergateway --sse) connect a LOCAL
|
|
748
|
+
// agent to a remote server — they do not give an outside AI this machine, so
|
|
749
|
+
// they are labelled in the MCP inventory and never asked about.
|
|
750
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
751
|
+
exports.REMOTE_CONTROL_CATALOG_VERSION = '2026-09-26.1';
|
|
752
|
+
// The first positional argument after the package (flags may precede it).
|
|
753
|
+
const RC_FIRST_POSITIONAL_REMOTE = String.raw `(?:\s+-{1,2}[a-z][\w-]*(?:=[^\s;&|]*)?)*\s+["']?remote["']?(?=[\s;&|)]|$)`;
|
|
754
|
+
const RC_SERVES_STDIO = String.raw `(?=[^;&|\n]*\s--stdio(?:[=\s]|$))`;
|
|
755
|
+
const RC_SERVES_PORT = String.raw `(?=[^;&|\n]*\s--(?:sse-)?(?:port|host)(?:[=\s]|$))`;
|
|
756
|
+
const RC_CONNECTS_URL = String.raw `(?=[^;&|\n]*\s["']?https?://)`;
|
|
757
|
+
exports.REMOTE_CONTROL_BRIDGES = [
|
|
758
|
+
{
|
|
759
|
+
id: 'desktop_commander_remote', title: 'Desktop Commander', kind: 'remote_control',
|
|
760
|
+
packages: ['@wonderwhy-er/desktop-commander'], bins: ['desktop-commander'],
|
|
761
|
+
mode: 'remote', modePattern: RC_FIRST_POSITIONAL_REMOTE,
|
|
762
|
+
label: 'Desktop Commander in remote mode',
|
|
763
|
+
card: "This starts Desktop Commander in remote mode, which lets an AI outside your company control this computer's terminal and files.",
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
id: 'supergateway_stdio_serve', title: 'Supergateway', kind: 'remote_control',
|
|
767
|
+
packages: ['supergateway'], bins: ['supergateway'],
|
|
768
|
+
mode: 'serve', modePattern: RC_SERVES_STDIO,
|
|
769
|
+
label: 'Supergateway publishing a local MCP server',
|
|
770
|
+
card: "This starts Supergateway, which publishes a local MCP server on a network port so an AI outside this computer can use its tools — often this computer's terminal and files.",
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
id: 'mcp_proxy_serve', title: 'mcp-proxy', kind: 'remote_control',
|
|
774
|
+
packages: ['mcp-proxy'], bins: ['mcp-proxy'],
|
|
775
|
+
mode: 'serve', modePattern: RC_SERVES_PORT,
|
|
776
|
+
label: 'mcp-proxy publishing a local MCP server',
|
|
777
|
+
card: "This starts mcp-proxy in server mode, which publishes a local MCP server on a network port so an AI outside this computer can use its tools — often this computer's terminal and files.",
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
id: 'mcp_remote_connector', title: 'mcp-remote', kind: 'remote_connector',
|
|
781
|
+
packages: ['mcp-remote'], bins: ['mcp-remote'],
|
|
782
|
+
mode: 'connect', modePattern: RC_CONNECTS_URL,
|
|
783
|
+
label: 'mcp-remote connecting to a remote MCP server',
|
|
784
|
+
card: 'This connects a local AI client to an MCP server on the internet. It does not give an outside AI control of this computer.',
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
id: 'supergateway_connector', title: 'Supergateway', kind: 'remote_connector',
|
|
788
|
+
packages: ['supergateway'], bins: ['supergateway'],
|
|
789
|
+
mode: 'connect', modePattern: String.raw `(?![^;&|\n]*\s--stdio(?:[=\s]|$))(?=[^;&|\n]*\s--(?:sse|streamablehttp)(?:[=\s]|$))`,
|
|
790
|
+
label: 'Supergateway connecting to a remote MCP server',
|
|
791
|
+
card: 'This connects a local AI client to an MCP server on the internet. It does not give an outside AI control of this computer.',
|
|
792
|
+
},
|
|
793
|
+
];
|
|
794
|
+
function escapeRegexLiteral(value) {
|
|
795
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
796
|
+
}
|
|
797
|
+
const RC_EXT = String.raw `(?:\.cmd|\.exe|\.ps1)?`;
|
|
798
|
+
// Statement start, then transparent wrappers (`sudo`, `env X=1`, `cmd /c`, `bash -c "`, `pwsh -Command "`).
|
|
799
|
+
const RC_START = String.raw `(?:^|[;&|(\n])\s*`;
|
|
800
|
+
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
|
+
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
|
+
// Launcher options (`-y`, `--package=x`, `-p x`, `--from x`); a value never starts with `-`.
|
|
804
|
+
const RC_LAUNCHER_OPTIONS = String.raw `(?:\s+-{1,2}[a-z][\w-]*(?:=[^\s;&|]*)?(?:\s+[^\s;&|-][^\s;&|]*)?)*?`;
|
|
805
|
+
const RC_TOKEN_END = String.raw `(?=[\s;&|)]|$)`;
|
|
806
|
+
/** The positional pattern for one catalog entry: program at a statement start, package, then its mode. */
|
|
807
|
+
function remoteControlBridgePattern(bridge) {
|
|
808
|
+
const names = Array.from(new Set([...bridge.packages, ...bridge.bins]))
|
|
809
|
+
.sort((a, b) => b.length - a.length).map(escapeRegexLiteral).join('|');
|
|
810
|
+
const bins = bridge.bins.slice().sort((a, b) => b.length - a.length).map(escapeRegexLiteral).join('|');
|
|
811
|
+
const packageToken = String.raw `["']?(?:${names})(?:@[^\s"';&|)]*|==[^\s"';&|)]*|\[[^\]\s]*\])?["']?${RC_TOKEN_END}`;
|
|
812
|
+
const binToken = String.raw `(?:${bins})${RC_EXT}${RC_TOKEN_END}`;
|
|
813
|
+
return `${RC_START}${RC_WRAPPERS}${RC_PATH}(?:${RC_LAUNCHER}${RC_LAUNCHER_OPTIONS}(?:\\s+--)?\\s+${packageToken}|${binToken})${bridge.modePattern}`;
|
|
814
|
+
}
|
|
815
|
+
const REMOTE_CONTROL_BRIDGE_REGEXES = exports.REMOTE_CONTROL_BRIDGES.map(bridge => ({ bridge, re: new RegExp(remoteControlBridgePattern(bridge), 'i') }));
|
|
816
|
+
/** Shared command rules for the terminal guards: one per remote-control entry, same Local Safety item. */
|
|
817
|
+
exports.REMOTE_CONTROL_COMMAND_RULES = exports.REMOTE_CONTROL_BRIDGES
|
|
818
|
+
.filter(bridge => bridge.kind === 'remote_control')
|
|
819
|
+
.map(bridge => ({
|
|
820
|
+
itemId: 'remote_control_bridge',
|
|
821
|
+
categoryId: 'remote_control',
|
|
822
|
+
severity: 'critical',
|
|
823
|
+
reason: bridge.card,
|
|
824
|
+
pattern: remoteControlBridgePattern(bridge),
|
|
825
|
+
commands: ['npx', 'pnpx', 'bunx', 'uvx', 'pipx', 'npm', 'pnpm', 'yarn', 'bun', 'uv', ...bridge.bins],
|
|
826
|
+
}));
|
|
827
|
+
const RC_LAUNCHER_NAME = /(?:^|[\s\\/"'(;&|])(npx|pnpx|bunx|uvx|npm\s+(?:exec|x)|pnpm\s+(?:dlx|exec)|yarn\s+dlx|bun\s+x|uv\s+tool\s+run|pipx\s+run)(?=\s)/i;
|
|
828
|
+
function launcherOf(matched) {
|
|
829
|
+
const hit = RC_LAUNCHER_NAME.exec(matched.replace(/\.(?:cmd|exe|ps1)\b/gi, ''));
|
|
830
|
+
return hit ? hit[1].toLowerCase().replace(/\s+/g, ' ') : 'direct';
|
|
831
|
+
}
|
|
832
|
+
function matchRemoteBridge(text, kind) {
|
|
833
|
+
for (const { bridge, re } of REMOTE_CONTROL_BRIDGE_REGEXES) {
|
|
834
|
+
if (bridge.kind !== kind)
|
|
835
|
+
continue;
|
|
836
|
+
const hit = re.exec(text);
|
|
837
|
+
if (hit)
|
|
838
|
+
return { bridge, facts: { bridge: bridge.id, mode: bridge.mode, launcher: launcherOf(hit[0]) } };
|
|
839
|
+
}
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
/** A shell line that starts a remote-control bridge (any statement, any de-obfuscated variant). */
|
|
843
|
+
function remoteControlBridgeInCommand(command) {
|
|
844
|
+
if (!command || command.length > 32_000)
|
|
845
|
+
return undefined;
|
|
846
|
+
return matchCommand(stripInertDataSegments(command), text => matchRemoteBridge(text, 'remote_control'));
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* An MCP server launch (config `command` + `args`) that is a catalogued bridge:
|
|
850
|
+
* a remote-control bridge first, else a remote connector. Arguments with
|
|
851
|
+
* spaces are re-quoted so a `--stdio "npx server"` value stays one argument.
|
|
852
|
+
*/
|
|
853
|
+
function classifyRemoteMcpLaunch(command, args = []) {
|
|
854
|
+
if (!command)
|
|
855
|
+
return undefined;
|
|
856
|
+
const line = [command, ...args].map(part => (/\s/.test(part) ? `"${part.replace(/"/g, '')}"` : part)).join(' ');
|
|
857
|
+
return matchRemoteBridge(line, 'remote_control') || matchRemoteBridge(line, 'remote_connector');
|
|
858
|
+
}
|
|
859
|
+
const RC_LAUNCHERS = new Set(['npx', 'pnpx', 'bunx', 'uvx', 'npm exec', 'npm x', 'pnpm dlx', 'pnpm exec', 'yarn dlx', 'bun x', 'uv tool run', 'pipx run', 'direct']);
|
|
860
|
+
/** Wire-supplied facts constrained to the catalog vocabulary (backend ingest); anything else is dropped. */
|
|
861
|
+
function sanitizeRemoteControlFacts(raw) {
|
|
862
|
+
if (!raw || typeof raw !== 'object')
|
|
863
|
+
return undefined;
|
|
864
|
+
const value = raw;
|
|
865
|
+
const bridge = exports.REMOTE_CONTROL_BRIDGES.find(entry => entry.id === value.bridge);
|
|
866
|
+
if (!bridge || value.mode !== bridge.mode || typeof value.launcher !== 'string' || !RC_LAUNCHERS.has(value.launcher))
|
|
867
|
+
return undefined;
|
|
868
|
+
return { bridge: bridge.id, mode: bridge.mode, launcher: value.launcher };
|
|
869
|
+
}
|
|
870
|
+
/** The engine fact provider: the catalog id of the remote-control bridge this line starts. */
|
|
871
|
+
function remoteControlFactFor(command) {
|
|
872
|
+
return remoteControlBridgeInCommand(command)?.bridge.id;
|
|
873
|
+
}
|
|
874
|
+
/** What an "Always allow" on a bridge is bound to: the package and its mode, never the launcher that ran it. */
|
|
875
|
+
function remoteControlGrantKey(bridge) {
|
|
876
|
+
return `${bridge.packages[0]} ${bridge.mode}`;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Every distinct remote-control bridge a line starts (as grant keys), plus the
|
|
880
|
+
* line with each verbatim launch cut out — statement separators kept — so the
|
|
881
|
+
* caller can still read the OTHER programs on the line. A launch seen only in a
|
|
882
|
+
* de-obfuscated variant is not cut: the rest is over-counted, never under-counted.
|
|
883
|
+
*/
|
|
884
|
+
function remoteControlLaunches(command) {
|
|
885
|
+
if (!command || command.length > 32_000)
|
|
886
|
+
return { bridges: [], rest: command || '' };
|
|
887
|
+
const seen = new Set();
|
|
888
|
+
matchCommand(stripInertDataSegments(command), text => {
|
|
889
|
+
for (const { bridge, re } of REMOTE_CONTROL_BRIDGE_REGEXES) {
|
|
890
|
+
if (bridge.kind === 'remote_control' && !seen.has(bridge.id) && re.test(text))
|
|
891
|
+
seen.add(bridge.id);
|
|
892
|
+
}
|
|
893
|
+
return undefined;
|
|
894
|
+
});
|
|
895
|
+
const bridges = new Set();
|
|
896
|
+
let rest = command;
|
|
897
|
+
for (const { bridge, re } of REMOTE_CONTROL_BRIDGE_REGEXES) {
|
|
898
|
+
if (!seen.has(bridge.id))
|
|
899
|
+
continue;
|
|
900
|
+
bridges.add(remoteControlGrantKey(bridge));
|
|
901
|
+
rest = rest.replace(new RegExp(re.source, 'gi'), hit => `${/^[;&|(\n]/.test(hit) ? hit[0] : ''} `);
|
|
902
|
+
}
|
|
903
|
+
return { bridges: [...bridges].sort(), rest };
|
|
904
|
+
}
|
|
905
|
+
function canonicalRemoteLaunch(match) {
|
|
906
|
+
const direct = match.facts.launcher === 'direct';
|
|
907
|
+
const program = direct ? match.bridge.bins[0] : `${match.facts.launcher} ${match.bridge.packages[0]}`;
|
|
908
|
+
const mode = match.bridge.mode === 'remote' ? ' remote' : match.bridge.id === 'supergateway_stdio_serve' ? ' --stdio' : ' --port';
|
|
909
|
+
return `${program}${mode}`;
|
|
910
|
+
}
|
|
721
911
|
// Container-scoped execution context immediately before an `rm` hit: a
|
|
722
912
|
// docker/podman/nerdctl/kubectl exec|run (incl. `compose exec/run`) earlier in
|
|
723
913
|
// the SAME statement (no ; & | between). Inside that context a bare `rm -rf *`
|
|
@@ -817,7 +1007,7 @@ function astDestructiveRm(facts, inContainer = false) {
|
|
|
817
1007
|
for (const target of targets) {
|
|
818
1008
|
const t = target.value.trim().replace(/\\/g, '/');
|
|
819
1009
|
const lower = t.toLowerCase();
|
|
820
|
-
if (
|
|
1010
|
+
if (isRootRemovalTarget(t))
|
|
821
1011
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
822
1012
|
if (/^(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/+\*?)?$/.test(lower))
|
|
823
1013
|
return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
|
|
@@ -857,6 +1047,11 @@ function destructiveCommandDecision(value) {
|
|
|
857
1047
|
return regexHit;
|
|
858
1048
|
if (astHit)
|
|
859
1049
|
return astHit;
|
|
1050
|
+
// The AST parsed the literal command only. A hit that exists only in a
|
|
1051
|
+
// decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`) is
|
|
1052
|
+
// outside what it saw, so it cannot clear it.
|
|
1053
|
+
if (regexRm && !RM_SCOPE_ITEMS.has(destructiveCommandReason(value, { literalOnly: true })?.itemId || ''))
|
|
1054
|
+
return regexHit;
|
|
860
1055
|
if (regexRm) {
|
|
861
1056
|
// The AST cleared the rm-scope hit; other destructive rules still apply.
|
|
862
1057
|
return destructiveCommandReason(value, { skipRmScope: true });
|
|
@@ -898,8 +1093,60 @@ function cdScopedBeforeBareStar(before) {
|
|
|
898
1093
|
// At least one real path component that is not `.`/`..`.
|
|
899
1094
|
return lower.split('/').some(part => part && part !== '.' && part !== '..');
|
|
900
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* An absolute removal target that resolves to the filesystem root or every
|
|
1098
|
+
* entry in it: `/`, `//`, `/*`, `/./`, `/tmp/..`, `/var/tmp/../../*`. A named
|
|
1099
|
+
* path under a root (`/tmp/test-crate-cache`) is not root scope.
|
|
1100
|
+
*/
|
|
1101
|
+
function isRootRemovalTarget(raw) {
|
|
1102
|
+
const target = raw.replace(/^["']|["']$/g, '').replace(/\\/g, '/');
|
|
1103
|
+
if (!target.startsWith('/'))
|
|
1104
|
+
return false;
|
|
1105
|
+
const parts = [];
|
|
1106
|
+
for (const part of target.split('/')) {
|
|
1107
|
+
if (!part || part === '.')
|
|
1108
|
+
continue;
|
|
1109
|
+
if (part === '..')
|
|
1110
|
+
parts.pop();
|
|
1111
|
+
else
|
|
1112
|
+
parts.push(part);
|
|
1113
|
+
}
|
|
1114
|
+
return parts.length === 0 || (parts.length === 1 && parts[0] === '*');
|
|
1115
|
+
}
|
|
1116
|
+
/** Every operand of a recursive, forced `rm` — not only the first one after the flags. */
|
|
1117
|
+
function rmRemovesRoot(lower) {
|
|
1118
|
+
for (const invocation of lower.matchAll(/(?:^|[\s;&|(`])(?:sudo\s+)?rm((?:\s+[^\s;&|)`]+)+)/g)) {
|
|
1119
|
+
const words = invocation[1].trim().split(/\s+/);
|
|
1120
|
+
let recursive = false, force = false, flagsDone = false;
|
|
1121
|
+
const operands = [];
|
|
1122
|
+
for (const word of words) {
|
|
1123
|
+
if (!flagsDone && word === '--') {
|
|
1124
|
+
flagsDone = true;
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (!flagsDone && /^--/.test(word)) {
|
|
1128
|
+
if (word === '--recursive')
|
|
1129
|
+
recursive = true;
|
|
1130
|
+
if (word === '--force')
|
|
1131
|
+
force = true;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (!flagsDone && /^-[a-z]+$/.test(word)) {
|
|
1135
|
+
if (word.includes('r'))
|
|
1136
|
+
recursive = true;
|
|
1137
|
+
if (word.includes('f'))
|
|
1138
|
+
force = true;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
operands.push(word);
|
|
1142
|
+
}
|
|
1143
|
+
if (recursive && force && operands.some(isRootRemovalTarget))
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
901
1148
|
function destructiveCommandReason(value, opts = {}) {
|
|
902
|
-
|
|
1149
|
+
const check = (text, lower) => {
|
|
903
1150
|
if (!opts.skipRmScope) {
|
|
904
1151
|
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
|
|
905
1152
|
// include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
|
|
@@ -913,6 +1160,9 @@ function destructiveCommandReason(value, opts = {}) {
|
|
|
913
1160
|
const splitFlags = /\b(?:sudo\s+)?rm\s+((?:-(?:[a-z]+|-recursive|-force|-no-preserve-root)\s+){2,})["']?\/\*?["']?(?:\s|$|[;&|)])/.exec(lower);
|
|
914
1161
|
if (splitFlags && /(?:^|\s)-(?:[a-z]*r|-recursive)/.test(splitFlags[1]) && /(?:^|\s)-(?:[a-z]*f|-force)/.test(splitFlags[1]))
|
|
915
1162
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
1163
|
+
// A root operand after another one, or reached by climbing (`rm -rf /tmp/x /`, `rm -rf /tmp/..`).
|
|
1164
|
+
if (rmRemovesRoot(lower))
|
|
1165
|
+
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
916
1166
|
// Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
|
|
917
1167
|
// A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
|
|
918
1168
|
if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
|
|
@@ -963,7 +1213,11 @@ function destructiveCommandReason(value, opts = {}) {
|
|
|
963
1213
|
if (/\bgit\s+(?:reset\s+--hard|clean\s+-f(?:d|x|dx)?\b)/i.test(text))
|
|
964
1214
|
return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
|
|
965
1215
|
return undefined;
|
|
966
|
-
}
|
|
1216
|
+
};
|
|
1217
|
+
if (!opts.literalOnly)
|
|
1218
|
+
return matchCommand(value, check);
|
|
1219
|
+
const literal = normalizeForCommand(value);
|
|
1220
|
+
return check(literal, literal.toLowerCase());
|
|
967
1221
|
}
|
|
968
1222
|
function reverseShellReason(value) {
|
|
969
1223
|
return matchCommand(value, (text) => {
|
|
@@ -1101,12 +1355,29 @@ const METADATA_SEARCH_PROGRAMS = new Set([
|
|
|
1101
1355
|
* Split on unquoted `;` / `&&` / `||` / `|` / newline so a pipe cannot hide a
|
|
1102
1356
|
* later client (`rg … | curl http://169.254…`) while `rg "a|b"` stays one segment.
|
|
1103
1357
|
*/
|
|
1358
|
+
/**
|
|
1359
|
+
* PowerShell escape: inside `"…"` a backtick makes the next character literal
|
|
1360
|
+
* (`` `" `` does not close the string); outside quotes `` `" `` / `` `' `` /
|
|
1361
|
+
* `` `$ `` and a trailing backtick (line continuation) are escapes, not a
|
|
1362
|
+
* POSIX command substitution.
|
|
1363
|
+
*/
|
|
1364
|
+
function isPowerShellBacktickEscape(text, i, quote) {
|
|
1365
|
+
if (text[i] !== '`' || i + 1 >= text.length)
|
|
1366
|
+
return false;
|
|
1367
|
+
if (quote === '"')
|
|
1368
|
+
return true;
|
|
1369
|
+
return quote === null && /["'$\s]/.test(text[i + 1]);
|
|
1370
|
+
}
|
|
1104
1371
|
function splitUnquotedCommandSegments(command) {
|
|
1105
1372
|
const out = [];
|
|
1106
1373
|
let cur = '';
|
|
1107
1374
|
let quote = null;
|
|
1108
1375
|
for (let i = 0; i < command.length; i++) {
|
|
1109
1376
|
const c = command[i];
|
|
1377
|
+
if (isPowerShellBacktickEscape(command, i, quote)) {
|
|
1378
|
+
cur += c + command[++i];
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1110
1381
|
if (quote) {
|
|
1111
1382
|
cur += c;
|
|
1112
1383
|
if (c === '\\' && i + 1 < command.length) {
|
|
@@ -1272,11 +1543,15 @@ function unquotedCode(text) {
|
|
|
1272
1543
|
let out = '';
|
|
1273
1544
|
for (let i = 0; i < text.length; i++) {
|
|
1274
1545
|
const c = text[i];
|
|
1546
|
+
if (isPowerShellBacktickEscape(text, i, null)) {
|
|
1547
|
+
out += c + text[++i];
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1275
1550
|
if (c === "'" || c === '"' || c === '`') {
|
|
1276
1551
|
const q = c;
|
|
1277
1552
|
i += 1;
|
|
1278
1553
|
while (i < text.length) {
|
|
1279
|
-
if (text[i] === '\\') {
|
|
1554
|
+
if (text[i] === '\\' || (q === '"' && text[i] === '`')) {
|
|
1280
1555
|
i += 2;
|
|
1281
1556
|
continue;
|
|
1282
1557
|
}
|
|
@@ -1304,7 +1579,7 @@ function stripInertDataSegments(text) {
|
|
|
1304
1579
|
// Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
|
|
1305
1580
|
// unless the span sits in a code position (invariants 2–4 above).
|
|
1306
1581
|
const src = out;
|
|
1307
|
-
out = src.replace(/"((?:[^"
|
|
1582
|
+
out = src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
|
|
1308
1583
|
const body = dq !== undefined ? dq : (sq || '');
|
|
1309
1584
|
if (!/\s/.test(body))
|
|
1310
1585
|
return whole; // single token: argv, not prose
|
|
@@ -1417,7 +1692,58 @@ function credentialAccessCommandText(text) {
|
|
|
1417
1692
|
return text;
|
|
1418
1693
|
statements.push(current);
|
|
1419
1694
|
// A real shell separator is a boundary of the preceding path, even without spaces.
|
|
1420
|
-
return statements.map(statement =>
|
|
1695
|
+
return statements.map(statement => {
|
|
1696
|
+
if (!allowMetadata)
|
|
1697
|
+
return statement;
|
|
1698
|
+
if (isFileMetadataQuery(statement) || isMetadataOnlyStatement(statement))
|
|
1699
|
+
return 'fcd_file_metadata';
|
|
1700
|
+
return withoutWrittenLiterals(statement);
|
|
1701
|
+
}).join('\n');
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* access.role of a QUOTED LITERAL, from the programs that receive it. A string
|
|
1705
|
+
* that is only printed or written into a file is data, whatever path it spells:
|
|
1706
|
+
* `'{"file_path":"~/.aws/credentials"}' | Set-Content fixture.json` and
|
|
1707
|
+
* `echo '…/.aws/credentials' > case.txt` open nothing. The producer is a bare
|
|
1708
|
+
* literal (a PowerShell value) or a printer; every consumer is a content
|
|
1709
|
+
* writer or formatter whose pipeline input is TEXT, never a path it opens
|
|
1710
|
+
* (`'~/.aws/credentials' | Get-Content` is a read and stays one). Unquoted
|
|
1711
|
+
* operands — the file written, a `>` target — are never masked, so writing TO a
|
|
1712
|
+
* credential file is still seen. Substitution, grouping, script blocks and
|
|
1713
|
+
* stdin redirection keep the statement as it is.
|
|
1714
|
+
*/
|
|
1715
|
+
const LITERAL_PRINTERS = new Set(['echo', 'printf', 'write-output', 'write', 'write-host']);
|
|
1716
|
+
const LITERAL_CONTENT_SINKS = new Set([
|
|
1717
|
+
'set-content', 'sc', 'add-content', 'ac', 'out-file', 'tee', 'tee-object', 'out-null', 'out-string', 'convertto-json', 'convertfrom-json',
|
|
1718
|
+
]);
|
|
1719
|
+
const QUOTED_LITERAL_RE = /^(?:"[^"\r\n]*"|'(?:[^'\r\n]|'')*')$/;
|
|
1720
|
+
function withoutWrittenLiterals(statement) {
|
|
1721
|
+
if (/\$\(|`/.test(statement) || /[(){}<]/.test(unquotedCode(statement)))
|
|
1722
|
+
return statement;
|
|
1723
|
+
const tokens = statement.trim().match(/"[^"\r\n]*"|'(?:[^'\r\n]|'')*'|[^\s"']+/g) || [];
|
|
1724
|
+
if (!tokens.some(token => QUOTED_LITERAL_RE.test(token)))
|
|
1725
|
+
return statement;
|
|
1726
|
+
const segments = [[]];
|
|
1727
|
+
for (const token of tokens) {
|
|
1728
|
+
if (token === '|')
|
|
1729
|
+
segments.push([]);
|
|
1730
|
+
else
|
|
1731
|
+
segments[segments.length - 1].push(token);
|
|
1732
|
+
}
|
|
1733
|
+
const producer = segments[0];
|
|
1734
|
+
const bareLiteral = producer.length > 0 && producer.every(token => QUOTED_LITERAL_RE.test(token));
|
|
1735
|
+
if (!bareLiteral && !LITERAL_PRINTERS.has(leadingProgram(producer).program))
|
|
1736
|
+
return statement;
|
|
1737
|
+
if (!segments.slice(1).every(segment => LITERAL_CONTENT_SINKS.has(leadingProgram(segment).program)))
|
|
1738
|
+
return statement;
|
|
1739
|
+
// Only the producer's quoted operands are data; `>` targets and sink operands stay.
|
|
1740
|
+
let redirectTarget = false;
|
|
1741
|
+
const masked = producer.map(token => {
|
|
1742
|
+
const isTarget = redirectTarget;
|
|
1743
|
+
redirectTarget = /^[\d*&]?>>?\|?$/.test(token);
|
|
1744
|
+
return !isTarget && QUOTED_LITERAL_RE.test(token) ? "'fcd_written_literal'" : token;
|
|
1745
|
+
});
|
|
1746
|
+
return [masked, ...segments.slice(1)].map(segment => segment.join(' ')).join(' | ');
|
|
1421
1747
|
}
|
|
1422
1748
|
/**
|
|
1423
1749
|
* access.role of a statement, from its LEADING PROGRAM: what does this program
|
|
@@ -1548,6 +1874,13 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1548
1874
|
if (finding)
|
|
1549
1875
|
return finding;
|
|
1550
1876
|
}
|
|
1877
|
+
const remoteBridge = remoteControlBridgeInCommand(value);
|
|
1878
|
+
if (remoteBridge) {
|
|
1879
|
+
// Evidence is the canonical launch (launcher, package, mode) — never the argument text.
|
|
1880
|
+
const finding = builtIn('remote_control_bridge', 'remote_control', 'remote_control', 'local-remote-control-bridge', remoteBridge.bridge.card, canonicalRemoteLaunch(remoteBridge), remoteBridge.bridge.label, options);
|
|
1881
|
+
if (finding)
|
|
1882
|
+
return { ...finding, remoteControl: remoteBridge.facts };
|
|
1883
|
+
}
|
|
1551
1884
|
const destructive = destructiveCommandDecision(value);
|
|
1552
1885
|
if (destructive) {
|
|
1553
1886
|
return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
|
|
@@ -1562,6 +1895,213 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1562
1895
|
}
|
|
1563
1896
|
return undefined;
|
|
1564
1897
|
}
|
|
1898
|
+
// ---- agent guard tamper: the agent switching off the hooks that police it ----
|
|
1899
|
+
// Every rule in this file stops working once the agent edits the file that
|
|
1900
|
+
// registers the hook. These rules decide on one structural fact: the TARGET is
|
|
1901
|
+
// an AI agent's hook/settings file and the action turns hooks off, removes or
|
|
1902
|
+
// rewrites that file, or uninstalls FullCourtDefense. Reading those files, or
|
|
1903
|
+
// editing them for anything else (permissions, MCP servers, env), never matches.
|
|
1904
|
+
/** Agent hook/settings files, matched on the normalized path (lowercase, `/`). */
|
|
1905
|
+
const AGENT_HOOK_CONFIG_RE = /(?:^|[/\s'"=:(])(?:\.claude\/settings(?:\.local)?\.json|\.cursor\/hooks\.json|\.codex\/hooks\.json|\.copilot\/settings\.json|\.gemini\/settings\.json|\.codeium\/windsurf\/hooks\.json|\.kiro\/hooks\/[^\s'"/]+\.json|(?:claudecode|claude-code)\/managed-settings(?:\.d\/[^\s'"/]+)?\.json|(?:programdata|etc|application support)\/cursor\/hooks\.json)(?=$|[\s'"`;|&)>,])/;
|
|
1906
|
+
/** A value that turns every non-managed hook off (Claude-format settings keys). */
|
|
1907
|
+
const HOOKS_OFF_SETTING_RE = /\b(?:disableAllHooks|allowManagedHooksOnly)["']?\s*[:=]\s*(?:true|\$true|1)\b/i;
|
|
1908
|
+
const CLAUDE_CONFIG_SET_HOOKS_OFF_RE = /\bconfig\s+set\s+(?:(?:-g|--global)\s+)?(?:disableAllHooks|allowManagedHooksOnly)\s+(?:true|1)\b/i;
|
|
1909
|
+
const HOOK_CONFIG_MUTATING_PROGRAMS = new Set([
|
|
1910
|
+
'rm', 'rmdir', 'unlink', 'shred', 'truncate', 'del', 'erase', 'rd', 'remove-item', 'ri',
|
|
1911
|
+
'mv', 'move', 'move-item', 'mi', 'ren', 'rename', 'rename-item', 'rni',
|
|
1912
|
+
'tee', 'tee-object', 'sponge', 'set-content', 'add-content', 'ac', 'clear-content', 'clc', 'out-file',
|
|
1913
|
+
]);
|
|
1914
|
+
const HOOK_CONFIG_COPY_PROGRAMS = new Set(['cp', 'copy', 'copy-item', 'cpi', 'xcopy', 'robocopy']);
|
|
1915
|
+
const HOOK_CONFIG_READ_ONLY_PROGRAMS = new Set([
|
|
1916
|
+
'cat', 'type', 'get-content', 'gc', 'less', 'more', 'head', 'tail', 'jq', 'rg', 'grep', 'egrep', 'fgrep',
|
|
1917
|
+
'findstr', 'select-string', 'sls', 'ls', 'dir', 'get-item', 'gi', 'get-childitem', 'gci', 'stat', 'test-path',
|
|
1918
|
+
'code', 'cursor', 'notepad', 'wc', 'diff', 'fc', 'git', 'echo', 'write-output', 'write-host', 'printf',
|
|
1919
|
+
]);
|
|
1920
|
+
const INLINE_INTERPRETERS = new Set(['node', 'deno', 'bun', 'python', 'python3', 'py', 'perl', 'ruby', 'pwsh', 'powershell']);
|
|
1921
|
+
const INLINE_FILE_MUTATION_RE = /\b(?:writeFileSync|appendFileSync|unlinkSync|rmSync|renameSync|copyFileSync|WriteAllText|WriteAllLines|WriteAllBytes|AppendAllText|os\.remove|os\.unlink|os\.replace|shutil\.move|shutil\.copy(?:file)?)\b|\[(?:system\.)?io\.file\]::(?:delete|move|copy|writealltext|writealllines)|\bopen\([^)]*,\s*['"][wa]/i;
|
|
1922
|
+
const FCD_PROGRAMS = new Set(['fullcourtdefense', 'fcd', 'fullcourtdefense-cli']);
|
|
1923
|
+
const TAMPER_PATH_KEY_RE = /(?:path|file|filename|target|dest|destination|uri)$/i;
|
|
1924
|
+
const DELETE_TOOL_NAMES = new Set(['deletefile', 'delete', 'removefile', 'remove', 'rm', 'unlink']);
|
|
1925
|
+
function mentionsAgentHookConfig(text) {
|
|
1926
|
+
return AGENT_HOOK_CONFIG_RE.test(normalizeForPath(text));
|
|
1927
|
+
}
|
|
1928
|
+
function segmentTokens(segment) {
|
|
1929
|
+
return (segment.match(/(?:[^\s"']+|"(?:[^"`]|`[\s\S])*"|'[^']*')+/g) || []).map(token => token.replace(/^["']|["']$/g, ''));
|
|
1930
|
+
}
|
|
1931
|
+
function segmentProgram(tokens) {
|
|
1932
|
+
let i = 0;
|
|
1933
|
+
while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || /^(?:sudo|doas|env|command|builtin|exec|nohup|time|&|\.)$/i.test(tokens[i])))
|
|
1934
|
+
i++;
|
|
1935
|
+
const program = (tokens[i] || '').toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(?:exe|cmd|bat|ps1)$/, '');
|
|
1936
|
+
return { program, rest: tokens.slice(i + 1) };
|
|
1937
|
+
}
|
|
1938
|
+
/** Targets of unquoted `>` / `>>` redirections (stderr `2>` and `=>` / `->` are not file writes). */
|
|
1939
|
+
function redirectTargets(segment) {
|
|
1940
|
+
const out = [];
|
|
1941
|
+
for (const match of segment.matchAll(/(?:^|[^0-9&>=-])>{1,2}\s*(["']?)([^\s"'|;&<>]+)\1/g))
|
|
1942
|
+
out.push(match[2]);
|
|
1943
|
+
return out;
|
|
1944
|
+
}
|
|
1945
|
+
function isFcdUninstall(program, rest) {
|
|
1946
|
+
let prog = program;
|
|
1947
|
+
let args = rest;
|
|
1948
|
+
if (prog === 'npx' || prog === 'pnpx' || prog === 'bunx') {
|
|
1949
|
+
const operands = args.filter(token => !token.startsWith('-'));
|
|
1950
|
+
prog = (operands[0] || '').toLowerCase().replace(/(?!^)@.*$/, '');
|
|
1951
|
+
args = operands.slice(1);
|
|
1952
|
+
}
|
|
1953
|
+
if (FCD_PROGRAMS.has(prog))
|
|
1954
|
+
return /^uninstall/i.test(args.find(token => !token.startsWith('-')) || '');
|
|
1955
|
+
if (prog === 'npm' || prog === 'pnpm' || prog === 'yarn' || prog === 'bun') {
|
|
1956
|
+
return args.some(token => /^(?:uninstall|remove|rm|r|un|unlink)$/i.test(token))
|
|
1957
|
+
&& args.some(token => /^fullcourtdefense-cli(?:@|$)/i.test(token));
|
|
1958
|
+
}
|
|
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] || '');
|
|
1962
|
+
}
|
|
1963
|
+
if (prog === 'schtasks') {
|
|
1964
|
+
if (!args.some(token => /^[/-](?:delete|disable)$/i.test(token)))
|
|
1965
|
+
return false;
|
|
1966
|
+
const at = args.findIndex(token => /^[/-]tn$/i.test(token));
|
|
1967
|
+
return at >= 0 && isFcdTaskName(args[at + 1] || '');
|
|
1968
|
+
}
|
|
1969
|
+
if (prog === 'unregister-scheduledtask' || prog === 'disable-scheduledtask') {
|
|
1970
|
+
return optionValues(args, /^-taskname$/i, /^-(?:taskpath|cimsession)$/i).some(isFcdTaskName);
|
|
1971
|
+
}
|
|
1972
|
+
if (prog === 'winget')
|
|
1973
|
+
return /^(?:uninstall|remove)$/i.test(args[0] || '') && args.slice(1).some(token => /fullcourtdefense/i.test(token));
|
|
1974
|
+
if (prog === 'taskkill') {
|
|
1975
|
+
const at = args.findIndex(token => /^[/-]im$/i.test(token));
|
|
1976
|
+
return at >= 0 && /^fullcourtdefense/i.test(args[at + 1] || '');
|
|
1977
|
+
}
|
|
1978
|
+
if (prog === 'stop-process' || prog === 'spps' || prog === 'kill' || prog === 'pkill' || prog === 'killall') {
|
|
1979
|
+
return optionValues(args, /^-(?:name|processname)$/i, /^-id$/i).some(name => /^fullcourtdefense/i.test(name));
|
|
1980
|
+
}
|
|
1981
|
+
if (INSTALL_ROOT_REMOVERS.has(prog)) {
|
|
1982
|
+
return args.filter(token => !/^-/.test(token) && !/^\/[a-z]{1,3}$/i.test(token)).some(isFcdInstallRoot);
|
|
1983
|
+
}
|
|
1984
|
+
return false;
|
|
1985
|
+
}
|
|
1986
|
+
/** Scheduled tasks the installer and CLI register. A name outside the product namespace is not ours. */
|
|
1987
|
+
const FCD_TASK_NAMES = [
|
|
1988
|
+
'FullCourtDefense Updater', 'FullCourtDefense Daemon', 'FullCourtDefense Daemon Watchdog',
|
|
1989
|
+
'FullCourtDefense Recovery', 'FullCourtDefense Auto-Protect', 'FullCourtDefenseDesktopDiscover',
|
|
1990
|
+
];
|
|
1991
|
+
const INSTALL_ROOT_REMOVERS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'rd', 'remove-item', 'ri', 'rimraf', 'mv', 'move', 'move-item', 'mi', 'ren', 'rename-item', 'rni']);
|
|
1992
|
+
/** A task name (or wildcard) that selects a FullCourtDefense task — by its product-namespace name, not a substring. */
|
|
1993
|
+
function isFcdTaskName(raw) {
|
|
1994
|
+
const name = raw.replace(/^["']|["']$/g, '').replace(/\\$/, '').replace(/^.*\\/, '').trim();
|
|
1995
|
+
if (!name)
|
|
1996
|
+
return false;
|
|
1997
|
+
if (!/[*?]/.test(name))
|
|
1998
|
+
return /^fullcourtdefense/i.test(name);
|
|
1999
|
+
const glob = new RegExp(`^${name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i');
|
|
2000
|
+
return FCD_TASK_NAMES.some(task => glob.test(task));
|
|
2001
|
+
}
|
|
2002
|
+
/** The install root (`Program Files\FullCourtDefense`) or the machine state root (`ProgramData\FullCourtDefense`), or a path inside them. */
|
|
2003
|
+
function isFcdInstallRoot(raw) {
|
|
2004
|
+
const path = raw.replace(/^["']|["']$/g, '').replace(/\\/g, '/').toLowerCase();
|
|
2005
|
+
return /(?:^|\/|%|\$(?:env:|\{env:))(?:program ?files(?: ?\(x86\))?|programfiles\(x86\)|programdata|allusersprofile)[%}]?\/fullcourtdefense(?:\/|$)/.test(path);
|
|
2006
|
+
}
|
|
2007
|
+
/** Values of a named parameter plus positional operands (skipping the values of `skipNamed`). */
|
|
2008
|
+
function optionValues(args, named, skipNamed) {
|
|
2009
|
+
const out = [];
|
|
2010
|
+
for (let i = 0; i < args.length; i++) {
|
|
2011
|
+
const token = args[i];
|
|
2012
|
+
if (named.test(token)) {
|
|
2013
|
+
if (args[i + 1] !== undefined)
|
|
2014
|
+
out.push(...args[i + 1].split(','));
|
|
2015
|
+
i++;
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
if (skipNamed.test(token)) {
|
|
2019
|
+
i++;
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
if (/^-/.test(token))
|
|
2023
|
+
continue;
|
|
2024
|
+
out.push(...token.split(','));
|
|
2025
|
+
}
|
|
2026
|
+
return out;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Shell view: per unquoted segment, the leading program decides. Quoted prose
|
|
2030
|
+
* (commit messages, issue bodies, echo text) is removed before redirections
|
|
2031
|
+
* and off-switch keys are judged, so writing ABOUT the setting never matches.
|
|
2032
|
+
*/
|
|
2033
|
+
function hookConfigShellChange(command) {
|
|
2034
|
+
for (const rawSegment of splitUnquotedCommandSegments(command)) {
|
|
2035
|
+
const raw = rawSegment.trim();
|
|
2036
|
+
if (!raw)
|
|
2037
|
+
continue;
|
|
2038
|
+
const { program, rest } = segmentProgram(segmentTokens(raw));
|
|
2039
|
+
if (isFcdUninstall(program, rest)) {
|
|
2040
|
+
return { itemId: 'fcd_uninstalled_by_agent', reason: 'agent removing FullCourtDefense from this machine' };
|
|
2041
|
+
}
|
|
2042
|
+
const stripped = stripInertDataSegments(raw);
|
|
2043
|
+
if (program === 'claude' && CLAUDE_CONFIG_SET_HOOKS_OFF_RE.test(stripped)) {
|
|
2044
|
+
return { itemId: 'agent_hooks_switched_off', reason: 'agent turning off AI agent hooks' };
|
|
2045
|
+
}
|
|
2046
|
+
if (!mentionsAgentHookConfig(raw))
|
|
2047
|
+
continue;
|
|
2048
|
+
if (redirectTargets(stripped).some(mentionsAgentHookConfig)) {
|
|
2049
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent overwriting an AI agent hook configuration file' };
|
|
2050
|
+
}
|
|
2051
|
+
if (HOOK_CONFIG_READ_ONLY_PROGRAMS.has(program))
|
|
2052
|
+
continue;
|
|
2053
|
+
const operands = rest.filter(token => !/^-/.test(token) && !/^\/[a-z]{1,3}$/i.test(token));
|
|
2054
|
+
if (HOOK_CONFIG_MUTATING_PROGRAMS.has(program) && operands.some(mentionsAgentHookConfig)) {
|
|
2055
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent removing or rewriting an AI agent hook configuration file' };
|
|
2056
|
+
}
|
|
2057
|
+
if ((program === 'new-item' || program === 'ni') && rest.some(token => /^-force$/i.test(token)) && operands.some(mentionsAgentHookConfig)) {
|
|
2058
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent overwriting an AI agent hook configuration file' };
|
|
2059
|
+
}
|
|
2060
|
+
if ((program === 'sed' || program === 'perl') && rest.some(token => /^-[a-z]*i/i.test(token)) && operands.some(mentionsAgentHookConfig)) {
|
|
2061
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent editing an AI agent hook configuration file in place' };
|
|
2062
|
+
}
|
|
2063
|
+
if (HOOK_CONFIG_COPY_PROGRAMS.has(program) && operands.length >= 2 && mentionsAgentHookConfig(operands[operands.length - 1])) {
|
|
2064
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent replacing an AI agent hook configuration file' };
|
|
2065
|
+
}
|
|
2066
|
+
if (INLINE_FILE_MUTATION_RE.test(INLINE_INTERPRETERS.has(program) ? raw : stripped)) {
|
|
2067
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent rewriting an AI agent hook configuration file from a script' };
|
|
2068
|
+
}
|
|
2069
|
+
if (HOOKS_OFF_SETTING_RE.test(stripped)) {
|
|
2070
|
+
return { itemId: 'agent_hooks_switched_off', reason: 'agent turning off AI agent hooks' };
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
return undefined;
|
|
2074
|
+
}
|
|
2075
|
+
/** Tool view (file edit / write / delete tools): the target path decides, the content only for the off-switch. */
|
|
2076
|
+
function hookConfigToolChange(toolName, candidates) {
|
|
2077
|
+
const target = candidates.find(candidate => {
|
|
2078
|
+
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
2079
|
+
return TAMPER_PATH_KEY_RE.test(lastKey) && !/[\r\n]/.test(candidate.value) && candidate.value.length < 1024
|
|
2080
|
+
&& mentionsAgentHookConfig(candidate.value);
|
|
2081
|
+
});
|
|
2082
|
+
if (!target)
|
|
2083
|
+
return undefined;
|
|
2084
|
+
if (DELETE_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_-]/g, ''))) {
|
|
2085
|
+
return { itemId: 'agent_hook_config_removed', reason: 'agent deleting an AI agent hook configuration file', evidence: target.value };
|
|
2086
|
+
}
|
|
2087
|
+
if (candidates.some(candidate => candidate !== target && HOOKS_OFF_SETTING_RE.test(candidate.value))) {
|
|
2088
|
+
return { itemId: 'agent_hooks_switched_off', reason: 'agent turning off AI agent hooks', evidence: target.value };
|
|
2089
|
+
}
|
|
2090
|
+
return undefined;
|
|
2091
|
+
}
|
|
2092
|
+
function agentGuardTamperFinding(toolName, candidates, options) {
|
|
2093
|
+
const finding = (hit, value) => builtIn(hit.itemId, 'agent_guard_tamper', 'agent_guard_tamper', 'local-agent-guard-tamper', `Agent action would switch off the protection on this machine: ${hit.reason}.`, value, hit.reason, options);
|
|
2094
|
+
for (const candidate of candidates) {
|
|
2095
|
+
if (!isCommandContext(toolName, candidate))
|
|
2096
|
+
continue;
|
|
2097
|
+
const hit = hookConfigShellChange(candidate.value);
|
|
2098
|
+
const found = hit && finding(hit, candidate.value);
|
|
2099
|
+
if (found)
|
|
2100
|
+
return found;
|
|
2101
|
+
}
|
|
2102
|
+
const hit = hookConfigToolChange(toolName, candidates);
|
|
2103
|
+
return hit ? finding(hit, hit.evidence) : undefined;
|
|
2104
|
+
}
|
|
1565
2105
|
/**
|
|
1566
2106
|
* A string can only DO something in an actionable context: a shell command, an outbound
|
|
1567
2107
|
* request, or a path/target argument. File CONTENTS being written or edited are inert —
|
|
@@ -1592,6 +2132,19 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
|
|
|
1592
2132
|
if (custom)
|
|
1593
2133
|
return custom;
|
|
1594
2134
|
}
|
|
2135
|
+
// A tamper finding asks by default; a chained `rm -rf / ; rm ~/.cursor/hooks.json`
|
|
2136
|
+
// must still hit the stricter built-in block, so a non-block tamper finding
|
|
2137
|
+
// yields only to a block.
|
|
2138
|
+
const tamper = agentGuardTamperFinding(toolName, candidates, options);
|
|
2139
|
+
const blocks = (finding) => (finding.action || 'block') === 'block';
|
|
2140
|
+
if (tamper && blocks(tamper))
|
|
2141
|
+
return tamper;
|
|
2142
|
+
const other = scanBuiltInRules(toolName, toolArgs, candidates, outbound, options);
|
|
2143
|
+
if (tamper && !(other && blocks(other)))
|
|
2144
|
+
return tamper;
|
|
2145
|
+
return other;
|
|
2146
|
+
}
|
|
2147
|
+
function scanBuiltInRules(toolName, toolArgs, candidates, outbound, options) {
|
|
1595
2148
|
for (const candidate of candidates) {
|
|
1596
2149
|
const finding = scanTextValue(toolName, candidate.value, options, isCommandContext(toolName, candidate));
|
|
1597
2150
|
if (!finding)
|