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.
Files changed (91) hide show
  1. package/README.md +1 -0
  2. package/dist/actionIdentity.d.ts +1 -0
  3. package/dist/actionIdentity.js +2 -0
  4. package/dist/actionPolicyEngine.d.ts +5 -0
  5. package/dist/actionPolicyEngine.js +273 -30
  6. package/dist/agentTerminal.d.ts +1 -3
  7. package/dist/agentTerminal.js +14 -29
  8. package/dist/approvalPrompt.d.ts +7 -0
  9. package/dist/approvalPrompt.js +46 -0
  10. package/dist/askDialog.d.ts +87 -0
  11. package/dist/askDialog.js +209 -0
  12. package/dist/blockExplanation.js +18 -6
  13. package/dist/boundaryVerification.d.ts +20 -0
  14. package/dist/boundaryVerification.js +147 -0
  15. package/dist/commands/daemon.js +198 -70
  16. package/dist/commands/deterministicGuard.d.ts +2 -0
  17. package/dist/commands/deterministicGuard.js +161 -9
  18. package/dist/commands/hook.js +81 -29
  19. package/dist/commands/installClaudeHook.js +11 -5
  20. package/dist/commands/installCursorHook.js +5 -4
  21. package/dist/commands/mcpGateway.d.ts +2 -0
  22. package/dist/commands/mcpGateway.js +163 -56
  23. package/dist/commands/onboard.js +6 -5
  24. package/dist/commands/onboardingSummary.d.ts +5 -0
  25. package/dist/commands/onboardingSummary.js +11 -0
  26. package/dist/commands/protectedRun.d.ts +14 -0
  27. package/dist/commands/protectedRun.js +207 -0
  28. package/dist/containment-runtime/index.mjs +30118 -0
  29. package/dist/containment-runtime/licenses/_anthropic-ai_sandbox-runtime.txt +201 -0
  30. package/dist/containment-runtime/licenses/_pondwader_socks5-server.txt +20 -0
  31. package/dist/containment-runtime/licenses/commander.txt +22 -0
  32. package/dist/containment-runtime/licenses/node-forge.txt +331 -0
  33. package/dist/containment-runtime/licenses/zod.txt +21 -0
  34. package/dist/containment-runtime/runtime-version.json +1 -0
  35. package/dist/containment-runtime/vendor/java-proxy-agent/build.ts +95 -0
  36. package/dist/containment-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
  37. package/dist/containment-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
  38. package/dist/containment-runtime/vendor/seccomp/build.ts +69 -0
  39. package/dist/containment-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
  40. package/dist/containment-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
  41. package/dist/containment-runtime/vendor/srt-win/build.ts +21 -0
  42. package/dist/containment-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
  43. package/dist/containmentLease.d.ts +5 -0
  44. package/dist/containmentLease.js +72 -0
  45. package/dist/detectionUpdates.d.ts +23 -3
  46. package/dist/detectionUpdates.js +45 -14
  47. package/dist/detectorArtifact.d.ts +17 -0
  48. package/dist/detectorArtifact.js +35 -0
  49. package/dist/detectorBaseline.d.ts +2 -0
  50. package/dist/detectorBaseline.js +5 -0
  51. package/dist/detectorModule.d.ts +9 -0
  52. package/dist/detectorModule.js +84 -0
  53. package/dist/detectorRuntime.d.ts +21 -0
  54. package/dist/detectorRuntime.js +49 -0
  55. package/dist/detectorUpdates.d.ts +21 -0
  56. package/dist/detectorUpdates.js +86 -0
  57. package/dist/devConfirm.d.ts +97 -15
  58. package/dist/devConfirm.js +447 -117
  59. package/dist/distress.d.ts +2 -0
  60. package/dist/distress.js +2 -0
  61. package/dist/frictionRollout.js +103 -0
  62. package/dist/gatewayRuntime.d.ts +19 -0
  63. package/dist/gatewayRuntime.js +93 -0
  64. package/dist/hookSlim.js +17 -18
  65. package/dist/index.js +35 -0
  66. package/dist/localDetectionUpdates.d.ts +3 -1
  67. package/dist/localDetectionUpdates.js +8 -1
  68. package/dist/localSafetySnapshot.d.ts +9 -5
  69. package/dist/localSafetySnapshot.js +42 -15
  70. package/dist/mcpToolReview.d.ts +17 -0
  71. package/dist/mcpToolReview.js +127 -0
  72. package/dist/mcpToolTrust.d.ts +22 -0
  73. package/dist/mcpToolTrust.js +66 -0
  74. package/dist/nativeHook.d.ts +4 -0
  75. package/dist/nativeHook.js +31 -17
  76. package/dist/policyRefresh.d.ts +3 -0
  77. package/dist/policyRefresh.js +27 -0
  78. package/dist/protectionMaintenance.d.ts +10 -0
  79. package/dist/protectionMaintenance.js +64 -0
  80. package/dist/protectionProfile.d.ts +26 -0
  81. package/dist/protectionProfile.js +63 -0
  82. package/dist/runtimeConfig.d.ts +2 -0
  83. package/dist/runtimeConfig.js +65 -26
  84. package/dist/selfTest.d.ts +2 -0
  85. package/dist/selfTest.js +9 -1
  86. package/dist/sessionLimits.d.ts +2 -0
  87. package/dist/sessionLimits.js +24 -0
  88. package/dist/telemetry.d.ts +5 -0
  89. package/dist/telemetry.js +8 -0
  90. package/dist/version.json +1 -1
  91. 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
