fullcourtdefense-cli 1.3.6 → 1.4.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.
@@ -53,7 +53,7 @@ function looksLikeShieldKey(value) {
53
53
  return /^shsk_[A-Za-z0-9]{24,160}$/.test(value);
54
54
  }
55
55
  function looksLikeApiKey(value) {
56
- return /^ag_[A-Za-z0-9]{24,160}$/.test(value);
56
+ return /^ag_[A-Za-z0-9_-]{24,200}$/.test(value);
57
57
  }
58
58
  function validateLocalShape(input) {
59
59
  const problems = [];
@@ -3,12 +3,21 @@ import { BotGuardConfig } from '../config';
3
3
  * `fullcourtdefense hook` — Cursor hook bridge.
4
4
  *
5
5
  * Cursor invokes this command for agent lifecycle events (beforeSubmitPrompt,
6
- * beforeShellExecution, beforeMCPExecution, afterFileEdit, ...). Cursor pipes a
7
- * JSON payload on stdin and reads a JSON verdict on stdout. Exit code 2 also
8
- * blocks the action, which we use as the cross-event, cross-version-safe block
9
- * signal (not every event documents a `permission` output field).
6
+ * beforeShellExecution, beforeMCPExecution, afterFileEdit, beforeReadFile).
7
+ * Cursor pipes a JSON payload on stdin and reads a JSON verdict on stdout.
10
8
  *
11
- * Verdict source of truth: POST {apiUrl}/api/shield/analyze/{shieldId}.
9
+ * Two enforcement engines, by event:
10
+ * - prompt -> Shield text analysis (POST /api/shield/proxy/{shieldId}).
11
+ * Action Policies don't apply to free-form chat, so prompts are
12
+ * judged by the developer-chat Shield (monitor or block per mode).
13
+ * - shell / mcp / file / read -> Action Policies, the SAME engine the MCP
14
+ * gateway uses (POST /api/agent-security/runtime/check-tool-call).
15
+ * The org's policies attached to the runtime identity decide:
16
+ * allow / block / require_approval — enforced in EVERY repo on the
17
+ * machine, regardless of which folder Cursor is open in.
18
+ *
19
+ * require_approval pauses the action and polls the approval endpoint until a
20
+ * human approves/rejects in the FullCourtDefense console (same as the gateway).
12
21
  */
