fullcourtdefense-cli 1.34.21 → 1.34.25
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/README.md +1 -0
- package/dist/actionIdentity.d.ts +1 -0
- package/dist/actionIdentity.js +2 -0
- package/dist/actionPolicyEngine.d.ts +5 -0
- package/dist/actionPolicyEngine.js +273 -30
- package/dist/agentTerminal.d.ts +1 -3
- package/dist/agentTerminal.js +14 -29
- package/dist/approvalPrompt.d.ts +7 -0
- package/dist/approvalPrompt.js +46 -0
- package/dist/askDialog.d.ts +87 -0
- package/dist/askDialog.js +209 -0
- package/dist/blockExplanation.js +18 -6
- package/dist/boundaryVerification.d.ts +20 -0
- package/dist/boundaryVerification.js +147 -0
- package/dist/commands/daemon.js +198 -70
- package/dist/commands/deterministicGuard.d.ts +2 -0
- package/dist/commands/deterministicGuard.js +161 -9
- package/dist/commands/hook.js +81 -29
- package/dist/commands/installClaudeHook.js +11 -5
- package/dist/commands/installCursorHook.js +5 -4
- package/dist/commands/mcpGateway.d.ts +2 -0
- package/dist/commands/mcpGateway.js +163 -56
- package/dist/commands/onboard.js +6 -5
- package/dist/commands/onboardingSummary.d.ts +5 -0
- package/dist/commands/onboardingSummary.js +11 -0
- package/dist/commands/protectedRun.d.ts +14 -0
- package/dist/commands/protectedRun.js +207 -0
- package/dist/containment-runtime/index.mjs +30118 -0
- package/dist/containment-runtime/licenses/_anthropic-ai_sandbox-runtime.txt +201 -0
- package/dist/containment-runtime/licenses/_pondwader_socks5-server.txt +20 -0
- package/dist/containment-runtime/licenses/commander.txt +22 -0
- package/dist/containment-runtime/licenses/node-forge.txt +331 -0
- package/dist/containment-runtime/licenses/zod.txt +21 -0
- package/dist/containment-runtime/runtime-version.json +1 -0
- package/dist/containment-runtime/vendor/java-proxy-agent/build.ts +95 -0
- package/dist/containment-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
- package/dist/containment-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/dist/containment-runtime/vendor/seccomp/build.ts +69 -0
- package/dist/containment-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
- package/dist/containment-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
- package/dist/containment-runtime/vendor/srt-win/build.ts +21 -0
- package/dist/containment-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
- package/dist/containmentLease.d.ts +5 -0
- package/dist/containmentLease.js +72 -0
- package/dist/detectionUpdates.d.ts +23 -3
- package/dist/detectionUpdates.js +45 -14
- package/dist/detectorArtifact.d.ts +17 -0
- package/dist/detectorArtifact.js +35 -0
- package/dist/detectorBaseline.d.ts +2 -0
- package/dist/detectorBaseline.js +5 -0
- package/dist/detectorModule.d.ts +9 -0
- package/dist/detectorModule.js +84 -0
- package/dist/detectorRuntime.d.ts +21 -0
- package/dist/detectorRuntime.js +49 -0
- package/dist/detectorUpdates.d.ts +21 -0
- package/dist/detectorUpdates.js +86 -0
- package/dist/devConfirm.d.ts +97 -15
- package/dist/devConfirm.js +447 -117
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/frictionRollout.js +103 -0
- package/dist/gatewayRuntime.d.ts +19 -0
- package/dist/gatewayRuntime.js +93 -0
- package/dist/hookSlim.js +17 -18
- package/dist/index.js +35 -0
- package/dist/localDetectionUpdates.d.ts +3 -1
- package/dist/localDetectionUpdates.js +8 -1
- package/dist/localSafetySnapshot.d.ts +9 -5
- package/dist/localSafetySnapshot.js +42 -15
- package/dist/mcpToolReview.d.ts +17 -0
- package/dist/mcpToolReview.js +127 -0
- package/dist/mcpToolTrust.d.ts +22 -0
- package/dist/mcpToolTrust.js +66 -0
- package/dist/nativeHook.d.ts +4 -0
- package/dist/nativeHook.js +31 -17
- package/dist/policyRefresh.d.ts +3 -0
- package/dist/policyRefresh.js +27 -0
- package/dist/protectionMaintenance.d.ts +10 -0
- package/dist/protectionMaintenance.js +64 -0
- package/dist/protectionProfile.d.ts +26 -0
- package/dist/protectionProfile.js +63 -0
- package/dist/runtimeConfig.d.ts +2 -0
- package/dist/runtimeConfig.js +65 -26
- package/dist/selfTest.d.ts +2 -0
- package/dist/selfTest.js +9 -1
- package/dist/sessionLimits.d.ts +2 -0
- package/dist/sessionLimits.js +24 -0
- package/dist/telemetry.d.ts +5 -0
- package/dist/telemetry.js +8 -0
- package/dist/version.json +1 -1
- package/package.json +10 -6
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.computeRuntimeDigest = computeRuntimeDigest;
|
|
37
|
+
exports.runBoundaryVerification = runBoundaryVerification;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const child_process_1 = require("child_process");
|
|
42
|
+
const crypto_1 = require("crypto");
|
|
43
|
+
/** Fingerprint the packaged JavaScript; shared by checker evidence and benchmarks. */
|
|
44
|
+
function computeRuntimeDigest() {
|
|
45
|
+
const digest = (0, crypto_1.createHash)('sha256');
|
|
46
|
+
const hashDirectory = (directory) => {
|
|
47
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
48
|
+
const file = path.join(directory, item.name);
|
|
49
|
+
if (item.isSymbolicLink())
|
|
50
|
+
throw new Error('Cannot fingerprint redirected CLI runtime.');
|
|
51
|
+
if (item.isDirectory())
|
|
52
|
+
hashDirectory(file);
|
|
53
|
+
else if (item.name.endsWith('.js'))
|
|
54
|
+
digest.update(path.relative(__dirname, file).replace(/\\/g, '/')).update('\0').update(fs.readFileSync(file)).update('\0');
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
hashDirectory(__dirname);
|
|
58
|
+
return digest.digest('hex');
|
|
59
|
+
}
|
|
60
|
+
/** Exercises the installed hook executable, never the commands in its stdin.
|
|
61
|
+
* A private HOME, cache and IPC address isolate the probe from customer state.
|
|
62
|
+
* This is checker proof, not proof that an IDE invokes the hook or obeys it.
|
|
63
|
+
*/
|
|
64
|
+
async function runBoundaryVerification() {
|
|
65
|
+
const entry = path.join(__dirname, 'index.js');
|
|
66
|
+
const report = {
|
|
67
|
+
version: 1, ranAt: new Date().toISOString(), basis: 'isolated_hook_fixture',
|
|
68
|
+
runtimeDigest: computeRuntimeDigest(), checks: [],
|
|
69
|
+
};
|
|
70
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'fcd-boundary-check-'));
|
|
71
|
+
const shieldId = 'boundary-verification-fixture';
|
|
72
|
+
try {
|
|
73
|
+
fs.writeFileSync(path.join(home, '.fullcourtdefense.yml'), 'apiUrl: http://127.0.0.1:9\n');
|
|
74
|
+
fs.writeFileSync(path.join(home, '.fullcourtdefense-runtime.json'), JSON.stringify({ [shieldId]: {
|
|
75
|
+
mode: 'block', version: 'boundary-verification-v1', policyHash: 'boundary-verification-v1',
|
|
76
|
+
fetchedAt: Date.now(), failClosed: true,
|
|
77
|
+
actionPolicies: [{ id: 'boundary-verification-deny', name: 'Boundary verification deny fixture',
|
|
78
|
+
resourceType: 'tool', enforcement: 'hard', rules: [
|
|
79
|
+
{ operations: ['ANY'], verdict: 'block', constraints: [{ field: 'command', operator: 'contains', value: 'fcd_probe_denied' }] },
|
|
80
|
+
{ operations: ['ANY'], verdict: 'block', constraints: [{ field: 'toolName', operator: 'contains', value: 'fcd_probe_denied' }] },
|
|
81
|
+
{ operations: ['ANY'], verdict: 'block', constraints: [{ field: 'path', operator: 'contains', value: 'fcd_probe_denied' }] },
|
|
82
|
+
] }],
|
|
83
|
+
} }));
|
|
84
|
+
// Do not inherit credentials, developer test overrides or a live daemon pipe.
|
|
85
|
+
const env = {};
|
|
86
|
+
for (const key of ['SystemRoot', 'WINDIR', 'PATH', 'PATHEXT', 'TEMP', 'TMP', 'LANG']) {
|
|
87
|
+
if (process.env[key])
|
|
88
|
+
env[key] = process.env[key];
|
|
89
|
+
}
|
|
90
|
+
Object.assign(env, { HOME: home, USERPROFILE: home, APPDATA: home, LOCALAPPDATA: home,
|
|
91
|
+
FCD_MACHINE_KIND: 'workstation', FCD_DISABLE_OS_NOTIFICATIONS: 'true', FULLCOURTDEFENSE_DEVELOPER_ID: 'boundary-fixture',
|
|
92
|
+
FCD_VERDICT_PIPE: process.platform === 'win32' ? `\\\\.\\pipe\\fcd-probe-${path.basename(home)}` : path.join(home, 'probe.sock') });
|
|
93
|
+
const samples = [
|
|
94
|
+
{ boundary: 'shell', event: 'shell', payload: (denied) => ({ command: `echo fcd_probe_${denied ? 'denied' : 'allowed'}` }) },
|
|
95
|
+
{ boundary: 'files', event: 'read', payload: (denied) => ({ file_path: path.join(home, `fcd_probe_${denied ? 'denied' : 'allowed'}.txt`) }) },
|
|
96
|
+
{ boundary: 'mcp', event: 'mcp', payload: (denied) => ({ tool_name: `fcd_probe_${denied ? 'denied' : 'allowed'}`, tool_input: {} }) },
|
|
97
|
+
];
|
|
98
|
+
for (const sample of samples) {
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
let allowed = 0;
|
|
101
|
+
let denied = 0;
|
|
102
|
+
for (const block of [false, true]) {
|
|
103
|
+
const result = await new Promise(resolve => {
|
|
104
|
+
const child = (0, child_process_1.spawn)(process.execPath, [entry, 'hook', '--event', sample.event,
|
|
105
|
+
'--api-url', 'http://127.0.0.1:9', '--shield-id', shieldId, '--shield-key', 'fixture-only',
|
|
106
|
+
'--timeout', '100', '--approval-mode', 'block'], {
|
|
107
|
+
env, cwd: home, windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'],
|
|
108
|
+
});
|
|
109
|
+
let stdout = '';
|
|
110
|
+
let failed = false;
|
|
111
|
+
const timeout = setTimeout(() => { failed = true; child.kill(); }, 5000);
|
|
112
|
+
child.stdout.setEncoding('utf8');
|
|
113
|
+
child.stdout.on('data', (chunk) => {
|
|
114
|
+
if (stdout.length + chunk.length > 128 * 1024) {
|
|
115
|
+
failed = true;
|
|
116
|
+
child.kill();
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
stdout += chunk;
|
|
120
|
+
});
|
|
121
|
+
child.on('error', () => { clearTimeout(timeout); resolve({ stdout: '', ok: false }); });
|
|
122
|
+
child.on('close', code => { clearTimeout(timeout); resolve({ stdout, ok: !failed && code === 0 }); });
|
|
123
|
+
child.stdin.on('error', () => { failed = true; });
|
|
124
|
+
child.stdin.end(JSON.stringify(sample.payload(block)));
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
const verdict = JSON.parse(result.stdout || '');
|
|
128
|
+
if (result.ok) {
|
|
129
|
+
if (!block && verdict.permission === 'allow')
|
|
130
|
+
allowed++;
|
|
131
|
+
// Require the expected policy evidence: an unrelated infrastructure deny isn't a pass.
|
|
132
|
+
if (block && verdict.permission === 'deny' && /Boundary verification deny fixture/.test(verdict.user_message || ''))
|
|
133
|
+
denied++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch { /* Invalid output is a failed probe, never an allow. */ }
|
|
137
|
+
}
|
|
138
|
+
report.checks.push({ boundary: sample.boundary, status: allowed === 1 && denied === 1 ? 'passed' : 'failed', allowed, denied, durationMs: Date.now() - start });
|
|
139
|
+
}
|
|
140
|
+
report.checks.push({ boundary: 'network', status: 'unavailable', allowed: 0, denied: 0, durationMs: 0 });
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
// Only the exact directory returned by mkdtemp above; no user-provided path.
|
|
145
|
+
await fs.promises.rm(home, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/commands/daemon.js
CHANGED
|
@@ -71,9 +71,11 @@ const protectionRepair_1 = require("./protectionRepair");
|
|
|
71
71
|
const runtimeConfig_1 = require("../runtimeConfig");
|
|
72
72
|
const discoverPaths_1 = require("./discoverPaths");
|
|
73
73
|
const localSafetySnapshot_1 = require("../localSafetySnapshot");
|
|
74
|
+
const policyRefresh_1 = require("../policyRefresh");
|
|
74
75
|
const telemetry_1 = require("../telemetry");
|
|
75
76
|
const notify_1 = require("../notify");
|
|
76
77
|
const integrity_1 = require("../integrity");
|
|
78
|
+
const protectionMaintenance_1 = require("../protectionMaintenance");
|
|
77
79
|
const machineIdentity_1 = require("../machineIdentity");
|
|
78
80
|
const localDetectionUpdates_1 = require("../localDetectionUpdates");
|
|
79
81
|
const discoveryMarker_1 = require("../discoveryMarker");
|
|
@@ -102,19 +104,18 @@ function envMs(name, fallback) {
|
|
|
102
104
|
const value = Number(process.env[name]);
|
|
103
105
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
104
106
|
}
|
|
105
|
-
/** How long after our own protect-all writes we ignore watcher events (self-inflicted churn). */
|
|
106
|
-
const SELF_WRITE_QUIET_MS = envMs('FCD_DAEMON_QUIET_MS', 5_000);
|
|
107
107
|
/** Debounce window for filesystem events before re-protecting. */
|
|
108
108
|
const DEBOUNCE_MS = envMs('FCD_DAEMON_DEBOUNCE_MS', 2_000);
|
|
109
|
+
/** Recheck a failed repair after concurrent editor writes have settled. */
|
|
110
|
+
const PROTECTION_NOTICE_DELAY_MS = envMs('FCD_PROTECTION_NOTICE_DELAY_MS', 5_000);
|
|
109
111
|
/** Re-enumerate config files this often to pick up newly installed clients. */
|
|
110
112
|
const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
|
|
111
113
|
/** Heartbeat / spool flush cadence. */
|
|
112
114
|
const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
|
|
113
115
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* a repair cannot fix
|
|
117
|
-
* install) an uncooled retry would re-run protect-all every heartbeat forever.
|
|
116
|
+
* Backoff after an unsuccessful repair. A successful repair must not delay a
|
|
117
|
+
* later, independent gap (for example a new Git worktree's MCP configuration).
|
|
118
|
+
* If a repair cannot fix a gap, retrying every heartbeat would churn configs.
|
|
118
119
|
*/
|
|
119
120
|
const INTEGRITY_HEAL_COOLDOWN_MS = envMs('FCD_INTEGRITY_HEAL_COOLDOWN_MS', 60 * 60_000);
|
|
120
121
|
/** Bundle (mode / suspension / policy version) poll cadence. */
|
|
@@ -536,11 +537,14 @@ async function runDaemon(args, config) {
|
|
|
536
537
|
// --- state ---------------------------------------------------------------
|
|
537
538
|
const watchers = new Map(); // directory -> watcher
|
|
538
539
|
const rootWatchers = new Map(); // admin protection roots -> recursive watcher
|
|
539
|
-
const watchedFiles = new Set(); //
|
|
540
|
-
|
|
540
|
+
const watchedFiles = new Set(); // actual paths, also read on case-sensitive filesystems
|
|
541
|
+
const watchedFileKeys = new Set();
|
|
541
542
|
let debounceTimer = null;
|
|
542
|
-
let
|
|
543
|
-
let
|
|
543
|
+
let repairInFlight;
|
|
544
|
+
let repairFailed = false;
|
|
545
|
+
let observedConfigurations = new Map();
|
|
546
|
+
let refreshPending = false;
|
|
547
|
+
let lastIntegrityHealFailureAt;
|
|
544
548
|
let suspended = false;
|
|
545
549
|
let stopped = false;
|
|
546
550
|
const executingActionIds = new Set();
|
|
@@ -637,62 +641,173 @@ async function runDaemon(args, config) {
|
|
|
637
641
|
}
|
|
638
642
|
catch { /* next tick */ }
|
|
639
643
|
};
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
// Persist only whether the user has been warned, so daemon restarts do not
|
|
645
|
+
// repeat the same unresolved incident. No configuration or action contents.
|
|
646
|
+
const noticeFile = path.join(daemonDir(), 'protection-notice.json');
|
|
647
|
+
let attentionShown = false;
|
|
648
|
+
let attentionTimer;
|
|
649
|
+
try {
|
|
650
|
+
attentionShown = JSON.parse(fs.readFileSync(noticeFile, 'utf8')).attentionShown === true;
|
|
651
|
+
}
|
|
652
|
+
catch { /* first run */ }
|
|
653
|
+
const deliverProtectionHealth = (healthy) => {
|
|
654
|
+
const next = (0, protectionMaintenance_1.protectionNoticeTransition)(attentionShown, healthy, quiet);
|
|
655
|
+
if (next.attentionShown !== attentionShown) {
|
|
656
|
+
attentionShown = next.attentionShown;
|
|
657
|
+
try {
|
|
658
|
+
const tmp = `${noticeFile}.tmp-${process.pid}`;
|
|
659
|
+
fs.writeFileSync(tmp, JSON.stringify({ attentionShown }), 'utf8');
|
|
660
|
+
fs.renameSync(tmp, noticeFile);
|
|
661
|
+
}
|
|
662
|
+
catch { /* notification persistence is best-effort */ }
|
|
663
|
+
}
|
|
664
|
+
if (!next.notice)
|
|
665
|
+
return;
|
|
666
|
+
(0, notify_1.notifyOs)({
|
|
667
|
+
title: next.notice === 'attention' ? 'FullCourtDefense needs attention' : 'FullCourtDefense protection restored',
|
|
668
|
+
message: next.notice === 'attention'
|
|
669
|
+
? 'Some protection checks are still failing. Open this machine to review and repair its protection.'
|
|
670
|
+
: 'Protection checks now pass. You can review the repair in this machine’s activity.',
|
|
671
|
+
url: (0, notify_1.consoleUrl)(`/agent-security/machines/${encodeURIComponent((0, machineIdentity_1.getMachineIdentity)().machineId)}`),
|
|
672
|
+
});
|
|
673
|
+
};
|
|
674
|
+
const reportProtectionHealth = (healthy) => {
|
|
675
|
+
if (healthy) {
|
|
676
|
+
if (attentionTimer)
|
|
677
|
+
clearTimeout(attentionTimer);
|
|
678
|
+
attentionTimer = undefined;
|
|
679
|
+
deliverProtectionHealth(true);
|
|
680
|
+
}
|
|
681
|
+
else if (!quiet && !attentionShown && !attentionTimer && !suspended && !stopped) {
|
|
682
|
+
// This delays only the courtesy notice. Repairs and unhealthy heartbeat
|
|
683
|
+
// evidence continue immediately, even while this one recheck is pending.
|
|
684
|
+
attentionTimer = setTimeout(() => {
|
|
685
|
+
attentionTimer = undefined;
|
|
686
|
+
if (stopped || suspended)
|
|
687
|
+
return;
|
|
688
|
+
let verified = false;
|
|
689
|
+
try {
|
|
690
|
+
verified = !repairFailed && (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true }).ok;
|
|
691
|
+
}
|
|
692
|
+
catch { /* cannot verify */ }
|
|
693
|
+
deliverProtectionHealth(verified);
|
|
694
|
+
}, PROTECTION_NOTICE_DELAY_MS);
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
const inspectProtection = () => {
|
|
698
|
+
// Snapshot BEFORE verification. An external write during/after inspection
|
|
699
|
+
// remains different from this baseline and its queued event gets checked.
|
|
700
|
+
const snapshot = (0, protectionMaintenance_1.captureProtectionConfiguration)(watchedFiles);
|
|
701
|
+
const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
702
|
+
observedConfigurations = snapshot;
|
|
703
|
+
return integrity;
|
|
704
|
+
};
|
|
705
|
+
/** One serialized repair path, including remote requests and new discovery. */
|
|
646
706
|
const reprotect = async (reasonPaths, cause = 'drift') => {
|
|
647
|
-
|
|
648
|
-
|
|
707
|
+
if (repairInFlight) {
|
|
708
|
+
await repairInFlight;
|
|
709
|
+
// Re-check after the previous pass: do not discard damage discovered
|
|
710
|
+
// while it was running, or overlap a remote repair with a watch repair.
|
|
711
|
+
return reprotect(reasonPaths, cause);
|
|
712
|
+
}
|
|
713
|
+
const headline = cause === 'integrity' ? 'Integrity self-heal'
|
|
714
|
+
: cause === 'discovery' ? 'New protection configuration discovered' : 'Config drift detected';
|
|
715
|
+
if (stopped)
|
|
649
716
|
return;
|
|
650
717
|
if (suspended) {
|
|
651
718
|
log(`${headline} (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
|
|
652
719
|
return;
|
|
653
720
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
721
|
+
repairInFlight = (async () => {
|
|
722
|
+
try {
|
|
723
|
+
const before = inspectProtection();
|
|
724
|
+
// Integrity/discovery triggers use the shared healable taxonomy. A real
|
|
725
|
+
// content change still runs the full idempotent repair pipeline: the
|
|
726
|
+
// integrity summary does not cover every hook migration/companion.
|
|
727
|
+
if ((cause === 'integrity' || cause === 'discovery') && !repairFailed && !(0, integrity_1.hasHealableIntegrityReason)(before.reasons)) {
|
|
728
|
+
reportProtectionHealth(before.ok && !repairFailed);
|
|
729
|
+
return before;
|
|
730
|
+
}
|
|
731
|
+
const startedAt = Date.now();
|
|
732
|
+
const beforeFiles = observedConfigurations;
|
|
733
|
+
log(`${headline}: ${reasonPaths.join(', ')} — re-running protect-all.`);
|
|
734
|
+
let integrity = before;
|
|
735
|
+
// One bounded convergence retry handles an editor writing immediately
|
|
736
|
+
// after our write, including when its watch event is lost. Persistent
|
|
737
|
+
// failures still enter the existing backoff, never a repair spin loop.
|
|
738
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
739
|
+
const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
|
|
740
|
+
refreshWatchTargets();
|
|
741
|
+
integrity = inspectProtection();
|
|
742
|
+
log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
|
|
743
|
+
if (!(0, integrity_1.hasHealableIntegrityReason)(integrity.reasons) || suspended || stopped)
|
|
744
|
+
break;
|
|
745
|
+
if (attempt === 1)
|
|
746
|
+
log('Re-protection has not converged; rechecking once before failure backoff.');
|
|
747
|
+
}
|
|
748
|
+
const changedFiles = (0, protectionMaintenance_1.changedProtectionConfiguration)(beforeFiles, observedConfigurations);
|
|
749
|
+
repairFailed = false;
|
|
750
|
+
log(`Re-protection verified: ${integrity.ok ? 'healthy' : 'incomplete'}; ${changedFiles.length} config file(s) changed; ${Date.now() - startedAt}ms.`);
|
|
751
|
+
lastIntegrityHealFailureAt = (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons) ? Date.now() : undefined;
|
|
752
|
+
if (!integrity.ok)
|
|
753
|
+
log(`Re-protection verification still failing: ${integrity.reasons.join(', ')}.`);
|
|
754
|
+
reportProtectionHealth(integrity.ok);
|
|
755
|
+
await uploadLogTail();
|
|
756
|
+
return integrity;
|
|
671
757
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
758
|
+
catch (error) {
|
|
759
|
+
repairFailed = true;
|
|
760
|
+
lastIntegrityHealFailureAt = Date.now();
|
|
761
|
+
log(`Re-protection failed: ${error.message}`);
|
|
762
|
+
reportProtectionHealth(false);
|
|
763
|
+
// A remote repair must report its execution failure even when the
|
|
764
|
+
// narrower integrity summary does not describe the failing component.
|
|
765
|
+
if (cause === 'manual')
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
})();
|
|
769
|
+
try {
|
|
770
|
+
return await repairInFlight;
|
|
675
771
|
}
|
|
676
772
|
finally {
|
|
677
|
-
|
|
773
|
+
repairInFlight = undefined;
|
|
678
774
|
}
|
|
679
775
|
};
|
|
776
|
+
const scheduleProtectionCheck = () => {
|
|
777
|
+
if (debounceTimer)
|
|
778
|
+
clearTimeout(debounceTimer);
|
|
779
|
+
debounceTimer = setTimeout(() => {
|
|
780
|
+
debounceTimer = null;
|
|
781
|
+
void (async () => {
|
|
782
|
+
if (repairInFlight)
|
|
783
|
+
await repairInFlight;
|
|
784
|
+
if (stopped)
|
|
785
|
+
return;
|
|
786
|
+
if (refreshPending) {
|
|
787
|
+
refreshPending = false;
|
|
788
|
+
refreshWatchTargets();
|
|
789
|
+
}
|
|
790
|
+
const current = (0, protectionMaintenance_1.captureProtectionConfiguration)(watchedFiles);
|
|
791
|
+
const changed = (0, protectionMaintenance_1.changedProtectionConfiguration)(observedConfigurations, current);
|
|
792
|
+
if (changed.length > 0)
|
|
793
|
+
await reprotect(changed);
|
|
794
|
+
})().catch(error => log(`Protection change check failed: ${error.message}`));
|
|
795
|
+
}, DEBOUNCE_MS);
|
|
796
|
+
};
|
|
680
797
|
const onFsEvent = (dir, filename) => {
|
|
681
|
-
if (stopped
|
|
798
|
+
if (stopped)
|
|
682
799
|
return;
|
|
683
800
|
const full = filename ? path.resolve(dir, filename.toString()) : dir;
|
|
684
801
|
const key = full.toLowerCase();
|
|
685
802
|
// Ignore our own backup files and unrelated churn in watched directories.
|
|
686
803
|
if (key.includes('.fcd-backup-'))
|
|
687
804
|
return;
|
|
688
|
-
if (filename && !
|
|
805
|
+
if (filename && !watchedFileKeys.has(key))
|
|
689
806
|
return;
|
|
690
|
-
|
|
691
|
-
clearTimeout(debounceTimer);
|
|
692
|
-
debounceTimer = setTimeout(() => { void reprotect([full]); }, DEBOUNCE_MS);
|
|
807
|
+
scheduleProtectionCheck();
|
|
693
808
|
};
|
|
694
809
|
const onAdminRootEvent = (root, filename) => {
|
|
695
|
-
if (stopped ||
|
|
810
|
+
if (stopped || !filename)
|
|
696
811
|
return;
|
|
697
812
|
const full = path.resolve(root, filename.toString());
|
|
698
813
|
const normalized = full.replace(/\\/g, '/').toLowerCase();
|
|
@@ -703,12 +818,8 @@ async function runDaemon(args, config) {
|
|
|
703
818
|
const { disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
|
|
704
819
|
if ((0, discoverPaths_1.isExcludedPath)(full, disabledScanRoots))
|
|
705
820
|
return;
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
debounceTimer = setTimeout(() => {
|
|
709
|
-
refreshWatchTargets();
|
|
710
|
-
void reprotect([full]);
|
|
711
|
-
}, DEBOUNCE_MS);
|
|
821
|
+
refreshPending = true;
|
|
822
|
+
scheduleProtectionCheck();
|
|
712
823
|
};
|
|
713
824
|
/**
|
|
714
825
|
* (Re)build the watch set from the current MCP client configs + hook files.
|
|
@@ -721,9 +832,11 @@ async function runDaemon(args, config) {
|
|
|
721
832
|
...hookConfigFiles(),
|
|
722
833
|
];
|
|
723
834
|
watchedFiles.clear();
|
|
835
|
+
watchedFileKeys.clear();
|
|
724
836
|
const wantedDirs = new Set();
|
|
725
837
|
for (const file of targets) {
|
|
726
|
-
watchedFiles.add(path.resolve(file)
|
|
838
|
+
watchedFiles.add(path.resolve(file));
|
|
839
|
+
watchedFileKeys.add(path.resolve(file).toLowerCase());
|
|
727
840
|
wantedDirs.add(path.dirname(path.resolve(file)));
|
|
728
841
|
}
|
|
729
842
|
for (const [dir, watcher] of watchers) {
|
|
@@ -1036,16 +1149,9 @@ async function runDaemon(args, config) {
|
|
|
1036
1149
|
throw new Error('Shield not configured on this machine.');
|
|
1037
1150
|
const shieldId = creds.shieldId;
|
|
1038
1151
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
1039
|
-
log('Policy refresh: clearing Local Safety snapshot caches…');
|
|
1040
|
-
// Clear EVERY snapshot cache file, not one derived key: the hooks key
|
|
1041
|
-
// their cache by hookSnapshotIdentity() (raw username@hostname) while
|
|
1042
|
-
// getMachineIdentity() normalizes both — a targeted unlink under the
|
|
1043
|
-
// normalized key used to MISS the hooks' actual cache file, so
|
|
1044
|
-
// "policy refresh" never actually refreshed the hot-path snapshot.
|
|
1045
|
-
(0, localSafetySnapshot_1.clearAllLocalSafetySnapshotCaches)();
|
|
1046
1152
|
await uploadLogTail();
|
|
1047
1153
|
log('Policy refresh: pulling latest policy bundle from control plane…');
|
|
1048
|
-
const bundle = await (0,
|
|
1154
|
+
const bundle = await (0, policyRefresh_1.refreshMachinePolicies)({
|
|
1049
1155
|
apiUrl: creds.apiUrl,
|
|
1050
1156
|
shieldId,
|
|
1051
1157
|
shieldKey: creds.shieldKey,
|
|
@@ -1068,7 +1174,7 @@ async function runDaemon(args, config) {
|
|
|
1068
1174
|
throw new Error('Machine is suspended; resume it before repairing protection.');
|
|
1069
1175
|
log('Repair protection: running protect-all (IDE hooks + MCP gateways)…');
|
|
1070
1176
|
await uploadLogTail();
|
|
1071
|
-
await (
|
|
1177
|
+
await reprotect(['remote repair request'], 'manual');
|
|
1072
1178
|
log('Repair protection: verifying hooks, gateways and daemon integrity…');
|
|
1073
1179
|
const verification = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1074
1180
|
if (!verification.ok) {
|
|
@@ -1206,6 +1312,7 @@ async function runDaemon(args, config) {
|
|
|
1206
1312
|
if (!creds.shieldId)
|
|
1207
1313
|
return;
|
|
1208
1314
|
void localDetectionUpdates_1.localDetectionUpdates.refresh();
|
|
1315
|
+
void localDetectionUpdates_1.localDetectorUpdates.refresh();
|
|
1209
1316
|
if (!creds.shieldKey) {
|
|
1210
1317
|
// Credential-broken machine: stay reachable via the key-less signed-
|
|
1211
1318
|
// action channel while credential recovery keeps retrying.
|
|
@@ -1343,23 +1450,33 @@ async function runDaemon(args, config) {
|
|
|
1343
1450
|
let reported = false;
|
|
1344
1451
|
try {
|
|
1345
1452
|
let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1453
|
+
// Recovery outside our repair pass (for example a manual fix) also clears
|
|
1454
|
+
// the old failure's backoff. It must not suppress an unrelated later gap.
|
|
1455
|
+
// An execution error can concern a component outside this summary (e.g.
|
|
1456
|
+
// Copilot settings); keep its backoff until the full pipeline retries.
|
|
1457
|
+
if (integrity.ok && !repairFailed)
|
|
1458
|
+
lastIntegrityHealFailureAt = undefined;
|
|
1346
1459
|
// A failing verdict used to be REPORTED and nothing more, so a machine
|
|
1347
1460
|
// could sit "Damaged" in the console indefinitely on a reason the daemon
|
|
1348
1461
|
// already knows how to fix — the drift watcher only fires when a config
|
|
1349
1462
|
// file changes, and a stale gateway path changes nothing. Heal first, then
|
|
1350
1463
|
// report what is true AFTER the repair rather than the verdict that
|
|
1351
1464
|
// triggered it.
|
|
1352
|
-
if (!integrity.ok
|
|
1465
|
+
if ((!integrity.ok || repairFailed)
|
|
1353
1466
|
&& !suspended
|
|
1354
|
-
&&
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
await reprotect(integrity.reasons, 'integrity');
|
|
1467
|
+
&& (lastIntegrityHealFailureAt === undefined
|
|
1468
|
+
|| Date.now() - lastIntegrityHealFailureAt >= INTEGRITY_HEAL_COOLDOWN_MS)
|
|
1469
|
+
&& (repairFailed || (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons))) {
|
|
1470
|
+
const repaired = await reprotect(integrity.reasons, 'integrity');
|
|
1358
1471
|
integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1472
|
+
if (repaired) {
|
|
1473
|
+
log(integrity.ok
|
|
1474
|
+
? 'Integrity self-heal: protection restored.'
|
|
1475
|
+
: `Integrity self-heal: still failing (${integrity.reasons.join(', ')}) — reported for support.`);
|
|
1476
|
+
}
|
|
1362
1477
|
}
|
|
1478
|
+
if (!suspended)
|
|
1479
|
+
reportProtectionHealth(integrity.ok && !repairFailed);
|
|
1363
1480
|
// Report a previous daemon's unclean death exactly once.
|
|
1364
1481
|
const crash = (0, daemonForensics_1.readPostmortem)();
|
|
1365
1482
|
const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
|
|
@@ -1437,7 +1554,7 @@ async function runDaemon(args, config) {
|
|
|
1437
1554
|
// legitimately leave stale hook/wrap paths behind, and heartbeating first
|
|
1438
1555
|
// would report a false "damaged" state (and fire admin integrity alerts)
|
|
1439
1556
|
// for a condition the very next line repairs.
|
|
1440
|
-
await reprotect(['startup pass']);
|
|
1557
|
+
await reprotect(['startup pass'], 'startup');
|
|
1441
1558
|
// The FIRST beat must not be able to abort the rest of this function.
|
|
1442
1559
|
// `heartbeat` throws when the report did not reach the backend (that is how
|
|
1443
1560
|
// the poll loop knows to retry in seconds), and an exception here propagates
|
|
@@ -1553,8 +1670,17 @@ async function runDaemon(args, config) {
|
|
|
1553
1670
|
const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
|
|
1554
1671
|
const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
|
|
1555
1672
|
const rescanTimer = setInterval(() => {
|
|
1673
|
+
const previousTargets = new Set(watchedFiles);
|
|
1556
1674
|
const count = refreshWatchTargets();
|
|
1557
1675
|
log(`Rescan: watching ${count} config file(s).`);
|
|
1676
|
+
// A config created before its parent was watched emits no event for us.
|
|
1677
|
+
// Discovery is therefore a repair trigger, even during another gap's
|
|
1678
|
+
// failure backoff. Repeated scans of the same targets do not retry it.
|
|
1679
|
+
const added = [...watchedFiles].filter(file => !previousTargets.has(file));
|
|
1680
|
+
if (added.length > 0 && !suspended && !stopped
|
|
1681
|
+
&& (0, integrity_1.hasHealableIntegrityReason)((0, integrity_1.getLocalIntegrityReport)().reasons)) {
|
|
1682
|
+
void reprotect(added, 'discovery');
|
|
1683
|
+
}
|
|
1558
1684
|
}, RESCAN_INTERVAL_MS);
|
|
1559
1685
|
// Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
|
|
1560
1686
|
// a fleet enrolled behind one corporate proxy must not hit the gate in
|
|
@@ -1679,6 +1805,8 @@ async function runDaemon(args, config) {
|
|
|
1679
1805
|
clearTimeout(initialDiscoverTimer);
|
|
1680
1806
|
if (debounceTimer)
|
|
1681
1807
|
clearTimeout(debounceTimer);
|
|
1808
|
+
if (attentionTimer)
|
|
1809
|
+
clearTimeout(attentionTimer);
|
|
1682
1810
|
if (desktopChatGuard)
|
|
1683
1811
|
desktopChatGuard.stop();
|
|
1684
1812
|
if (verdictServer)
|
|
@@ -55,6 +55,8 @@ export interface LocalSafetyScanOptions {
|
|
|
55
55
|
* legacy "absent item = block" contract for callers that don't set it.
|
|
56
56
|
*/
|
|
57
57
|
snapshotPresent?: boolean;
|
|
58
|
+
/** Captured transport identity, separate from untrusted tool argument data. */
|
|
59
|
+
mcpServerName?: string;
|
|
58
60
|
customBlocks?: LocalSafetyCustomBlock[];
|
|
59
61
|
policyHash?: string;
|
|
60
62
|
cwd?: string;
|