fullcourtdefense-cli 1.6.14 → 1.7.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/flushSpool.d.ts +13 -0
- package/dist/commands/flushSpool.js +28 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +32 -1
- package/dist/commands/installAll.js +2 -1
- package/dist/commands/login.d.ts +15 -0
- package/dist/commands/login.js +61 -0
- package/dist/index.js +21 -0
- package/dist/machineIdentity.d.ts +23 -0
- package/dist/machineIdentity.js +147 -0
- package/dist/runtimeConfig.d.ts +40 -0
- package/dist/runtimeConfig.js +109 -0
- package/dist/telemetry.d.ts +36 -0
- package/dist/telemetry.js +170 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
export interface FlushSpoolArgs {
|
|
3
|
+
apiUrl?: string;
|
|
4
|
+
shieldId?: string;
|
|
5
|
+
shieldKey?: string;
|
|
6
|
+
heartbeat?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `fullcourtdefense flush-spool` — drain the local telemetry spool to the backend
|
|
10
|
+
* and send a heartbeat. Normally invoked detached by the hook; can also be run
|
|
11
|
+
* from cron/launchd/Task Scheduler for steady liveness on idle machines.
|
|
12
|
+
*/
|
|
13
|
+
export declare function flushSpoolCommand(args: FlushSpoolArgs, config: BotGuardConfig): Promise<void>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.flushSpoolCommand = flushSpoolCommand;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const telemetry_1 = require("../telemetry");
|
|
6
|
+
/**
|
|
7
|
+
* `fullcourtdefense flush-spool` — drain the local telemetry spool to the backend
|
|
8
|
+
* and send a heartbeat. Normally invoked detached by the hook; can also be run
|
|
9
|
+
* from cron/launchd/Task Scheduler for steady liveness on idle machines.
|
|
10
|
+
*/
|
|
11
|
+
async function flushSpoolCommand(args, config) {
|
|
12
|
+
const creds = (0, config_1.resolveCliCredentials)(config, {
|
|
13
|
+
shieldId: args.shieldId,
|
|
14
|
+
shieldKey: args.shieldKey,
|
|
15
|
+
apiUrl: args.apiUrl,
|
|
16
|
+
});
|
|
17
|
+
if (!creds.shieldId)
|
|
18
|
+
return; // nothing to attribute to
|
|
19
|
+
const result = await (0, telemetry_1.flushSpool)({
|
|
20
|
+
apiUrl: creds.apiUrl,
|
|
21
|
+
shieldId: creds.shieldId,
|
|
22
|
+
shieldKey: creds.shieldKey,
|
|
23
|
+
heartbeat: args.heartbeat !== 'false',
|
|
24
|
+
});
|
|
25
|
+
if (result) {
|
|
26
|
+
console.log(`Flushed ${result.accepted} event(s)${result.deduped ? `, ${result.deduped} deduped` : ''}.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/commands/hook.d.ts
CHANGED
package/dist/commands/hook.js
CHANGED
|
@@ -38,6 +38,8 @@ const fs = __importStar(require("fs"));
|
|
|
38
38
|
const os = __importStar(require("os"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const config_1 = require("../config");
|
|
41
|
+
const runtimeConfig_1 = require("../runtimeConfig");
|
|
42
|
+
const telemetry_1 = require("../telemetry");
|
|
41
43
|
const deterministicGuard_1 = require("./deterministicGuard");
|
|
42
44
|
const taintLedger_1 = require("./taintLedger");
|
|
43
45
|
const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
|
|
@@ -323,7 +325,11 @@ function machineMetadata() {
|
|
|
323
325
|
};
|
|
324
326
|
}
|
|
325
327
|
async function hookCommand(args, config) {
|
|
326
|
-
|
|
328
|
+
// Mode is server-authoritative (pulled from the cached runtime bundle), with
|
|
329
|
+
// local flags as explicit overrides. Resolved after creds below.
|
|
330
|
+
let shadow = args.shadow === 'true';
|
|
331
|
+
const forceMonitor = args.shadow === 'true';
|
|
332
|
+
const forceEnforce = args.enforce === 'true';
|
|
327
333
|
const failClosed = args.failClosed === 'true';
|
|
328
334
|
const timeoutMs = Number(args.timeout) > 0 ? Number(args.timeout) : 8000;
|
|
329
335
|
const approvalMode = args.approvalMode === 'wait' ? 'wait' : 'block';
|
|
@@ -337,6 +343,24 @@ async function hookCommand(args, config) {
|
|
|
337
343
|
const shieldId = creds.shieldId;
|
|
338
344
|
const shieldKey = creds.shieldKey;
|
|
339
345
|
const apiUrl = creds.apiUrl;
|
|
346
|
+
// Server-authoritative enforcement mode: the console flips shield.mode and the
|
|
347
|
+
// machine applies it on the next poll. Cached locally (stale-while-error), so
|
|
348
|
+
// this is instant when fresh and bounded (~1.5s) only when stale.
|
|
349
|
+
if (!forceMonitor && !forceEnforce && shieldId) {
|
|
350
|
+
try {
|
|
351
|
+
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey });
|
|
352
|
+
if (bundle.source !== 'default') {
|
|
353
|
+
shadow = bundle.mode !== 'block'; // 'monitor'/'shadow' => report-only
|
|
354
|
+
dbg({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch { /* keep the local default */ }
|
|
358
|
+
}
|
|
359
|
+
if (forceEnforce)
|
|
360
|
+
shadow = false;
|
|
361
|
+
// Keep device liveness fresh during active use (throttled, detached — never blocks).
|
|
362
|
+
if (shieldId)
|
|
363
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
340
364
|
let raw = '';
|
|
341
365
|
let stdinErr = '';
|
|
342
366
|
const isTTY = !!process.stdin.isTTY;
|
|
@@ -437,6 +461,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
437
461
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
|
|
438
462
|
return;
|
|
439
463
|
}
|
|
464
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: localBlock.reason, ruleId: localBlock.ruleId, offlineEnforced: true });
|
|
465
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
440
466
|
respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
|
|
441
467
|
return;
|
|
442
468
|
}
|
|
@@ -453,6 +479,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
453
479
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
|
|
454
480
|
return;
|
|
455
481
|
}
|
|
482
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
|
|
483
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
456
484
|
respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason}`, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry.`);
|
|
457
485
|
return;
|
|
458
486
|
}
|
|
@@ -487,6 +515,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
487
515
|
clearTimeout(timer);
|
|
488
516
|
if (!resp.ok) {
|
|
489
517
|
dbg({ phase: 'policy_http_error', event, status: resp.status });
|
|
518
|
+
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `degraded: backend HTTP ${resp.status}`, offlineEnforced: true });
|
|
490
519
|
respond(false, undefined, degradedAllowMessage(event, `Backend returned HTTP ${resp.status}.`));
|
|
491
520
|
return;
|
|
492
521
|
}
|
|
@@ -500,6 +529,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
500
529
|
}
|
|
501
530
|
catch (err) {
|
|
502
531
|
dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err) });
|
|
532
|
+
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `degraded: ${err instanceof Error ? err.message : String(err)}`, offlineEnforced: true });
|
|
533
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
503
534
|
respond(false, undefined, degradedAllowMessage(event, `Hook error: ${err instanceof Error ? err.message : String(err)}.`));
|
|
504
535
|
return;
|
|
505
536
|
}
|
|
@@ -33,7 +33,8 @@ async function installAllCommand(args, config) {
|
|
|
33
33
|
shieldKey: creds.shieldKey,
|
|
34
34
|
apiUrl: creds.apiUrl,
|
|
35
35
|
project: args.cursorProject,
|
|
36
|
-
|
|
36
|
+
// Scope is MCP/tool actions only — prompt text never leaves the machine.
|
|
37
|
+
events: 'shell,mcp',
|
|
37
38
|
failClosed: 'true',
|
|
38
39
|
};
|
|
39
40
|
try {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
export interface LoginArgs {
|
|
3
|
+
/** Reusable, non-expiring fleet enrollment token (or FCD_ENROLL_TOKEN env). */
|
|
4
|
+
token?: string;
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `fullcourtdefense login` — zero-paste machine enrollment.
|
|
9
|
+
*
|
|
10
|
+
* The machine authenticates to the org with a reusable fleet token (distributed
|
|
11
|
+
* by IT/MDM), self-enrolls under its stable machine id, and receives a dedicated
|
|
12
|
+
* per-machine Shield (monitor-first). Credentials are written to
|
|
13
|
+
* ~/.fullcourtdefense.yml so no manual copy/paste of Shield IDs/keys is needed.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loginCommand(args: LoginArgs, config: BotGuardConfig): Promise<void>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loginCommand = loginCommand;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const machineIdentity_1 = require("../machineIdentity");
|
|
6
|
+
/**
|
|
7
|
+
* `fullcourtdefense login` — zero-paste machine enrollment.
|
|
8
|
+
*
|
|
9
|
+
* The machine authenticates to the org with a reusable fleet token (distributed
|
|
10
|
+
* by IT/MDM), self-enrolls under its stable machine id, and receives a dedicated
|
|
11
|
+
* per-machine Shield (monitor-first). Credentials are written to
|
|
12
|
+
* ~/.fullcourtdefense.yml so no manual copy/paste of Shield IDs/keys is needed.
|
|
13
|
+
*/
|
|
14
|
+
async function loginCommand(args, config) {
|
|
15
|
+
const creds = (0, config_1.resolveCliCredentials)(config, { apiUrl: args.apiUrl });
|
|
16
|
+
const apiUrl = creds.apiUrl;
|
|
17
|
+
const token = (args.token || process.env.FCD_ENROLL_TOKEN || '').trim();
|
|
18
|
+
if (!token) {
|
|
19
|
+
throw new Error('A fleet enrollment token is required. Pass --token <token> or set FCD_ENROLL_TOKEN.\n'
|
|
20
|
+
+ 'An org admin issues one from the AI Fleet console (Settings → Fleet enrollment token).');
|
|
21
|
+
}
|
|
22
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
23
|
+
const shieldName = (0, machineIdentity_1.suggestedShieldName)(identity);
|
|
24
|
+
console.log('\x1b[1mEnrolling this machine…\x1b[0m');
|
|
25
|
+
console.log(` Device: ${identity.developerName}`);
|
|
26
|
+
console.log(` OS: ${identity.osFriendly}`);
|
|
27
|
+
console.log(` Shield: ${shieldName}`);
|
|
28
|
+
const resp = await fetch(`${apiUrl}/api/cli/enroll`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'x-fleet-token': token,
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
machineId: identity.machineId,
|
|
36
|
+
user: identity.user,
|
|
37
|
+
hostname: identity.hostname,
|
|
38
|
+
developerName: identity.developerName,
|
|
39
|
+
osFriendly: identity.osFriendly,
|
|
40
|
+
platform: identity.platform,
|
|
41
|
+
shieldName,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
const data = (await resp.json().catch(() => ({})));
|
|
45
|
+
if (!resp.ok || data.success === false || !data.data) {
|
|
46
|
+
throw new Error(data.error || `Enrollment failed (HTTP ${resp.status})`);
|
|
47
|
+
}
|
|
48
|
+
const result = data.data;
|
|
49
|
+
const savedPath = (0, config_1.saveSetupConfig)({
|
|
50
|
+
organizationId: result.organizationId,
|
|
51
|
+
shieldId: result.shieldId,
|
|
52
|
+
shieldKey: result.shieldKey,
|
|
53
|
+
apiUrl,
|
|
54
|
+
});
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(`\x1b[32m✓ ${result.reused ? 'Re-enrolled' : 'Enrolled'}\x1b[0m ${result.shieldName}`);
|
|
57
|
+
console.log(` Mode: ${result.mode} (monitor-first — no blocking until you enable enforcement)`);
|
|
58
|
+
console.log(` Config: ${savedPath}`);
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log('Next: \x1b[1mfullcourtdefense install-all\x1b[0m to wrap your MCP clients and Cursor hooks.');
|
|
61
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,8 @@ const credits_1 = require("./commands/credits");
|
|
|
40
40
|
const init_1 = require("./commands/init");
|
|
41
41
|
const doctor_1 = require("./commands/doctor");
|
|
42
42
|
const configure_1 = require("./commands/configure");
|
|
43
|
+
const login_1 = require("./commands/login");
|
|
44
|
+
const flushSpool_1 = require("./commands/flushSpool");
|
|
43
45
|
const discover_1 = require("./commands/discover");
|
|
44
46
|
const hook_1 = require("./commands/hook");
|
|
45
47
|
const installCursorHook_1 = require("./commands/installCursorHook");
|
|
@@ -435,6 +437,24 @@ async function main() {
|
|
|
435
437
|
await (0, configure_1.configureCommand)(args, config);
|
|
436
438
|
break;
|
|
437
439
|
}
|
|
440
|
+
case 'login': {
|
|
441
|
+
const args = {
|
|
442
|
+
token: flags.token || flags['enroll-token'],
|
|
443
|
+
apiUrl: flags['api-url'],
|
|
444
|
+
};
|
|
445
|
+
await (0, login_1.loginCommand)(args, config);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
case 'flush-spool': {
|
|
449
|
+
const args = {
|
|
450
|
+
apiUrl: flags['api-url'],
|
|
451
|
+
shieldId: flags['shield-id'],
|
|
452
|
+
shieldKey: flags['shield-key'],
|
|
453
|
+
heartbeat: flags.heartbeat,
|
|
454
|
+
};
|
|
455
|
+
await (0, flushSpool_1.flushSpoolCommand)(args, config);
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
438
458
|
case 'credits': {
|
|
439
459
|
const args = {
|
|
440
460
|
apiKey: flags['api-key'],
|
|
@@ -488,6 +508,7 @@ async function main() {
|
|
|
488
508
|
shieldId: flags['shield-id'],
|
|
489
509
|
shieldKey: flags['shield-key'],
|
|
490
510
|
shadow: flags.shadow,
|
|
511
|
+
enforce: flags.enforce,
|
|
491
512
|
failClosed: flags['fail-closed'],
|
|
492
513
|
timeout: flags.timeout,
|
|
493
514
|
approvalMode: flags['approval-mode'],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface MachineIdentity {
|
|
2
|
+
/** Stable, OS-native hardware/OS id (hashed). Same value across networks + reboots. */
|
|
3
|
+
machineId: string;
|
|
4
|
+
/** Raw OS user name. */
|
|
5
|
+
user: string;
|
|
6
|
+
/** Normalized hostname (lowercased, `.local`/domain suffix stripped). */
|
|
7
|
+
hostname: string;
|
|
8
|
+
/** Human display identity, e.g. `boazl@boaz-pc`. Normalized for dedupe. */
|
|
9
|
+
developerName: string;
|
|
10
|
+
/** node platform: win32 | darwin | linux | ... */
|
|
11
|
+
platform: NodeJS.Platform;
|
|
12
|
+
/** Friendly OS label, e.g. `Windows 11`, `macOS 14`, `Ubuntu 22.04`. */
|
|
13
|
+
osFriendly: string;
|
|
14
|
+
}
|
|
15
|
+
/** Normalize a hostname: strip `.local`/domain suffix, lowercase. */
|
|
16
|
+
export declare function normalizeHostname(raw: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Compute (and cache) the stable machine identity. The raw OS id is hashed
|
|
19
|
+
* (SHA-256, 32 hex chars) so we never transmit the raw hardware UUID.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getMachineIdentity(): MachineIdentity;
|
|
22
|
+
/** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
|
|
23
|
+
export declare function suggestedShieldName(identity?: MachineIdentity): string;
|
|
@@ -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.normalizeHostname = normalizeHostname;
|
|
37
|
+
exports.getMachineIdentity = getMachineIdentity;
|
|
38
|
+
exports.suggestedShieldName = suggestedShieldName;
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
/**
|
|
44
|
+
* OS-agnostic stable machine identity.
|
|
45
|
+
*
|
|
46
|
+
* The problem this solves: `os.hostname()` is unstable — macOS returns a
|
|
47
|
+
* transient Bonjour/`.local` name that changes per network, Linux hostnames are
|
|
48
|
+
* often generic (`localhost`, container ids), and Windows uppercases the name.
|
|
49
|
+
* Relying on it produces duplicate / drifting fleet entries for the SAME laptop.
|
|
50
|
+
*
|
|
51
|
+
* So we key fleet identity on an OS-native STABLE machine id:
|
|
52
|
+
* - Windows: MachineGuid (HKLM\SOFTWARE\Microsoft\Cryptography)
|
|
53
|
+
* - macOS: IOPlatformUUID (ioreg)
|
|
54
|
+
* - Linux: /etc/machine-id (fallback /var/lib/dbus/machine-id)
|
|
55
|
+
*
|
|
56
|
+
* `username@hostname` is kept only as a human-readable display label.
|
|
57
|
+
*/
|
|
58
|
+
let cached;
|
|
59
|
+
function safe(fn) {
|
|
60
|
+
try {
|
|
61
|
+
const value = fn();
|
|
62
|
+
return value && value.trim() ? value.trim() : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Normalize a hostname: strip `.local`/domain suffix, lowercase. */
|
|
69
|
+
function normalizeHostname(raw) {
|
|
70
|
+
return raw
|
|
71
|
+
.trim()
|
|
72
|
+
.replace(/\.local$/i, '')
|
|
73
|
+
.replace(/\..*$/, '') // drop any remaining domain suffix
|
|
74
|
+
.toLowerCase() || 'unknown-host';
|
|
75
|
+
}
|
|
76
|
+
/** Raw OS-native stable id (UNHASHED). Internal — prefer `getMachineIdentity().machineId`. */
|
|
77
|
+
function rawStableId() {
|
|
78
|
+
const platform = os.platform();
|
|
79
|
+
if (platform === 'win32') {
|
|
80
|
+
return safe(() => {
|
|
81
|
+
const out = (0, child_process_1.execFileSync)('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
|
|
82
|
+
const match = out.match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i);
|
|
83
|
+
return match ? match[1] : undefined;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (platform === 'darwin') {
|
|
87
|
+
return safe(() => {
|
|
88
|
+
const out = (0, child_process_1.execFileSync)('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { encoding: 'utf8', timeout: 4000 });
|
|
89
|
+
const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
|
90
|
+
return match ? match[1] : undefined;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// linux + others
|
|
94
|
+
return (safe(() => fs.readFileSync('/etc/machine-id', 'utf8'))
|
|
95
|
+
|| safe(() => fs.readFileSync('/var/lib/dbus/machine-id', 'utf8')));
|
|
96
|
+
}
|
|
97
|
+
function friendlyOs() {
|
|
98
|
+
const platform = os.platform();
|
|
99
|
+
const release = os.release();
|
|
100
|
+
if (platform === 'win32') {
|
|
101
|
+
// Windows 11 reports 10.0.22000+; 10.0.x below that is Windows 10.
|
|
102
|
+
const build = parseInt(release.split('.')[2] || '0', 10);
|
|
103
|
+
return build >= 22000 ? 'Windows 11' : 'Windows 10';
|
|
104
|
+
}
|
|
105
|
+
if (platform === 'darwin') {
|
|
106
|
+
const major = parseInt(release.split('.')[0] || '0', 10);
|
|
107
|
+
// Darwin kernel major -> macOS major (Darwin 23 => macOS 14, etc.)
|
|
108
|
+
const macMajor = major >= 20 ? major - 9 : major;
|
|
109
|
+
return macMajor > 0 ? `macOS ${macMajor}` : 'macOS';
|
|
110
|
+
}
|
|
111
|
+
if (platform === 'linux') {
|
|
112
|
+
const pretty = safe(() => {
|
|
113
|
+
const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
|
|
114
|
+
const match = osRelease.match(/^PRETTY_NAME="?([^"\n]+)"?/m);
|
|
115
|
+
return match ? match[1] : undefined;
|
|
116
|
+
});
|
|
117
|
+
return pretty || 'Linux';
|
|
118
|
+
}
|
|
119
|
+
return `${platform} ${release}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Compute (and cache) the stable machine identity. The raw OS id is hashed
|
|
123
|
+
* (SHA-256, 32 hex chars) so we never transmit the raw hardware UUID.
|
|
124
|
+
*/
|
|
125
|
+
function getMachineIdentity() {
|
|
126
|
+
if (cached)
|
|
127
|
+
return cached;
|
|
128
|
+
const user = safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
|
|
129
|
+
const hostname = normalizeHostname(safe(() => os.hostname()) || 'unknown-host');
|
|
130
|
+
// Seed the hash with the OS-native id when available; fall back to a stable
|
|
131
|
+
// composite so we still produce a deterministic id on locked-down machines.
|
|
132
|
+
const raw = rawStableId() || `${hostname}|${user}|${os.platform()}|${os.arch()}`;
|
|
133
|
+
const machineId = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
|
|
134
|
+
cached = {
|
|
135
|
+
machineId,
|
|
136
|
+
user,
|
|
137
|
+
hostname,
|
|
138
|
+
developerName: `${user.toLowerCase()}@${hostname}`,
|
|
139
|
+
platform: os.platform(),
|
|
140
|
+
osFriendly: friendlyOs(),
|
|
141
|
+
};
|
|
142
|
+
return cached;
|
|
143
|
+
}
|
|
144
|
+
/** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
|
|
145
|
+
function suggestedShieldName(identity = getMachineIdentity()) {
|
|
146
|
+
return `${identity.developerName} · ${identity.osFriendly}`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-authoritative enforcement mode + policy version, pulled from
|
|
3
|
+
* GET /api/cli/bundle and cached locally so the hot path (the Cursor hook) never
|
|
4
|
+
* blocks on the network for the mode decision.
|
|
5
|
+
*
|
|
6
|
+
* Behavior:
|
|
7
|
+
* - Fresh cache (< TTL): return immediately, no network.
|
|
8
|
+
* - Stale cache: try a SHORT-timeout refresh; on success update + return; on
|
|
9
|
+
* failure fall back to the last cached value (stale-while-error / fail-static).
|
|
10
|
+
* - No cache + network fails: return source 'default' so the caller applies its
|
|
11
|
+
* local fallback (the install-time flag).
|
|
12
|
+
*
|
|
13
|
+
* A console "Enforce" click flips shield.mode server-side; the machine picks it up
|
|
14
|
+
* on the next refresh (no push to the machine required).
|
|
15
|
+
*/
|
|
16
|
+
export type ShieldMode = 'block' | 'monitor' | 'shadow';
|
|
17
|
+
export interface RuntimeBundle {
|
|
18
|
+
mode: ShieldMode;
|
|
19
|
+
version: string;
|
|
20
|
+
policyCount?: number;
|
|
21
|
+
pollIntervalMs?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface FetchBundleInput {
|
|
24
|
+
apiUrl: string;
|
|
25
|
+
shieldId: string;
|
|
26
|
+
shieldKey?: string;
|
|
27
|
+
/** Override cache TTL (ms). */
|
|
28
|
+
ttlMs?: number;
|
|
29
|
+
/** Force a network refresh regardless of cache freshness. */
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface EffectiveBundle extends RuntimeBundle {
|
|
33
|
+
source: 'server' | 'cache' | 'default';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the effective runtime bundle for a Shield, using the local cache when
|
|
37
|
+
* fresh and refreshing (briefly) when stale. Never throws — falls back to cache
|
|
38
|
+
* or a 'default' marker so the caller can apply its local fallback.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getRuntimeBundle(input: FetchBundleInput): Promise<EffectiveBundle>;
|
|
@@ -0,0 +1,109 @@
|
|
|
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.getRuntimeBundle = getRuntimeBundle;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.json');
|
|
41
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
42
|
+
const REFRESH_TIMEOUT_MS = 1_500; // tight: the hook must stay fast
|
|
43
|
+
function readCacheFile() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeCacheFile(data) {
|
|
54
|
+
try {
|
|
55
|
+
fs.writeFileSync(CACHE_PATH, JSON.stringify(data), { encoding: 'utf-8', mode: 0o600 });
|
|
56
|
+
}
|
|
57
|
+
catch { /* cache is best-effort */ }
|
|
58
|
+
}
|
|
59
|
+
function isMode(value) {
|
|
60
|
+
return value === 'block' || value === 'monitor' || value === 'shadow';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the effective runtime bundle for a Shield, using the local cache when
|
|
64
|
+
* fresh and refreshing (briefly) when stale. Never throws — falls back to cache
|
|
65
|
+
* or a 'default' marker so the caller can apply its local fallback.
|
|
66
|
+
*/
|
|
67
|
+
async function getRuntimeBundle(input) {
|
|
68
|
+
const ttl = input.ttlMs ?? DEFAULT_TTL_MS;
|
|
69
|
+
const cache = readCacheFile();
|
|
70
|
+
const cached = cache[input.shieldId];
|
|
71
|
+
const fresh = cached && Date.now() - cached.fetchedAt < ttl;
|
|
72
|
+
if (cached && fresh && !input.force) {
|
|
73
|
+
return { mode: cached.mode, version: cached.version, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
77
|
+
if (input.shieldKey)
|
|
78
|
+
headers['x-shield-key'] = input.shieldKey;
|
|
79
|
+
if (cached?.version)
|
|
80
|
+
headers['If-None-Match'] = cached.version;
|
|
81
|
+
const resp = await fetch(`${input.apiUrl}/api/cli/bundle?shieldId=${encodeURIComponent(input.shieldId)}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
|
|
82
|
+
// 304 Not Modified — cache is still valid; refresh its timestamp.
|
|
83
|
+
if (resp.status === 304 && cached) {
|
|
84
|
+
cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
|
|
85
|
+
writeCacheFile(cache);
|
|
86
|
+
return { mode: cached.mode, version: cached.version, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
|
|
87
|
+
}
|
|
88
|
+
if (resp.ok) {
|
|
89
|
+
const body = await resp.json().catch(() => ({}));
|
|
90
|
+
if (body.success && body.data && isMode(body.data.mode)) {
|
|
91
|
+
const entry = {
|
|
92
|
+
mode: body.data.mode,
|
|
93
|
+
version: body.data.version,
|
|
94
|
+
policyCount: body.data.policyCount,
|
|
95
|
+
pollIntervalMs: body.data.pollIntervalMs,
|
|
96
|
+
fetchedAt: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
cache[input.shieldId] = entry;
|
|
99
|
+
writeCacheFile(cache);
|
|
100
|
+
return { ...entry, source: 'server' };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch { /* fall through to cache / default */ }
|
|
105
|
+
if (cached) {
|
|
106
|
+
return { mode: cached.mode, version: cached.version, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, source: 'cache' };
|
|
107
|
+
}
|
|
108
|
+
return { mode: 'block', version: '', source: 'default' };
|
|
109
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SpoolEvent {
|
|
2
|
+
eventId: string;
|
|
3
|
+
type: 'verdict';
|
|
4
|
+
decision: 'allow' | 'block' | 'approval' | 'mask';
|
|
5
|
+
toolName?: string;
|
|
6
|
+
operation?: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
ruleId?: string;
|
|
9
|
+
/** True when this decision was enforced locally while the backend was unreachable. */
|
|
10
|
+
offlineEnforced?: boolean;
|
|
11
|
+
occurredAt: string;
|
|
12
|
+
}
|
|
13
|
+
/** Append one decision to the spool. Never throws (telemetry must not break the hook). */
|
|
14
|
+
export declare function spoolEvent(event: Omit<SpoolEvent, 'eventId' | 'occurredAt' | 'type'> & Partial<Pick<SpoolEvent, 'occurredAt' | 'type'>>): void;
|
|
15
|
+
export interface FlushInput {
|
|
16
|
+
apiUrl: string;
|
|
17
|
+
shieldId: string;
|
|
18
|
+
shieldKey?: string;
|
|
19
|
+
/** Include a heartbeat in this flush (drives device liveness). */
|
|
20
|
+
heartbeat?: boolean;
|
|
21
|
+
agentVersion?: string;
|
|
22
|
+
/** Whether protection points still look intact (tamper signal). */
|
|
23
|
+
integrityOk?: boolean;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
|
|
27
|
+
export declare function flushSpool(input: FlushInput): Promise<{
|
|
28
|
+
accepted: number;
|
|
29
|
+
deduped: number;
|
|
30
|
+
} | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Opportunistically spawn a DETACHED flusher so the hot path (hook) never blocks
|
|
33
|
+
* on network I/O. Throttled via a marker file. `immediate` bypasses the throttle
|
|
34
|
+
* (used for critical blocks). The child reads creds from ~/.fullcourtdefense.yml.
|
|
35
|
+
*/
|
|
36
|
+
export declare function triggerFlush(immediate?: boolean): void;
|
|
@@ -0,0 +1,170 @@
|
|
|
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.spoolEvent = spoolEvent;
|
|
37
|
+
exports.flushSpool = flushSpool;
|
|
38
|
+
exports.triggerFlush = triggerFlush;
|
|
39
|
+
const child_process_1 = require("child_process");
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const machineIdentity_1 = require("./machineIdentity");
|
|
45
|
+
/**
|
|
46
|
+
* Local-first telemetry: every enforcement decision is appended to an on-disk
|
|
47
|
+
* spool (instant, offline-safe) and flushed to the backend in batches. Critical
|
|
48
|
+
* blocks trigger an immediate (detached) flush; everything else drains on the
|
|
49
|
+
* next opportunistic/heartbeat flush. Decisions enforced while the backend was
|
|
50
|
+
* unreachable are tagged `offlineEnforced` and replayed (idempotent on eventId)
|
|
51
|
+
* when connectivity returns.
|
|
52
|
+
*/
|
|
53
|
+
const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
|
|
54
|
+
const FLUSH_MARKER = path.join(os.homedir(), '.fullcourtdefense-flush.ts');
|
|
55
|
+
const FLUSH_THROTTLE_MS = 30_000; // don't spawn a flusher more than this often
|
|
56
|
+
const MAX_BATCH = 200;
|
|
57
|
+
/** Append one decision to the spool. Never throws (telemetry must not break the hook). */
|
|
58
|
+
function spoolEvent(event) {
|
|
59
|
+
try {
|
|
60
|
+
const full = {
|
|
61
|
+
eventId: crypto.randomUUID(),
|
|
62
|
+
occurredAt: event.occurredAt || new Date().toISOString(),
|
|
63
|
+
type: 'verdict',
|
|
64
|
+
decision: event.decision,
|
|
65
|
+
toolName: event.toolName,
|
|
66
|
+
operation: event.operation,
|
|
67
|
+
reason: event.reason,
|
|
68
|
+
ruleId: event.ruleId,
|
|
69
|
+
offlineEnforced: event.offlineEnforced,
|
|
70
|
+
};
|
|
71
|
+
fs.appendFileSync(SPOOL_PATH, JSON.stringify(full) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
72
|
+
}
|
|
73
|
+
catch { /* best-effort */ }
|
|
74
|
+
}
|
|
75
|
+
function readSpool() {
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs.readFileSync(SPOOL_PATH, 'utf-8');
|
|
78
|
+
return raw.split('\n').map(l => l.trim()).filter(Boolean).map(l => {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(l);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}).filter((e) => !!e && !!e.eventId);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function rewriteSpool(remaining) {
|
|
92
|
+
try {
|
|
93
|
+
if (remaining.length === 0) {
|
|
94
|
+
if (fs.existsSync(SPOOL_PATH))
|
|
95
|
+
fs.unlinkSync(SPOOL_PATH);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
fs.writeFileSync(SPOOL_PATH, remaining.map(e => JSON.stringify(e)).join('\n') + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
99
|
+
}
|
|
100
|
+
catch { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
/** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
|
|
103
|
+
async function flushSpool(input) {
|
|
104
|
+
const all = readSpool();
|
|
105
|
+
const batch = all.slice(0, MAX_BATCH);
|
|
106
|
+
const overflow = all.slice(MAX_BATCH);
|
|
107
|
+
if (batch.length === 0 && !input.heartbeat)
|
|
108
|
+
return null;
|
|
109
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
110
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
111
|
+
if (input.shieldKey)
|
|
112
|
+
headers['x-shield-key'] = input.shieldKey;
|
|
113
|
+
try {
|
|
114
|
+
const resp = await fetch(`${input.apiUrl}/api/cli/events:batch`, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
shieldId: input.shieldId,
|
|
119
|
+
machineId: identity.machineId,
|
|
120
|
+
heartbeat: input.heartbeat
|
|
121
|
+
? { agentVersion: input.agentVersion, integrityOk: input.integrityOk, coverage: 'hooks' }
|
|
122
|
+
: undefined,
|
|
123
|
+
events: batch,
|
|
124
|
+
}),
|
|
125
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 8000),
|
|
126
|
+
});
|
|
127
|
+
if (!resp.ok)
|
|
128
|
+
return null;
|
|
129
|
+
const body = await resp.json().catch(() => ({}));
|
|
130
|
+
if (!body.success)
|
|
131
|
+
return null;
|
|
132
|
+
// Successfully delivered → drop the flushed events, keep any overflow.
|
|
133
|
+
rewriteSpool(overflow);
|
|
134
|
+
return body.data || { accepted: batch.length, deduped: 0 };
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null; // keep the spool intact for the next attempt (offline-safe)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Opportunistically spawn a DETACHED flusher so the hot path (hook) never blocks
|
|
142
|
+
* on network I/O. Throttled via a marker file. `immediate` bypasses the throttle
|
|
143
|
+
* (used for critical blocks). The child reads creds from ~/.fullcourtdefense.yml.
|
|
144
|
+
*/
|
|
145
|
+
function triggerFlush(immediate = false) {
|
|
146
|
+
try {
|
|
147
|
+
if (!immediate) {
|
|
148
|
+
try {
|
|
149
|
+
const last = Number(fs.readFileSync(FLUSH_MARKER, 'utf-8'));
|
|
150
|
+
if (Number.isFinite(last) && Date.now() - last < FLUSH_THROTTLE_MS)
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
catch { /* no marker yet */ }
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
fs.writeFileSync(FLUSH_MARKER, String(Date.now()), 'utf-8');
|
|
157
|
+
}
|
|
158
|
+
catch { /* ignore */ }
|
|
159
|
+
const entry = process.argv[1];
|
|
160
|
+
if (!entry)
|
|
161
|
+
return;
|
|
162
|
+
const child = (0, child_process_1.spawn)(process.execPath, [entry, 'flush-spool', '--heartbeat', 'true'], {
|
|
163
|
+
detached: true,
|
|
164
|
+
stdio: 'ignore',
|
|
165
|
+
windowsHide: true,
|
|
166
|
+
});
|
|
167
|
+
child.unref();
|
|
168
|
+
}
|
|
169
|
+
catch { /* best-effort */ }
|
|
170
|
+
}
|
package/dist/version.json
CHANGED