fullcourtdefense-cli 1.7.23 → 1.7.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,6 +108,90 @@ function inferEvent(explicit, payload) {
108
108
  return 'unknown';
109
109
  }
110
110
  const str = (v) => (typeof v === 'string' ? v : '');
111
+ /**
112
+ * Claude-format hooks (Claude Code, VS Code Copilot agent mode, GitHub Copilot
113
+ * CLI — all share the same schema) send `hook_event_name` + `tool_name` +
114
+ * `tool_input` on stdin. Normalize that payload into the field shapes the rest
115
+ * of this file already understands (command/file_path/tool_name/tool_input),
116
+ * and map the tool onto our event taxonomy.
117
+ *
118
+ * Returns null when the payload is not Claude-format.
119
+ */
120
+ function normalizeClaudePayload(payload) {
121
+ const hookEventName = str(payload.hook_event_name);
122
+ if (!hookEventName)
123
+ return null;
124
+ if (hookEventName === 'UserPromptSubmit') {
125
+ return { event: 'prompt', payload };
126
+ }
127
+ if (hookEventName !== 'PreToolUse') {
128
+ // PostToolUse / Stop / SessionStart etc. — nothing to enforce pre-execution.
129
+ return { event: 'ignore', payload };
130
+ }
131
+ const toolName = str(payload.tool_name);
132
+ let toolInput = payload.tool_input;
133
+ if (typeof toolInput === 'string') {
134
+ const trimmed = toolInput.trim();
135
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
136
+ try {
137
+ toolInput = JSON.parse(trimmed);
138
+ }
139
+ catch { /* keep raw */ }
140
+ }
141
+ }
142
+ const input = (toolInput && typeof toolInput === 'object' ? toolInput : {});
143
+ // MCP tools arrive as `mcp__<server>__<tool>` (same in Claude Code and VS Code).
144
+ if (toolName.startsWith('mcp__')) {
145
+ const parts = toolName.split('__');
146
+ const server = parts[1] || 'mcp';
147
+ const shortTool = parts.slice(2).join('__') || toolName;
148
+ return {
149
+ event: 'mcp',
150
+ payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
151
+ };
152
+ }
153
+ // Claude Code, VS Code Copilot, and Copilot CLI each use their own tool names
154
+ // for the same operations — compare case/underscore-insensitively.
155
+ const canon = toolName.toLowerCase().replace(/[_-]/g, '');
156
+ const SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal']);
157
+ const READ_TOOLS = new Set(['read', 'readfile', 'view']);
158
+ const WRITE_TOOLS = new Set([
159
+ 'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles',
160
+ 'replacestringinfile', 'editfile', 'applypatch', 'strreplaceeditor', 'insertedittofile',
161
+ ]);
162
+ if (SHELL_TOOLS.has(canon)) {
163
+ return {
164
+ event: 'shell',
165
+ payload: { ...payload, command: str(input.command), cwd: str(input.cwd) || str(payload.cwd) || undefined },
166
+ };
167
+ }
168
+ if (READ_TOOLS.has(canon)) {
169
+ return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.path) } };
170
+ }
171
+ if (WRITE_TOOLS.has(canon)) {
172
+ const content = str(input.content) || str(input.new_string) || str(input.new_source) || str(input.code)
173
+ || (Array.isArray(input.edits)
174
+ ? input.edits.map((e) => str(e?.new_string) || str(e?.content)).filter(Boolean).join('\n')
175
+ : '');
176
+ const filePath = str(input.file_path) || str(input.notebook_path) || str(input.path) || str(input.filePath)
177
+ || (Array.isArray(input.files) ? input.files.map((f) => str(f)).filter(Boolean).join(', ') : '');
178
+ return { event: 'file', payload: { ...payload, file_path: filePath, content } };
179
+ }
180
+ // Glob/Grep/Task/WebFetch/etc. — no enforcement surface; let the client's own
181
+ // permission flow handle them.
182
+ return { event: 'ignore', payload };
183
+ }
184
+ /**
185
+ * Which AI client invoked a Claude-format hook. Claude Code sets CLAUDECODE=1;
186
+ * VS Code Copilot runs hooks inside the VS Code process environment.
187
+ */
188
+ function claudeFormatClient() {
189
+ if (process.env.CLAUDECODE || process.env.CLAUDE_PROJECT_DIR)
190
+ return 'claude-code';
191
+ if (process.env.VSCODE_PID || process.env.TERM_PROGRAM === 'vscode')
192
+ return 'vscode';
193
+ return 'claude-code';
194
+ }
111
195
  function collectPromptStrings(value, out = [], depth = 0) {
112
196
  if (depth > 5 || value === null || value === undefined)
113
197
  return out;
@@ -240,6 +324,22 @@ function safeJson(v) {
240
324
  function degradedAllowMessage(event, detail) {
241
325
  return `FullCourtDefense policy gate unavailable; allowed ${event} in degraded mode. ${detail}`.trim();
242
326
  }
327
+ /**
328
+ * The policy gate could not be reached (or errored). Apply the machine's
329
+ * configured offline stance: fail-closed blocks the action, fail-open allows it
330
+ * in degraded mode. Either way the decision is spooled (`offlineEnforced`) and
331
+ * replayed to the backend when connectivity returns, so nothing is invisible.
332
+ */
333
+ function respondDegraded(ctx, detail, toolName) {
334
+ if (ctx.failClosed && !ctx.shadow) {
335
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, reason: `fail-closed: ${detail}`, offlineEnforced: true });
336
+ (0, telemetry_1.triggerFlush)(true);
337
+ ctx.respond(true, `Blocked by FullCourtDefense — policy service unreachable and this machine is set to fail-closed. ${detail}`, `FullCourtDefense could not verify this ${ctx.event} against your org policies (${detail}) and fail-closed mode is on. Do not retry until the connection is restored.`);
338
+ }
339
+ (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, reason: `degraded: ${detail}`, offlineEnforced: true });
340
+ (0, telemetry_1.triggerFlush)(false);
341
+ ctx.respond(false, undefined, degradedAllowMessage(ctx.event, detail));
342
+ }
243
343
  /** Per-event attribution that maps onto the existing Shield runtime headers (prompt path). */
