fullcourtdefense-cli 1.7.23 → 1.7.24
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 +182 -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 = {};
|
|
@@ -294,13 +394,13 @@ function mcpServerName(p) {
|
|
|
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.
|
|
296
396
|
*/
|
|
297
|
-
function agentNameForCall(event, p) {
|
|
397
|
+
function agentNameForCall(event, p, client = 'cursor') {
|
|
298
398
|
if (event === 'mcp') {
|
|
299
399
|
const server = mcpServerName(p);
|
|
300
400
|
if (server)
|
|
301
401
|
return `${safeIdentityPart(developerId())}-${safeIdentityPart(server)}`;
|
|
302
402
|
}
|
|
303
|
-
return
|
|
403
|
+
return `${client}-${developerId()}`;
|
|
304
404
|
}
|
|
305
405
|
/** Cursor conversation/session id when present, else a stable per-machine id. */
|
|
306
406
|
function sessionId(p) {
|
|
@@ -309,7 +409,7 @@ function sessionId(p) {
|
|
|
309
409
|
return fromPayload;
|
|
310
410
|
return `cursor-${os.hostname()}`;
|
|
311
411
|
}
|
|
312
|
-
function machineMetadata() {
|
|
412
|
+
function machineMetadata(client = 'cursor') {
|
|
313
413
|
let username = 'unknown';
|
|
314
414
|
try {
|
|
315
415
|
username = os.userInfo().username;
|
|
@@ -322,7 +422,7 @@ function machineMetadata() {
|
|
|
322
422
|
username,
|
|
323
423
|
cwd: process.cwd(),
|
|
324
424
|
workspacePath: process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd(),
|
|
325
|
-
agentClient:
|
|
425
|
+
agentClient: client,
|
|
326
426
|
};
|
|
327
427
|
}
|
|
328
428
|
function spoolLocalFinding(input) {
|
|
@@ -405,28 +505,79 @@ async function hookCommand(args, config) {
|
|
|
405
505
|
payload = {};
|
|
406
506
|
}
|
|
407
507
|
}
|
|
408
|
-
|
|
409
|
-
|
|
508
|
+
// Claude-format hooks (Claude Code / VS Code Copilot / Copilot CLI) are
|
|
509
|
+
// detected from the payload itself, so ONE installed command serves every
|
|
510
|
+
// client that reads ~/.claude/settings.json.
|
|
511
|
+
const claudeNormalized = normalizeClaudePayload(payload);
|
|
512
|
+
const hookFormat = claudeNormalized ? 'claude' : 'cursor';
|
|
513
|
+
const client = claudeNormalized ? claudeFormatClient() : 'cursor';
|
|
514
|
+
let event;
|
|
515
|
+
let ignoreEvent = false;
|
|
516
|
+
if (claudeNormalized) {
|
|
517
|
+
payload = claudeNormalized.payload;
|
|
518
|
+
if (claudeNormalized.event === 'ignore') {
|
|
519
|
+
event = 'unknown';
|
|
520
|
+
ignoreEvent = true;
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
event = claudeNormalized.event;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
event = inferEvent(args.event, payload);
|
|
528
|
+
}
|
|
529
|
+
dbg({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
|
|
410
530
|
isTTY, rawLen: raw.length, rawPreview: raw.slice(0, 300), stdinErr, parseErr,
|
|
411
531
|
argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
|
|
412
|
-
// Emit the verdict in the shape
|
|
413
|
-
// beforeSubmitPrompt blocks via { continue: false };
|
|
414
|
-
//
|
|
532
|
+
// Emit the verdict in the shape THIS client expects, then exit.
|
|
533
|
+
// Cursor: beforeSubmitPrompt blocks via { continue: false }; execution hooks
|
|
534
|
+
// block via { permission: "deny" }.
|
|
535
|
+
// Claude format (Claude Code / VS Code / Copilot CLI):
|
|
536
|
+
// UserPromptSubmit blocks via { decision: "block" }; PreToolUse blocks
|
|
537
|
+
// via hookSpecificOutput.permissionDecision "deny". On allow we emit
|
|
538
|
+
// NO permission decision so the client's own approval flow still runs
|
|
539
|
+
// (an explicit "allow" would silently bypass its permission prompts).
|
|
415
540
|
const respond = (blocked, userMsg, agentMsg) => {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
541
|
+
let decision;
|
|
542
|
+
if (hookFormat === 'claude') {
|
|
543
|
+
if (event === 'prompt') {
|
|
544
|
+
decision = blocked ? { decision: 'block', reason: userMsg || agentMsg || 'Blocked by FullCourtDefense.' } : {};
|
|
545
|
+
}
|
|
546
|
+
else if (blocked) {
|
|
547
|
+
decision = {
|
|
548
|
+
hookSpecificOutput: {
|
|
549
|
+
hookEventName: 'PreToolUse',
|
|
550
|
+
permissionDecision: 'deny',
|
|
551
|
+
permissionDecisionReason: userMsg || agentMsg || 'Blocked by FullCourtDefense.',
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
decision = {};
|
|
557
|
+
}
|
|
558
|
+
if (!blocked && agentMsg)
|
|
559
|
+
decision.systemMessage = agentMsg;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
decision = event === 'prompt'
|
|
563
|
+
? (blocked ? { continue: false } : { continue: true })
|
|
564
|
+
: { permission: blocked ? 'deny' : 'allow' };
|
|
565
|
+
if (userMsg)
|
|
566
|
+
decision.user_message = userMsg;
|
|
567
|
+
if (agentMsg)
|
|
568
|
+
decision.agent_message = agentMsg;
|
|
569
|
+
}
|
|
570
|
+
dbg({ phase: 'verdict', event, format: hookFormat, blocked, decision });
|
|
424
571
|
process.stdout.write(JSON.stringify(decision));
|
|
425
|
-
// ALWAYS exit 0 so
|
|
572
|
+
// ALWAYS exit 0 so the client uses our JSON verdict. Exit 2 is interpreted as
|
|
426
573
|
// `permission: "deny"`, which beforeSubmitPrompt ignores (it uses `continue`),
|
|
427
574
|
// so exiting 2 would silently let blocked prompts through.
|
|
428
575
|
process.exit(0);
|
|
429
576
|
};
|
|
577
|
+
// Claude-format lifecycle events with no pre-execution surface (PostToolUse,
|
|
578
|
+
// SessionStart, ...) and tools we don't police (Glob/Grep/Task/...) — no opinion.
|
|
579
|
+
if (ignoreEvent)
|
|
580
|
+
respond(false);
|
|
430
581
|
// No Shield configured → allow (fail-open by design: a misconfigured machine
|
|
431
582
|
// must not brick the developer's IDE).
|
|
432
583
|
if (!shieldId) {
|
|
@@ -437,7 +588,7 @@ async function hookCommand(args, config) {
|
|
|
437
588
|
await enforceActionPolicy({
|
|
438
589
|
event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
439
590
|
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond,
|
|
440
|
-
effectivePolicyHash,
|
|
591
|
+
effectivePolicyHash, client,
|
|
441
592
|
});
|
|
442
593
|
return;
|
|
443
594
|
}
|
|
@@ -472,7 +623,7 @@ async function hookCommand(args, config) {
|
|
|
472
623
|
}
|
|
473
624
|
await enforceShieldText({
|
|
474
625
|
event, text, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
475
|
-
shadow, failClosed, timeoutMs, respond,
|
|
626
|
+
shadow, failClosed, timeoutMs, respond, client,
|
|
476
627
|
});
|
|
477
628
|
return;
|
|
478
629
|
}
|
|
@@ -544,37 +695,34 @@ async function enforceActionPolicy(ctx) {
|
|
|
544
695
|
// For MCP calls this mirrors the gateway's runtime-agnostic per-server identity
|
|
545
696
|
// (`<developer>-<server>`), so ONE per-agent policy matches the same server on the
|
|
546
697
|
// gateway path AND this in-IDE hook path, across every client/runtime.
|
|
547
|
-
agentName: agentNameForCall(event, payload),
|
|
698
|
+
agentName: agentNameForCall(event, payload, ctx.client),
|
|
548
699
|
toolName: call.toolName,
|
|
549
700
|
toolArgs: call.toolArgs,
|
|
550
701
|
argsSummary: call.toolArgs,
|
|
551
702
|
sessionId: sessionId(payload),
|
|
552
703
|
source: 'cursor_hook',
|
|
553
704
|
...(event === 'mcp' && mcpServerName(payload) ? { mcpServer: mcpServerName(payload) } : {}),
|
|
554
|
-
...machineMetadata(),
|
|
705
|
+
...machineMetadata(ctx.client),
|
|
555
706
|
}),
|
|
556
707
|
signal: controller.signal,
|
|
557
708
|
});
|
|
558
709
|
clearTimeout(timer);
|
|
559
710
|
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}.`));
|
|
711
|
+
dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
|
|
712
|
+
respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, call.toolName);
|
|
563
713
|
return;
|
|
564
714
|
}
|
|
565
715
|
const body = await resp.json().catch(() => ({}));
|
|
566
716
|
if (!body.success || !body.data) {
|
|
567
|
-
dbg({ phase: 'policy_no_data', event, error: body.error });
|
|
568
|
-
|
|
717
|
+
dbg({ phase: 'policy_no_data', event, error: body.error, failClosed: ctx.failClosed });
|
|
718
|
+
respondDegraded(ctx, body.error ? `Backend error: ${body.error}` : 'Backend returned no policy data.', call.toolName);
|
|
569
719
|
return;
|
|
570
720
|
}
|
|
571
721
|
result = body.data;
|
|
572
722
|
}
|
|
573
723
|
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)}.`));
|
|
724
|
+
dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err), failClosed: ctx.failClosed });
|
|
725
|
+
respondDegraded(ctx, `Hook error: ${err instanceof Error ? err.message : String(err)}.`, call.toolName);
|
|
578
726
|
return;
|
|
579
727
|
}
|
|
580
728
|
const reason = result.actionPolicy?.reason
|
|
@@ -646,7 +794,7 @@ async function waitForApproval(input) {
|
|
|
646
794
|
return { status: 'timeout', message: 'approval timed out — still waiting for human review.' };
|
|
647
795
|
}
|
|
648
796
|
async function enforceShieldText(ctx) {
|
|
649
|
-
const { event, text, payload, apiUrl, shieldId, shieldKey, shadow,
|
|
797
|
+
const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond } = ctx;
|
|
650
798
|
try {
|
|
651
799
|
const controller = new AbortController();
|
|
652
800
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -667,7 +815,7 @@ async function enforceShieldText(ctx) {
|
|
|
667
815
|
});
|
|
668
816
|
clearTimeout(timer);
|
|
669
817
|
if (!resp.ok) {
|
|
670
|
-
|
|
818
|
+
respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, 'prompt');
|
|
671
819
|
return;
|
|
672
820
|
}
|
|
673
821
|
const data = await resp.json().catch(() => ({}));
|
|
@@ -683,6 +831,6 @@ async function enforceShieldText(ctx) {
|
|
|
683
831
|
respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
|
|
684
832
|
}
|
|
685
833
|
catch (err) {
|
|
686
|
-
|
|
834
|
+
respondDegraded(ctx, `Hook error: ${err instanceof Error ? err.message : String(err)}.`, 'prompt');
|
|
687
835
|
}
|
|
688
836
|
}
|
|
@@ -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
|
+
}
|