fullcourtdefense-cli 1.25.6 → 1.26.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/commands/cmdGuard.js +23 -12
- package/dist/commands/daemon.js +129 -8
- 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/commands/shellGuard.js +8 -0
- 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/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/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);}`,
|
package/dist/commands/daemon.js
CHANGED
|
@@ -76,9 +76,12 @@ const discoveryMarker_1 = require("../discoveryMarker");
|
|
|
76
76
|
const selfUpdate_1 = require("../selfUpdate");
|
|
77
77
|
const cmdGuard_1 = require("./cmdGuard");
|
|
78
78
|
const machineActionVerify_1 = require("../machineActionVerify");
|
|
79
|
+
const perfSnapshot_1 = require("../perfSnapshot");
|
|
79
80
|
const desktopChatGuard_1 = require("./desktopChatGuard");
|
|
80
81
|
const honeypot_1 = require("../honeypot");
|
|
81
82
|
const windowsAudit_1 = require("./windowsAudit");
|
|
83
|
+
const hook_1 = require("./hook");
|
|
84
|
+
const verdictIpc_1 = require("../verdictIpc");
|
|
82
85
|
const COLOR = {
|
|
83
86
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
84
87
|
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
@@ -108,6 +111,11 @@ const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60
|
|
|
108
111
|
const DISCOVER_STALE_MS = envMs('FCD_DAEMON_DISCOVER_STALE_MS', 20 * 60 * 60_000);
|
|
109
112
|
/** How often the daemon re-checks discovery freshness. */
|
|
110
113
|
const DISCOVER_CHECK_INTERVAL_MS = envMs('FCD_DAEMON_DISCOVER_CHECK_MS', 60 * 60_000);
|
|
114
|
+
/** Boot catch-up sweep delay — well past login so it never competes with the
|
|
115
|
+
* developer's IDE/browser startup (the sweep also runs at idle priority). */
|
|
116
|
+
const DISCOVER_BOOT_CATCHUP_DELAY_MS = envMs('FCD_DAEMON_DISCOVER_BOOT_DELAY_MS', 15 * 60_000);
|
|
117
|
+
/** Random jitter added to the boot catch-up so a fleet doesn't sweep in lockstep. */
|
|
118
|
+
const DISCOVER_BOOT_CATCHUP_JITTER_MS = 5 * 60_000;
|
|
111
119
|
/** Rotate the daemon log when it grows past this size. */
|
|
112
120
|
const LOG_MAX_BYTES = 1_000_000;
|
|
113
121
|
function daemonDir() {
|
|
@@ -225,6 +233,14 @@ function runDiscoverSweep(credentials, timeoutMs = 300_000) {
|
|
|
225
233
|
// home so the posture scope reports a meaningful folder, not an OS dir.
|
|
226
234
|
cwd: os.homedir(),
|
|
227
235
|
});
|
|
236
|
+
// Idle CPU priority (IDLE_PRIORITY_CLASS on Windows, nice 19 on POSIX): a
|
|
237
|
+
// deep sweep must never compete with the developer's build/IDE for CPU.
|
|
238
|
+
// The sweep just takes longer on a busy machine — which is the point.
|
|
239
|
+
try {
|
|
240
|
+
if (child.pid)
|
|
241
|
+
os.setPriority(child.pid, 19);
|
|
242
|
+
}
|
|
243
|
+
catch { /* best-effort */ }
|
|
228
244
|
let stderrTail = '';
|
|
229
245
|
child.stderr?.on('data', (chunk) => {
|
|
230
246
|
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096);
|
|
@@ -898,6 +914,31 @@ async function runDaemon(args, config) {
|
|
|
898
914
|
log('Discovery scan: upload complete — dashboard discovery + posture timestamps will refresh.');
|
|
899
915
|
resultSummary = 'Discovery + posture scan completed and uploaded.';
|
|
900
916
|
}
|
|
917
|
+
else if (action.type === 'perf_snapshot') {
|
|
918
|
+
// On-demand performance proof: per-process footprint + hot-path
|
|
919
|
+
// micro-bench + disk state, collected in one shot (no resident
|
|
920
|
+
// profiler, no periodic sampling — the lightness principle applies
|
|
921
|
+
// to the measurement itself). Same collection as `doctor --perf`,
|
|
922
|
+
// so the console table matches what the customer sees locally.
|
|
923
|
+
log('Perf snapshot: measuring hot-path latency, process footprint, and disk state (~10s)…');
|
|
924
|
+
await uploadLogTail();
|
|
925
|
+
let bundleState = {};
|
|
926
|
+
try {
|
|
927
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
928
|
+
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
|
|
929
|
+
apiUrl: creds.apiUrl, shieldId: creds.shieldId || '', shieldKey: creds.shieldKey,
|
|
930
|
+
developerName: identity.developerName, machineName: identity.hostname, hotPath: true,
|
|
931
|
+
});
|
|
932
|
+
bundleState = { mode: bundle.mode, source: bundle.source, policyHash: bundle.policyHash };
|
|
933
|
+
}
|
|
934
|
+
catch { /* snapshot still useful without mode context */ }
|
|
935
|
+
const snapshot = (0, perfSnapshot_1.collectPerfSnapshot)({ cliVersion: cliVersion(), ...bundleState });
|
|
936
|
+
const uploaded = await uploadDiagnostics({ perfSnapshot: snapshot });
|
|
937
|
+
if (!uploaded)
|
|
938
|
+
throw new Error('Perf snapshot could not be uploaded (network or backend rejection).');
|
|
939
|
+
resultSummary = (0, perfSnapshot_1.summarizePerfSnapshot)(snapshot);
|
|
940
|
+
log(`Perf snapshot: ${resultSummary}`);
|
|
941
|
+
}
|
|
901
942
|
await reportMachineAction(action.id, 'succeeded', { resultSummary });
|
|
902
943
|
log(`Remote action succeeded: ${action.type}.`);
|
|
903
944
|
}
|
|
@@ -938,6 +979,9 @@ async function runDaemon(args, config) {
|
|
|
938
979
|
}
|
|
939
980
|
catch { /* offline — next poll retries */ }
|
|
940
981
|
};
|
|
982
|
+
// Base cadence for the bundle poll — the server can slow a whole fleet down
|
|
983
|
+
// via pollIntervalMs (bounded: 15s..1h) without shipping a new CLI.
|
|
984
|
+
let bundlePollBaseMs = BUNDLE_POLL_MS;
|
|
941
985
|
const pollBundle = async () => {
|
|
942
986
|
if (!creds.shieldId)
|
|
943
987
|
return;
|
|
@@ -957,6 +1001,9 @@ async function runDaemon(args, config) {
|
|
|
957
1001
|
machineId: identity.machineId,
|
|
958
1002
|
force: true,
|
|
959
1003
|
});
|
|
1004
|
+
if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
|
|
1005
|
+
bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
|
|
1006
|
+
}
|
|
960
1007
|
if (bundle.suspended && !suspended) {
|
|
961
1008
|
suspended = true;
|
|
962
1009
|
log('Machine SUSPENDED by admin — hooks/gateway deny all actions; daemon pauses re-protection.');
|
|
@@ -1020,6 +1067,22 @@ async function runDaemon(args, config) {
|
|
|
1020
1067
|
log(`Honeypot: removal failed: ${error.message}`);
|
|
1021
1068
|
}
|
|
1022
1069
|
}
|
|
1070
|
+
// Keep the IDE hooks' Local Safety snapshot warm. Hooks on monitor
|
|
1071
|
+
// machines read this cache with preferCached and never fetch — the
|
|
1072
|
+
// daemon is the only process paying the network cost, off the hot path.
|
|
1073
|
+
try {
|
|
1074
|
+
const hookIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
|
|
1075
|
+
await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
|
|
1076
|
+
apiUrl: creds.apiUrl,
|
|
1077
|
+
shieldId: creds.shieldId,
|
|
1078
|
+
shieldKey: creds.shieldKey,
|
|
1079
|
+
developerName: hookIdentity.developerName,
|
|
1080
|
+
machineName: hookIdentity.machineName,
|
|
1081
|
+
expectedPolicyHash: bundle.policyHash || bundle.version,
|
|
1082
|
+
timeoutMs: 8_000,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
catch { /* hooks fall back to their own bounded fetch */ }
|
|
1023
1086
|
}
|
|
1024
1087
|
catch { /* offline — cached stance applies */ }
|
|
1025
1088
|
};
|
|
@@ -1115,6 +1178,20 @@ async function runDaemon(args, config) {
|
|
|
1115
1178
|
if (desktopChatGuard)
|
|
1116
1179
|
log('Claude Desktop chat guard: supervising (advisory — warns on secrets typed into Claude Desktop).');
|
|
1117
1180
|
}
|
|
1181
|
+
// Verdict IPC server (resident-agent pattern): per-event hook processes
|
|
1182
|
+
// become thin clients that ask THIS process for the verdict over a named
|
|
1183
|
+
// pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
|
|
1184
|
+
// is warm here, so verdicts resolve in milliseconds instead of repeating
|
|
1185
|
+
// cold-start work per IDE event. Config is re-read per request (small YAML,
|
|
1186
|
+
// cheap) so on-disk edits apply without a daemon restart. Hooks fall back
|
|
1187
|
+
// to full local evaluation whenever this server is down — never a blocker.
|
|
1188
|
+
let verdictServer;
|
|
1189
|
+
try {
|
|
1190
|
+
verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)()), log);
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
|
|
1194
|
+
}
|
|
1118
1195
|
// Fresh machines have never uploaded an inventory (MSI/onboard defers the
|
|
1119
1196
|
// initial discovery to keep setup fast), so the dashboard shows "Never" for
|
|
1120
1197
|
// discovery + posture until the daily scheduled job fires — up to 24h later.
|
|
@@ -1178,16 +1255,51 @@ async function runDaemon(args, config) {
|
|
|
1178
1255
|
await uploadLogTail();
|
|
1179
1256
|
}
|
|
1180
1257
|
};
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1258
|
+
// Boot deep sweeps were the "my PC is slow every morning" complaint: a
|
|
1259
|
+
// machine off overnight is always >20h stale, so the old 2-minute grace
|
|
1260
|
+
// launched a full deep sweep right while the developer opened their IDE and
|
|
1261
|
+
// browser. Defer the boot catch-up well past login (default 15 min) and add
|
|
1262
|
+
// jitter so a fleet behind one proxy doesn't sweep in lockstep. The hourly
|
|
1263
|
+
// re-check still guarantees same-day freshness.
|
|
1264
|
+
const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
|
|
1184
1265
|
const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
|
|
1185
1266
|
const rescanTimer = setInterval(() => {
|
|
1186
1267
|
const count = refreshWatchTargets();
|
|
1187
1268
|
log(`Rescan: watching ${count} config file(s).`);
|
|
1188
1269
|
}, RESCAN_INTERVAL_MS);
|
|
1189
|
-
|
|
1190
|
-
|
|
1270
|
+
// Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
|
|
1271
|
+
// a fleet enrolled behind one corporate proxy must not hit the gate in
|
|
1272
|
+
// lockstep, and one machine's timers must not stack into the same tick.
|
|
1273
|
+
// The bundle poll additionally honors the server's pollIntervalMs, and the
|
|
1274
|
+
// next tick is scheduled only AFTER the previous poll finishes — a slow
|
|
1275
|
+
// gate can never pile up overlapping polls.
|
|
1276
|
+
const jittered = (baseMs) => Math.max(5_000, Math.round(baseMs * (0.8 + Math.random() * 0.4)));
|
|
1277
|
+
let bundleTimer;
|
|
1278
|
+
const scheduleBundlePoll = () => {
|
|
1279
|
+
if (stopped)
|
|
1280
|
+
return;
|
|
1281
|
+
bundleTimer = setTimeout(async () => {
|
|
1282
|
+
try {
|
|
1283
|
+
await pollBundle();
|
|
1284
|
+
}
|
|
1285
|
+
catch { /* poll never throws, but never stop the loop */ }
|
|
1286
|
+
scheduleBundlePoll();
|
|
1287
|
+
}, jittered(bundlePollBaseMs));
|
|
1288
|
+
};
|
|
1289
|
+
scheduleBundlePoll();
|
|
1290
|
+
let heartbeatTimer;
|
|
1291
|
+
const scheduleHeartbeat = () => {
|
|
1292
|
+
if (stopped)
|
|
1293
|
+
return;
|
|
1294
|
+
heartbeatTimer = setTimeout(async () => {
|
|
1295
|
+
try {
|
|
1296
|
+
await heartbeat();
|
|
1297
|
+
}
|
|
1298
|
+
catch { /* keep the loop alive */ }
|
|
1299
|
+
scheduleHeartbeat();
|
|
1300
|
+
}, jittered(HEARTBEAT_INTERVAL_MS));
|
|
1301
|
+
};
|
|
1302
|
+
scheduleHeartbeat();
|
|
1191
1303
|
// PowerShell transcript retention (Windows): the Transcription policy FCD
|
|
1192
1304
|
// enables writes a file per session forever — prune anything older than the
|
|
1193
1305
|
// retention window once a day (plus once shortly after boot, so laptops
|
|
@@ -1211,8 +1323,10 @@ async function runDaemon(args, config) {
|
|
|
1211
1323
|
stopped = true;
|
|
1212
1324
|
log(`Received ${signal} — shutting down.`);
|
|
1213
1325
|
clearInterval(rescanTimer);
|
|
1214
|
-
|
|
1215
|
-
|
|
1326
|
+
if (bundleTimer)
|
|
1327
|
+
clearTimeout(bundleTimer);
|
|
1328
|
+
if (heartbeatTimer)
|
|
1329
|
+
clearTimeout(heartbeatTimer);
|
|
1216
1330
|
clearTimeout(transcriptPruneBootTimer);
|
|
1217
1331
|
clearInterval(transcriptPruneTimer);
|
|
1218
1332
|
clearTimeout(discoverCatchUpBootTimer);
|
|
@@ -1223,6 +1337,8 @@ async function runDaemon(args, config) {
|
|
|
1223
1337
|
clearTimeout(debounceTimer);
|
|
1224
1338
|
if (desktopChatGuard)
|
|
1225
1339
|
desktopChatGuard.stop();
|
|
1340
|
+
if (verdictServer)
|
|
1341
|
+
verdictServer.close();
|
|
1226
1342
|
for (const watcher of watchers.values())
|
|
1227
1343
|
watcher.close();
|
|
1228
1344
|
for (const watcher of rootWatchers.values())
|
|
@@ -1815,6 +1931,7 @@ function installMacos() {
|
|
|
1815
1931
|
</dict>
|
|
1816
1932
|
<key>RunAtLoad</key><true/>
|
|
1817
1933
|
<key>KeepAlive</key><true/>
|
|
1934
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
1818
1935
|
<key>StandardOutPath</key><string>${logFile()}</string>
|
|
1819
1936
|
<key>StandardErrorPath</key><string>${logFile()}</string>
|
|
1820
1937
|
</dict>
|
|
@@ -1873,7 +1990,11 @@ Description=FullCourtDefense resident daemon (config watch + heartbeat)
|
|
|
1873
1990
|
[Service]
|
|
1874
1991
|
ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
|
|
1875
1992
|
Restart=always
|
|
1876
|
-
RestartSec=
|
|
1993
|
+
RestartSec=30
|
|
1994
|
+
# Crash-loop circuit breaker: a daemon that dies 8 times in 10 minutes stops
|
|
1995
|
+
# being restarted (no infinite 10s spawn storm eating CPU on a broken install).
|
|
1996
|
+
StartLimitIntervalSec=600
|
|
1997
|
+
StartLimitBurst=8
|
|
1877
1998
|
|
|
1878
1999
|
[Install]
|
|
1879
2000
|
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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { BotGuardConfig } from '../config';
|
|
2
2
|
export interface DoctorArgs {
|
|
3
3
|
apiUrl?: string;
|
|
4
|
+
/** `doctor --perf` — measure this machine's real per-event overhead. */
|
|
5
|
+
perf?: string;
|
|
4
6
|
}
|
|
5
7
|
export declare function doctorCommand(args: DoctorArgs, config: BotGuardConfig): Promise<void>;
|