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.
@@ -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
+ }
@@ -25,6 +25,7 @@ export interface HookArgs {
25
25
  shieldId?: string;
26
26
  shieldKey?: string;
27
27
  shadow?: string;
28
+ enforce?: string;
28
29
  failClosed?: string;
29
30
  timeout?: string;
30
31
  approvalMode?: string;
@@ -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
- const shadow = args.shadow === 'true';
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
- events: 'prompt,shell,mcp',
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.6.14"
2
+ "version": "1.7.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.6.14",
3
+ "version": "1.7.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {