fullcourtdefense-cli 1.25.7 → 1.26.1
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/commands/cmdGuard.js +23 -12
- package/dist/commands/daemon.d.ts +2 -9
- package/dist/commands/daemon.js +146 -45
- package/dist/commands/desktopChatGuard.js +131 -6
- package/dist/commands/deterministicGuard.js +17 -5
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +100 -1
- package/dist/commands/hook.d.ts +38 -0
- package/dist/commands/hook.js +324 -67
- package/dist/commands/installClaudeHook.js +9 -5
- package/dist/commands/installCursorHook.js +9 -9
- package/dist/commands/login.js +5 -0
- package/dist/commands/mcpGateway.js +275 -64
- package/dist/index.js +131 -73
- package/dist/localSafetySnapshot.d.ts +17 -0
- package/dist/localSafetySnapshot.js +21 -0
- package/dist/perfSnapshot.d.ts +91 -0
- package/dist/perfSnapshot.js +219 -0
- package/dist/policyGateHealth.d.ts +4 -0
- package/dist/policyGateHealth.js +7 -0
- package/dist/runtimeConfig.d.ts +37 -1
- package/dist/runtimeConfig.js +48 -0
- package/dist/selfTest.js +36 -24
- package/dist/selfUpdate.js +33 -9
- package/dist/telemetry.js +20 -10
- package/dist/verdictIpc.d.ts +85 -0
- package/dist/verdictIpc.js +323 -0
- package/dist/version.json +1 -1
- package/dist/windowsTaskState.d.ts +40 -0
- package/dist/windowsTaskState.js +43 -0
- package/package.json +4 -1
|
@@ -74,21 +74,29 @@ const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
|
|
|
74
74
|
* cmd (they ARE under PowerShell/zsh/bash and `shell-guard-check`).
|
|
75
75
|
*/
|
|
76
76
|
const INTERCEPTED_COMMANDS = [
|
|
77
|
-
// Destructive filesystem / disk
|
|
77
|
+
// Destructive filesystem / disk / permission tampering
|
|
78
78
|
'del', 'erase', 'rd', 'rmdir', 'format', 'cipher', 'diskpart', 'fsutil',
|
|
79
|
-
'
|
|
79
|
+
'takeown', 'icacls', 'attrib', 'compact',
|
|
80
80
|
// Backup / recovery / shadow-copy tampering (ransomware patterns)
|
|
81
81
|
'vssadmin', 'wbadmin', 'bcdedit', 'wevtutil',
|
|
82
|
-
//
|
|
83
|
-
'powershell', 'pwsh', '
|
|
84
|
-
'rundll32', 'regsvr32', 'wscript', 'cscript', 'wmic',
|
|
85
|
-
// Service / firewall / registry / accounts
|
|
86
|
-
'reg', 'net', 'net1', 'sc', 'netsh', 'schtasks',
|
|
87
|
-
// Infra / cloud CLIs
|
|
88
|
-
'terraform', 'pulumi', 'kubectl', 'aws', 'gcloud', 'az', 'docker',
|
|
89
|
-
// VCS + database clients
|
|
90
|
-
'git', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
|
|
82
|
+
// Script hosts / LOLBin download-and-exec vectors (rare in dev workflows)
|
|
83
|
+
'powershell', 'pwsh', 'certutil', 'bitsadmin', 'mshta',
|
|
84
|
+
'rundll32', 'regsvr32', 'wscript', 'cscript', 'wmic',
|
|
85
|
+
// Service / firewall / registry / accounts / scheduled-task tampering
|
|
86
|
+
'reg', 'net', 'net1', 'sc', 'netsh', 'schtasks',
|
|
91
87
|
];
|
|
88
|
+
/**
|
|
89
|
+
* LIGHTNESS DIET (deliberate non-goals of the cmd guard): high-frequency
|
|
90
|
+
* developer commands are NOT doskey-intercepted. Every macro costs a full
|
|
91
|
+
* Node spawn per typed command — plus a corporate-AV scan of node.exe — so
|
|
92
|
+
* wrapping `docker`/`git` made every docker-heavy terminal feel sticky.
|
|
93
|
+
* Dropped: docker, git, kubectl, aws, gcloud, az, terraform, pulumi, wsl,
|
|
94
|
+
* curl, wget, taskkill, robocopy, xcopy, psql, mysql, mongo, mongosh, sqlcmd.
|
|
95
|
+
* Coverage is NOT lost where it matters: agent-driven commands go through the
|
|
96
|
+
* IDE hooks (full ruleset, any position in the line), and humans typing in
|
|
97
|
+
* PowerShell are covered in-process by the PSReadLine guard. Interactive
|
|
98
|
+
* cmd.exe keeps only the rare, high-signal destructive/tamper commands above.
|
|
99
|
+
*/
|
|
92
100
|
function regString(key, value) {
|
|
93
101
|
try {
|
|
94
102
|
const out = (0, child_process_1.execFileSync)('reg', ['query', key, '/v', value], {
|
|
@@ -130,7 +138,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
130
138
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
|
|
131
139
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
132
140
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
133
|
-
|
|
141
|
+
// TOP-LEVEL FAIL-OPEN: any unexpected exception in rule loading/matching
|
|
142
|
+
// must never break the user's typed command — treat it as "no hit" and
|
|
143
|
+
// delegate. A stack trace or nonzero exit here IS an outage for the shell.
|
|
144
|
+
`const line=args.join(' ');let mode='monitor';let hit=null;try{const r=loadRules();mode=r.mode;hit=matchLine(line,r.rules);}catch{hit=null;}`,
|
|
134
145
|
`if(!hit)delegate(args);`,
|
|
135
146
|
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
136
147
|
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BotGuardConfig } from '../config';
|
|
2
2
|
import { ProtectAllArgs } from './mcpGateway';
|
|
3
|
+
import { windowsTaskRunsInteractive, windowsTaskRunsVisibly } from '../windowsTaskState';
|
|
3
4
|
export interface DaemonArgs extends ProtectAllArgs {
|
|
4
5
|
install?: string;
|
|
5
6
|
uninstall?: string;
|
|
@@ -83,15 +84,7 @@ export declare function registerHiddenTask(taskName: string, xml: string | strin
|
|
|
83
84
|
/** True when an existing scheduled task's action still runs wscript/cscript
|
|
84
85
|
* (the pre-1.22.1 VBS launcher) — the migration trigger. */
|
|
85
86
|
export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
|
|
86
|
-
|
|
87
|
-
* the 1.22.1 window-flash bug (console window on every trigger). Migration
|
|
88
|
-
* trigger for the windowless S4U principal. */
|
|
89
|
-
export declare function windowsTaskRunsInteractive(taskName: string): boolean;
|
|
90
|
-
/** True when triggering the task would open a VISIBLE console window:
|
|
91
|
-
* InteractiveToken principal AND a bare console action (not wrapped in
|
|
92
|
-
* `conhost --headless`). This — not the principal alone — is the flash
|
|
93
|
-
* condition; a conhost-wrapped InteractiveToken task is fully windowless. */
|
|
94
|
-
export declare function windowsTaskRunsVisibly(taskName: string): boolean;
|
|
87
|
+
export { windowsTaskRunsInteractive, windowsTaskRunsVisibly };
|
|
95
88
|
/** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
|
|
96
89
|
export interface DaemonRuntimeState {
|
|
97
90
|
alive: boolean;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.windowsTaskRunsVisibly = exports.windowsTaskRunsInteractive = void 0;
|
|
36
37
|
exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
|
|
37
38
|
exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
|
|
38
39
|
exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
|
|
@@ -44,8 +45,6 @@ exports.buildTaskXml = buildTaskXml;
|
|
|
44
45
|
exports.buildTaskXmlVariants = buildTaskXmlVariants;
|
|
45
46
|
exports.registerHiddenTask = registerHiddenTask;
|
|
46
47
|
exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
|
|
47
|
-
exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
|
|
48
|
-
exports.windowsTaskRunsVisibly = windowsTaskRunsVisibly;
|
|
49
48
|
exports.daemonRuntimeState = daemonRuntimeState;
|
|
50
49
|
exports.spawnDetachedDaemon = spawnDetachedDaemon;
|
|
51
50
|
exports.pidIsAlive = pidIsAlive;
|
|
@@ -76,9 +75,15 @@ const discoveryMarker_1 = require("../discoveryMarker");
|
|
|
76
75
|
const selfUpdate_1 = require("../selfUpdate");
|
|
77
76
|
const cmdGuard_1 = require("./cmdGuard");
|
|
78
77
|
const machineActionVerify_1 = require("../machineActionVerify");
|
|
78
|
+
const perfSnapshot_1 = require("../perfSnapshot");
|
|
79
79
|
const desktopChatGuard_1 = require("./desktopChatGuard");
|
|
80
80
|
const honeypot_1 = require("../honeypot");
|
|
81
81
|
const windowsAudit_1 = require("./windowsAudit");
|
|
82
|
+
const hook_1 = require("./hook");
|
|
83
|
+
const verdictIpc_1 = require("../verdictIpc");
|
|
84
|
+
const windowsTaskState_1 = require("../windowsTaskState");
|
|
85
|
+
Object.defineProperty(exports, "windowsTaskRunsInteractive", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsInteractive; } });
|
|
86
|
+
Object.defineProperty(exports, "windowsTaskRunsVisibly", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsVisibly; } });
|
|
82
87
|
const COLOR = {
|
|
83
88
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
84
89
|
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
@@ -108,6 +113,11 @@ const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60
|
|
|
108
113
|
const DISCOVER_STALE_MS = envMs('FCD_DAEMON_DISCOVER_STALE_MS', 20 * 60 * 60_000);
|
|
109
114
|
/** How often the daemon re-checks discovery freshness. */
|
|
110
115
|
const DISCOVER_CHECK_INTERVAL_MS = envMs('FCD_DAEMON_DISCOVER_CHECK_MS', 60 * 60_000);
|
|
116
|
+
/** Boot catch-up sweep delay — well past login so it never competes with the
|
|
117
|
+
* developer's IDE/browser startup (the sweep also runs at idle priority). */
|
|
118
|
+
const DISCOVER_BOOT_CATCHUP_DELAY_MS = envMs('FCD_DAEMON_DISCOVER_BOOT_DELAY_MS', 15 * 60_000);
|
|
119
|
+
/** Random jitter added to the boot catch-up so a fleet doesn't sweep in lockstep. */
|
|
120
|
+
const DISCOVER_BOOT_CATCHUP_JITTER_MS = 5 * 60_000;
|
|
111
121
|
/** Rotate the daemon log when it grows past this size. */
|
|
112
122
|
const LOG_MAX_BYTES = 1_000_000;
|
|
113
123
|
function daemonDir() {
|
|
@@ -225,6 +235,14 @@ function runDiscoverSweep(credentials, timeoutMs = 300_000) {
|
|
|
225
235
|
// home so the posture scope reports a meaningful folder, not an OS dir.
|
|
226
236
|
cwd: os.homedir(),
|
|
227
237
|
});
|
|
238
|
+
// Idle CPU priority (IDLE_PRIORITY_CLASS on Windows, nice 19 on POSIX): a
|
|
239
|
+
// deep sweep must never compete with the developer's build/IDE for CPU.
|
|
240
|
+
// The sweep just takes longer on a busy machine — which is the point.
|
|
241
|
+
try {
|
|
242
|
+
if (child.pid)
|
|
243
|
+
os.setPriority(child.pid, 19);
|
|
244
|
+
}
|
|
245
|
+
catch { /* best-effort */ }
|
|
228
246
|
let stderrTail = '';
|
|
229
247
|
child.stderr?.on('data', (chunk) => {
|
|
230
248
|
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096);
|
|
@@ -616,10 +634,18 @@ async function runDaemon(args, config) {
|
|
|
616
634
|
body: JSON.stringify({ shieldId: creds.shieldId, machineId: identity.machineId, ...payload }),
|
|
617
635
|
signal: AbortSignal.timeout(15_000),
|
|
618
636
|
});
|
|
637
|
+
if (!resp.ok) {
|
|
638
|
+
// Log the STATUS and the server's own words. Swallowing them turned a
|
|
639
|
+
// precise backend rejection into "network or backend rejection" in the
|
|
640
|
+
// console, which cost a Cloud Run log dig to diagnose — on the very
|
|
641
|
+
// feature whose job is to explain a machine remotely.
|
|
642
|
+
const body = await resp.text().catch(() => '');
|
|
643
|
+
log(`Diagnostics upload rejected: HTTP ${resp.status}${body ? ` — ${body.slice(0, 300).replace(/\s+/g, ' ')}` : ''}`);
|
|
644
|
+
}
|
|
619
645
|
return resp.ok;
|
|
620
646
|
}
|
|
621
|
-
catch {
|
|
622
|
-
log(
|
|
647
|
+
catch (error) {
|
|
648
|
+
log(`Diagnostics upload failed (network: ${error.message}) — the bundle stays available locally.`);
|
|
623
649
|
return false;
|
|
624
650
|
}
|
|
625
651
|
};
|
|
@@ -898,6 +924,31 @@ async function runDaemon(args, config) {
|
|
|
898
924
|
log('Discovery scan: upload complete — dashboard discovery + posture timestamps will refresh.');
|
|
899
925
|
resultSummary = 'Discovery + posture scan completed and uploaded.';
|
|
900
926
|
}
|
|
927
|
+
else if (action.type === 'perf_snapshot') {
|
|
928
|
+
// On-demand performance proof: per-process footprint + hot-path
|
|
929
|
+
// micro-bench + disk state, collected in one shot (no resident
|
|
930
|
+
// profiler, no periodic sampling — the lightness principle applies
|
|
931
|
+
// to the measurement itself). Same collection as `doctor --perf`,
|
|
932
|
+
// so the console table matches what the customer sees locally.
|
|
933
|
+
log('Perf snapshot: measuring hot-path latency, process footprint, and disk state (~10s)…');
|
|
934
|
+
await uploadLogTail();
|
|
935
|
+
let bundleState = {};
|
|
936
|
+
try {
|
|
937
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
938
|
+
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
|
|
939
|
+
apiUrl: creds.apiUrl, shieldId: creds.shieldId || '', shieldKey: creds.shieldKey,
|
|
940
|
+
developerName: identity.developerName, machineName: identity.hostname, hotPath: true,
|
|
941
|
+
});
|
|
942
|
+
bundleState = { mode: bundle.mode, source: bundle.source, policyHash: bundle.policyHash };
|
|
943
|
+
}
|
|
944
|
+
catch { /* snapshot still useful without mode context */ }
|
|
945
|
+
const snapshot = (0, perfSnapshot_1.collectPerfSnapshot)({ cliVersion: cliVersion(), ...bundleState });
|
|
946
|
+
const uploaded = await uploadDiagnostics({ perfSnapshot: snapshot });
|
|
947
|
+
if (!uploaded)
|
|
948
|
+
throw new Error('Perf snapshot could not be uploaded (network or backend rejection).');
|
|
949
|
+
resultSummary = (0, perfSnapshot_1.summarizePerfSnapshot)(snapshot);
|
|
950
|
+
log(`Perf snapshot: ${resultSummary}`);
|
|
951
|
+
}
|
|
901
952
|
await reportMachineAction(action.id, 'succeeded', { resultSummary });
|
|
902
953
|
log(`Remote action succeeded: ${action.type}.`);
|
|
903
954
|
}
|
|
@@ -938,6 +989,9 @@ async function runDaemon(args, config) {
|
|
|
938
989
|
}
|
|
939
990
|
catch { /* offline — next poll retries */ }
|
|
940
991
|
};
|
|
992
|
+
// Base cadence for the bundle poll — the server can slow a whole fleet down
|
|
993
|
+
// via pollIntervalMs (bounded: 15s..1h) without shipping a new CLI.
|
|
994
|
+
let bundlePollBaseMs = BUNDLE_POLL_MS;
|
|
941
995
|
const pollBundle = async () => {
|
|
942
996
|
if (!creds.shieldId)
|
|
943
997
|
return;
|
|
@@ -957,6 +1011,9 @@ async function runDaemon(args, config) {
|
|
|
957
1011
|
machineId: identity.machineId,
|
|
958
1012
|
force: true,
|
|
959
1013
|
});
|
|
1014
|
+
if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
|
|
1015
|
+
bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
|
|
1016
|
+
}
|
|
960
1017
|
if (bundle.suspended && !suspended) {
|
|
961
1018
|
suspended = true;
|
|
962
1019
|
log('Machine SUSPENDED by admin — hooks/gateway deny all actions; daemon pauses re-protection.');
|
|
@@ -1020,6 +1077,22 @@ async function runDaemon(args, config) {
|
|
|
1020
1077
|
log(`Honeypot: removal failed: ${error.message}`);
|
|
1021
1078
|
}
|
|
1022
1079
|
}
|
|
1080
|
+
// Keep the IDE hooks' Local Safety snapshot warm. Hooks on monitor
|
|
1081
|
+
// machines read this cache with preferCached and never fetch — the
|
|
1082
|
+
// daemon is the only process paying the network cost, off the hot path.
|
|
1083
|
+
try {
|
|
1084
|
+
const hookIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
|
|
1085
|
+
await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
|
|
1086
|
+
apiUrl: creds.apiUrl,
|
|
1087
|
+
shieldId: creds.shieldId,
|
|
1088
|
+
shieldKey: creds.shieldKey,
|
|
1089
|
+
developerName: hookIdentity.developerName,
|
|
1090
|
+
machineName: hookIdentity.machineName,
|
|
1091
|
+
expectedPolicyHash: bundle.policyHash || bundle.version,
|
|
1092
|
+
timeoutMs: 8_000,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
catch { /* hooks fall back to their own bounded fetch */ }
|
|
1023
1096
|
}
|
|
1024
1097
|
catch { /* offline — cached stance applies */ }
|
|
1025
1098
|
};
|
|
@@ -1115,6 +1188,20 @@ async function runDaemon(args, config) {
|
|
|
1115
1188
|
if (desktopChatGuard)
|
|
1116
1189
|
log('Claude Desktop chat guard: supervising (advisory — warns on secrets typed into Claude Desktop).');
|
|
1117
1190
|
}
|
|
1191
|
+
// Verdict IPC server (resident-agent pattern): per-event hook processes
|
|
1192
|
+
// become thin clients that ask THIS process for the verdict over a named
|
|
1193
|
+
// pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
|
|
1194
|
+
// is warm here, so verdicts resolve in milliseconds instead of repeating
|
|
1195
|
+
// cold-start work per IDE event. Config is re-read per request (small YAML,
|
|
1196
|
+
// cheap) so on-disk edits apply without a daemon restart. Hooks fall back
|
|
1197
|
+
// to full local evaluation whenever this server is down — never a blocker.
|
|
1198
|
+
let verdictServer;
|
|
1199
|
+
try {
|
|
1200
|
+
verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)()), log);
|
|
1201
|
+
}
|
|
1202
|
+
catch (error) {
|
|
1203
|
+
log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
|
|
1204
|
+
}
|
|
1118
1205
|
// Fresh machines have never uploaded an inventory (MSI/onboard defers the
|
|
1119
1206
|
// initial discovery to keep setup fast), so the dashboard shows "Never" for
|
|
1120
1207
|
// discovery + posture until the daily scheduled job fires — up to 24h later.
|
|
@@ -1178,16 +1265,51 @@ async function runDaemon(args, config) {
|
|
|
1178
1265
|
await uploadLogTail();
|
|
1179
1266
|
}
|
|
1180
1267
|
};
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1268
|
+
// Boot deep sweeps were the "my PC is slow every morning" complaint: a
|
|
1269
|
+
// machine off overnight is always >20h stale, so the old 2-minute grace
|
|
1270
|
+
// launched a full deep sweep right while the developer opened their IDE and
|
|
1271
|
+
// browser. Defer the boot catch-up well past login (default 15 min) and add
|
|
1272
|
+
// jitter so a fleet behind one proxy doesn't sweep in lockstep. The hourly
|
|
1273
|
+
// re-check still guarantees same-day freshness.
|
|
1274
|
+
const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
|
|
1184
1275
|
const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
|
|
1185
1276
|
const rescanTimer = setInterval(() => {
|
|
1186
1277
|
const count = refreshWatchTargets();
|
|
1187
1278
|
log(`Rescan: watching ${count} config file(s).`);
|
|
1188
1279
|
}, RESCAN_INTERVAL_MS);
|
|
1189
|
-
|
|
1190
|
-
|
|
1280
|
+
// Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
|
|
1281
|
+
// a fleet enrolled behind one corporate proxy must not hit the gate in
|
|
1282
|
+
// lockstep, and one machine's timers must not stack into the same tick.
|
|
1283
|
+
// The bundle poll additionally honors the server's pollIntervalMs, and the
|
|
1284
|
+
// next tick is scheduled only AFTER the previous poll finishes — a slow
|
|
1285
|
+
// gate can never pile up overlapping polls.
|
|
1286
|
+
const jittered = (baseMs) => Math.max(5_000, Math.round(baseMs * (0.8 + Math.random() * 0.4)));
|
|
1287
|
+
let bundleTimer;
|
|
1288
|
+
const scheduleBundlePoll = () => {
|
|
1289
|
+
if (stopped)
|
|
1290
|
+
return;
|
|
1291
|
+
bundleTimer = setTimeout(async () => {
|
|
1292
|
+
try {
|
|
1293
|
+
await pollBundle();
|
|
1294
|
+
}
|
|
1295
|
+
catch { /* poll never throws, but never stop the loop */ }
|
|
1296
|
+
scheduleBundlePoll();
|
|
1297
|
+
}, jittered(bundlePollBaseMs));
|
|
1298
|
+
};
|
|
1299
|
+
scheduleBundlePoll();
|
|
1300
|
+
let heartbeatTimer;
|
|
1301
|
+
const scheduleHeartbeat = () => {
|
|
1302
|
+
if (stopped)
|
|
1303
|
+
return;
|
|
1304
|
+
heartbeatTimer = setTimeout(async () => {
|
|
1305
|
+
try {
|
|
1306
|
+
await heartbeat();
|
|
1307
|
+
}
|
|
1308
|
+
catch { /* keep the loop alive */ }
|
|
1309
|
+
scheduleHeartbeat();
|
|
1310
|
+
}, jittered(HEARTBEAT_INTERVAL_MS));
|
|
1311
|
+
};
|
|
1312
|
+
scheduleHeartbeat();
|
|
1191
1313
|
// PowerShell transcript retention (Windows): the Transcription policy FCD
|
|
1192
1314
|
// enables writes a file per session forever — prune anything older than the
|
|
1193
1315
|
// retention window once a day (plus once shortly after boot, so laptops
|
|
@@ -1211,8 +1333,10 @@ async function runDaemon(args, config) {
|
|
|
1211
1333
|
stopped = true;
|
|
1212
1334
|
log(`Received ${signal} — shutting down.`);
|
|
1213
1335
|
clearInterval(rescanTimer);
|
|
1214
|
-
|
|
1215
|
-
|
|
1336
|
+
if (bundleTimer)
|
|
1337
|
+
clearTimeout(bundleTimer);
|
|
1338
|
+
if (heartbeatTimer)
|
|
1339
|
+
clearTimeout(heartbeatTimer);
|
|
1216
1340
|
clearTimeout(transcriptPruneBootTimer);
|
|
1217
1341
|
clearInterval(transcriptPruneTimer);
|
|
1218
1342
|
clearTimeout(discoverCatchUpBootTimer);
|
|
@@ -1223,6 +1347,8 @@ async function runDaemon(args, config) {
|
|
|
1223
1347
|
clearTimeout(debounceTimer);
|
|
1224
1348
|
if (desktopChatGuard)
|
|
1225
1349
|
desktopChatGuard.stop();
|
|
1350
|
+
if (verdictServer)
|
|
1351
|
+
verdictServer.close();
|
|
1226
1352
|
for (const watcher of watchers.values())
|
|
1227
1353
|
watcher.close();
|
|
1228
1354
|
for (const watcher of rootWatchers.values())
|
|
@@ -1437,36 +1563,6 @@ function windowsTaskReferencesScriptHost(taskName) {
|
|
|
1437
1563
|
return false;
|
|
1438
1564
|
}
|
|
1439
1565
|
}
|
|
1440
|
-
/** True when an existing task still runs with an InteractiveToken principal —
|
|
1441
|
-
* the 1.22.1 window-flash bug (console window on every trigger). Migration
|
|
1442
|
-
* trigger for the windowless S4U principal. */
|
|
1443
|
-
function windowsTaskRunsInteractive(taskName) {
|
|
1444
|
-
return windowsTaskXmlState(taskName).interactive;
|
|
1445
|
-
}
|
|
1446
|
-
/** True when triggering the task would open a VISIBLE console window:
|
|
1447
|
-
* InteractiveToken principal AND a bare console action (not wrapped in
|
|
1448
|
-
* `conhost --headless`). This — not the principal alone — is the flash
|
|
1449
|
-
* condition; a conhost-wrapped InteractiveToken task is fully windowless. */
|
|
1450
|
-
function windowsTaskRunsVisibly(taskName) {
|
|
1451
|
-
const state = windowsTaskXmlState(taskName);
|
|
1452
|
-
return state.interactive && !state.headless;
|
|
1453
|
-
}
|
|
1454
|
-
function windowsTaskXmlState(taskName) {
|
|
1455
|
-
try {
|
|
1456
|
-
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
|
|
1457
|
-
encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
1458
|
-
});
|
|
1459
|
-
if (query.status !== 0 || typeof query.stdout !== 'string')
|
|
1460
|
-
return { interactive: false, headless: false };
|
|
1461
|
-
return {
|
|
1462
|
-
interactive: /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout),
|
|
1463
|
-
headless: /conhost(\.exe)?<\/Command>[\s\S]*?--headless/i.test(query.stdout),
|
|
1464
|
-
};
|
|
1465
|
-
}
|
|
1466
|
-
catch {
|
|
1467
|
-
return { interactive: false, headless: false };
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
1566
|
/**
|
|
1471
1567
|
* Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
|
|
1472
1568
|
* ONLY the S4U XML — on failure (unelevated daemon) the current task is left
|
|
@@ -1599,7 +1695,7 @@ function startDaemonNowWindows(viaTask = false) {
|
|
|
1599
1695
|
}
|
|
1600
1696
|
}
|
|
1601
1697
|
catch { /* not running */ }
|
|
1602
|
-
if (viaTask && !windowsTaskRunsVisibly(TASK_NAME)) {
|
|
1698
|
+
if (viaTask && !(0, windowsTaskState_1.windowsTaskRunsVisibly)(TASK_NAME)) {
|
|
1603
1699
|
// Start through the scheduled task so the daemon runs with the task's
|
|
1604
1700
|
// LIMITED (non-elevated) token. Launching directly from an elevated MSI
|
|
1605
1701
|
// custom action would leave an elevated daemon that a normal-user CLI can
|
|
@@ -1709,12 +1805,12 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1709
1805
|
// registration always works and is just as windowless. The updater kick
|
|
1710
1806
|
// stays as the final backstop for machines where even that failed.
|
|
1711
1807
|
let migrationDenied = false;
|
|
1712
|
-
if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsVisibly(TASK_NAME)) {
|
|
1808
|
+
if (daemonTask.status === 0 && !legacyDaemon && (0, windowsTaskState_1.windowsTaskRunsVisibly)(TASK_NAME)) {
|
|
1713
1809
|
const daemonXml = buildDaemonTaskXml();
|
|
1714
1810
|
migrationDenied = !(upgradeTaskWindowless(TASK_NAME, daemonXml[0], logFn)
|
|
1715
1811
|
|| rewrapTaskHeadless(TASK_NAME, daemonXml[1], logFn)) || migrationDenied;
|
|
1716
1812
|
}
|
|
1717
|
-
if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsVisibly(WATCHDOG_TASK_NAME)) {
|
|
1813
|
+
if (watchdogQuery.status === 0 && !legacyWatchdog && (0, windowsTaskState_1.windowsTaskRunsVisibly)(WATCHDOG_TASK_NAME)) {
|
|
1718
1814
|
const watchdogXml = buildWatchdogTaskXml();
|
|
1719
1815
|
migrationDenied = !(upgradeTaskWindowless(WATCHDOG_TASK_NAME, watchdogXml[0], logFn)
|
|
1720
1816
|
|| rewrapTaskHeadless(WATCHDOG_TASK_NAME, watchdogXml[1], logFn)) || migrationDenied;
|
|
@@ -1815,6 +1911,7 @@ function installMacos() {
|
|
|
1815
1911
|
</dict>
|
|
1816
1912
|
<key>RunAtLoad</key><true/>
|
|
1817
1913
|
<key>KeepAlive</key><true/>
|
|
1914
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
1818
1915
|
<key>StandardOutPath</key><string>${logFile()}</string>
|
|
1819
1916
|
<key>StandardErrorPath</key><string>${logFile()}</string>
|
|
1820
1917
|
</dict>
|
|
@@ -1873,7 +1970,11 @@ Description=FullCourtDefense resident daemon (config watch + heartbeat)
|
|
|
1873
1970
|
[Service]
|
|
1874
1971
|
ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
|
|
1875
1972
|
Restart=always
|
|
1876
|
-
RestartSec=
|
|
1973
|
+
RestartSec=30
|
|
1974
|
+
# Crash-loop circuit breaker: a daemon that dies 8 times in 10 minutes stops
|
|
1975
|
+
# being restarted (no infinite 10s spawn storm eating CPU on a broken install).
|
|
1976
|
+
StartLimitIntervalSec=600
|
|
1977
|
+
StartLimitBurst=8
|
|
1877
1978
|
|
|
1878
1979
|
[Install]
|
|
1879
1980
|
WantedBy=default.target
|
|
@@ -100,10 +100,24 @@ const STATUS_PATH = path.join(os.homedir(), '.fullcourtdefense', 'claude-desktop
|
|
|
100
100
|
const STDOUT_PREFIX = 'FCD:';
|
|
101
101
|
/** Prefix for clipboard payloads (distinct from composer reads). */
|
|
102
102
|
const CLIP_PREFIX = 'FCDCLIP:';
|
|
103
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
103
|
+
/** FALLBACK poll cadence while Claude Desktop is the FOREGROUND window (ms),
|
|
104
|
+
* used only when the event subscriptions could not initialize. 800ms lost
|
|
105
|
+
* the race against a fast paste+Enter (the composer clears before the next
|
|
106
|
+
* read); 250ms is needed only while the user can actually type. */
|
|
106
107
|
const POLL_MS = 250;
|
|
108
|
+
/** Foreground safety-tick in EVENT mode (ms): clipboard/focus arrive as
|
|
109
|
+
* events, so this timeout only paces the best-effort MSAA composer read —
|
|
110
|
+
* the paste race is covered by WM_CLIPBOARDUPDATE, not by this cadence. */
|
|
111
|
+
const POLL_FOCUSED_EVT_MS = 1000;
|
|
112
|
+
/** FALLBACK poll cadence while Claude Desktop runs in the BACKGROUND (ms). No
|
|
113
|
+
* typing or pasting can reach the composer without focus, so the expensive
|
|
114
|
+
* MSAA tree walk is skipped — this loop only watches for focus returning.
|
|
115
|
+
* (Event mode blocks on the focus-change event instead.) */
|
|
116
|
+
const POLL_BG_MS = 1500;
|
|
117
|
+
/** Safety timeout while idle/background (ms) — in event mode the loop BLOCKS
|
|
118
|
+
* on the event signal for this long; in fallback mode it is the idle poll
|
|
119
|
+
* cadence (one cheap Get-Process per tick, nothing else). */
|
|
120
|
+
const POLL_IDLE_MS = 4000;
|
|
107
121
|
/** Don't re-toast the same finding value more often than this. */
|
|
108
122
|
const TOAST_DEBOUNCE_MS = 60_000;
|
|
109
123
|
/** Refresh the cached Local Safety snapshot on this cadence. */
|
|
@@ -111,6 +125,11 @@ const SNAPSHOT_REFRESH_MS = 5 * 60_000;
|
|
|
111
125
|
/** Watcher restart backoff bounds. */
|
|
112
126
|
const RESTART_MIN_MS = 2_000;
|
|
113
127
|
const RESTART_MAX_MS = 30_000;
|
|
128
|
+
/** Give up after this many restarts with NO healthy output in between — a
|
|
129
|
+
* watcher that can never start (powershell.exe blocked by EDR/GPO) must not
|
|
130
|
+
* spawn-loop forever; each spawn costs CPU and an EDR scan. The daemon's
|
|
131
|
+
* next full restart (or an admin repair_protection action) tries again. */
|
|
132
|
+
const RESTART_GIVE_UP_AFTER = 10;
|
|
114
133
|
/** Only meaningful where Claude Desktop runs and MSAA is available. */
|
|
115
134
|
function desktopChatGuardSupported() {
|
|
116
135
|
return process.platform === 'win32' && (0, discoverPaths_1.claudeDesktopLikelyInstalled)();
|
|
@@ -242,19 +261,114 @@ function desktopChatWatcherScript() {
|
|
|
242
261
|
"'@",
|
|
243
262
|
'} catch { }',
|
|
244
263
|
'',
|
|
264
|
+
// Event subscriptions (WM_CLIPBOARDUPDATE + EVENT_SYSTEM_FOREGROUND): the
|
|
265
|
+
// loop below BLOCKS on these instead of spinning at 250ms. A hidden
|
|
266
|
+
// message-only window on a background STA thread receives clipboard-change
|
|
267
|
+
// messages and a WinEvent hook fires on every foreground-window change —
|
|
268
|
+
// both set flags and pulse one AutoResetEvent the main loop waits on.
|
|
269
|
+
// If any of this fails to initialize (hardened hosts, odd session types),
|
|
270
|
+
// [FcdEvents]::Ok stays false and the loop falls back to the old polling.
|
|
271
|
+
'try {',
|
|
272
|
+
" Add-Type -TypeDefinition @'",
|
|
273
|
+
'using System;',
|
|
274
|
+
'using System.Runtime.InteropServices;',
|
|
275
|
+
'using System.Threading;',
|
|
276
|
+
'public static class FcdEvents {',
|
|
277
|
+
' const uint WM_CLIPBOARDUPDATE = 0x031D;',
|
|
278
|
+
' const uint EVENT_SYSTEM_FOREGROUND = 0x0003;',
|
|
279
|
+
' static readonly IntPtr HWND_MESSAGE = new IntPtr(-3);',
|
|
280
|
+
' public static bool Ok = false;',
|
|
281
|
+
' static AutoResetEvent signal = new AutoResetEvent(false);',
|
|
282
|
+
' static int clipFlag = 0, fgFlag = 0;',
|
|
283
|
+
' delegate IntPtr WndProc(IntPtr h, uint m, IntPtr w, IntPtr l);',
|
|
284
|
+
' delegate void WinEventProc(IntPtr hook, uint ev, IntPtr hwnd, int obj, int child, uint tid, uint time);',
|
|
285
|
+
' static WndProc wndProcRef; static WinEventProc fgProcRef; // keep delegates alive (GC)',
|
|
286
|
+
' [StructLayout(LayoutKind.Sequential)] struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int ptX; public int ptY; }',
|
|
287
|
+
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct WNDCLASS { public uint style; public WndProc lpfnWndProc; public int cbClsExtra; public int cbWndExtra; public IntPtr hInstance; public IntPtr hIcon; public IntPtr hCursor; public IntPtr hbrBackground; [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; }',
|
|
288
|
+
' [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern ushort RegisterClassW(ref WNDCLASS wc);',
|
|
289
|
+
' [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr CreateWindowExW(int ex, string cls, string name, int style, int x, int y, int w, int h, IntPtr parent, IntPtr menu, IntPtr inst, IntPtr param);',
|
|
290
|
+
' [DllImport("user32.dll")] static extern IntPtr DefWindowProcW(IntPtr h, uint m, IntPtr w, IntPtr l);',
|
|
291
|
+
' [DllImport("user32.dll")] static extern bool AddClipboardFormatListener(IntPtr h);',
|
|
292
|
+
' [DllImport("user32.dll")] static extern IntPtr SetWinEventHook(uint mn, uint mx, IntPtr mod, WinEventProc cb, uint pid, uint tid, uint flags);',
|
|
293
|
+
' [DllImport("user32.dll")] static extern int GetMessageW(out MSG m, IntPtr h, uint mn, uint mx);',
|
|
294
|
+
' [DllImport("user32.dll")] static extern bool TranslateMessage(ref MSG m);',
|
|
295
|
+
' [DllImport("user32.dll")] static extern IntPtr DispatchMessageW(ref MSG m);',
|
|
296
|
+
' [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr GetModuleHandleW(string n);',
|
|
297
|
+
' public static void Start() {',
|
|
298
|
+
' var t = new Thread(Pump); t.IsBackground = true; t.SetApartmentState(ApartmentState.STA); t.Start();',
|
|
299
|
+
' }',
|
|
300
|
+
' static void Pump() {',
|
|
301
|
+
' try {',
|
|
302
|
+
' wndProcRef = HandleMsg;',
|
|
303
|
+
' var wc = new WNDCLASS(); wc.lpfnWndProc = wndProcRef; wc.hInstance = GetModuleHandleW(null); wc.lpszClassName = "FcdDesktopGuardEvt";',
|
|
304
|
+
' RegisterClassW(ref wc);',
|
|
305
|
+
' IntPtr hwnd = CreateWindowExW(0, "FcdDesktopGuardEvt", "", 0, 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, wc.hInstance, IntPtr.Zero);',
|
|
306
|
+
' if (hwnd == IntPtr.Zero) return;',
|
|
307
|
+
' bool clipOk = AddClipboardFormatListener(hwnd);',
|
|
308
|
+
' fgProcRef = HandleFg;',
|
|
309
|
+
' IntPtr hook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, fgProcRef, 0, 0, 0);',
|
|
310
|
+
' Ok = clipOk && hook != IntPtr.Zero;',
|
|
311
|
+
' MSG m;',
|
|
312
|
+
' while (GetMessageW(out m, IntPtr.Zero, 0, 0) > 0) { TranslateMessage(ref m); DispatchMessageW(ref m); }',
|
|
313
|
+
' } catch { Ok = false; }',
|
|
314
|
+
' }',
|
|
315
|
+
' static IntPtr HandleMsg(IntPtr h, uint m, IntPtr w, IntPtr l) {',
|
|
316
|
+
' if (m == WM_CLIPBOARDUPDATE) { Interlocked.Exchange(ref clipFlag, 1); signal.Set(); return IntPtr.Zero; }',
|
|
317
|
+
' return DefWindowProcW(h, m, w, l);',
|
|
318
|
+
' }',
|
|
319
|
+
' static void HandleFg(IntPtr hook, uint ev, IntPtr hwnd, int obj, int child, uint tid, uint time) {',
|
|
320
|
+
' Interlocked.Exchange(ref fgFlag, 1); signal.Set();',
|
|
321
|
+
' }',
|
|
322
|
+
' public static bool Wait(int ms) { try { return signal.WaitOne(ms); } catch { return false; } }',
|
|
323
|
+
' public static bool TakeClip() { return Interlocked.Exchange(ref clipFlag, 0) == 1; }',
|
|
324
|
+
' public static bool TakeFg() { return Interlocked.Exchange(ref fgFlag, 0) == 1; }',
|
|
325
|
+
'}',
|
|
326
|
+
"'@",
|
|
327
|
+
'} catch { }',
|
|
328
|
+
'',
|
|
329
|
+
'$evt = $false',
|
|
330
|
+
'try { [FcdEvents]::Start(); Start-Sleep -Milliseconds 300; $evt = [FcdEvents]::Ok } catch { $evt = $false }',
|
|
331
|
+
// Observability: doctor/tests can confirm which mode the watcher runs in.
|
|
332
|
+
'if ($env:FCD_DESKTOP_GUARD_DEBUG) { [Console]::Out.WriteLine("FCDEVT:" + $evt); [Console]::Out.Flush() }',
|
|
333
|
+
'',
|
|
245
334
|
'$last = ""',
|
|
246
335
|
'$lastClip = ""',
|
|
247
336
|
'$lastWake = [DateTime]::MinValue',
|
|
337
|
+
'$lastComposer = [DateTime]::MinValue',
|
|
338
|
+
// EVENT-DRIVEN loop (when FcdEvents initialized): the loop BLOCKS on the
|
|
339
|
+
// clipboard/foreground event signal with a safety timeout — zero wakeups
|
|
340
|
+
// while nothing happens. Clipboard is read only when it actually changed
|
|
341
|
+
// (or focus just moved, covering copy-elsewhere-then-paste-into-Claude);
|
|
342
|
+
// the MSAA composer read relaxes to ~1s because the clipboard EVENT now
|
|
343
|
+
// wins the paste+Enter race the old 250ms poll existed for.
|
|
344
|
+
// FALLBACK (events unavailable): the original adaptive polling — 250ms
|
|
345
|
+
// focused / 1500ms background / 4000ms idle.
|
|
346
|
+
'$sleepMs = ' + POLL_IDLE_MS,
|
|
248
347
|
'while ($true) {',
|
|
249
|
-
'
|
|
348
|
+
' $clipEvt = $true',
|
|
349
|
+
' $fgEvt = $true',
|
|
350
|
+
' if ($evt) {',
|
|
351
|
+
' [void][FcdEvents]::Wait($sleepMs)',
|
|
352
|
+
' $clipEvt = [FcdEvents]::TakeClip()',
|
|
353
|
+
' $fgEvt = [FcdEvents]::TakeFg()',
|
|
354
|
+
' } else {',
|
|
355
|
+
' Start-Sleep -Milliseconds $sleepMs',
|
|
356
|
+
' }',
|
|
250
357
|
' try {',
|
|
251
358
|
' $pids = New-Object \'System.Collections.Generic.HashSet[uint32]\'',
|
|
252
359
|
" Get-Process -Name 'Claude' -ErrorAction SilentlyContinue | ForEach-Object { [void]$pids.Add([uint32]$_.Id) }",
|
|
253
|
-
' if ($pids.Count -eq 0) { continue }',
|
|
360
|
+
' if ($pids.Count -eq 0) { $sleepMs = ' + POLL_IDLE_MS + '; continue }',
|
|
361
|
+
' if (-not [FcdMsaa]::ClaudeIsForeground($pids)) {',
|
|
362
|
+
' if ($evt) { $sleepMs = ' + POLL_IDLE_MS + ' } else { $sleepMs = ' + POLL_BG_MS + ' }',
|
|
363
|
+
' continue',
|
|
364
|
+
' }',
|
|
365
|
+
' if ($evt) { $sleepMs = ' + POLL_FOCUSED_EVT_MS + ' } else { $sleepMs = ' + POLL_MS + ' }',
|
|
254
366
|
// Clipboard paste guard: only while Claude Desktop is the foreground app, so
|
|
255
367
|
// a copy staged for any OTHER application is never inspected or reported.
|
|
256
368
|
// The deterministic engine (parent process) decides if the text is a secret.
|
|
257
|
-
|
|
369
|
+
// Event mode reads it only when the clipboard changed or focus just landed
|
|
370
|
+
// on Claude — not on every tick.
|
|
371
|
+
' if ($clipEvt -or $fgEvt) {',
|
|
258
372
|
' $clip = ""',
|
|
259
373
|
' try { $clip = Get-Clipboard -Raw -ErrorAction SilentlyContinue } catch { }',
|
|
260
374
|
' if (-not [string]::IsNullOrEmpty($clip) -and $clip.Length -le 8000 -and $clip -ne $lastClip) {',
|
|
@@ -264,6 +378,10 @@ function desktopChatWatcherScript() {
|
|
|
264
378
|
' [Console]::Out.Flush()',
|
|
265
379
|
' }',
|
|
266
380
|
' }',
|
|
381
|
+
// Composer read throttle: event wakes can arrive in bursts (every copy on
|
|
382
|
+
// the machine); the MSAA tree walk stays on its own ~1s cadence.
|
|
383
|
+
' if ($evt -and ([DateTime]::UtcNow - $lastComposer).TotalMilliseconds -lt 900) { continue }',
|
|
384
|
+
' $lastComposer = [DateTime]::UtcNow',
|
|
267
385
|
' $main = [FcdMsaa]::FindMain($pids)',
|
|
268
386
|
' if ($main -eq [IntPtr]::Zero) { continue }',
|
|
269
387
|
' if (([DateTime]::UtcNow - $lastWake).TotalSeconds -ge 30) { [FcdMsaa]::Wake($main); $lastWake = [DateTime]::UtcNow }',
|
|
@@ -326,6 +444,7 @@ function startDesktopChatGuard(runtime) {
|
|
|
326
444
|
let child;
|
|
327
445
|
let restartTimer;
|
|
328
446
|
let restartDelay = RESTART_MIN_MS;
|
|
447
|
+
let consecutiveRestarts = 0;
|
|
329
448
|
let findings = 0;
|
|
330
449
|
const lastToastAt = new Map();
|
|
331
450
|
let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
|
|
@@ -424,6 +543,7 @@ function startDesktopChatGuard(runtime) {
|
|
|
424
543
|
if (decoded === undefined)
|
|
425
544
|
return;
|
|
426
545
|
restartDelay = RESTART_MIN_MS; // healthy output resets backoff
|
|
546
|
+
consecutiveRestarts = 0; // …and the give-up counter
|
|
427
547
|
handleText(decoded.text, decoded.source);
|
|
428
548
|
});
|
|
429
549
|
child.on('exit', () => { rl.close(); if (!stopped)
|
|
@@ -436,6 +556,11 @@ function startDesktopChatGuard(runtime) {
|
|
|
436
556
|
const scheduleRestart = () => {
|
|
437
557
|
if (stopped || restartTimer)
|
|
438
558
|
return;
|
|
559
|
+
consecutiveRestarts += 1;
|
|
560
|
+
if (consecutiveRestarts > RESTART_GIVE_UP_AFTER) {
|
|
561
|
+
runtime.log(`Claude Desktop chat guard: watcher failed ${RESTART_GIVE_UP_AFTER} consecutive starts — giving up until the daemon restarts (powershell.exe may be blocked by EDR/policy).`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
439
564
|
restartTimer = setTimeout(() => {
|
|
440
565
|
restartTimer = undefined;
|
|
441
566
|
spawnWatcher();
|
|
@@ -613,15 +613,27 @@ function credentialCommandReason(value) {
|
|
|
613
613
|
return undefined;
|
|
614
614
|
});
|
|
615
615
|
}
|
|
616
|
+
// Container-scoped execution context immediately before an `rm` hit: a
|
|
617
|
+
// docker/podman/nerdctl/kubectl exec|run (incl. `compose exec/run`) earlier in
|
|
618
|
+
// the SAME statement (no ; & | between). Inside that context a bare `rm -rf *`
|
|
619
|
+
// wipes the CONTAINER's workdir — routine build/image cleanup on a
|
|
620
|
+
// docker-heavy fleet — not the developer's machine, so it must never warn or
|
|
621
|
+
// stall. Root targets (`/`, `/*`) stay blocked even inside containers (the
|
|
622
|
+
// Patria case: an agent nuking a running container's filesystem).
|
|
623
|
+
const CONTAINER_EXEC_BEFORE = /\b(?:docker|podman|nerdctl|kubectl)(?:\.exe)?\s+(?:compose\s+)?(?:exec|run)\b[^;&|]*$/i;
|
|
616
624
|
function destructiveCommandReason(value) {
|
|
617
625
|
return matchCommand(value, (text, lower) => {
|
|
618
|
-
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`)
|
|
619
|
-
// may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
|
|
626
|
+
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
|
|
620
627
|
// include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
|
|
621
|
-
// outer command — is caught too.
|
|
622
|
-
|
|
628
|
+
// outer command — is caught too. Path-aware: only the root `/`, the root
|
|
629
|
+
// wildcard `/*`, and a bare `*` are treated as destructive — scoped paths
|
|
630
|
+
// (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
|
|
631
|
+
if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
|
|
623
632
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
624
|
-
|
|
633
|
+
// Bare `*` deletes the current directory tree — destructive on the HOST,
|
|
634
|
+
// but inside a container exec/run it is the container workdir (cleanup).
|
|
635
|
+
const bareStar = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/.exec(lower);
|
|
636
|
+
if (bareStar && !CONTAINER_EXEC_BEFORE.test(lower.slice(0, bareStar.index)))
|
|
625
637
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
626
638
|
// Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
|
|
627
639
|
// is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
|