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.
- package/README.md +690 -690
- package/dist/commands/hook.js +186 -34
- package/dist/commands/installAll.d.ts +2 -0
- package/dist/commands/installAll.js +39 -0
- package/dist/commands/installClaudeHook.d.ts +27 -0
- package/dist/commands/installClaudeHook.js +201 -0
- package/dist/commands/mcpGateway.js +71 -5
- package/dist/index.js +28 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
409
|
-
|
|
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
|
|
413
|
-
// beforeSubmitPrompt blocks via { continue: false };
|
|
414
|
-
//
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
838
|
+
respondDegraded(ctx, `Hook error: ${err instanceof Error ? err.message : String(err)}.`, 'prompt');
|
|
687
839
|
}
|
|
688
840
|
}
|
|
@@ -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
|
+
}
|