fullcourtdefense-cli 1.35.10 → 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/commands/cmdGuard.js +2 -0
- package/dist/commands/daemon.js +42 -2
- package/dist/commands/deterministicGuard.d.ts +62 -2
- package/dist/commands/deterministicGuard.js +283 -16
- package/dist/commands/discover.js +13 -2
- package/dist/commands/hook.js +15 -3
- package/dist/commands/knownMcpServers.d.ts +8 -2
- package/dist/commands/knownMcpServers.js +33 -2
- package/dist/commands/shellGuard.js +16 -1
- package/dist/commands/watchdog.js +17 -3
- package/dist/devConfirm.d.ts +7 -0
- package/dist/devConfirm.js +61 -4
- package/dist/selfUpdate.d.ts +59 -0
- package/dist/selfUpdate.js +138 -1
- package/dist/shellAstLoader.js +2 -0
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +1 -0
- 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 +6 -1
|
@@ -268,6 +268,7 @@ export interface ShellAstOperationComparison {
|
|
|
268
268
|
agree: boolean;
|
|
269
269
|
}
|
|
270
270
|
export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
|
|
271
|
+
export declare function setRemoteControlProvider(provider: ((command: string) => string | undefined) | undefined): void;
|
|
271
272
|
export declare function setShellAstMode(mode: ShellAstMode): void;
|
|
272
273
|
export declare function getShellAstMode(): ShellAstMode;
|
|
273
274
|
/** Shadow telemetry: the comparison recorded by the most recent top-level shell classification (consumed once). */
|
|
@@ -42,6 +42,7 @@ __export(actionPolicyEngine_exports, {
|
|
|
42
42
|
resolveApprovalOptions: () => resolveApprovalOptions,
|
|
43
43
|
roleExceptionError: () => roleExceptionError,
|
|
44
44
|
ruleNeedsContentFact: () => ruleNeedsContentFact,
|
|
45
|
+
setRemoteControlProvider: () => setRemoteControlProvider,
|
|
45
46
|
setShellAstMode: () => setShellAstMode,
|
|
46
47
|
setShellAstProvider: () => setShellAstProvider,
|
|
47
48
|
takeShellAstOperationComparison: () => takeShellAstOperationComparison,
|
|
@@ -6777,6 +6778,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
6777
6778
|
"tool.status",
|
|
6778
6779
|
"operation.status",
|
|
6779
6780
|
"agent.baseline",
|
|
6781
|
+
"toolArgs.remoteControl",
|
|
6780
6782
|
// Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
|
|
6781
6783
|
// pollute operation matching.
|
|
6782
6784
|
"toolName",
|
|
@@ -8160,6 +8162,10 @@ var lastShellAstOperationComparison;
|
|
|
8160
8162
|
function setShellAstProvider(provider) {
|
|
8161
8163
|
shellAstProvider = provider;
|
|
8162
8164
|
}
|
|
8165
|
+
var remoteControlProvider;
|
|
8166
|
+
function setRemoteControlProvider(provider) {
|
|
8167
|
+
remoteControlProvider = provider;
|
|
8168
|
+
}
|
|
8163
8169
|
function setShellAstMode(mode) {
|
|
8164
8170
|
shellAstMode = mode;
|
|
8165
8171
|
}
|
|
@@ -8719,9 +8725,22 @@ function inferToolContext(toolName, args) {
|
|
|
8719
8725
|
context["destination.type"] = context["request.destination.type"];
|
|
8720
8726
|
context["destination.domain"] = context["request.destination.domain"];
|
|
8721
8727
|
}
|
|
8728
|
+
applyRemoteControlFact(toolName, args, context);
|
|
8722
8729
|
applyWebReadFacts(toolName, args, context);
|
|
8723
8730
|
return { operation, context };
|
|
8724
8731
|
}
|
|
8732
|
+
function applyRemoteControlFact(toolName, args, context) {
|
|
8733
|
+
delete context["toolArgs.remoteControl"];
|
|
8734
|
+
const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
|
|
8735
|
+
if (!shellText || !remoteControlProvider) return;
|
|
8736
|
+
let bridge;
|
|
8737
|
+
try {
|
|
8738
|
+
bridge = remoteControlProvider(shellText);
|
|
8739
|
+
} catch {
|
|
8740
|
+
bridge = void 0;
|
|
8741
|
+
}
|
|
8742
|
+
if (bridge) context["toolArgs.remoteControl"] = bridge;
|
|
8743
|
+
}
|
|
8725
8744
|
var WEB_READ_TOOL_NAMES = /* @__PURE__ */ new Set(["webfetch", "fetchwebpage", "webextract"]);
|
|
8726
8745
|
function applyWebReadFacts(toolName, args, context) {
|
|
8727
8746
|
if (!WEB_READ_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_\-.\s]/g, ""))) return;
|
|
@@ -8855,6 +8874,7 @@ function inferBundledToolContext(toolName, args) {
|
|
|
8855
8874
|
context["destination.type"] = isInternalHost(context["destination.domain"]) ? "internal" : "external";
|
|
8856
8875
|
}
|
|
8857
8876
|
}
|
|
8877
|
+
applyRemoteControlFact(toolName, args, context);
|
|
8858
8878
|
applyWebReadFacts(toolName, args, context);
|
|
8859
8879
|
return { operation, context };
|
|
8860
8880
|
}
|
|
@@ -8975,6 +8995,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
|
|
|
8975
8995
|
resolveApprovalOptions,
|
|
8976
8996
|
roleExceptionError,
|
|
8977
8997
|
ruleNeedsContentFact,
|
|
8998
|
+
setRemoteControlProvider,
|
|
8978
8999
|
setShellAstMode,
|
|
8979
9000
|
setShellAstProvider,
|
|
8980
9001
|
takeShellAstOperationComparison,
|
|
@@ -106,6 +106,8 @@ const FIXED_INTERCEPTED_COMMANDS = [
|
|
|
106
106
|
const CMD_HIGH_FREQUENCY_COMMANDS = new Set([
|
|
107
107
|
'docker', 'git', 'kubectl', 'aws', 'gcloud', 'az', 'terraform', 'pulumi', 'wsl',
|
|
108
108
|
'curl', 'wget', 'taskkill', 'robocopy', 'xcopy', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
|
|
109
|
+
// Package managers: `npm exec` / `pnpm dlx` bridges are covered in PowerShell and the IDE; cmd keeps `npx`.
|
|
110
|
+
'npm', 'pnpm', 'yarn', 'bun', 'uv',
|
|
109
111
|
]);
|
|
110
112
|
/**
|
|
111
113
|
* Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
|
package/dist/commands/daemon.js
CHANGED
|
@@ -466,7 +466,26 @@ function hookConfigFiles() {
|
|
|
466
466
|
// ---------------------------------------------------------------------------
|
|
467
467
|
// The resident loop
|
|
468
468
|
// ---------------------------------------------------------------------------
|
|
469
|
+
/**
|
|
470
|
+
* A daemon started while the updater replaces the install (logon trigger, a
|
|
471
|
+
* pre-fix watchdog, a manual start) would put the old build — and its own
|
|
472
|
+
* update kick — into the middle of msiexec. Wait for the run instead. Bounded
|
|
473
|
+
* by the lock's max age; stage 2 relaunches the daemon when it finishes.
|
|
474
|
+
*/
|
|
475
|
+
async function waitForActiveUpdaterRun() {
|
|
476
|
+
let run = (0, selfUpdate_1.readActiveUpdaterRun)();
|
|
477
|
+
if (!run)
|
|
478
|
+
return;
|
|
479
|
+
log(`An updater run is installing${run.targetVersion ? ` ${run.targetVersion}` : ''} (${run.stage || 'running'}, since ${run.startedAt || 'unknown'}) — daemon start waits for it to finish.`);
|
|
480
|
+
const deadline = Date.now() + (0, selfUpdate_1.updaterLockMaxAgeMs)();
|
|
481
|
+
while (run && Date.now() < deadline) {
|
|
482
|
+
await new Promise(resolve => setTimeout(resolve, 5_000));
|
|
483
|
+
run = (0, selfUpdate_1.readActiveUpdaterRun)();
|
|
484
|
+
}
|
|
485
|
+
log('Updater run finished — continuing daemon start.');
|
|
486
|
+
}
|
|
469
487
|
async function runDaemon(args, config) {
|
|
488
|
+
await waitForActiveUpdaterRun();
|
|
470
489
|
if (!acquirePidLock(await detectRecycledPidOwner())) {
|
|
471
490
|
console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
|
|
472
491
|
return;
|
|
@@ -518,7 +537,11 @@ async function runDaemon(args, config) {
|
|
|
518
537
|
log(`Shield key UNAVAILABLE${ph && !ph.spawnOk ? ' — powershell.exe could not start (EDR/policy block?), so the DPAPI-protected key cannot be decrypted' : ''}. Telemetry/control-plane sync disabled; retrying periodically.`);
|
|
519
538
|
}
|
|
520
539
|
// Crash forensics: was the previous daemon killed without a clean shutdown?
|
|
540
|
+
const previousMarker = (0, daemonForensics_1.readAliveMarker)();
|
|
521
541
|
const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
|
|
542
|
+
if (previousMarker?.cleanExit?.reason === 'stopped-for-update' && previousMarker.pid !== process.pid) {
|
|
543
|
+
log(`Previous daemon (v${previousMarker.version || '?'}) was stopped for update by the updater (${previousMarker.cleanExit.at}) — expected, not a crash.`);
|
|
544
|
+
}
|
|
522
545
|
if (postmortem) {
|
|
523
546
|
const current = cliVersion();
|
|
524
547
|
if (postmortem.version && current && postmortem.version !== current) {
|
|
@@ -529,6 +552,13 @@ async function runDaemon(args, config) {
|
|
|
529
552
|
else if (postmortem.cause === 'machine_restart') {
|
|
530
553
|
log(`Previous daemon (v${postmortem.version || '?'}) stopped when the machine restarted (booted ${postmortem.machineBootedAt || 'after its last heartbeat'}; last alive ${postmortem.lastAliveAt || 'unknown'}). Expected, not a crash. Post-mortem recorded for the timeline.`);
|
|
531
554
|
}
|
|
555
|
+
else if ((0, selfUpdate_1.deathDuringUpdaterRun)(postmortem.lastAliveAt, (0, selfUpdate_1.readActiveUpdaterRun)() || (0, selfUpdate_1.readLastUpdaterResult)())) {
|
|
556
|
+
// Same version, but an updater run was replacing the install when it
|
|
557
|
+
// died (an update that failed, or a run whose marker stamp was lost):
|
|
558
|
+
// stopped for update — never an EDR/AV kill in the console.
|
|
559
|
+
(0, daemonForensics_1.markPostmortemReported)();
|
|
560
|
+
log(`Previous daemon (v${postmortem.version || '?'}) stopped for update — an updater run was replacing the install when it died (last alive ${postmortem.lastAliveAt || 'unknown'}). Not a crash; not reported as one.`);
|
|
561
|
+
}
|
|
532
562
|
else {
|
|
533
563
|
log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
|
|
534
564
|
}
|
|
@@ -1067,13 +1097,23 @@ async function runDaemon(args, config) {
|
|
|
1067
1097
|
return;
|
|
1068
1098
|
}
|
|
1069
1099
|
const startedAt = marker.startedAt ? Date.parse(marker.startedAt) : NaN;
|
|
1070
|
-
|
|
1100
|
+
// The install is still running: its own outcome settles this, not a timer.
|
|
1101
|
+
if ((0, selfUpdate_1.readActiveUpdaterRun)())
|
|
1102
|
+
return;
|
|
1103
|
+
// The updater recorded a failed run for this target after the kick —
|
|
1104
|
+
// report its reason now instead of waiting out the stale timer.
|
|
1105
|
+
const lastRun = (0, selfUpdate_1.readLastUpdaterResult)();
|
|
1106
|
+
const runFailed = lastRun?.outcome === 'failed' && lastRun.targetVersion === marker.target
|
|
1107
|
+
&& Number.isFinite(startedAt) && Date.parse(lastRun.finishedAt || '') >= startedAt;
|
|
1108
|
+
if (runFailed || !Number.isFinite(startedAt) || Date.now() - startedAt > PENDING_UPGRADE_STALE_MS) {
|
|
1071
1109
|
try {
|
|
1072
1110
|
fs.unlinkSync(pendingUpgradeFile());
|
|
1073
1111
|
}
|
|
1074
1112
|
catch { /* ignore */ }
|
|
1075
1113
|
await reportMachineAction(marker.actionId, 'failed', {
|
|
1076
|
-
error:
|
|
1114
|
+
error: runFailed
|
|
1115
|
+
? `The updater could not install ${marker.target}: ${lastRun?.error || 'unknown error'} The machine stays on ${current || 'unknown'}; see %ProgramData%\\FullCourtDefense\\updater.log.`
|
|
1116
|
+
: `The updater was triggered but the CLI still reports ${current || 'unknown'} after ${Math.round(PENDING_UPGRADE_STALE_MS / 60_000)} minutes (target ${marker.target}). Check %ProgramData%\\FullCourtDefense\\updater.log on the machine.`,
|
|
1077
1117
|
}, marker.signatureProof);
|
|
1078
1118
|
log(`Upgrade verification failed: still on ${current || 'unknown'}, target was ${marker.target} (action ${marker.actionId}).`);
|
|
1079
1119
|
await uploadLogTail();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type DeterministicDirection = 'request' | 'response';
|
|
2
|
-
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot' | 'agent_guard_tamper';
|
|
2
|
+
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'remote_control' | 'honeypot' | 'agent_guard_tamper';
|
|
3
3
|
/**
|
|
4
4
|
* Per-rule enforcement action (org-configured, shipped in the Local Safety
|
|
5
5
|
* snapshot). Absent everywhere = 'block' — the only behavior older CLIs and
|
|
@@ -34,6 +34,8 @@ export interface DeterministicFinding {
|
|
|
34
34
|
* findings — the seam 'mask' uses to redact tool responses. Never spooled.
|
|
35
35
|
*/
|
|
36
36
|
matchedSecret?: string;
|
|
37
|
+
/** Catalog facts for remote_control findings (bridge id, mode, launcher) — no argument text. */
|
|
38
|
+
remoteControl?: RemoteControlFacts;
|
|
37
39
|
}
|
|
38
40
|
export interface LocalSafetyCustomBlock {
|
|
39
41
|
id: string;
|
|
@@ -90,7 +92,7 @@ export declare const DEFAULT_DISABLED_BUILT_INS: ReadonlySet<string>;
|
|
|
90
92
|
export interface SharedCommandRule {
|
|
91
93
|
itemId: string;
|
|
92
94
|
/** Catalog category id (local-safety-catalog.ts). */
|
|
93
|
-
categoryId: 'credential_commands';
|
|
95
|
+
categoryId: 'credential_commands' | 'remote_control';
|
|
94
96
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
95
97
|
pattern: string;
|
|
96
98
|
reason: string;
|
|
@@ -106,6 +108,64 @@ export interface SharedCommandRule {
|
|
|
106
108
|
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
107
109
|
*/
|
|
108
110
|
export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
111
|
+
export declare const REMOTE_CONTROL_CATALOG_VERSION = "2026-09-26.1";
|
|
112
|
+
export interface RemoteControlFacts {
|
|
113
|
+
/** Catalog entry id. */
|
|
114
|
+
bridge: string;
|
|
115
|
+
/** Catalog mode label: 'remote' | 'serve' | 'connect'. */
|
|
116
|
+
mode: string;
|
|
117
|
+
/** How it was started: 'npx', 'npm exec', 'pnpm dlx', …, or 'direct' for an installed binary. */
|
|
118
|
+
launcher: string;
|
|
119
|
+
}
|
|
120
|
+
export interface RemoteControlBridge {
|
|
121
|
+
id: string;
|
|
122
|
+
title: string;
|
|
123
|
+
kind: 'remote_control' | 'remote_connector';
|
|
124
|
+
/** Registry package names (npm / PyPI), as typed after a launcher. */
|
|
125
|
+
packages: string[];
|
|
126
|
+
/** Executable names when installed globally or run from node_modules/.bin. */
|
|
127
|
+
bins: string[];
|
|
128
|
+
mode: 'remote' | 'serve' | 'connect';
|
|
129
|
+
/** Regex source applied right after the package token (JS + .NET, IgnoreCase). */
|
|
130
|
+
modePattern: string;
|
|
131
|
+
/** Short label for the console and evidence. */
|
|
132
|
+
label: string;
|
|
133
|
+
/** Decision Card sentence — what starting it means, in plain words. */
|
|
134
|
+
card: string;
|
|
135
|
+
}
|
|
136
|
+
export declare const REMOTE_CONTROL_BRIDGES: readonly RemoteControlBridge[];
|
|
137
|
+
/** The positional pattern for one catalog entry: program at a statement start, package, then its mode. */
|
|
138
|
+
export declare function remoteControlBridgePattern(bridge: RemoteControlBridge): string;
|
|
139
|
+
/** Shared command rules for the terminal guards: one per remote-control entry, same Local Safety item. */
|
|
140
|
+
export declare const REMOTE_CONTROL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
141
|
+
export interface RemoteControlMatch {
|
|
142
|
+
bridge: RemoteControlBridge;
|
|
143
|
+
facts: RemoteControlFacts;
|
|
144
|
+
}
|
|
145
|
+
/** A shell line that starts a remote-control bridge (any statement, any de-obfuscated variant). */
|
|
146
|
+
export declare function remoteControlBridgeInCommand(command: string): RemoteControlMatch | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* An MCP server launch (config `command` + `args`) that is a catalogued bridge:
|
|
149
|
+
* a remote-control bridge first, else a remote connector. Arguments with
|
|
150
|
+
* spaces are re-quoted so a `--stdio "npx server"` value stays one argument.
|
|
151
|
+
*/
|
|
152
|
+
export declare function classifyRemoteMcpLaunch(command: string | undefined, args?: readonly string[]): RemoteControlMatch | undefined;
|
|
153
|
+
/** Wire-supplied facts constrained to the catalog vocabulary (backend ingest); anything else is dropped. */
|
|
154
|
+
export declare function sanitizeRemoteControlFacts(raw: unknown): RemoteControlFacts | undefined;
|
|
155
|
+
/** The engine fact provider: the catalog id of the remote-control bridge this line starts. */
|
|
156
|
+
export declare function remoteControlFactFor(command: string): string | undefined;
|
|
157
|
+
/** What an "Always allow" on a bridge is bound to: the package and its mode, never the launcher that ran it. */
|
|
158
|
+
export declare function remoteControlGrantKey(bridge: RemoteControlBridge): string;
|
|
159
|
+
/**
|
|
160
|
+
* Every distinct remote-control bridge a line starts (as grant keys), plus the
|
|
161
|
+
* line with each verbatim launch cut out — statement separators kept — so the
|
|
162
|
+
* caller can still read the OTHER programs on the line. A launch seen only in a
|
|
163
|
+
* de-obfuscated variant is not cut: the rest is over-counted, never under-counted.
|
|
164
|
+
*/
|
|
165
|
+
export declare function remoteControlLaunches(command: string): {
|
|
166
|
+
bridges: string[];
|
|
167
|
+
rest: string;
|
|
168
|
+
};
|
|
109
169
|
export type ShellAstMode = 'off' | 'shadow' | 'primary';
|
|
110
170
|
/** Structural subset of shellAst.ts ShellFacts that the guard reads. */
|
|
111
171
|
export interface ShellAstFactsLike {
|
|
@@ -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;
|
|
@@ -446,7 +453,7 @@ const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
|
|
|
446
453
|
* snapshot built before the item existed (older backend) must not turn a new
|
|
447
454
|
* ask-first rule into a hard block. An explicit org 'block' still wins.
|
|
448
455
|
*/
|
|
449
|
-
const DEFAULT_ASK_BUILT_INS = new Set(['agent_hooks_switched_off', 'agent_hook_config_removed', 'fcd_uninstalled_by_agent']);
|
|
456
|
+
const DEFAULT_ASK_BUILT_INS = new Set(['agent_hooks_switched_off', 'agent_hook_config_removed', 'fcd_uninstalled_by_agent', 'remote_control_bridge']);
|
|
450
457
|
function actionFor(itemId, options) {
|
|
451
458
|
const action = toLocalSafetyRuleAction(options?.itemActions?.[itemId]);
|
|
452
459
|
if (action)
|
|
@@ -726,6 +733,181 @@ function credentialCommandReason(value, options) {
|
|
|
726
733
|
return undefined;
|
|
727
734
|
});
|
|
728
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
|
+
}
|
|
729
911
|
// Container-scoped execution context immediately before an `rm` hit: a
|
|
730
912
|
// docker/podman/nerdctl/kubectl exec|run (incl. `compose exec/run`) earlier in
|
|
731
913
|
// the SAME statement (no ; & | between). Inside that context a bare `rm -rf *`
|
|
@@ -1173,12 +1355,29 @@ const METADATA_SEARCH_PROGRAMS = new Set([
|
|
|
1173
1355
|
* Split on unquoted `;` / `&&` / `||` / `|` / newline so a pipe cannot hide a
|
|
1174
1356
|
* later client (`rg … | curl http://169.254…`) while `rg "a|b"` stays one segment.
|
|
1175
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
|
+
}
|
|
1176
1371
|
function splitUnquotedCommandSegments(command) {
|
|
1177
1372
|
const out = [];
|
|
1178
1373
|
let cur = '';
|
|
1179
1374
|
let quote = null;
|
|
1180
1375
|
for (let i = 0; i < command.length; i++) {
|
|
1181
1376
|
const c = command[i];
|
|
1377
|
+
if (isPowerShellBacktickEscape(command, i, quote)) {
|
|
1378
|
+
cur += c + command[++i];
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1182
1381
|
if (quote) {
|
|
1183
1382
|
cur += c;
|
|
1184
1383
|
if (c === '\\' && i + 1 < command.length) {
|
|
@@ -1344,11 +1543,15 @@ function unquotedCode(text) {
|
|
|
1344
1543
|
let out = '';
|
|
1345
1544
|
for (let i = 0; i < text.length; i++) {
|
|
1346
1545
|
const c = text[i];
|
|
1546
|
+
if (isPowerShellBacktickEscape(text, i, null)) {
|
|
1547
|
+
out += c + text[++i];
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1347
1550
|
if (c === "'" || c === '"' || c === '`') {
|
|
1348
1551
|
const q = c;
|
|
1349
1552
|
i += 1;
|
|
1350
1553
|
while (i < text.length) {
|
|
1351
|
-
if (text[i] === '\\') {
|
|
1554
|
+
if (text[i] === '\\' || (q === '"' && text[i] === '`')) {
|
|
1352
1555
|
i += 2;
|
|
1353
1556
|
continue;
|
|
1354
1557
|
}
|
|
@@ -1376,7 +1579,7 @@ function stripInertDataSegments(text) {
|
|
|
1376
1579
|
// Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
|
|
1377
1580
|
// unless the span sits in a code position (invariants 2–4 above).
|
|
1378
1581
|
const src = out;
|
|
1379
|
-
out = src.replace(/"((?:[^"
|
|
1582
|
+
out = src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
|
|
1380
1583
|
const body = dq !== undefined ? dq : (sq || '');
|
|
1381
1584
|
if (!/\s/.test(body))
|
|
1382
1585
|
return whole; // single token: argv, not prose
|
|
@@ -1671,6 +1874,13 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1671
1874
|
if (finding)
|
|
1672
1875
|
return finding;
|
|
1673
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
|
+
}
|
|
1674
1884
|
const destructive = destructiveCommandDecision(value);
|
|
1675
1885
|
if (destructive) {
|
|
1676
1886
|
return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
|
|
@@ -1716,7 +1926,7 @@ function mentionsAgentHookConfig(text) {
|
|
|
1716
1926
|
return AGENT_HOOK_CONFIG_RE.test(normalizeForPath(text));
|
|
1717
1927
|
}
|
|
1718
1928
|
function segmentTokens(segment) {
|
|
1719
|
-
return (segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map(token => token.replace(/^["']|["']$/g, ''));
|
|
1929
|
+
return (segment.match(/(?:[^\s"']+|"(?:[^"`]|`[\s\S])*"|'[^']*')+/g) || []).map(token => token.replace(/^["']|["']$/g, ''));
|
|
1720
1930
|
}
|
|
1721
1931
|
function segmentProgram(tokens) {
|
|
1722
1932
|
let i = 0;
|
|
@@ -1732,7 +1942,7 @@ function redirectTargets(segment) {
|
|
|
1732
1942
|
out.push(match[2]);
|
|
1733
1943
|
return out;
|
|
1734
1944
|
}
|
|
1735
|
-
function isFcdUninstall(program, rest
|
|
1945
|
+
function isFcdUninstall(program, rest) {
|
|
1736
1946
|
let prog = program;
|
|
1737
1947
|
let args = rest;
|
|
1738
1948
|
if (prog === 'npx' || prog === 'pnpx' || prog === 'bunx') {
|
|
@@ -1746,18 +1956,75 @@ function isFcdUninstall(program, rest, segment) {
|
|
|
1746
1956
|
return args.some(token => /^(?:uninstall|remove|rm|r|un|unlink)$/i.test(token))
|
|
1747
1957
|
&& args.some(token => /^fullcourtdefense-cli(?:@|$)/i.test(token));
|
|
1748
1958
|
}
|
|
1749
|
-
if (
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
if (prog === 'schtasks')
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
+
}
|
|
1757
1972
|
if (prog === 'winget')
|
|
1758
|
-
return /^(?:uninstall|remove)$/i.test(args[0] || '');
|
|
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
|
+
}
|
|
1759
1984
|
return false;
|
|
1760
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
|
+
}
|
|
1761
2028
|
/**
|
|
1762
2029
|
* Shell view: per unquoted segment, the leading program decides. Quoted prose
|
|
1763
2030
|
* (commit messages, issue bodies, echo text) is removed before redirections
|
|
@@ -1769,7 +2036,7 @@ function hookConfigShellChange(command) {
|
|
|
1769
2036
|
if (!raw)
|
|
1770
2037
|
continue;
|
|
1771
2038
|
const { program, rest } = segmentProgram(segmentTokens(raw));
|
|
1772
|
-
if (isFcdUninstall(program, rest
|
|
2039
|
+
if (isFcdUninstall(program, rest)) {
|
|
1773
2040
|
return { itemId: 'fcd_uninstalled_by_agent', reason: 'agent removing FullCourtDefense from this machine' };
|
|
1774
2041
|
}
|
|
1775
2042
|
const stripped = stripInertDataSegments(raw);
|