fullcourtdefense-cli 1.6.13 → 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,7 +38,10 @@ 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");
44
+ const taintLedger_1 = require("./taintLedger");
42
45
  const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
43
46
  /** Append a diagnostic line so we can see exactly what Cursor invoked + the verdict. */
44
47
  function dbg(obj) {
@@ -322,7 +325,11 @@ function machineMetadata() {
322
325
  };
323
326
  }
324
327
  async function hookCommand(args, config) {
325
- 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';
326
333
  const failClosed = args.failClosed === 'true';
327
334
  const timeoutMs = Number(args.timeout) > 0 ? Number(args.timeout) : 8000;
328
335
  const approvalMode = args.approvalMode === 'wait' ? 'wait' : 'block';
@@ -336,6 +343,24 @@ async function hookCommand(args, config) {
336
343
  const shieldId = creds.shieldId;
337
344
  const shieldKey = creds.shieldKey;
338
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);
339
364
  let raw = '';
340
365
  let stdinErr = '';
341
366
  const isTTY = !!process.stdin.isTTY;
@@ -400,6 +425,9 @@ async function hookCommand(args, config) {
400
425
  dbg({ phase: 'prompt_text', textLen: text.length, textPreview: text.slice(0, 300) });
401
426
  if (!text)
402
427
  respond(false);
428
+ // Record the developer's prompt so taint-tracking can later tell whether a
429
+ // sensitive action (network/git) was actually requested by the human.
430
+ (0, taintLedger_1.recordUserPrompt)(sessionId(payload), text);
403
431
  const localBlock = (0, deterministicGuard_1.scanDeterministicPrompt)(text);
404
432
  if (localBlock) {
405
433
  dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
@@ -433,9 +461,31 @@ async function enforceActionPolicy(ctx) {
433
461
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
434
462
  return;
435
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);
436
466
  respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
437
467
  return;
438
468
  }
469
+ // --- Deterministic taint tracking (local, no backend) ---
470
+ // Evaluate the sink against the PRIOR ledger state first, BEFORE this event
471
+ // records its own ingress (so a lone remote pull doesn't self-taint then
472
+ // self-block). Reads / web / MCP output are never blocked here — only a
473
+ // sensitive sink the user never requested, after untrusted ingestion.
474
+ const sessId = sessionId(payload);
475
+ const taintFinding = (0, taintLedger_1.checkTaintedSink)(sessId, event, call.toolName, call.toolArgs);
476
+ if (taintFinding) {
477
+ dbg({ phase: 'local_taint_block', event, tool: call.toolName, ruleId: taintFinding.ruleId, sink: taintFinding.sink.kind, targets: taintFinding.sink.targets });
478
+ if (shadow) {
479
+ respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
480
+ return;
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);
484
+ respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason}`, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry.`);
485
+ return;
486
+ }
487
+ // Record untrusted ingress for this event (never blocks).
488
+ (0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
439
489
  const headers = { 'Content-Type': 'application/json' };
440
490
  if (shieldKey)
441
491
  headers['x-shield-key'] = shieldKey;
@@ -465,6 +515,7 @@ async function enforceActionPolicy(ctx) {
465
515
  clearTimeout(timer);
466
516
  if (!resp.ok) {
467
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 });
468
519
  respond(false, undefined, degradedAllowMessage(event, `Backend returned HTTP ${resp.status}.`));
469
520
  return;
470
521
  }
@@ -478,6 +529,8 @@ async function enforceActionPolicy(ctx) {
478
529
  }
479
530
  catch (err) {
480
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);
481
534
  respond(false, undefined, degradedAllowMessage(event, `Hook error: ${err instanceof Error ? err.message : String(err)}.`));
482
535
  return;