244
344
  function attributionFor(event, p) {
245
345
  const h = {};
@@ -293,14 +393,18 @@ function mcpServerName(p) {
293
393
  * `perServerAgentName`, so ONE per-agent policy matches the same server across every runtime.
294
394
  * The client is still reported separately as `agentClient` metadata for optional per-runtime
295
395
  * constraints. Non-MCP events keep the collapsed per-developer IDE-runtime identity.
396
+ *
397
+ * The IDE-runtime identity is MACHINE-FREE (`<client>-<username>`, e.g. `cursor-boazl`):
398
+ * safeIdentityPart strips the `@hostname` part, so the same user's IDE on two laptops is
399
+ * ONE agent. Machine attribution stays in the separate `machineName` metadata field.
296
400
  */
297
- function agentNameForCall(event, p) {
401
+ function agentNameForCall(event, p, client = 'cursor') {
298
402
  if (event === 'mcp') {
299
403
  const server = mcpServerName(p);
300
404
  if (server)
301
405
  return `${safeIdentityPart(developerId())}-${safeIdentityPart(server)}`;
302
406
  }
303
- return `cursor-${developerId()}`;
407
+ return `${client}-${safeIdentityPart(developerId())}`;
304
408
  }
305
409
  /** Cursor conversation/session id when present, else a stable per-machine id. */
306
410
  function sessionId(p) {
@@ -309,7 +413,7 @@ function sessionId(p) {
309
413
  return fromPayload;
310
414
  return `cursor-${os.hostname()}`;
311
415
  }
312
- function machineMetadata() {
416
+ function machineMetadata(client = 'cursor') {
313
417
  let username = 'unknown';
314
418
  try {
315
419
  username = os.userInfo().username;
@@ -322,7 +426,7 @@ function machineMetadata() {
322
426
  username,
323
427
  cwd: process.cwd(),
324
428
  workspacePath: process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd(),
325
- agentClient: 'cursor',
429
+ agentClient: client,
326
430
  };
327
431
  }
328
432
  function spoolLocalFinding(input) {
@@ -405,28 +509,79 @@ async function hookCommand(args, config) {
405
509
  payload = {};
406
510
  }
407
511
  }
408
- const event = inferEvent(args.event, payload);
409
- dbg({ phase: 'invoke', event, argEvent: args.event, pid: process.pid,
512
+ // Claude-format hooks (Claude Code / VS Code Copilot / Copilot CLI) are
513
+ // detected from the payload itself, so ONE installed command serves every
514
+ // client that reads ~/.claude/settings.json.
515
+ const claudeNormalized = normalizeClaudePayload(payload);
516
+ const hookFormat = claudeNormalized ? 'claude' : 'cursor';
517
+ const client = claudeNormalized ? claudeFormatClient() : 'cursor';
518
+ let event;
519
+ let ignoreEvent = false;
520
+ if (claudeNormalized) {
521
+ payload = claudeNormalized.payload;
522
+ if (claudeNormalized.event === 'ignore') {
523
+ event = 'unknown';
524
+ ignoreEvent = true;
525
+ }
526
+ else {
527
+ event = claudeNormalized.event;
528
+ }
529
+ }
530
+ else {
531
+ event = inferEvent(args.event, payload);
532
+ }
533
+ dbg({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
410
534
  isTTY, rawLen: raw.length, rawPreview: raw.slice(0, 300), stdinErr, parseErr,
411
535
  argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
412
- // Emit the verdict in the shape Cursor expects for THIS event, then exit.
413
- // beforeSubmitPrompt blocks via { continue: false }; the execution hooks
414
- // (shell/mcp/read) block via { permission: "deny" }.
536
+ // Emit the verdict in the shape THIS client expects, then exit.
537
+ // Cursor: beforeSubmitPrompt blocks via { continue: false }; execution hooks
538
+ // block via { permission: "deny" }.
539
+ // Claude format (Claude Code / VS Code / Copilot CLI):
540
+ // UserPromptSubmit blocks via { decision: "block" }; PreToolUse blocks
541
+ // via hookSpecificOutput.permissionDecision "deny". On allow we emit
542
+ // NO permission decision so the client's own approval flow still runs
543
+ // (an explicit "allow" would silently bypass its permission prompts).
415
544
  const respond = (blocked, userMsg, agentMsg) => {
416
- const decision = event === 'prompt'
417
- ? (blocked ? { continue: false } : { continue: true })
418
- : { permission: blocked ? 'deny' : 'allow' };
419
- if (userMsg)
420
- decision.user_message = userMsg;
421
- if (agentMsg)
422
- decision.agent_message = agentMsg;
423
- dbg({ phase: 'verdict', event, blocked, decision });
545
+ let decision;
546
+ if (hookFormat === 'claude') {
547
+ if (event === 'prompt') {
548
+ decision = blocked ? { decision: 'block', reason: userMsg || agentMsg || 'Blocked by FullCourtDefense.' } : {};
549
+ }
550
+ else if (blocked) {
551
+ decision = {
552
+ hookSpecificOutput: {
553
+ hookEventName: 'PreToolUse',
554
+ permissionDecision: 'deny',
555
+ permissionDecisionReason: userMsg || agentMsg || 'Blocked by FullCourtDefense.',
556
+ },
557
+ };
558
+ }
559
+ else {
560
+ decision = {};
561
+ }
562
+ if (!blocked && agentMsg)
563
+ decision.systemMessage = agentMsg;
564
+ }
565
+ else {
566
+ decision = event === 'prompt'
567
+ ? (blocked ? { continue: false } : { continue: true })
568
+ : { permission: blocked ? 'deny' : 'allow' };
569
+ if (userMsg)
570
+ decision.user_message = userMsg;
571
+ if (agentMsg)
572
+ decision.agent_message = agentMsg;
573
+ }
574
+ dbg({ phase: 'verdict', event, format: hookFormat, blocked, decision });
424
575
  process.stdout.write(JSON.stringify(decision));
425
- // ALWAYS exit 0 so Cursor uses our JSON verdict. Exit 2 is interpreted as
576
+ // ALWAYS exit 0 so the client uses our JSON verdict. Exit 2 is interpreted as
426
577
  // `permission: "deny"`, which beforeSubmitPrompt ignores (it uses `continue`),
427
578
  // so exiting 2 would silently let blocked prompts through.
428
579
  process.exit(0);
429
580
  };
581
+ // Claude-format lifecycle events with no pre-execution surface (PostToolUse,
582
+ // SessionStart, ...) and tools we don't police (Glob/Grep/Task/...) — no opinion.
583
+ if (ignoreEvent)
584
+ respond(false);
430
585
  // No Shield configured → allow (fail-open by design: a misconfigured machine
431
586
  // must not brick the developer's IDE).
432
587
  if (!shieldId) {
@@ -437,7 +592,7 @@ async function hookCommand(args, config) {
437
592
  await enforceActionPolicy({
438
593
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
439
594
  shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond,
440
- effectivePolicyHash,
595
+ effectivePolicyHash, client,
441
596
  });
442
597
  return;
443
598
  }
@@ -472,7 +627,7 @@ async function hookCommand(args, config) {
472
627
  }
473
628
  await enforceShieldText({
474
629
  event, text, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
475
- shadow, failClosed, timeoutMs, respond,
630
+ shadow, failClosed, timeoutMs, respond, client,
476
631
  });
477
632
  return;
478
633
  }
@@ -544,37 +699,34 @@ async function enforceActionPolicy(ctx) {
544
699
  // For MCP calls this mirrors the gateway's runtime-agnostic per-server identity
545
700
  // (`<developer>-<server>`), so ONE per-agent policy matches the same server on the
546
701
  // gateway path AND this in-IDE hook path, across every client/runtime.
547
- agentName: agentNameForCall(event, payload),
702
+ agentName: agentNameForCall(event, payload, ctx.client),
548
703
  toolName: call.toolName,
549
704
  toolArgs: call.toolArgs,
550
705
  argsSummary: call.toolArgs,
551
706
  sessionId: sessionId(payload),
552
707
  source: 'cursor_hook',
553
708
  ...(event === 'mcp' && mcpServerName(payload) ? { mcpServer: mcpServerName(payload) } : {}),
554
- ...machineMetadata(),
709
+ ...machineMetadata(ctx.client),
555
710
  }),
556
711
  signal: controller.signal,
557
712
  });
558
713
  clearTimeout(timer);
559
714
  if (!resp.ok) {
560
- dbg({ phase: 'policy_http_error', event, status: resp.status });
561
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `degraded: backend HTTP ${resp.status}`, offlineEnforced: true });
562
- respond(false, undefined, degradedAllowMessage(event, `Backend returned HTTP ${resp.status}.`));
715
+ dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
716
+ respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, call.toolName);
563
717
  return;
564
718
  }
565
719
  const body = await resp.json().catch(() => ({}));
566
720
  if (!body.success || !body.data) {
567
- dbg({ phase: 'policy_no_data', event, error: body.error });
568
- respond(false, undefined, degradedAllowMessage(event, body.error ? `Backend error: ${body.error}` : 'Backend returned no policy data.'));
721
+ dbg({ phase: 'policy_no_data', event, error: body.error, failClosed: ctx.failClosed });
722
+ respondDegraded(ctx, body.error ? `Backend error: ${body.error}` : 'Backend returned no policy data.', call.toolName);
569
723
  return;
570
724
  }
571
725
  result = body.data;
572
726
  }
573
727
  catch (err) {
574
- dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err) });
575
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `degraded: ${err instanceof Error ? err.message : String(err)}`, offlineEnforced: true });
576
- (0, telemetry_1.triggerFlush)(false);
577
- respond(false, undefined, degradedAllowMessage(event, `Hook error: ${err instanceof Error ? err.message : String(err)}.`));
728
+ dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err), failClosed: ctx.failClosed });
729
+ respondDegraded(ctx, `Hook error: ${err instanceof Error ? err.message : String(err)}.`, call.toolName);
578
730
  return;
