fullcourtdefense-cli 1.33.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.
@@ -215,6 +215,8 @@ function candidateConfigPaths(cwd, extra) {
215
215
  { path: path.join(xdg, 'Code', 'User', 'settings.json'), source: 'VS Code (user, linux)', clientKey: 'vscode' },
216
216
  { path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), source: 'Windsurf', clientKey: 'windsurf' },
217
217
  { path: path.join(home, '.kiro', 'mcp.json'), source: 'Kiro', clientKey: 'kiro' },
218
+ { path: path.join(home, '.kiro', 'settings', 'mcp.json'), source: 'Kiro (settings)', clientKey: 'kiro' },
219
+ { path: path.join(cwd, '.kiro', 'settings', 'mcp.json'), source: 'Kiro (project settings)', clientKey: 'kiro' },
218
220
  { path: path.join(cwd, '.mcp.json'), source: 'Repo (.mcp.json)', clientKey: 'other' },
219
221
  { path: path.join(cwd, 'mcp.json'), source: 'Repo (mcp.json)', clientKey: 'other' },
220
222
  ];
@@ -20,6 +20,7 @@ import { BotGuardConfig } from '../config';
20
20
  * human approves/rejects in the FullCourtDefense console (same as the gateway).
21
21
  */
22
22
  export interface HookArgs {
23
+ agentClient?: string;
23
24
  event?: string;
24
25
  apiUrl?: string;
25
26
  shieldId?: string;
@@ -191,10 +191,10 @@ function mapClaudeToolPayload(payload) {
191
191
  // a file read is `read_file { path }`. Built-ins with no enforcement surface
192
192
  // (search, task, web) are ignored. Nothing here decides anything.
193
193
  // ---------------------------------------------------------------------------
194
- const BUILTIN_SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal']);
195
- const BUILTIN_READ_TOOLS = new Set(['read', 'readfile', 'view']);
194
+ const BUILTIN_SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal', 'runshellcommand', 'executebash', 'execcommand']);
195
+ const BUILTIN_READ_TOOLS = new Set(['read', 'readfile', 'view', 'fsread']);
196
196
  const BUILTIN_WRITE_TOOLS = new Set([
197
- 'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles',
197
+ 'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles', 'writefile', 'fswrite', 'replace',
198
198
  'replacestringinfile', 'editfile', 'applypatch', 'strreplaceeditor', 'insertedittofile', 'strreplace', 'editnotebook',
199
199
  ]);
200
200
  /**
@@ -221,11 +221,11 @@ function mapBuiltinToolPayload(toolName, input, payload) {
221
221
  if (BUILTIN_SHELL_TOOLS.has(canon)) {
222
222
  return {
223
223
  event: 'shell',
224
- payload: { ...payload, command: str(input.command), cwd: str(input.cwd) || str(input.working_directory) || str(payload.cwd) || undefined },
224
+ payload: { ...payload, command: str(input.command) || str(input.cmd), cwd: str(input.cwd) || str(input.workdir) || str(input.working_directory) || str(payload.cwd) || undefined },
225
225
  };
226
226
  }
227
227
  if (BUILTIN_READ_TOOLS.has(canon)) {
228
- return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.path) || str(input.target_file) } };
228
+ return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.filePath) || str(input.absolute_path) || str(input.path) || str(input.target_file) } };
229
229
  }
230
230
  if (BUILTIN_WRITE_TOOLS.has(canon)) {
231
231
  const filePath = str(input.file_path) || str(input.notebook_path) || str(input.path) || str(input.filePath) || str(input.target_file)
@@ -235,7 +235,7 @@ function mapBuiltinToolPayload(toolName, input, payload) {
235
235
  // `new_source`) are reduced to the lines they add. Both then drop the
236
236
  // credential-shaped words the file on disk already contains — re-stating a
237
237
  // value that is already in the file is not putting a secret into it.
238
- const delta = str(input.new_string) || str(input.new_str) || str(input.new_text)
238
+ const delta = str(input.new_string) || str(input.newString) || str(input.newText) || str(input.new_str) || str(input.new_text)
239
239
  || (Array.isArray(input.edits)
240
240
  ? input.edits.map((e) => str(e?.new_string) || str(e?.new_text) || str(e?.newText) || str(e?.content)).filter(Boolean).join('\n')
241
241
  : '');
@@ -1345,7 +1345,8 @@ async function hookCommandInner(args, config, io) {
1345
1345
  // client that reads ~/.claude/settings.json.
1346
1346
  const claudeNormalized = normalizeClaudePayload(payload);
1347
1347
  const hookFormat = claudeNormalized ? 'claude' : 'cursor';
1348
- const client = claudeNormalized ? claudeFormatClient() : 'cursor';
1348
+ const client = args.agentClient || (claudeNormalized ? claudeFormatClient() : 'cursor');
1349
+ (0, telemetry_1.setVerdictClient)(client);
1349
1350
  let event;
1350
1351
  let ignoreEvent = false;
1351
1352
  if (claudeNormalized) {
@@ -5,6 +5,7 @@ const config_1 = require("../config");
5
5
  const discover_1 = require("./discover");
6
6
  const installCursorHook_1 = require("./installCursorHook");
7
7
  const installClaudeHook_1 = require("./installClaudeHook");
8
+ const installNativeHooks_1 = require("./installNativeHooks");
8
9
  const discoverSchedule_1 = require("./discoverSchedule");
9
10
  const mcpGateway_1 = require("./mcpGateway");
10
11
  const autoProtect_1 = require("./autoProtect");
@@ -35,6 +36,16 @@ async function installAllCommand(args, config) {
35
36
  console.log('\x1b[2mAuto-protect MCP servers across AI clients + Cursor built-in action hooks. No --mcp-command or folder path needed.\x1b[0m\n');
36
37
  await (0, mcpGateway_1.protectAllCommand)(gatewayArgs, config);
37
38
  if (args.hooks !== 'false') {
39
+ try {
40
+ const nativeFiles = (0, installNativeHooks_1.installNativeHooks)();
41
+ for (const file of nativeFiles)
42
+ console.log(`Native action hooks configured: ${file}`);
43
+ if (nativeFiles.some(file => file.includes('.codex')))
44
+ console.log('Codex: review and trust the new hooks using /hooks before they can run.');
45
+ }
46
+ catch (error) {
47
+ console.error(`Native hooks incomplete: ${error instanceof Error ? error.message : String(error)}. Repair the settings and rerun install-native-hooks.`);
48
+ }
38
49
  console.log('\n\x1b[1mInstalling Cursor runtime hooks…\x1b[0m');
39
50
  const hookArgs = {
40
51
  shieldId: creds.shieldId,
@@ -35,5 +35,11 @@ export interface ClaudeManagedHookRepairResult {
35
35
  export declare function inspectClaudeManagedHooks(): ClaudeManagedHookRepairResult;
36
36
  /** Repair managed commands without changing matcher/event/fail-open behavior. */
37
37
  export declare function repairClaudeManagedHooks(): ClaudeManagedHookRepairResult;
38
+ /** Copilot CLI user settings are separate from Claude's global settings.
39
+ * Use inline settings, not ~/.copilot/hooks, which VS Code also loads and
40
+ * would therefore execute twice alongside its Claude hook.
41
+ */
42
+ export declare function syncCopilotToolHooks(command?: string): boolean;
43
+ export declare function installCopilotHookCommand(): void;
38
44
  export declare function installClaudeHookCommand(args: InstallClaudeHookArgs, config: BotGuardConfig): Promise<void>;
39
45
  export declare function uninstallClaudeHookCommand(args: InstallClaudeHookArgs): Promise<void>;
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.inspectClaudeManagedHooks = inspectClaudeManagedHooks;
37
37
  exports.repairClaudeManagedHooks = repairClaudeManagedHooks;
38
+ exports.syncCopilotToolHooks = syncCopilotToolHooks;
39
+ exports.installCopilotHookCommand = installCopilotHookCommand;
38
40
  exports.installClaudeHookCommand = installClaudeHookCommand;
39
41
  exports.uninstallClaudeHookCommand = uninstallClaudeHookCommand;
40
42
  const fs = __importStar(require("fs"));
@@ -211,7 +213,7 @@ function syncCopilotToolHooks(command) {
211
213
  const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
212
214
  const kept = entries.filter(entry => !isManagedCommand(entry));
213
215
  if (command)
214
- kept.push({ type: 'command', command, timeoutSec: event === 'PreToolUse' ? TOOL_HOOK_TIMEOUT_SEC : POST_TOOL_HOOK_TIMEOUT_SEC });
216
+ kept.push({ type: 'command', command: `${command} --agent-client copilot`, timeoutSec: event === 'PreToolUse' ? TOOL_HOOK_TIMEOUT_SEC : POST_TOOL_HOOK_TIMEOUT_SEC });
215
217
  if (kept.length)
216
218
  hooks[event] = kept;
217
219
  else
@@ -227,6 +229,10 @@ function syncCopilotToolHooks(command) {
227
229
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
228
230
  return true;
229
231
  }
232
+ function installCopilotHookCommand() {
233
+ syncCopilotToolHooks(buildHookCommand({ shadow: false, failClosed: false }));
234
+ console.log('Copilot CLI tool hooks configured. Restart Copilot CLI to load the changes.');
235
+ }
230
236
  // PostToolUse companion of PreToolUse: enforces nothing; it is how a developer's
231
237
  // Allow on the client's own `ask` prompt (developer-scoped approval) is learned.
232
238
  // Same command — the bridge reads `hook_event_name` from the payload.
@@ -0,0 +1,9 @@
1
+ import type { NativeClient } from '../nativeHook';
2
+ export declare function nativeHookCommand(client: NativeClient): string;
3
+ /** Preserve unrelated settings and hooks; refuse malformed files. */
4
+ export declare function installNativeHooks(options?: {
5
+ home?: string;
6
+ project?: string;
7
+ clients?: NativeClient[];
8
+ remove?: boolean;
9
+ }): string[];
@@ -0,0 +1,145 @@
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.nativeHookCommand = nativeHookCommand;
37
+ exports.installNativeHooks = installNativeHooks;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const marker = '--fcd-native-managed true';
42
+ const managed = (value) => {
43
+ if (typeof value?.command !== 'string')
44
+ return false;
45
+ if (value.command.includes(marker))
46
+ return true;
47
+ const encoded = /-EncodedCommand ([A-Za-z0-9+/=]+)$/.exec(value.command)?.[1];
48
+ return Boolean(encoded && Buffer.from(encoded, 'base64').toString('utf16le').includes(marker));
49
+ };
50
+ const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
51
+ function nativeHookCommand(client) {
52
+ if (!['codex', 'gemini_cli', 'windsurf', 'kiro'].includes(client))
53
+ throw new Error('Unsupported native hook client');
54
+ const entry = path.resolve(__dirname, '..', 'nativeHook.js');
55
+ const flags = `--agent-client ${client} ${marker} --fcd-managed true --local-only true`;
56
+ if (process.platform !== 'win32')
57
+ return `${quote(process.execPath)} ${quote(entry)} ${flags}`;
58
+ // Windows vendors use different shells. An encoded PowerShell launcher is
59
+ // valid in both cmd and PowerShell, preserves paths with spaces, and forwards
60
+ // the JSON stdin explicitly. Paths are data, never interpolated expressions.
61
+ const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
62
+ const script = `$ProgressPreference='SilentlyContinue'; $OutputEncoding=[Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$OutputEncoding; [Console]::OutputEncoding=$OutputEncoding; $raw=[Console]::In.ReadToEnd(); $raw | & ${psQuote(process.execPath)} ${psQuote(entry)} ${flags}; exit $LASTEXITCODE`;
63
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`;
64
+ }
65
+ /** Preserve unrelated settings and hooks; refuse malformed files. */
66
+ function installNativeHooks(options = {}) {
67
+ // Validate library callers too, before any filesystem changes.
68
+ for (const client of options.clients || [])
69
+ nativeHookCommand(client);
70
+ const home = options.home || os.homedir();
71
+ const files = [];
72
+ const errors = [];
73
+ for (const client of options.clients || ['codex', 'gemini_cli', 'windsurf', 'kiro']) {
74
+ const root = client === 'codex' ? path.join(home, '.codex') : client === 'gemini_cli' ? path.join(home, '.gemini')
75
+ : client === 'windsurf' ? path.join(home, '.codeium', 'windsurf') : path.join(options.project || process.cwd(), '.kiro');
76
+ // Install only detected clients. Explicit client selection also supports setup before first launch.
77
+ if (!options.clients && !fs.existsSync(root))
78
+ continue;
79
+ const file = path.join(root, client === 'kiro' ? 'hooks/fullcourtdefense.json' : client === 'gemini_cli' ? 'settings.json' : 'hooks.json');
80
+ if (options.remove && !fs.existsSync(file))
81
+ continue;
82
+ try {
83
+ const data = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
84
+ if (!data || typeof data !== 'object' || Array.isArray(data))
85
+ throw new Error(`Invalid hook settings: ${file}`);
86
+ const before = JSON.stringify(data);
87
+ const command = nativeHookCommand(client);
88
+ if (client === 'kiro') {
89
+ if (data.hooks !== undefined && !Array.isArray(data.hooks))
90
+ throw new Error(`Invalid Kiro hooks: ${file}`);
91
+ const kept = (data.hooks || []).filter((hook) => !managed(hook.action));
92
+ data.version = 'v1';
93
+ data.hooks = options.remove ? kept : [...kept, ...['PreToolUse', 'PostToolUse', 'UserPromptSubmit'].map(trigger => ({
94
+ name: `FullCourtDefense ${trigger}`, trigger, action: { type: 'command', command }, timeout: 130,
95
+ }))];
96
+ }
97
+ else {
98
+ if (data.hooks !== undefined && (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)))
99
+ throw new Error(`Invalid hooks: ${file}`);
100
+ const hooks = data.hooks || {};
101
+ const events = client === 'windsurf'
102
+ ? ['pre_run_command', 'post_run_command', 'pre_read_code', 'pre_write_code', 'post_write_code', 'pre_mcp_tool_use', 'post_mcp_tool_use', 'pre_user_prompt']
103
+ : client === 'gemini_cli' ? ['BeforeTool', 'AfterTool', 'BeforeAgent'] : ['PreToolUse', 'PostToolUse', 'UserPromptSubmit'];
104
+ for (const event of events) {
105
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
106
+ throw new Error(`Invalid ${event} hooks: ${file}`);
107
+ const entries = hooks[event] || [];
108
+ const kept = client === 'windsurf' ? entries.filter((hook) => !managed(hook)) : entries.flatMap((group) => {
109
+ if (!Array.isArray(group?.hooks))
110
+ throw new Error(`Invalid ${event} hook group: ${file}`);
111
+ const rest = group.hooks.filter((hook) => !managed(hook));
112
+ return rest.length ? [{ ...group, hooks: rest }] : [];
113
+ });
114
+ if (!options.remove) {
115
+ if (client === 'windsurf')
116
+ kept.push({ command, powershell: `${command}; exit $LASTEXITCODE`, show_output: true });
117
+ else
118
+ kept.push({ ...(event.includes('Tool') ? { matcher: '.*' } : {}), hooks: [{ type: 'command', command,
119
+ ...(client === 'gemini_cli' ? { name: `fullcourtdefense-${event}`, timeout: 130000 } : { timeout: 130 }) }] });
120
+ }
121
+ if (kept.length)
122
+ hooks[event] = kept;
123
+ else
124
+ delete hooks[event];
125
+ }
126
+ data.hooks = hooks;
127
+ }
128
+ if (JSON.stringify(data) !== before) {
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ if (fs.existsSync(file) && !fs.existsSync(`${file}.fcd-backup`))
131
+ fs.copyFileSync(file, `${file}.fcd-backup`);
132
+ const temporary = `${file}.fcd-${process.pid}.tmp`;
133
+ fs.writeFileSync(temporary, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
134
+ fs.renameSync(temporary, file);
135
+ }
136
+ files.push(file);
137
+ }
138
+ catch (error) {
139
+ errors.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
140
+ }
141
+ }
142
+ if (errors.length)
143
+ throw new Error(`Native hook settings need repair: ${errors.join('; ')}`);
144
+ return files;
145
+ }
@@ -1086,7 +1086,7 @@ class McpGatewayServer {
1086
1086
  // waited for OUR decision (path 'gateway'). Pure in-memory context — no
1087
1087
  // probe, no I/O; the clock is restarted after the downstream call so the
1088
1088
  // tool's own runtime is never billed to FullCourtDefense.
1089
- return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'gateway' }, () => this.handleToolCallInner(params));
1089
+ return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'gateway', agentClient: this.gatewayConfig.agentClient }, () => this.handleToolCallInner(params));
1090
1090
  }
1091
1091
  async handleToolCallInner(params) {
1092
1092
  await this.ensureDownstream();
@@ -53,6 +53,7 @@ const mcpGateway_1 = require("./mcpGateway");
53
53
  const discoverSchedule_1 = require("./discoverSchedule");
54
54
  const installCursorHook_1 = require("./installCursorHook");
55
55
  const installClaudeHook_1 = require("./installClaudeHook");
56
+ const installNativeHooks_1 = require("./installNativeHooks");
56
57
  const shellGuard_1 = require("./shellGuard");
57
58
  const posixShellGuard_1 = require("./posixShellGuard");
58
59
  const cmdGuard_1 = require("./cmdGuard");
@@ -246,6 +247,7 @@ async function runLocalDetach(args, config) {
246
247
  { label: 'MCP gateway wraps (restore original server commands)', run: () => (0, mcpGateway_1.unprotectAllCommand)({ ...args, clients: args.clients || 'all' }, config) },
247
248
  { label: 'Cursor runtime hooks', run: () => (0, installCursorHook_1.uninstallCursorHookCommand)({}) },
248
249
  { label: 'Claude Code / VS Code / Copilot CLI hooks', run: () => (0, installClaudeHook_1.uninstallClaudeHookCommand)({}) },
250
+ { label: 'Codex / Gemini / Windsurf / current Kiro project hooks', run: () => { (0, installNativeHooks_1.installNativeHooks)({ remove: true }); } },
249
251
  ...(process.platform === 'win32'
250
252
  ? [
251
253
  { label: 'PowerShell shell guard', run: () => (0, shellGuard_1.uninstallShellGuardCommand)() },
package/dist/hookIo.js CHANGED
@@ -129,6 +129,7 @@ function readStdin() {
129
129
  */
130
130
  function mapHookFlags(flags) {
131
131
  return {
132
+ agentClient: flags['agent-client'],
132
133
  event: flags.event,
133
134
  apiUrl: flags['api-url'],
134
135
  shieldId: flags['shield-id'],
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
+ });
@@ -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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.33.0"
2
+ "version": "1.34.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.33.0",
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,7 @@
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",
16
17
  "test:endpoint-approvals": "npm run build && node scripts/audit-endpoint-mcp-shell.js",
17
18
  "test:endpoint-clients": "npm run build && node scripts/test-endpoint-client-matrix.js && node scripts/test-copilot-hook-install.js",
18
19
  "build": "tsc && node scripts/copy-attack-corpus.js",