483
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
+ }
@@ -0,0 +1,58 @@
1
+ export type TaintSourceType = 'mcp_output' | 'web_fetch' | 'external_file' | 'remote_pull';
2
+ export interface TaintSource {
3
+ type: TaintSourceType;
4
+ detail: string;
5
+ at: string;
6
+ }
7
+ export interface TaintLedger {
8
+ sessionId: string;
9
+ tainted: boolean;
10
+ sources: TaintSource[];
11
+ userPrompts: string[];
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ }
15
+ export interface TaintSink {
16
+ kind: 'network' | 'git_push' | 'git_remote_add' | 'mcp_outbound';
17
+ /** External hosts/destinations referenced by the action. */
18
+ targets: string[];
19
+ detail: string;
20
+ }
21
+ export interface TaintFinding {
22
+ blocked: true;
23
+ ruleId: string;
24
+ reason: string;
25
+ evidence: string;
26
+ source: TaintSource;
27
+ sink: TaintSink;
28
+ }
29
+ /** Whether taint tracking is enabled (opt-out via env). */
30
+ export declare function taintEnabled(): boolean;
31
+ export declare function loadLedger(sessionId: string): TaintLedger;
32
+ /** Record the developer's prompt text so sink actions can be checked against it. */
33
+ export declare function recordUserPrompt(sessionId: string, text: string): void;
34
+ /** Mark the session as having ingested untrusted content. Reading is never blocked. */
35
+ export declare function markTaint(sessionId: string, source: TaintSource): void;
36
+ type EventKind = 'prompt' | 'shell' | 'mcp' | 'file' | 'read' | 'unknown';
37
+ /** Extract hostnames from any URLs / git/scp targets present in a string. */
38
+ export declare function extractHosts(text: string): string[];
39
+ /**
40
+ * Classify an event as untrusted INGRESS. Returns the source to record, or
41
+ * undefined if this event does not ingest untrusted content.
42
+ */
43
+ export declare function classifyIngress(event: EventKind, toolName: string, toolArgs: Record<string, unknown>, workspacePath?: string): TaintSource | undefined;
44
+ /**
45
+ * Detect whether an event is a SENSITIVE SINK (an action that can exfiltrate or
46
+ * reach attacker-controlled infrastructure). Returns sink info or undefined.
47
+ */
48
+ export declare function detectSink(event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintSink | undefined;
49
+ /**
50
+ * The deterministic 3-rule check, evaluated against the CURRENT ledger state
51
+ * (call this BEFORE recording any ingress for the same event):
52
+ *
53
+ * block IF session.tainted AND is_sensitive_sink AND NOT user_authorized
54
+ */
55
+ export declare function checkTaintedSink(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintFinding | undefined;
56
+ /** Record ingress for an event if applicable. Safe to call on every event. */
57
+ export declare function noteIngress(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>, workspacePath?: string): TaintSource | undefined;
58
+ export {};
@@ -0,0 +1,370 @@
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.taintEnabled = taintEnabled;
37
+ exports.loadLedger = loadLedger;
38
+ exports.recordUserPrompt = recordUserPrompt;
39
+ exports.markTaint = markTaint;
40
+ exports.extractHosts = extractHosts;
41
+ exports.classifyIngress = classifyIngress;
42
+ exports.detectSink = detectSink;
43
+ exports.checkTaintedSink = checkTaintedSink;
44
+ exports.noteIngress = noteIngress;
45
+ const fs = __importStar(require("fs"));
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ /**
49
+ * Deterministic taint tracking for local IDE coding agents.
50
+ *
51
+ * Models indirect prompt injection as an information-flow problem, the same way
52
+ * Microsoft FIDES / Tessera / ShisaD do — but local and zero-backend. The idea:
53
+ *
54
+ * 1. INGRESS — when the agent reads UNTRUSTED content (web fetch, MCP tool
55
+ * output, a file outside the workspace), the session is marked
56
+ * "tainted". Reading is NEVER blocked.
57
+ * 2. SINK — when the agent later performs a SENSITIVE action (network
58
+ * command, git push, new remote) that the USER never asked for,
59
+ * AND the session is tainted, the action is blocked.
60
+ *
61
+ * Trust is "min-trust" (Tessera): one untrusted source drags the whole session
62
+ * to the floor, after which the agent may only do what the human explicitly
63
+ * authorized in their prompt — not what the ingested data suggests.
64
+ *
65
+ * Everything here is pure local logic over a per-session JSON file under
66
+ * ~/.fullcourtdefense/taint/<sessionId>.json. No model, no network call.
67
+ */
68
+ const LEDGER_TTL_MS = 24 * 60 * 60 * 1000; // sessions older than 24h are pruned
69
+ const MAX_PROMPTS = 30;
70
+ const MAX_SOURCES = 50;
71
+ /** Whether taint tracking is enabled (opt-out via env). */
72
+ function taintEnabled() {
73
+ return process.env.FCD_TAINT_DISABLED !== 'true' && process.env.FCD_TAINT_DISABLED !== '1';
74
+ }
75
+ function taintDir() {
76
+ return path.join(os.homedir(), '.fullcourtdefense', 'taint');
77
+ }
78
+ function ledgerPath(sessionId) {
79
+ return path.join(taintDir(), `${safeSessionFile(sessionId)}.json`);
80
+ }
81
+ /** Make a session id safe to use as a filename. */
82
+ function safeSessionFile(sessionId) {
83
+ const cleaned = sessionId.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120);
84
+ return cleaned || 'default-session';
85
+ }
86
+ function nowIso() {
87
+ return new Date().toISOString();
88
+ }
89
+ function emptyLedger(sessionId) {
90
+ const ts = nowIso();
91
+ return { sessionId, tainted: false, sources: [], userPrompts: [], createdAt: ts, updatedAt: ts };
92
+ }
93
+ /** Best-effort prune of stale ledger files so old sessions don't pile up. */
94
+ function pruneStale() {
95
+ try {
96
+ const dir = taintDir();
97
+ if (!fs.existsSync(dir))
98
+ return;
99
+ const cutoff = Date.now() - LEDGER_TTL_MS;
100
+ for (const name of fs.readdirSync(dir)) {
101
+ const file = path.join(dir, name);
102
+ try {
103
+ const stat = fs.statSync(file);
104
+ if (stat.mtimeMs < cutoff)
105
+ fs.unlinkSync(file);
106
+ }
107
+ catch { /* ignore individual file errors */ }
108
+ }
109
+ }
110
+ catch { /* ignore */ }
111
+ }
112
+ function loadLedger(sessionId) {
113
+ const file = ledgerPath(sessionId);
114
+ try {
115
+ if (!fs.existsSync(file))
116
+ return emptyLedger(sessionId);
117
+ const stat = fs.statSync(file);
118
+ if (Date.now() - stat.mtimeMs > LEDGER_TTL_MS)
119
+ return emptyLedger(sessionId);
120
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
121
+ return {
122
+ sessionId,
123
+ tainted: Boolean(parsed.tainted),
124
+ sources: Array.isArray(parsed.sources) ? parsed.sources.slice(-MAX_SOURCES) : [],
125
+ userPrompts: Array.isArray(parsed.userPrompts) ? parsed.userPrompts.slice(-MAX_PROMPTS) : [],
126
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : nowIso(),
127
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : nowIso(),
128
+ };
129
+ }
130
+ catch {
131
+ return emptyLedger(sessionId);
132
+ }
133
+ }
134
+ function saveLedger(ledger) {
135
+ try {
136
+ const dir = taintDir();
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ ledger.updatedAt = nowIso();
139
+ fs.writeFileSync(ledgerPath(ledger.sessionId), JSON.stringify(ledger, null, 2), 'utf8');
140
+ pruneStale();
141
+ }
142
+ catch { /* best effort — never break the hook on disk errors */ }
143
+ }
144
+ /** Record the developer's prompt text so sink actions can be checked against it. */
145
+ function recordUserPrompt(sessionId, text) {
146
+ if (!taintEnabled())
147
+ return;
148
+ const trimmed = (text || '').trim();
149
+ if (!trimmed)
150
+ return;
151
+ const ledger = loadLedger(sessionId);
152
+ ledger.userPrompts.push(trimmed.slice(0, 4000));
153
+ if (ledger.userPrompts.length > MAX_PROMPTS)
154
+ ledger.userPrompts = ledger.userPrompts.slice(-MAX_PROMPTS);
155
+ saveLedger(ledger);
156
+ }
157
+ /** Mark the session as having ingested untrusted content. Reading is never blocked. */
158
+ function markTaint(sessionId, source) {
159
+ if (!taintEnabled())
160
+ return;
161
+ const ledger = loadLedger(sessionId);
162
+ ledger.tainted = true;
163
+ ledger.sources.push(source);
164
+ if (ledger.sources.length > MAX_SOURCES)
165
+ ledger.sources = ledger.sources.slice(-MAX_SOURCES);
166
+ saveLedger(ledger);
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Ingress + sink classification
170
+ // ---------------------------------------------------------------------------
171
+ const WEB_MCP_TOOL_HINT = /(?:web|fetch|browse|browser|http|url|scrape|crawl|search|google|bing|read[_-]?page|open[_-]?url|wikipedia|news)/i;
172
+ const NETWORK_CMD_HINT = /\b(?:curl|wget|nc|ncat|netcat|telnet|ftp|sftp|scp|ssh|socat|http|httpie|invoke-webrequest|invoke-restmethod|iwr|irm)\b/i;
173
+ const REMOTE_PULL_HINT = /\b(?:curl|wget|git\s+clone|git\s+fetch|git\s+pull|svn\s+checkout|npx|pip\s+install|npm\s+install)\b/i;
174
+ /** Pull every string value out of an args object (shallow-ish, bounded). */
175
+ function collectStringValues(value, out = [], depth = 0) {
176
+ if (depth > 5 || value === null || value === undefined)
177
+ return out;
178
+ if (typeof value === 'string') {
179
+ if (value.trim())
180
+ out.push(value);
181
+ return out;
182
+ }
183
+ if (typeof value === 'number' || typeof value === 'boolean') {
184
+ out.push(String(value));
185
+ return out;
186
+ }
187
+ if (Array.isArray(value)) {
188
+ for (const v of value.slice(0, 40))
189
+ collectStringValues(v, out, depth + 1);
190
+ return out;
191
+ }
192
+ if (typeof value === 'object') {
193
+ for (const v of Object.values(value).slice(0, 60))
194
+ collectStringValues(v, out, depth + 1);
195
+ }
196
+ return out;
197
+ }
198
+ /** Extract hostnames from any URLs / git/scp targets present in a string. */
199
+ function extractHosts(text) {
200
+ const hosts = new Set();
201
+ if (!text)
202
+ return [];
203
+ const urlRe = /\bhttps?:\/\/([^/\s'"`)]+)/gi;
204
+ let m;
205
+ while ((m = urlRe.exec(text)) !== null) {
206
+ const host = m[1].replace(/^.*@/, '').replace(/:.*$/, '').toLowerCase();
207
+ if (host)
208
+ hosts.add(host);
209
+ }
210
+ // git@host:path and scp user@host:path
211
+ const sshRe = /\b[\w.-]+@([\w.-]+):/g;
212
+ while ((m = sshRe.exec(text)) !== null) {
213
+ const host = m[1].toLowerCase();
214
+ if (host)
215
+ hosts.add(host);
216
+ }
217
+ return [...hosts].filter(h => !isLocalHost(h));
218
+ }
219
+ function isLocalHost(host) {
220
+ return host === 'localhost'
221
+ || host === '127.0.0.1'
222
+ || host === '::1'
223
+ || host === '0.0.0.0'
224
+ || host.endsWith('.local');
225
+ }
226
+ /** Strip a host to its registrable-ish tail (last two labels) for loose matching. */
227
+ function registrableDomain(host) {
228
+ const parts = host.split('.').filter(Boolean);
229
+ if (parts.length <= 2)
230
+ return host;
231
+ return parts.slice(-2).join('.');
232
+ }
233
+ function workspaceRoot(workspacePath) {
234
+ return workspacePath || process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd();
235
+ }
236
+ function isExternalFile(filePath, workspacePath) {
237
+ try {
238
+ const root = path.resolve(workspaceRoot(workspacePath));
239
+ const resolved = path.resolve(root, filePath);
240
+ const rel = path.relative(root, resolved);
241
+ return rel === '' ? false : (rel.startsWith('..') || path.isAbsolute(rel));
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }
247
+ /**
248
+ * Classify an event as untrusted INGRESS. Returns the source to record, or
249
+ * undefined if this event does not ingest untrusted content.
250
+ */
251
+ function classifyIngress(event, toolName, toolArgs, workspacePath) {
252
+ const at = nowIso();
253
+ if (event === 'read') {
254
+ const p = typeof toolArgs.path === 'string' ? toolArgs.path : '';
255
+ if (p && isExternalFile(p, workspacePath)) {
256
+ return { type: 'external_file', detail: p.slice(0, 200), at };
257
+ }
258
+ return undefined;
259
+ }
260
+ if (event === 'mcp') {
261
+ if (WEB_MCP_TOOL_HINT.test(toolName)) {
262
+ const strings = collectStringValues(toolArgs);
263
+ const hosts = strings.flatMap(extractHosts);
264
+ const detail = hosts.length ? `${toolName} -> ${hosts.join(', ')}` : toolName;
265
+ return { type: 'web_fetch', detail: detail.slice(0, 200), at };
266
+ }
267
+ return undefined;
268
+ }
269
+ if (event === 'shell') {
270
+ const command = typeof toolArgs.command === 'string' ? toolArgs.command : collectStringValues(toolArgs).join(' ');
271
+ if (REMOTE_PULL_HINT.test(command) && extractHosts(command).length > 0) {
272
+ return { type: 'remote_pull', detail: command.slice(0, 200), at };
273
+ }
274
+ return undefined;
275
+ }
276
+ return undefined;
277
+ }
278
+ /**
279
+ * Detect whether an event is a SENSITIVE SINK (an action that can exfiltrate or
280
+ * reach attacker-controlled infrastructure). Returns sink info or undefined.
281
+ */
282
+ function detectSink(event, toolName, toolArgs) {
283
+ if (event === 'shell') {
284
+ const command = typeof toolArgs.command === 'string' ? toolArgs.command : collectStringValues(toolArgs).join(' ');
285
+ if (/\bgit\s+remote\s+add\b/i.test(command)) {
286
+ return { kind: 'git_remote_add', targets: extractHosts(command), detail: command.slice(0, 200) };
287
+ }
288
+ if (/\bgit\s+push\b/i.test(command) && extractHosts(command).length > 0) {
289
+ return { kind: 'git_push', targets: extractHosts(command), detail: command.slice(0, 200) };
290
+ }
291
+ if (NETWORK_CMD_HINT.test(command)) {
292
+ const targets = extractHosts(command);
293
+ if (targets.length > 0)
294
+ return { kind: 'network', targets, detail: command.slice(0, 200) };
295
+ }
296
+ return undefined;
297
+ }
298
+ if (event === 'mcp') {
299
+ const outboundTool = /(?:http|fetch|request|curl|wget|webhook|upload|send|email|mail|slack|discord|teams|post|put|publish|notify|sms|message)/i.test(toolName);
300
+ if (!outboundTool)
301
+ return undefined;
302
+ const strings = collectStringValues(toolArgs);
303
+ const targets = strings.flatMap(extractHosts);
304
+ if (targets.length > 0)
305
+ return { kind: 'mcp_outbound', targets, detail: `${toolName} -> ${targets.join(', ')}`.slice(0, 200) };
306
+ return undefined;
307
+ }
308
+ return undefined;
309
+ }
310
+ /** Did the developer's prompt authorize reaching this target host? */
311
+ function authorizedByPrompt(ledger, targets) {
312
+ if (targets.length === 0)
313
+ return true; // no external target to authorize
314
+ const haystack = ledger.userPrompts.join('\n').toLowerCase();
315
+ if (!haystack)
316
+ return false;
317
+ // Authorized only if EVERY external target the action contacts was mentioned
318
+ // by the user (host or its registrable domain appears in some prompt).
319
+ return targets.every(host => {
320
+ const h = host.toLowerCase();
321
+ return haystack.includes(h) || haystack.includes(registrableDomain(h));
322
+ });
323
+ }
324
+ /**
325
+ * The deterministic 3-rule check, evaluated against the CURRENT ledger state
326
+ * (call this BEFORE recording any ingress for the same event):
327
+ *
328
+ * block IF session.tainted AND is_sensitive_sink AND NOT user_authorized
329
+ */
330
+ function checkTaintedSink(sessionId, event, toolName, toolArgs) {
331
+ if (!taintEnabled())
332
+ return undefined;
333
+ const sink = detectSink(event, toolName, toolArgs);
334
+ if (!sink)
335
+ return undefined;
336
+ const ledger = loadLedger(sessionId);
337
+ if (!ledger.tainted)
338
+ return undefined;
339
+ if (authorizedByPrompt(ledger, sink.targets))
340
+ return undefined;
341
+ const source = ledger.sources[ledger.sources.length - 1] || { type: 'mcp_output', detail: 'untrusted content', at: nowIso() };
342
+ const targetText = sink.targets.join(', ') || 'an external destination';
343
+ return {
344
+ blocked: true,
345
+ ruleId: 'local-taint-unrequested-sink',
346
+ reason: `Blocked ${describeSink(sink)} to ${targetText} that you did not request, after the agent `
347
+ + `ingested untrusted content (${source.type}: ${source.detail}). Likely indirect prompt injection.`,
348
+ evidence: sink.detail,
349
+ source,
350
+ sink,
351
+ };
352
+ }
353
+ function describeSink(sink) {
354
+ switch (sink.kind) {
355
+ case 'git_push': return 'a git push';
356
+ case 'git_remote_add': return 'a new git remote';
357
+ case 'mcp_outbound': return 'an outbound MCP action';
358
+ case 'network':
359
+ default: return 'a network command';
360
+ }
361
+ }
362
+ /** Record ingress for an event if applicable. Safe to call on every event. */
363
+ function noteIngress(sessionId, event, toolName, toolArgs, workspacePath) {
364
+ if (!taintEnabled())
365
+ return undefined;
366
+ const source = classifyIngress(event, toolName, toolArgs, workspacePath);
367
+ if (source)
368
+ markTaint(sessionId, source);
369
+ return source;
370
+ }
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.13"
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.13",
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": {
@@ -15,6 +15,7 @@
15
15
  "scripts": {
16
16
  "build": "tsc && node scripts/copy-attack-corpus.js",
17
17
  "test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
18
+ "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
18
19
  "prepublishOnly": "npm run build"
19
20
  },
20
21
  "keywords": [