fullcourtdefense-cli 1.32.0 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actionPolicyEngine.js +18 -2
- package/dist/agentTerminal.d.ts +6 -3
- package/dist/agentTerminal.js +19 -16
- package/dist/commands/cmdGuard.js +2 -2
- package/dist/commands/deterministicGuard.d.ts +2 -0
- package/dist/commands/deterministicGuard.js +17 -4
- package/dist/commands/discoverPaths.js +2 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +34 -19
- package/dist/commands/installAll.js +13 -2
- package/dist/commands/installClaudeHook.d.ts +9 -3
- package/dist/commands/installClaudeHook.js +48 -2
- package/dist/commands/installNativeHooks.d.ts +9 -0
- package/dist/commands/installNativeHooks.js +145 -0
- package/dist/commands/mcpGateway.js +48 -41
- package/dist/commands/posixShellGuard.js +2 -2
- package/dist/commands/shellGuard.js +57 -14
- package/dist/commands/uninstallAll.js +2 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -0
- package/dist/devConfirm.d.ts +4 -0
- package/dist/devConfirm.js +15 -4
- package/dist/hookIo.js +1 -0
- package/dist/index.js +21 -0
- package/dist/nativeHook.d.ts +25 -0
- package/dist/nativeHook.js +239 -0
- package/dist/runtimeConfig.d.ts +5 -0
- package/dist/runtimeConfig.js +6 -0
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +7 -0
- package/dist/version.json +1 -1
- package/package.json +4 -1
package/dist/devConfirm.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.actionDigest = actionDigest;
|
|
|
40
40
|
exports.pendingKey = pendingKey;
|
|
41
41
|
exports.raiseDeveloperConfirmation = raiseDeveloperConfirmation;
|
|
42
42
|
exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
|
|
43
|
+
exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
|
|
43
44
|
exports.sweepExpiredConfirmations = sweepExpiredConfirmations;
|
|
44
45
|
exports.isConfirmationRemembered = isConfirmationRemembered;
|
|
45
46
|
/**
|
|
@@ -106,7 +107,8 @@ function readLedger() {
|
|
|
106
107
|
const parsed = JSON.parse(fs.readFileSync(ledgerFile(), 'utf8'));
|
|
107
108
|
return {
|
|
108
109
|
pending: Array.isArray(parsed?.pending) ? parsed.pending.filter((p) => p && typeof p === 'object') : [],
|
|
109
|
-
|
|
110
|
+
// Older ledgers could remember declines. Require fresh affirmative consent.
|
|
111
|
+
remembered: parsed?.formatVersion === 2 && Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
|
|
110
112
|
};
|
|
111
113
|
}
|
|
112
114
|
catch {
|
|
@@ -116,7 +118,9 @@ function readLedger() {
|
|
|
116
118
|
function writeLedger(ledger) {
|
|
117
119
|
try {
|
|
118
120
|
fs.mkdirSync(path.dirname(ledgerFile()), { recursive: true });
|
|
119
|
-
|
|
121
|
+
const temp = `${ledgerFile()}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
122
|
+
fs.writeFileSync(temp, JSON.stringify({ ...ledger, formatVersion: 2 }), { encoding: 'utf8', mode: 0o600 });
|
|
123
|
+
fs.renameSync(temp, ledgerFile());
|
|
120
124
|
}
|
|
121
125
|
catch { /* best-effort — losing a record must never change a verdict */ }
|
|
122
126
|
}
|
|
@@ -185,7 +189,7 @@ function settleDeveloperConfirmation(event, toolName, argsDigest, now = new Date
|
|
|
185
189
|
if (!marker)
|
|
186
190
|
return undefined;
|
|
187
191
|
ledger.pending = ledger.pending.filter(p => p.key !== key);
|
|
188
|
-
if (marker.remember !== 'none') {
|
|
192
|
+
if (marker.remember !== 'none' && Date.parse(marker.raisedAt) <= now.getTime() && now.getTime() - Date.parse(marker.raisedAt) < exports.CONFIRM_PENDING_TTL_MS) {
|
|
189
193
|
const ttl = marker.remember === '1h' ? REMEMBER_1H_MS : REMEMBER_SESSION_MAX_MS;
|
|
190
194
|
const remembered = {
|
|
191
195
|
key: rememberKey(marker),
|
|
@@ -206,6 +210,13 @@ function settleDeveloperConfirmation(event, toolName, argsDigest, now = new Date
|
|
|
206
210
|
writeLedger(ledger);
|
|
207
211
|
return { marker, confirmLatencyMs: Math.max(0, now.getTime() - Date.parse(marker.raisedAt)) };
|
|
208
212
|
}
|
|
213
|
+
/** Reject or cancel a pending request without creating approval memory. */
|
|
214
|
+
function rejectDeveloperConfirmation(event, toolName, argsDigest) {
|
|
215
|
+
const ledger = readLedger();
|
|
216
|
+
const key = pendingKey(event, toolName, argsDigest);
|
|
217
|
+
ledger.pending = ledger.pending.filter(p => p.key !== key);
|
|
218
|
+
writeLedger(ledger);
|
|
219
|
+
}
|
|
209
220
|
/**
|
|
210
221
|
* Markers older than the TTL never saw a post-execution hook: the developer denied the IDE
|
|
211
222
|
* prompt, dismissed it, or the client ignored `ask`. Removed and returned so the caller can
|
|
@@ -233,7 +244,7 @@ function isConfirmationRemembered(scope, sessionId, now = new Date()) {
|
|
|
233
244
|
const hit = ledger.remembered.find(r => r.key === key);
|
|
234
245
|
if (!hit)
|
|
235
246
|
return undefined;
|
|
236
|
-
if (Date.parse(hit.expiresAt) <= now.getTime())
|
|
247
|
+
if (!Number.isFinite(Date.parse(hit.expiresAt)) || Date.parse(hit.expiresAt) <= now.getTime())
|
|
237
248
|
return undefined;
|
|
238
249
|
if (hit.remember === 'session' && (!sessionId || hit.sessionId !== sessionId))
|
|
239
250
|
return undefined;
|
package/dist/hookIo.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
44
44
|
// touching this file.
|
|
45
45
|
const config_1 = require("./config");
|
|
46
46
|
const hook_1 = require("./commands/hook");
|
|
47
|
+
const installNativeHooks_1 = require("./commands/installNativeHooks");
|
|
47
48
|
const hookIo_1 = require("./hookIo");
|
|
48
49
|
const fs = __importStar(require("fs"));
|
|
49
50
|
const path = __importStar(require("path"));
|
|
@@ -202,6 +203,9 @@ function printHelp() {
|
|
|
202
203
|
uninstall-cursor-hook
|
|
203
204
|
Removes the FullCourtDefense Cursor hook entries.
|
|
204
205
|
install-claude-hook
|
|
206
|
+
install-native-hooks --clients codex,gemini_cli,windsurf,kiro
|
|
207
|
+
Native tool hooks (Kiro: current project; Codex: trust via /hooks)
|
|
208
|
+
uninstall-native-hooks
|
|
205
209
|
Installs Claude-format hooks in ~/.claude/settings.json — ONE file
|
|
206
210
|
enforces Action Policies + Local Safety rules in Claude Code,
|
|
207
211
|
VS Code Copilot agent mode, and GitHub Copilot CLI.
|
|
@@ -731,6 +735,23 @@ async function main() {
|
|
|
731
735
|
await (0, hook_1.hookCommand)(args, config);
|
|
732
736
|
break;
|
|
733
737
|
}
|
|
738
|
+
case 'install-native-hooks':
|
|
739
|
+
case 'uninstall-native-hooks': {
|
|
740
|
+
const clients = flags.clients?.split(',');
|
|
741
|
+
if (clients?.some(client => !['codex', 'gemini_cli', 'windsurf', 'kiro'].includes(client)))
|
|
742
|
+
throw new Error('Supported native clients: codex,gemini_cli,windsurf,kiro');
|
|
743
|
+
const files = (0, installNativeHooks_1.installNativeHooks)({ clients, project: flags.project, remove: command === 'uninstall-native-hooks' });
|
|
744
|
+
for (const file of files)
|
|
745
|
+
console.log(file);
|
|
746
|
+
if (command === 'install-native-hooks')
|
|
747
|
+
console.log('Restart the clients to load hooks. In Codex, review and trust the new definitions using /hooks. Kiro hooks apply to the selected project.');
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
case 'install-copilot-hook': {
|
|
751
|
+
const { installCopilotHookCommand } = await Promise.resolve().then(() => __importStar(require('./commands/installClaudeHook')));
|
|
752
|
+
installCopilotHookCommand();
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
734
755
|
case 'mcp-gateway': {
|
|
735
756
|
const { mcpGatewayCommand } = await Promise.resolve().then(() => __importStar(require('./commands/mcpGateway')));
|
|
736
757
|
await mcpGatewayCommand(buildGatewayArgs(), config);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { HookArgs } from './commands/hook';
|
|
2
|
+
import { BotGuardConfig } from './config';
|
|
3
|
+
export type NativeClient = 'codex' | 'gemini_cli' | 'windsurf' | 'kiro';
|
|
4
|
+
type Payload = Record<string, any>;
|
|
5
|
+
export declare const NATIVE_CLIENTS: NativeClient[];
|
|
6
|
+
export declare function patchToolCalls(raw: Payload): Payload[];
|
|
7
|
+
export declare function normalizeNativeInput(client: NativeClient, raw: Payload): Payload;
|
|
8
|
+
export declare function nativeVerdict(client: NativeClient, denied: boolean, reason?: string): {
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
exitCode: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function nativeErrorVerdict(client: NativeClient, reason: string): {
|
|
14
|
+
stdout: string;
|
|
15
|
+
stderr: string;
|
|
16
|
+
exitCode: number;
|
|
17
|
+
};
|
|
18
|
+
/** OS-owned dialog: an agent cannot approve by writing `yes` to hook stdin. */
|
|
19
|
+
export declare function confirmNativeAction(reason: string, timeoutMs?: number): Promise<boolean>;
|
|
20
|
+
export declare function evaluateNativeHook(client: NativeClient, raw: Payload, args: HookArgs, config: BotGuardConfig, confirm?: (reason: string) => Promise<boolean>): Promise<{
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
}>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NATIVE_CLIENTS = void 0;
|
|
4
|
+
exports.patchToolCalls = patchToolCalls;
|
|
5
|
+
exports.normalizeNativeInput = normalizeNativeInput;
|
|
6
|
+
exports.nativeVerdict = nativeVerdict;
|
|
7
|
+
exports.nativeErrorVerdict = nativeErrorVerdict;
|
|
8
|
+
exports.confirmNativeAction = confirmNativeAction;
|
|
9
|
+
exports.evaluateNativeHook = evaluateNativeHook;
|
|
10
|
+
/** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
const hook_1 = require("./commands/hook");
|
|
13
|
+
const config_1 = require("./config");
|
|
14
|
+
const runtimeConfig_1 = require("./runtimeConfig");
|
|
15
|
+
const hookIo_1 = require("./hookIo");
|
|
16
|
+
const devConfirm_1 = require("./devConfirm");
|
|
17
|
+
const telemetry_1 = require("./telemetry");
|
|
18
|
+
exports.NATIVE_CLIENTS = ['codex', 'gemini_cli', 'windsurf', 'kiro'];
|
|
19
|
+
function patchToolCalls(raw) {
|
|
20
|
+
const patch = typeof raw.tool_input === 'string' ? raw.tool_input : String(raw.tool_input?.command || raw.tool_input?.patch || '');
|
|
21
|
+
if (!patch.startsWith('*** Begin Patch') || !patch.trimEnd().endsWith('*** End Patch'))
|
|
22
|
+
throw new Error('Unrecognized apply_patch input');
|
|
23
|
+
const calls = [];
|
|
24
|
+
const blocks = patch.split(/(?=^\*\*\* (?:Add|Update|Delete) File: )/m).slice(1);
|
|
25
|
+
for (const block of blocks) {
|
|
26
|
+
const header = /^\*\*\* (Add|Update|Delete) File: (.+)\r?$/m.exec(block);
|
|
27
|
+
if (!header)
|
|
28
|
+
throw new Error('Malformed patch file header');
|
|
29
|
+
const [, operation, file] = header;
|
|
30
|
+
const moved = /^\*\*\* Move to: (.+)\r?$/m.exec(block)?.[1];
|
|
31
|
+
if (operation === 'Delete' || moved)
|
|
32
|
+
calls.push({ ...raw, tool_name: 'mcp__codex-builtin__delete_file', tool_input: { path: file.trim() } });
|
|
33
|
+
if (operation !== 'Delete')
|
|
34
|
+
calls.push({ ...raw, tool_name: 'Edit', tool_input: { path: (moved || file).trim(), new_string: block.split('\n').filter(line => line.startsWith('+')).map(line => line.slice(1)).join('\n') } });
|
|
35
|
+
}
|
|
36
|
+
if (!calls.length)
|
|
37
|
+
throw new Error('Patch contains no recognized file operations');
|
|
38
|
+
return calls;
|
|
39
|
+
}
|
|
40
|
+
function normalizeNativeInput(client, raw) {
|
|
41
|
+
let name = String(raw.hook_event_name || '');
|
|
42
|
+
let tool = String(raw.tool_name || '');
|
|
43
|
+
let input = raw.tool_input || {};
|
|
44
|
+
if (client === 'windsurf') {
|
|
45
|
+
const event = String(raw.agent_action_name || '');
|
|
46
|
+
const info = raw.tool_info || {};
|
|
47
|
+
name = event.startsWith('post_') ? 'PostToolUse' : 'PreToolUse';
|
|
48
|
+
if (event === 'pre_user_prompt')
|
|
49
|
+
return { ...raw, hook_event_name: 'UserPromptSubmit', prompt: info.user_prompt };
|
|
50
|
+
if (event.endsWith('run_command')) {
|
|
51
|
+
tool = 'Bash';
|
|
52
|
+
input = { command: info.command_line, cwd: info.cwd };
|
|
53
|
+
}
|
|
54
|
+
else if (event.endsWith('read_code')) {
|
|
55
|
+
tool = 'Read';
|
|
56
|
+
input = info;
|
|
57
|
+
}
|
|
58
|
+
else if (event.endsWith('write_code')) {
|
|
59
|
+
tool = 'Edit';
|
|
60
|
+
input = info;
|
|
61
|
+
}
|
|
62
|
+
else if (event.endsWith('mcp_tool_use')) {
|
|
63
|
+
tool = `mcp__${info.mcp_server_name}__${info.mcp_tool_name}`;
|
|
64
|
+
input = info.mcp_tool_arguments;
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
throw new Error(`Unsupported Windsurf event: ${event}`);
|
|
68
|
+
}
|
|
69
|
+
else if (client === 'gemini_cli') {
|
|
70
|
+
name = { BeforeTool: 'PreToolUse', AfterTool: 'PostToolUse', BeforeAgent: 'UserPromptSubmit' }[name] || name;
|
|
71
|
+
if (raw.mcp_context?.server_name && raw.mcp_context?.tool_name)
|
|
72
|
+
tool = `mcp__${raw.mcp_context.server_name}__${raw.mcp_context.tool_name}`;
|
|
73
|
+
else if (tool.startsWith('mcp_'))
|
|
74
|
+
tool = `mcp__gemini__${tool}`; // retain full name when the server split is ambiguous
|
|
75
|
+
}
|
|
76
|
+
else if (client === 'kiro') {
|
|
77
|
+
name = { preToolUse: 'PreToolUse', postToolUse: 'PostToolUse', userPromptSubmit: 'UserPromptSubmit' }[name] || name;
|
|
78
|
+
if (tool.startsWith('@') && tool.includes('/'))
|
|
79
|
+
tool = `mcp__${tool.slice(1).replace('/', '__')}`;
|
|
80
|
+
}
|
|
81
|
+
if (!['PreToolUse', 'PostToolUse', 'UserPromptSubmit'].includes(name))
|
|
82
|
+
throw new Error(`Unsupported hook event: ${name}`);
|
|
83
|
+
if (name !== 'UserPromptSubmit' && !tool)
|
|
84
|
+
throw new Error('Missing tool name');
|
|
85
|
+
// Unknown function tools still need Action Policy evaluation, not silent ignore.
|
|
86
|
+
let normalized = { ...raw, hook_event_name: name, tool_name: tool, tool_input: input };
|
|
87
|
+
if (name !== 'UserPromptSubmit' && (0, hook_1.normalizeClaudePayload)(normalized)?.event === 'ignore') {
|
|
88
|
+
normalized = { ...normalized, tool_name: `mcp__${client}-builtin__${tool}` };
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
function nativeVerdict(client, denied, reason = '') {
|
|
93
|
+
if (client === 'codex')
|
|
94
|
+
return { stdout: JSON.stringify(denied ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } } : {}), stderr: '', exitCode: 0 };
|
|
95
|
+
if (client === 'gemini_cli')
|
|
96
|
+
return { stdout: JSON.stringify(denied ? { decision: 'deny', reason } : {}), stderr: '', exitCode: 0 };
|
|
97
|
+
return { stdout: '', stderr: denied ? reason : '', exitCode: denied ? 2 : 0 };
|
|
98
|
+
}
|
|
99
|
+
function nativeErrorVerdict(client, reason) {
|
|
100
|
+
// Structured blocks survive PowerShell wrappers which collapse exit 2 to 1.
|
|
101
|
+
// The legacy Codex block shape is valid for both tool and prompt hooks.
|
|
102
|
+
if (client === 'codex')
|
|
103
|
+
return { stdout: JSON.stringify({ decision: 'block', reason }), stderr: '', exitCode: 0 };
|
|
104
|
+
return nativeVerdict(client, true, reason);
|
|
105
|
+
}
|
|
106
|
+
/** OS-owned dialog: an agent cannot approve by writing `yes` to hook stdin. */
|
|
107
|
+
async function confirmNativeAction(reason, timeoutMs = 60_000) {
|
|
108
|
+
if (!(0, devConfirm_1.isHumanPresent)())
|
|
109
|
+
return false;
|
|
110
|
+
let executable;
|
|
111
|
+
let args;
|
|
112
|
+
const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
|
|
113
|
+
if (process.platform === 'win32') {
|
|
114
|
+
executable = 'powershell.exe';
|
|
115
|
+
const encoded = Buffer.from(message, 'utf8').toString('base64');
|
|
116
|
+
const script = `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $w=New-Object -ComObject WScript.Shell; $answer=$w.Popup($msg,${Math.ceil(timeoutMs / 1000)},'FullCourtDefense',292); if($answer -eq 6){exit 0}else{exit 1}`;
|
|
117
|
+
args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
118
|
+
}
|
|
119
|
+
else if (process.platform === 'darwin') {
|
|
120
|
+
executable = '/usr/bin/osascript';
|
|
121
|
+
args = ['-e', 'on run argv', '-e', 'display dialog (item 1 of argv) buttons {"Deny", "Allow once"} default button "Deny" giving up after 60', '-e', 'if gave up of result or button returned of result is not "Allow once" then error number -128', '-e', 'end run', message];
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
executable = 'zenity';
|
|
125
|
+
args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, '--ok-label=Allow once', '--cancel-label=Deny', `--timeout=${Math.ceil(timeoutMs / 1000)}`];
|
|
126
|
+
}
|
|
127
|
+
return new Promise(resolve => {
|
|
128
|
+
let finished = false;
|
|
129
|
+
const child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
|
|
130
|
+
const cancelChild = () => child.kill();
|
|
131
|
+
process.once('exit', cancelChild);
|
|
132
|
+
const done = (allowed) => { if (!finished) {
|
|
133
|
+
finished = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
process.removeListener('exit', cancelChild);
|
|
136
|
+
resolve(allowed);
|
|
137
|
+
} };
|
|
138
|
+
const timer = setTimeout(() => { child.kill(); done(false); }, timeoutMs + 1000);
|
|
139
|
+
child.on('error', () => done(false));
|
|
140
|
+
child.on('exit', code => done(code === 0));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async function evaluateNativeHook(client, raw, args, config, confirm = confirmNativeAction) {
|
|
144
|
+
if (client === 'codex' && raw.tool_name === 'apply_patch') {
|
|
145
|
+
try {
|
|
146
|
+
// Evaluate every file operation before Codex executes the atomic patch.
|
|
147
|
+
for (const call of patchToolCalls(raw)) {
|
|
148
|
+
const result = await evaluateNativeHook(client, call, args, config, confirm);
|
|
149
|
+
const verdict = JSON.parse(result.stdout || '{}');
|
|
150
|
+
if (result.exitCode !== 0 || verdict.decision === 'block' || verdict.decision === 'deny' || verdict.hookSpecificOutput?.permissionDecision === 'deny')
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
return nativeVerdict(client, false);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return nativeErrorVerdict(client, `Patch validation failed: ${String(error)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let payload;
|
|
160
|
+
try {
|
|
161
|
+
payload = normalizeNativeInput(client, raw);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return nativeErrorVerdict(client, `FullCourtDefense could not parse the hook: ${String(error)}`);
|
|
165
|
+
}
|
|
166
|
+
return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'local', agentClient: client }, async () => {
|
|
167
|
+
const result = await (0, hook_1.evaluateHookRequest)({ ...args, agentClient: client }, JSON.stringify(payload), config);
|
|
168
|
+
let decision;
|
|
169
|
+
try {
|
|
170
|
+
decision = result.stdout ? JSON.parse(result.stdout) : {};
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return nativeErrorVerdict(client, 'Invalid FullCourtDefense verdict; action denied.');
|
|
174
|
+
}
|
|
175
|
+
if (!decision || typeof decision !== 'object' || Array.isArray(decision))
|
|
176
|
+
return nativeErrorVerdict(client, 'Invalid FullCourtDefense verdict shape; action denied.');
|
|
177
|
+
const permission = decision.hookSpecificOutput?.permissionDecision || decision.permissionDecision || decision.permission;
|
|
178
|
+
let denied = result.exitCode !== 0 || permission === 'deny' || decision.decision === 'block' || decision.continue === false;
|
|
179
|
+
const reason = decision.hookSpecificOutput?.permissionDecisionReason || decision.permissionDecisionReason || decision.reason || decision.user_message || result.stderr || 'FullCourtDefense policy decision.';
|
|
180
|
+
if (permission === 'ask' && !denied) {
|
|
181
|
+
const mapped = (0, hook_1.normalizeClaudePayload)(payload);
|
|
182
|
+
const call = mapped && mapped.event !== 'ignore' ? (0, hook_1.buildToolCall)(mapped.event, mapped.payload) : null;
|
|
183
|
+
// No remembered/pending handoff remains when an external dialog is used.
|
|
184
|
+
if (call && mapped)
|
|
185
|
+
(0, devConfirm_1.rejectDeveloperConfirmation)(mapped.event, call.toolName, (0, devConfirm_1.actionDigest)(call.toolArgs));
|
|
186
|
+
let allowed = false;
|
|
187
|
+
try {
|
|
188
|
+
allowed = (0, devConfirm_1.isHumanPresent)() && await confirm(`${client}: ${payload.tool_name}\n${reason}`);
|
|
189
|
+
}
|
|
190
|
+
catch { /* deny */ }
|
|
191
|
+
denied = !allowed;
|
|
192
|
+
(0, telemetry_1.spoolEvent)({ decision: allowed ? 'allow' : 'block', toolName: call?.toolName || payload.tool_name,
|
|
193
|
+
reason: allowed ? 'Developer approved once in the FullCourtDefense dialog.' : 'Developer declined, confirmation unavailable, or timed out.',
|
|
194
|
+
approvalScope: 'developer', ...(allowed ? { approvedBy: 'developer' } : {}) });
|
|
195
|
+
}
|
|
196
|
+
// Prompt blocks use the documented exit-code contract, not a PreToolUse object.
|
|
197
|
+
if (payload.hook_event_name === 'UserPromptSubmit' && denied)
|
|
198
|
+
return nativeErrorVerdict(client, reason);
|
|
199
|
+
return nativeVerdict(client, denied, reason);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async function main() {
|
|
203
|
+
const flags = (0, hookIo_1.parseHookFlags)(process.argv.slice(2));
|
|
204
|
+
const client = flags['agent-client'];
|
|
205
|
+
if (!exports.NATIVE_CLIENTS.includes(client))
|
|
206
|
+
throw new Error('Unsupported native hook client');
|
|
207
|
+
const config = (0, config_1.loadConfig)();
|
|
208
|
+
const shieldId = (0, config_1.resolveCliCredentials)(config, { shieldId: flags['shield-id'] }, { skipDpapi: true }).shieldId;
|
|
209
|
+
// Return before the vendor's 130s timeout: some clients fail open on timeout.
|
|
210
|
+
// Bound the entire hook, including multiple patch files and human dialogs.
|
|
211
|
+
const deadline = setTimeout(() => {
|
|
212
|
+
const deny = (0, runtimeConfig_1.isSuspendedByCache)() || (0, runtimeConfig_1.cachedEnforcementState)(shieldId).mode === 'block';
|
|
213
|
+
const result = deny ? nativeErrorVerdict(client, 'FullCourtDefense evaluation deadline exceeded; action not approved.') : nativeVerdict(client, false);
|
|
214
|
+
if (result.stdout)
|
|
215
|
+
process.stdout.write(result.stdout + '\n');
|
|
216
|
+
if (result.stderr)
|
|
217
|
+
process.stderr.write(result.stderr + '\n');
|
|
218
|
+
process.exit(result.exitCode);
|
|
219
|
+
}, 110_000);
|
|
220
|
+
deadline.unref();
|
|
221
|
+
const raw = JSON.parse((await (0, hookIo_1.readStdin)()).replace(/^\uFEFF/, ''));
|
|
222
|
+
const result = await evaluateNativeHook(client, raw, (0, hookIo_1.mapHookFlags)(flags), config);
|
|
223
|
+
clearTimeout(deadline);
|
|
224
|
+
if (result.stdout)
|
|
225
|
+
process.stdout.write(result.stdout + '\n');
|
|
226
|
+
if (result.stderr)
|
|
227
|
+
process.stderr.write(result.stderr + '\n');
|
|
228
|
+
process.exitCode = result.exitCode;
|
|
229
|
+
}
|
|
230
|
+
if (require.main === module)
|
|
231
|
+
main().catch(error => {
|
|
232
|
+
const client = (0, hookIo_1.parseHookFlags)(process.argv.slice(2))['agent-client'];
|
|
233
|
+
const result = nativeErrorVerdict(client, `FullCourtDefense hook failed: ${String(error)}`);
|
|
234
|
+
if (result.stdout)
|
|
235
|
+
process.stdout.write(result.stdout + '\n');
|
|
236
|
+
if (result.stderr)
|
|
237
|
+
process.stderr.write(result.stderr + '\n');
|
|
238
|
+
process.exitCode = result.exitCode;
|
|
239
|
+
});
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -168,6 +168,11 @@ export declare function resetCacheOnEnrollment(): void;
|
|
|
168
168
|
* "exception = allow" must not become a kill-switch bypass. Reads only the
|
|
169
169
|
* local cache file (no network, no config parsing).
|
|
170
170
|
*/
|
|
171
|
+
/** Failure handling reads only the requested Shield, never another enrollment. */
|
|
172
|
+
export declare function cachedEnforcementState(shieldId: string | undefined): {
|
|
173
|
+
mode: ShieldMode;
|
|
174
|
+
failClosed: boolean;
|
|
175
|
+
};
|
|
171
176
|
export declare function isSuspendedByCache(): boolean;
|
|
172
177
|
/**
|
|
173
178
|
* Admin scan-folder choices from the most recent cached bundle (any shield):
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.getRuntimeBundle = getRuntimeBundle;
|
|
37
37
|
exports.validateShieldCredentials = validateShieldCredentials;
|
|
38
38
|
exports.resetCacheOnEnrollment = resetCacheOnEnrollment;
|
|
39
|
+
exports.cachedEnforcementState = cachedEnforcementState;
|
|
39
40
|
exports.isSuspendedByCache = isSuspendedByCache;
|
|
40
41
|
exports.getCachedScanRootOverrides = getCachedScanRootOverrides;
|
|
41
42
|
exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
|
|
@@ -434,6 +435,11 @@ function resetCacheOnEnrollment() {
|
|
|
434
435
|
* "exception = allow" must not become a kill-switch bypass. Reads only the
|
|
435
436
|
* local cache file (no network, no config parsing).
|
|
436
437
|
*/
|
|
438
|
+
/** Failure handling reads only the requested Shield, never another enrollment. */
|
|
439
|
+
function cachedEnforcementState(shieldId) {
|
|
440
|
+
const entry = shieldId ? readCacheFile()[shieldId] : undefined;
|
|
441
|
+
return { mode: entry && isMode(entry.mode) ? entry.mode : 'monitor', failClosed: entry?.failClosed === true };
|
|
442
|
+
}
|
|
437
443
|
function isSuspendedByCache() {
|
|
438
444
|
try {
|
|
439
445
|
const cache = readCacheFile();
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export interface SpoolEvent {
|
|
2
|
+
agentClient?: string;
|
|
2
3
|
eventId: string;
|
|
3
4
|
type: 'verdict';
|
|
4
5
|
decision: 'allow' | 'block' | 'approval' | 'warn' | 'mask';
|
|
@@ -52,6 +53,7 @@ export type VerdictPath = 'ipc' | 'local' | 'fail_open' | 'gateway';
|
|
|
52
53
|
*/
|
|
53
54
|
export declare function evidenceFromToolArgs(toolArgs: Record<string, unknown> | undefined): string | undefined;
|
|
54
55
|
interface VerdictTiming {
|
|
56
|
+
agentClient?: string;
|
|
55
57
|
startedAt: number;
|
|
56
58
|
path: VerdictPath;
|
|
57
59
|
/** Tool args for this evaluation — stamps `evidence` onto every spooled event. */
|
|
@@ -64,6 +66,7 @@ export declare function markVerdictTiming<T>(timing: VerdictTiming, fn: () => T)
|
|
|
64
66
|
* Keeps the original start time so latency still covers the full wait.
|
|
65
67
|
*/
|
|
66
68
|
export declare function setVerdictPath(path: VerdictPath): void;
|
|
69
|
+
export declare function setVerdictClient(client: string): void;
|
|
67
70
|
/** Attach the current tool call's args so every spool in this evaluation carries the command. */
|
|
68
71
|
export declare function setVerdictToolArgs(toolArgs: Record<string, unknown>): void;
|
|
69
72
|
/**
|
package/dist/telemetry.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.evidenceFromToolArgs = evidenceFromToolArgs;
|
|
37
37
|
exports.markVerdictTiming = markVerdictTiming;
|
|
38
38
|
exports.setVerdictPath = setVerdictPath;
|
|
39
|
+
exports.setVerdictClient = setVerdictClient;
|
|
39
40
|
exports.setVerdictToolArgs = setVerdictToolArgs;
|
|
40
41
|
exports.restartVerdictTiming = restartVerdictTiming;
|
|
41
42
|
exports.spoolEvent = spoolEvent;
|
|
@@ -156,6 +157,11 @@ function setVerdictPath(path) {
|
|
|
156
157
|
if (store)
|
|
157
158
|
store.path = path;
|
|
158
159
|
}
|
|
160
|
+
function setVerdictClient(client) {
|
|
161
|
+
const store = verdictTiming.getStore();
|
|
162
|
+
if (store)
|
|
163
|
+
store.agentClient = client;
|
|
164
|
+
}
|
|
159
165
|
/** Attach the current tool call's args so every spool in this evaluation carries the command. */
|
|
160
166
|
function setVerdictToolArgs(toolArgs) {
|
|
161
167
|
const store = verdictTiming.getStore();
|
|
@@ -185,6 +191,7 @@ function spoolEvent(event) {
|
|
|
185
191
|
occurredAt: event.occurredAt || new Date().toISOString(),
|
|
186
192
|
type: 'verdict',
|
|
187
193
|
decision: event.decision,
|
|
194
|
+
agentClient: event.agentClient || timing?.agentClient,
|
|
188
195
|
toolName: event.toolName,
|
|
189
196
|
operation: event.operation,
|
|
190
197
|
reason: event.reason,
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.0",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
+
"test:native-hooks": "npm run build && node scripts/test-native-hook-launch.js && node scripts/test-native-hook-edges.js && node scripts/test-client-telemetry.js && node scripts/test-native-hook-clients.js",
|
|
17
|
+
"test:endpoint-approvals": "npm run build && node scripts/audit-endpoint-mcp-shell.js",
|
|
18
|
+
"test:endpoint-clients": "npm run build && node scripts/test-endpoint-client-matrix.js && node scripts/test-copilot-hook-install.js",
|
|
16
19
|
"build": "tsc && node scripts/copy-attack-corpus.js",
|
|
17
20
|
"test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
|
|
18
21
|
"test:drive-delete-guard": "npm run build && node scripts/test-drive-delete-guard.js",
|