+ }
@@ -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
- * Floor between integrity-triggered repairs. The drift watcher reacts to config
115
- * CHANGES; this path reacts to a failing verdict, which persists until fixed. If
116
- * a repair cannot fix it (no write permission, a config owned by another
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(); // lowercased absolute file paths we react to
540
- let quietUntil = 0; // ignore events until this time (self-writes)
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 reprotecting = false;
543
- let lastIntegrityHealAt = 0; // cooldown anchor for integrity-triggered repairs
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
- * One repair path, two triggers: a watched config CHANGED (drift), or the
642
- * integrity check reported a reason protect-all can fix (integrity). Keeping
643
- * them on the same function preserves the reprotecting/quiet-window guards —
644
- * a second copy would race this one and re-enter protect-all concurrently.
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
- const headline = cause === 'integrity' ? 'Integrity self-heal' : 'Config drift detected';
648
- if (reprotecting || stopped)
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
- reprotecting = true;
655
- quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
656
- log(`${headline}: ${reasonPaths.join(', ')} — re-running protect-all.`);
657
- try {
658
- const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
659
- quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
660
- log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
661
- refreshWatchTargets();
662
- await uploadLogTail();
663
- if (!quiet) {
664
- (0, notify_1.notifyOs)({
665
- title: 'FullCourtDefense re-protected this machine',
666
- message: cause === 'integrity'
667
- ? 'Protection was reported incomplete; the FullCourtDefense gateway was re-applied.'
668
- : 'An MCP or hook config changed; the FullCourtDefense gateway was re-applied.',
669
- url: (0, notify_1.consoleUrl)('/agent-security/users?view=desktop'),
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
- catch (error) {
674
- log(`Re-protection failed: ${error.message}`);
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
- reprotecting = false;
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 || Date.now() < quietUntil)
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 && !watchedFiles.has(key))
805
+ if (filename && !watchedFileKeys.has(key))
689
806
  return;
690
- if (debounceTimer)
691
- clearTimeout(debounceTimer);
692
- debounceTimer = setTimeout(() => { void reprotect([full]); }, DEBOUNCE_MS);
807
+ scheduleProtectionCheck();
693
808
  };
694
809
  const onAdminRootEvent = (root, filename) => {
695
- if (stopped || Date.now() < quietUntil || !filename)
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
- if (debounceTimer)
707
- clearTimeout(debounceTimer);
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).toLowerCase());
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, runtimeConfig_1.getRuntimeBundle)({
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 (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
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
- && Date.now() - lastIntegrityHealAt >= INTEGRITY_HEAL_COOLDOWN_MS
1355
- && (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons)) {
1356
- lastIntegrityHealAt = Date.now();
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
- log(integrity.ok
1360
- ? 'Integrity self-heal: protection restored.'
1361
- : `Integrity self-heal: still failing (${integrity.reasons.join(', ')}) — reported for support.`);
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;