579
731
  }
580
732
  const reason = result.actionPolicy?.reason
@@ -646,7 +798,7 @@ async function waitForApproval(input) {
646
798
  return { status: 'timeout', message: 'approval timed out — still waiting for human review.' };
647
799
  }
648
800
  async function enforceShieldText(ctx) {
649
- const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, failClosed, timeoutMs, respond } = ctx;
801
+ const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond } = ctx;
650
802
  try {
651
803
  const controller = new AbortController();
652
804
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -667,7 +819,7 @@ async function enforceShieldText(ctx) {
667
819
  });
668
820
  clearTimeout(timer);
669
821
  if (!resp.ok) {
670
- respond(false, undefined, degradedAllowMessage(event, `Backend returned HTTP ${resp.status}.`));
822
+ respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, 'prompt');
671
823
  return;
672
824
  }
673
825
  const data = await resp.json().catch(() => ({}));
@@ -683,6 +835,6 @@ async function enforceShieldText(ctx) {
683
835
  respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
684
836
  }
685
837
  catch (err) {
686
- respond(false, undefined, degradedAllowMessage(event, `Hook error: ${err instanceof Error ? err.message : String(err)}.`));
838
+ respondDegraded(ctx, `Hook error: ${err instanceof Error ? err.message : String(err)}.`, 'prompt');
687
839
  }