13
22
  export interface HookArgs {
14
23
  event?: string;
@@ -18,5 +27,8 @@ export interface HookArgs {
18
27
  shadow?: string;
19
28
  failClosed?: string;
20
29
  timeout?: string;
30
+ approvalMode?: string;
31
+ approvalTimeoutMs?: string;
32
+ approvalPollMs?: string;
21
33
  }
22
34
  export declare function hookCommand(args: HookArgs, config: BotGuardConfig): Promise<void>;
@@ -102,48 +102,72 @@ function inferEvent(explicit, payload) {
102
102
  return 'file';
103
103
  return 'unknown';
104
104
  }
105
- /** Pull the meaningful text to scan out of a Cursor hook payload, per event. */
106
- function extractText(event, p) {
107
- const str = (v) => (typeof v === 'string' ? v : '');
105
+ const str = (v) => (typeof v === 'string' ? v : '');
106
+ /** Pull the meaningful text to scan out of a Cursor PROMPT hook payload. */
107
+ function extractPromptText(p) {
108
+ const direct = str(p.prompt) || str(p.text) || str(p.message) || str(p.content);
109
+ if (direct)
110
+ return direct;
111
+ const promptObj = p.prompt;
112
+ if (promptObj && typeof promptObj === 'object') {
113
+ const t = str(promptObj.text) || str(promptObj.content);
114
+ if (t)
115
+ return t;
116
+ }
117
+ const messages = p.messages;
118
+ if (Array.isArray(messages) && messages.length) {
119
+ const last = messages[messages.length - 1];
120
+ return str(last?.content) || str(last?.text);
121
+ }
122
+ return '';
123
+ }
124
+ /**
125
+ * Map a Cursor execution-hook payload onto an Action Policy tool call.
126
+ * Returns null when there is nothing actionable to evaluate.
127
+ */
128
+ function buildToolCall(event, p) {
108
129
  switch (event) {
109
- case 'prompt': {
110
- const direct = str(p.prompt) || str(p.text) || str(p.message) || str(p.content);
111
- if (direct)
112
- return direct;
113
- // Some shapes nest the prompt or send a messages array.
114
- const promptObj = p.prompt;
115
- if (promptObj && typeof promptObj === 'object') {
116
- const t = str(promptObj.text) || str(promptObj.content);
117
- if (t)
118
- return t;
119
- }
120
- const messages = p.messages;
121
- if (Array.isArray(messages) && messages.length) {
122
- const last = messages[messages.length - 1];
123
- return str(last?.content) || str(last?.text);
124
- }
125
- return '';
130
+ case 'shell': {
131
+ const command = str(p.command) || str(p.cmd);
132
+ if (!command)
133
+ return null;
134
+ return { toolName: 'shell', toolArgs: { command, cwd: str(p.cwd) || undefined } };
126
135
  }
127
- case 'shell':
128
- return str(p.command) || str(p.cmd);
129
136
  case 'mcp': {
130
- const name = str(p.tool_name) || str(p.toolName) || str(p.name);
131
- const input = p.tool_input ?? p.arguments ?? p.input ?? p.params;
132
- const inputStr = typeof input === 'string' ? input : input ? safeJson(input) : '';
133
- return [name, inputStr].filter(Boolean).join(': ');
137
+ const toolName = str(p.tool_name) || str(p.toolName) || str(p.name) || 'mcp';
138
+ const rawInput = p.tool_input ?? p.arguments ?? p.input ?? p.params;
139
+ const toolArgs = rawInput && typeof rawInput === 'object' ? rawInput : { value: rawInput };
140
+ return { toolName, toolArgs };
134
141
  }
135
142
  case 'file': {
143
+ const filePath = str(p.file_path) || str(p.path);
136
144
  const edits = p.edits;
137
- if (Array.isArray(edits)) {
138
- return edits.map((e) => str(e.new_text) || str(e.text) || str(e.content)).filter(Boolean).join('\n');
139
- }
140
- return str(p.content) || str(p.new_text) || '';
145
+ const content = Array.isArray(edits)
146
+ ? edits.map((e) => str(e.new_text) || str(e.text) || str(e.content)).filter(Boolean).join('\n')
147
+ : (str(p.content) || str(p.new_text));
148
+ if (!filePath && !content)
149
+ return null;
150
+ return { toolName: 'edit_file', toolArgs: { path: filePath || undefined, content: content || undefined } };
151
+ }
152
+ case 'read': {
153
+ const filePath = str(p.file_path) || str(p.path);
154
+ if (!filePath)
155
+ return null;
156
+ return { toolName: 'read_file', toolArgs: { path: filePath } };
141
157
  }
142
- case 'read':
143
- return str(p.file_path) || str(p.path) || '';
144
158
  default:
145
- return str(p.prompt) || str(p.text) || str(p.message) || str(p.command) || '';
159
+ return null;
160
+ }
161
+ }
162
+ function summarizeToolCall(toolName, toolArgs) {
163
+ const parts = [toolName];
164
+ for (const [k, v] of Object.entries(toolArgs)) {
165
+ if (v === undefined || v === null)
166
+ continue;
167
+ const sv = typeof v === 'string' ? v : safeJson(v);
168
+ parts.push(`${k}=${sv.slice(0, 200)}`);
146
169
  }
170
+ return parts.join(' ').slice(0, 500);
147
171
  }
148
172
  function safeJson(v) {
149
173
  try {
@@ -153,7 +177,7 @@ function safeJson(v) {
153
177
  return String(v);
154
178
  }
155
179
  }
156
- /** Per-event attribution that maps onto the existing Shield runtime headers. */
180
+ /** Per-event attribution that maps onto the existing Shield runtime headers (prompt path). */
157
181
  function attributionFor(event, p) {
158
182
  const h = {};
159
183
  const set = (k, v) => { if (v)
@@ -164,33 +188,12 @@ function attributionFor(event, p) {
164
188
  set('x-runtime-event-type', 'user_message');
165
189
  set('x-runtime-trust', 'trusted_user');
166
190
  break;
167
- case 'shell':
168
- set('x-input-source', 'tool');
169
- set('x-runtime-event-type', 'action_request');
170
- set('x-action-type', 'shell');
171
- set('x-runtime-sink', 'execute');
172
- set('x-runtime-trust', 'untrusted');
173
- break;
174
- case 'mcp':
175
- set('x-input-source', 'tool');
176
- set('x-runtime-event-type', 'tool_call');
177
- set('x-tool-name', String(p.tool_name || p.toolName || p.name || 'mcp'));
178
- set('x-runtime-trust', 'untrusted');
179
- break;
180
- case 'file':
181
- set('x-input-source', 'generated');
182
- set('x-runtime-event-type', 'memory_write');
183
- break;
184
- case 'read':
185
- set('x-input-source', 'tool');
186
- set('x-runtime-event-type', 'memory_read');
187
- break;
188
191
  default:
189
192
  set('x-input-source', 'user');
190
193
  }
191
194
  return h;
192
195
  }
193
- /** Stable developer identity, used for per-developer attribution on shield events. */
196
+ /** Stable developer identity, used for per-developer attribution. */
194
197
  function developerId() {
195
198
  if (process.env.FULLCOURTDEFENSE_DEVELOPER_ID)
196
199
  return process.env.FULLCOURTDEFENSE_DEVELOPER_ID;
@@ -209,10 +212,29 @@ function sessionId(p) {
209
212
  return fromPayload;
210
213
  return `cursor-${os.hostname()}`;
211
214
  }
215
+ function machineMetadata() {
216
+ let username = 'unknown';
217
+ try {
218
+ username = os.userInfo().username;
219
+ }
220
+ catch { /* ignore */ }
221
+ return {
222
+ developerName: developerId(),
223
+ machineName: os.hostname(),
224
+ os: `${os.platform()} ${os.release()}`,
225
+ username,
226
+ cwd: process.cwd(),
227
+ workspacePath: process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd(),
228
+ agentClient: 'cursor',
229
+ };
230
+ }
212
231
  async function hookCommand(args, config) {
213
232
  const shadow = args.shadow === 'true';
214
233
  const failClosed = args.failClosed === 'true';
215
234
  const timeoutMs = Number(args.timeout) > 0 ? Number(args.timeout) : 8000;
235
+ const approvalMode = args.approvalMode === 'block' ? 'block' : 'wait';
236
+ const approvalTimeoutMs = Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 300000;
237
+ const approvalPollMs = Math.max(1000, Number(args.approvalPollMs) > 0 ? Number(args.approvalPollMs) : 3000);
216
238
  const creds = (0, config_1.resolveCliCredentials)(config, {
217
239
  shieldId: args.shieldId,
218
240
  shieldKey: args.shieldKey,
@@ -245,13 +267,12 @@ async function hookCommand(args, config) {
245
267
  }
246
268
  }
247
269
  const event = inferEvent(args.event, payload);
248
- const text = extractText(event, payload).trim();
249
270
  dbg({ phase: 'invoke', event, argEvent: args.event, pid: process.pid,
250
271
  isTTY, rawLen: raw.length, rawPreview: raw.slice(0, 300), stdinErr, parseErr,
251
- argv: process.argv.slice(2), payloadKeys: Object.keys(payload), textPreview: text.slice(0, 80) });
272
+ argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
252
273
  // Emit the verdict in the shape Cursor expects for THIS event, then exit.
253
274
  // beforeSubmitPrompt blocks via { continue: false }; the execution hooks
254
- // (shell/mcp/read) block via { permission: "deny" }. Exit 2 reinforces it.
275
+ // (shell/mcp/read) block via { permission: "deny" }.
255
276
  const respond = (blocked, userMsg, agentMsg) => {
256
277
  const decision = event === 'prompt'
257
278
  ? (blocked ? { continue: false } : { continue: true })
@@ -267,18 +288,154 @@ async function hookCommand(args, config) {
267
288
  // so exiting 2 would silently let blocked prompts through.
268
289
  process.exit(0);
269
290
  };
270
- // Nothing to scan, or no Shield configured → allow (fail-open by design here:
271
- // a misconfigured machine must not brick the developer's IDE).
272
- if (!text)
273
- respond(false);
291
+ // No Shield configured → allow (fail-open by design: a misconfigured machine
292
+ // must not brick the developer's IDE).
274
293
  if (!shieldId) {
275
- respond(false, undefined, 'FullCourtDefense hook installed but no Shield ID configured (run `fullcourtdefense configure`).');
294
+ respond(false, undefined, 'FullCourtDefense hook installed but no Shield ID configured (run `fullcourtdefense setup`).');
295
+ }
296
+ // --- Action Policy enforcement for actions (shell / mcp / file / read) ---
297
+ if (event === 'shell' || event === 'mcp' || event === 'file' || event === 'read') {
298
+ await enforceActionPolicy({
299
+ event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
300
+ shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond,
301
+ });
302
+ return;
303
+ }
304
+ // --- Shield text analysis for prompts (developer chat) ---
305
+ if (event === 'prompt') {
306
+ const text = extractPromptText(payload).trim();
307
+ if (!text)
308
+ respond(false);
309
+ await enforceShieldText({
310
+ event, text, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
311
+ shadow, failClosed, timeoutMs, respond,
312
+ });
313
+ return;
314
+ }
315
+ // Unknown event → nothing to enforce.
316
+ respond(false);
317
+ }
318
+ async function enforceActionPolicy(ctx) {
319
+ const { event, payload, apiUrl, shieldId, shieldKey, shadow, failClosed, timeoutMs, respond } = ctx;
320
+ const call = buildToolCall(event, payload);
321
+ if (!call) {
322
+ respond(false);
323
+ return;
276
324
  }
325
+ const headers = { 'Content-Type': 'application/json' };
326
+ if (shieldKey)
327
+ headers['x-shield-key'] = shieldKey;
328
+ let result;
329
+ try {
330
+ const controller = new AbortController();
331
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
332
+ const resp = await fetch(`${apiUrl}/api/agent-security/runtime/check-tool-call`, {
333
+ method: 'POST',
334
+ headers,
335
+ body: JSON.stringify({
336
+ shieldId,
337
+ agentName: `cursor-${developerId()}`,
338
+ toolName: call.toolName,
339
+ toolArgs: call.toolArgs,
340
+ argsSummary: summarizeToolCall(call.toolName, call.toolArgs),
341
+ sessionId: sessionId(payload),
342
+ source: 'cursor_hook',
343
+ ...machineMetadata(),
344
+ }),
345
+ signal: controller.signal,
346
+ });
347
+ clearTimeout(timer);
348
+ if (!resp.ok) {
349
+ dbg({ phase: 'policy_http_error', event, status: resp.status });
350
+ respond(failClosed, failClosed ? `FullCourtDefense policy gate unavailable (HTTP ${resp.status}) — blocked by fail-closed policy.` : undefined, `FullCourtDefense policy gate returned HTTP ${resp.status}.`);
351
+ return;
352
+ }
353
+ const body = await resp.json().catch(() => ({}));
354
+ if (!body.success || !body.data) {
355
+ dbg({ phase: 'policy_no_data', event, error: body.error });
356
+ respond(failClosed, failClosed ? 'FullCourtDefense policy gate error — blocked by fail-closed policy.' : undefined, body.error);
357
+ return;
358
+ }
359
+ result = body.data;
360
+ }
361
+ catch (err) {
362
+ dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err) });
363
+ respond(failClosed, failClosed ? 'FullCourtDefense policy gate unreachable — blocked by fail-closed policy.' : undefined, `FullCourtDefense hook error: ${err instanceof Error ? err.message : String(err)}.`);
364
+ return;
365
+ }
366
+ const reason = result.actionPolicy?.reason
367
+ || result.intentEvaluation?.reasons?.[0]
368
+ || `${call.toolName} ${result.decision}ed by Action Policy.`;
369
+ const policyName = result.actionPolicy?.policyName;
370
+ dbg({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
371
+ if (result.allowed && result.decision === 'allow') {
372
+ respond(false);
373
+ return;
374
+ }
375
+ // require_approval — pause and wait for a human to approve in the console.
376
+ if (result.decision === 'approval') {
377
+ if (shadow) {
378
+ respond(false, undefined, `[FullCourtDefense shadow] would require approval for ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
379
+ return;
380
+ }
381
+ if (ctx.approvalMode === 'block' || !result.approvalActionId) {
382
+ respond(true, `Approval required by FullCourtDefense${policyName ? ` (${policyName})` : ''} — ${reason}`, `This ${event} requires human approval per Action Policy. Stop and ask the developer to approve it in the FullCourtDefense console.`);
383
+ return;
384
+ }
385
+ const outcome = await waitForApproval({
386
+ apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
387
+ timeoutMs: ctx.approvalTimeoutMs, pollMs: ctx.approvalPollMs,
388
+ });
389
+ if (outcome.status === 'approved') {
390
+ respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
391
+ return;
392
+ }
393
+ respond(true, `FullCourtDefense: ${outcome.message}`, `This ${event} was not approved (${outcome.status}). Do not retry until a human approves it in the FullCourtDefense console.`);
394
+ return;
395
+ }
396
+ // block / mask / any other not-allowed decision.
397
+ if (shadow) {
398
+ respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
399
+ return;
400
+ }
401
+ respond(true, `Blocked by FullCourtDefense${policyName ? ` (${policyName})` : ''} — ${reason}`, `FullCourtDefense blocked this ${event} per Action Policy: ${reason}. Do not retry.`);
402
+ }
403
+ async function waitForApproval(input) {
404
+ const headers = { 'Content-Type': 'application/json' };
405
+ if (input.shieldKey)
406
+ headers['x-shield-key'] = input.shieldKey;
407
+ const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
408
+ const deadline = Date.now() + input.timeoutMs;
409
+ dbg({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
410
+ while (Date.now() <= deadline) {
411
+ try {
412
+ const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15000) });
413
+ if (resp.ok) {
414
+ const body = await resp.json().catch(() => ({}));
415
+ const approval = body.data?.approval;
416
+ if (approval?.approvalStatus === 'approved') {
417
+ return { status: 'approved', message: 'approved by a human reviewer.' };
418
+ }
419
+ if (approval?.approvalStatus === 'rejected') {
420
+ return { status: 'rejected', message: `approval rejected for ${approval.toolName}.` };
421
+ }
422
+ if (approval?.approvalStatus === 'expired') {
423
+ return { status: 'expired', message: `approval expired for ${approval.toolName}.` };
424
+ }
425
+ }
426
+ }
427
+ catch (err) {
428
+ dbg({ phase: 'approval_poll_error', actionId: input.actionId, error: err instanceof Error ? err.message : String(err) });
429
+ }
430
+ await new Promise((resolve) => setTimeout(resolve, Math.min(input.pollMs, Math.max(0, deadline - Date.now()))));
431
+ }
432
+ return { status: 'timeout', message: 'approval timed out — still waiting for human review.' };
433
+ }
434
+ async function enforceShieldText(ctx) {
435
+ const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, failClosed, timeoutMs, respond } = ctx;
277
436
  try {
278
437
  const controller = new AbortController();
279
438
  const timer = setTimeout(() => controller.abort(), timeoutMs);
280
- // Behaves exactly like a normal Shield agent calling the public proxy:
281
- // Shield key + runtime/developer attribution headers, body { message }.
282
439
  const headers = {
283
440
  'Content-Type': 'application/json',
284
441
  'x-runtime-source-id': developerId(),
@@ -296,28 +453,21 @@ async function hookCommand(args, config) {
296
453
  });
297
454
  clearTimeout(timer);
298
455
  if (!resp.ok) {
299
- // Backend rejected (quota, bad shield, etc.) — fail open unless told otherwise.
300
456
  respond(failClosed, failClosed ? `FullCourtDefense gate unavailable (HTTP ${resp.status}) — blocked by fail-closed policy.` : undefined, `FullCourtDefense gate returned HTTP ${resp.status}.`);
301
457
  }
302
458
  const data = await resp.json().catch(() => ({}));
303
459
  const sh = (data._shield || {});
304
460
  const action = String(sh.action || '');
305
- // Proxy returns 'blocked_input' / 'blocked_output' when it blocks; in monitor
306
- // mode it returns 'allowed' (never block, even if wouldBlock is set).
307
461
  const isBlocked = action.startsWith('blocked');
308
462
  const reason = String(sh.reason || 'policy_violation');
309
463
  const explanation = String(sh.reason || 'Flagged by FullCourtDefense.');
310
- if (!isBlocked) {
464
+ if (!isBlocked)
311
465
  respond(false);
312
- }
313
- // Blocked verdict. In shadow mode we annotate but let it through.
314
- if (shadow) {
466
+ if (shadow)
315
467
  respond(false, undefined, `[FullCourtDefense shadow] would block ${event} (${reason}): ${explanation}`);
316
- }
317
468
  respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
318
469
  }
319
470
  catch (err) {
320
- // Network error / timeout.
321
471
  respond(failClosed, failClosed ? 'FullCourtDefense gate unreachable — blocked by fail-closed policy.' : undefined, `FullCourtDefense hook error: ${err instanceof Error ? err.message : String(err)}.`);
322
472
  }
323
473
  }
@@ -47,12 +47,16 @@ const COLOR = {
47
47
  // find our entries regardless of the install path (npm global vs local dev).
48
48
  const MANAGED_MARKER = '--fcd-managed true';
49
49
  const MANAGED_TAG = 'fullcourtdefense';
50
+ // `timeoutSec` is the Cursor-side hook timeout. Action events can pause for a
51
+ // human approval decision, so they need a long timeout (Cursor kills the hook
52
+ // at this limit). Allow/block verdicts still return in <1s — the long timeout
53
+ // only matters while waiting for a human to approve in the console.
50
54
  const EVENT_MAP = {
51
- prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', failClosedDefault: false },
52
- shell: { hookKey: 'beforeShellExecution', flag: 'shell', failClosedDefault: true },
53
- mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', failClosedDefault: true },
54
- file: { hookKey: 'afterFileEdit', flag: 'file', failClosedDefault: false },
55
- read: { hookKey: 'beforeReadFile', flag: 'read', failClosedDefault: false },
55
+ prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', failClosedDefault: false, timeoutSec: 10 },
56
+ shell: { hookKey: 'beforeShellExecution', flag: 'shell', failClosedDefault: true, timeoutSec: 300 },
57
+ mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', failClosedDefault: true, timeoutSec: 300 },
58
+ file: { hookKey: 'afterFileEdit', flag: 'file', failClosedDefault: false, timeoutSec: 300 },
59
+ read: { hookKey: 'beforeReadFile', flag: 'read', failClosedDefault: false, timeoutSec: 300 },
56
60
  };
57
61
  /** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
58
62
  function buildHookCommand(flag, opts) {
@@ -120,11 +124,11 @@ async function installCursorHookCommand(args, config) {
120
124
  const credFile = ensureHomeShield(creds);
121
125
  const json = readHooksJson(file);
122
126
  for (const e of events) {
123
- const { hookKey, flag, failClosedDefault } = EVENT_MAP[e];
127
+ const { hookKey, flag, failClosedDefault, timeoutSec } = EVENT_MAP[e];
124
128
  const failClosed = args.failClosed !== undefined ? args.failClosed === 'true' : failClosedDefault;
125
129
  const entry = {
126
130
  command: buildHookCommand(flag, { shadow, failClosed }),
127
- timeout: 10,
131
+ timeout: timeoutSec,
128
132
  };
129
133
  if (failClosed)
130
134
  entry.failClosed = true;
@@ -140,6 +144,13 @@ async function installCursorHookCommand(args, config) {
140
144
  console.log(`${COLOR.gray}Scope:${COLOR.reset} ${projectScope ? 'project (.cursor/hooks.json)' : 'machine-wide (~/.cursor/hooks.json)'}`);
141
145
  console.log(`${COLOR.gray}File:${COLOR.reset} ${file}`);
142
146
  console.log(`${COLOR.gray}Events:${COLOR.reset} ${events.map((e) => EVENT_MAP[e].hookKey).join(', ')}`);
147
+ const actionEvents = events.filter((e) => e !== 'prompt');
148
+ if (actionEvents.length) {
149
+ console.log(`${COLOR.gray}Enforcement:${COLOR.reset} ${actionEvents.join(', ')} checked against your org Action Policies (allow / block / wait-for-human-approval) in every repo.`);
150
+ }
151
+ if (events.includes('prompt')) {
152
+ console.log(`${COLOR.gray}Prompts:${COLOR.reset} scanned by your Shield (developer-chat).`);
153
+ }
143
154
  if (shadow)
144
155
  console.log(`${COLOR.yellow}Mode: SHADOW (monitor only — nothing is blocked).${COLOR.reset}`);
145
156
  if (credFile)
package/dist/index.js CHANGED
@@ -118,8 +118,10 @@ function printHelp() {
118
118
  discover Finds MCP servers, local secrets, agent rules/skills, and machine
119
119
  blast radius on this laptop. Use --surface all --upload for AI Fleet.
120
120
  install-cursor-hook
121
- Installs a Cursor hook so every AI prompt + agent action
122
- (shell, MCP) on this machine is scanned by your Shield.
121
+ Installs a Cursor hook so EVERY agent action on this machine is
122
+ checked against your org's Action Policies in any repo/folder.
123
+ Prompts are scanned by your Shield; shell/MCP/file/read actions
124
+ are allowed, blocked, or paused for human approval per policy.
123
125
  uninstall-cursor-hook
124
126
  Removes the FullCourtDefense Cursor hook entries.
125
127
  install-mcp-gateway
@@ -472,6 +474,9 @@ async function main() {
472
474
  shadow: flags.shadow,
473
475
  failClosed: flags['fail-closed'],
474
476
  timeout: flags.timeout,
477
+ approvalMode: flags['approval-mode'],
478
+ approvalTimeoutMs: flags['approval-timeout-ms'],
479
+ approvalPollMs: flags['approval-poll-ms'],
475
480
  };
476
481
  await (0, hook_1.hookCommand)(args, config);
477
482
  break;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.3.6"
2
+ "version": "1.4.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.3.6",
3
+ "version": "1.4.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": {