688
840
  }
@@ -6,6 +6,8 @@ export interface InstallAllArgs extends ProtectAllArgs {
6
6
  upload?: string;
7
7
  hooks?: string;
8
8
  discover?: string;
9
+ schedule?: string;
10
+ scheduleHour?: string;
9
11
  autoProtect?: string;
10
12
  intervalMinutes?: string;
11
13
  }
@@ -4,6 +4,8 @@ exports.installAllCommand = installAllCommand;
4
4
  const config_1 = require("../config");
5
5
  const discover_1 = require("./discover");
6
6
  const installCursorHook_1 = require("./installCursorHook");
7
+ const installClaudeHook_1 = require("./installClaudeHook");
8
+ const discoverSchedule_1 = require("./discoverSchedule");
7
9
  const mcpGateway_1 = require("./mcpGateway");
8
10
  const autoProtect_1 = require("./autoProtect");
9
11
  const restartNotice_1 = require("./restartNotice");
@@ -45,6 +47,23 @@ async function installAllCommand(args, config) {
45
47
  catch (error) {
46
48
  console.log(`Cursor hooks skipped: ${error instanceof Error ? error.message : String(error)}`);
47
49
  }
50
+ // ONE Claude-format hook file (~/.claude/settings.json) covers Claude Code,
51
+ // VS Code Copilot agent mode, and GitHub Copilot CLI.
52
+ console.log('\n\x1b[1mInstalling Claude Code / VS Code / Copilot CLI runtime hooks…\x1b[0m');
53
+ const claudeHookArgs = {
54
+ shieldId: creds.shieldId,
55
+ shieldKey: creds.shieldKey,
56
+ apiUrl: creds.apiUrl,
57
+ // Tool actions only by default — same scope as the Cursor hooks. Prompt
58
+ // scanning stays opt-in (`install-claude-hook --events tools,prompt`).
59
+ events: 'tools',
60
+ };
61
+ try {
62
+ await (0, installClaudeHook_1.installClaudeHookCommand)(claudeHookArgs, config);
63
+ }
64
+ catch (error) {
65
+ console.log(`Claude-format hooks skipped: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
48
67
  }
49
68
  if (args.autoProtect === 'true') {
50
69
  console.log('\n\x1b[1mScheduling self-healing MCP protection…\x1b[0m');
@@ -70,6 +89,26 @@ async function installAllCommand(args, config) {
70
89
  };
71
90
  await (0, discover_1.discoverCommand)(discoverArgs, config);
72
91
  }
92
+ // Keep the fleet view fresh without any manual step: register a recurring
93
+ // discovery run (posture + inventory upload) once a day. Opt out with
94
+ // `--schedule false`.
95
+ if (args.schedule !== 'false') {
96
+ console.log('\n\x1b[1mScheduling daily desktop discovery…\x1b[0m');
97
+ try {
98
+ const hour = args.scheduleHour ? Number.parseInt(args.scheduleHour, 10) : 9;
99
+ (0, discoverSchedule_1.installDailyDiscoverSchedule)({
100
+ userEmail: args.developerName,
101
+ hour: Number.isFinite(hour) ? hour : 9,
102
+ surface: 'all',
103
+ trigger: 'daily',
104
+ });
105
+ console.log(`Desktop discovery scheduled daily at ${String(Number.isFinite(hour) ? hour : 9).padStart(2, '0')}:00 (posture + inventory upload).`);
106
+ }
107
+ catch (error) {
108
+ console.log(`Daily discovery schedule skipped: ${error instanceof Error ? error.message : String(error)}`);
109
+ console.log('You can retry with: fullcourtdefense discover --schedule daily');
110
+ }
111
+ }
73
112
  console.log('\n\x1b[32mDone.\x1b[0m Run \x1b[1mfullcourtdefense protect-all --dry-run true\x1b[0m to verify gateways.');
74
113
  await (0, restartNotice_1.promptRestartNotice)();
75
114
  }
@@ -0,0 +1,27 @@
1
+ import { BotGuardConfig } from '../config';
2
+ /**
3
+ * `fullcourtdefense install-claude-hook` — Claude-format hook installer.
4
+ *
5
+ * Writes PreToolUse + UserPromptSubmit hooks into ~/.claude/settings.json.
6
+ * That ONE file is read by THREE clients:
7
+ * - Claude Code (CLI + desktop)
8
+ * - VS Code Copilot agent mode (user-scope Claude-format hooks)
9
+ * - GitHub Copilot CLI
10
+ *
11
+ * The hook command is the same `fullcourtdefense hook` bridge used for Cursor —
12
+ * it detects the Claude payload format from stdin (`hook_event_name`), so no
13
+ * per-client configuration is needed. VS Code ignores `matcher` values (hooks
14
+ * run on every tool), so the bridge self-filters by tool name and returns an
15
+ * empty verdict for tools we don't police.
16
+ */
17
+ export interface InstallClaudeHookArgs {
18
+ project?: string;
19
+ shieldId?: string;
20
+ shieldKey?: string;
21
+ apiUrl?: string;
22
+ shadow?: string;
23
+ failClosed?: string;
24
+ events?: string;
25
+ }
26
+ export declare function installClaudeHookCommand(args: InstallClaudeHookArgs, config: BotGuardConfig): Promise<void>;
27
+ export declare function uninstallClaudeHookCommand(args: InstallClaudeHookArgs): Promise<void>;
@@ -0,0 +1,201 @@
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.installClaudeHookCommand = installClaudeHookCommand;
37
+ exports.uninstallClaudeHookCommand = uninstallClaudeHookCommand;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const config_1 = require("../config");
42
+ const restartNotice_1 = require("./restartNotice");
43
+ const COLOR = {
44
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
45
+ red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
46
+ };
47
+ // Stable marker embedded in every command we write, so uninstall/re-install can
48
+ // find our entries regardless of the install path (npm global vs local dev).
49
+ const MANAGED_MARKER = '--fcd-managed true';
50
+ const MANAGED_TAG = 'fullcourtdefense';
51
+ function settingsPath(projectScope) {
52
+ return projectScope
53
+ ? path.join(process.cwd(), '.claude', 'settings.json')
54
+ : path.join(os.homedir(), '.claude', 'settings.json');
55
+ }
56
+ function readSettings(file) {
57
+ if (!fs.existsSync(file))
58
+ return {};
59
+ try {
60
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
61
+ return parsed && typeof parsed === 'object' ? parsed : {};
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
67
+ function isManagedCommand(cmd) {
68
+ return typeof cmd?.command === 'string'
69
+ && (cmd.command.includes(MANAGED_MARKER) || cmd.command.includes(MANAGED_TAG));
70
+ }
71
+ /** Remove FullCourtDefense-managed commands from every event's matcher entries. */
72
+ function stripManaged(hooks) {
73
+ let removed = 0;
74
+ for (const eventName of Object.keys(hooks)) {
75
+ const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
76
+ const kept = [];
77
+ for (const entry of entries) {
78
+ const cmds = Array.isArray(entry?.hooks) ? entry.hooks : [];
79
+ const keptCmds = cmds.filter((c) => !isManagedCommand(c));
80
+ removed += cmds.length - keptCmds.length;
81
+ if (keptCmds.length > 0 || cmds.length === 0) {
82
+ kept.push({ ...entry, hooks: keptCmds });
83
+ }
84
+ }
85
+ if (kept.length)
86
+ hooks[eventName] = kept;
87
+ else
88
+ delete hooks[eventName];
89
+ }
90
+ return removed;
91
+ }
92
+ /** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
93
+ function buildHookCommand(opts) {
94
+ const nodeExe = process.execPath;
95
+ const scriptPath = process.argv[1] || path.join(__dirname, '..', 'index.js');
96
+ const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
97
+ // No --event flag: the bridge detects the Claude payload (hook_event_name) itself.
98
+ let cmd = `${q(nodeExe)} ${q(scriptPath)} hook --approval-mode wait`;
99
+ if (opts.shadow)
100
+ cmd += ' --shadow true';
101
+ if (opts.failClosed)
102
+ cmd += ' --fail-closed true';
103
+ cmd += ` ${MANAGED_MARKER}`;
104
+ return cmd;
105
+ }
106
+ function ensureHomeShield(input) {
107
+ if (!input.shieldId && !input.shieldKey && !input.apiUrl)
108
+ return undefined;
109
+ return (0, config_1.saveSetupConfig)({
110
+ shieldId: input.shieldId,
111
+ shieldKey: input.shieldKey,
112
+ apiUrl: input.apiUrl,
113
+ });
114
+ }
115
+ async function installClaudeHookCommand(args, config) {
116
+ const projectScope = args.project === 'true';
117
+ const shadow = args.shadow === 'true';
118
+ // Default fail-OPEN: when the backend is unreachable the developer keeps working;
119
+ // the local deterministic guard (cached Local Safety policies) still blocks
120
+ // offline, and every degraded allow is spooled + replayed. `--fail-closed true`
121
+ // opts into blocking all agent actions while offline.
122
+ const failClosed = args.failClosed === 'true';
123
+ const file = settingsPath(projectScope);
124
+ const requested = (args.events || 'tools,prompt')
125
+ .split(/[\s,]+/).map((e) => e.trim().toLowerCase()).filter(Boolean);
126
+ const wantTools = requested.includes('tools') || requested.includes('shell') || requested.includes('mcp');
127
+ const wantPrompt = requested.includes('prompt');
128
+ if (!wantTools && !wantPrompt) {
129
+ console.error(`${COLOR.red}No valid events. Choose from: tools, prompt${COLOR.reset}`);
130
+ process.exit(1);
131
+ }
132
+ const creds = (0, config_1.resolveCliCredentials)(config, {
133
+ shieldId: args.shieldId,
134
+ shieldKey: args.shieldKey,
135
+ apiUrl: args.apiUrl,
136
+ });
137
+ const credFile = ensureHomeShield(creds);
138
+ const settings = readSettings(file);
139
+ const hooks = (settings.hooks && typeof settings.hooks === 'object'
140
+ ? settings.hooks : {});
141
+ settings.hooks = hooks;
142
+ stripManaged(hooks);
143
+ const command = buildHookCommand({ shadow, failClosed });
144
+ if (wantTools) {
145
+ const list = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
146
+ // Long timeout: allow/block verdicts return in <1s; the timeout only matters
147
+ // while an action is paused waiting for a human approval in the console.
148
+ // (Claude hook timeouts are in seconds.)
149
+ list.push({ matcher: '*', hooks: [{ type: 'command', command, timeout: 300 }] });
150
+ hooks.PreToolUse = list;
151
+ }
152
+ if (wantPrompt) {
153
+ const list = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
154
+ list.push({ hooks: [{ type: 'command', command, timeout: 30 }] });
155
+ hooks.UserPromptSubmit = list;
156
+ }
157
+ fs.mkdirSync(path.dirname(file), { recursive: true });
158
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
159
+ console.log('');
160
+ console.log(`${COLOR.green}${COLOR.bold}FullCourtDefense Claude-format hook installed.${COLOR.reset}`);
161
+ console.log(`${COLOR.gray}Scope:${COLOR.reset} ${projectScope ? 'project (.claude/settings.json)' : 'machine-wide (~/.claude/settings.json)'}`);
162
+ console.log(`${COLOR.gray}File:${COLOR.reset} ${file}`);
163
+ console.log(`${COLOR.gray}Covers:${COLOR.reset} Claude Code, VS Code Copilot agent mode, GitHub Copilot CLI (all read this file)`);
164
+ const parts = [];
165
+ if (wantTools)
166
+ parts.push('tool calls (shell / MCP / file writes / reads) checked against org Action Policies + Local Safety rules');
167
+ if (wantPrompt)
168
+ parts.push('prompts scanned by your Shield');
169
+ console.log(`${COLOR.gray}Enforcement:${COLOR.reset} ${parts.join('; ')}`);
170
+ if (shadow)
171
+ console.log(`${COLOR.yellow}Mode: SHADOW (monitor only — nothing is blocked).${COLOR.reset}`);
172
+ if (failClosed)
173
+ console.log(`${COLOR.yellow}Fail-closed: agent actions are BLOCKED while the policy service is unreachable.${COLOR.reset}`);
174
+ if (credFile)
175
+ console.log(`${COLOR.gray}Creds:${COLOR.reset} ${credFile}`);
176
+ if (!creds.shieldId) {
177
+ console.log('');
178
+ console.log(`${COLOR.yellow}No Shield ID set.${COLOR.reset} Run ${COLOR.bold}fullcourtdefense login --token <fleet-enrollment-token>${COLOR.reset} first, then re-run install.`);
179
+ }
180
+ console.log('');
181
+ (0, restartNotice_1.printRestartNotice)('Claude Code, VS Code, Copilot CLI');
182
+ console.log(`${COLOR.gray}Remove with:${COLOR.reset} fullcourtdefense uninstall-claude-hook${projectScope ? ' --project true' : ''}`);
183
+ }
184
+ async function uninstallClaudeHookCommand(args) {
185
+ const projectScope = args.project === 'true';
186
+ const file = settingsPath(projectScope);
187
+ if (!fs.existsSync(file)) {
188
+ console.log(`${COLOR.yellow}No settings.json found at ${file}. Nothing to remove.${COLOR.reset}`);
189
+ return;
190
+ }
191
+ const settings = readSettings(file);
192
+ const hooks = (settings.hooks && typeof settings.hooks === 'object'
193
+ ? settings.hooks : {});
194
+ const removed = stripManaged(hooks);
195
+ if (Object.keys(hooks).length === 0)
196
+ delete settings.hooks;
197
+ else
198
+ settings.hooks = hooks;
199
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
200
+ console.log(`${COLOR.green}Removed ${removed} FullCourtDefense hook entr${removed === 1 ? 'y' : 'ies'} from ${file}.${COLOR.reset}`);
201
+ }