fullcourtdefense-cli 1.39.0 → 1.39.2

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.
@@ -157,7 +157,7 @@ function buildGuardJs(nodePath, cliEntry) {
157
157
  `function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
158
158
  `// "s = remember for this cmd window": keyed by the parent cmd.exe pid (doskey runs us directly under it); dies with the window, capped in time.`,
159
159
  `const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
160
- `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
160
+ `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',fallback:r.fallback===true,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
161
161
  `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
162
162
  `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
163
163
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
@@ -182,7 +182,7 @@ function buildGuardJs(nodePath, cliEntry) {
182
182
  `const line=args.join(' ');let mode='monitor';let hit=null;try{const r=loadRules();mode=r.mode;hit=matchLine(line,r.rules);}catch{hit=null;}`,
183
183
  `if(!hit)delegate(args);`,
184
184
  `// Monitor is quiet: a would-block / would-ask is recorded ('warn') and the command runs with nothing printed. A warn-only rule is a real warning (below).`,
185
- `if(mode==='monitor'&&hit.action!=='warn'){spoolEvent(hit,line,'warn');delegate(args);}`,
185
+ `if(mode==='monitor'&&(hit.action!=='warn'||hit.fallback)){spoolEvent(hit,line,'warn');delegate(args);}`,
186
186
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
187
187
  `if(hit.action==='ask'){`,
188
188
  ` if(agentTerminal()){`,
@@ -1921,9 +1921,8 @@ async function runDaemon(args, config) {
1921
1921
  // reports every failure by returning null and never throws, so without
1922
1922
  // this flag a heartbeat that never left the machine was indistinguishable
1923
1923
  // from a delivered one — and the loop happily waited a full cadence before
1924
- // trying again. `null` also covers the case where a hook flusher held the
1925
- // flush lock, which drops the daemon marker specifically (hook flushes do
1926
- // not set `daemon: true`), so retrying soon is right in that case too.
1924
+ // trying again. A hook flusher holding the spool lock is not a miss: the
1925
+ // flush op then sends this beat without the spool (housekeepingOps).
1927
1926
  let reported = false;
1928
1927
  let refusal;
1929
1928
  try {
@@ -2071,9 +2070,14 @@ async function runDaemon(args, config) {
2071
2070
  // the `unhandledRejection` handler, which is worse than dying: it holds the
2072
2071
  // pid lock, so the scheduled task declines to start a replacement, and it
2073
2072
  // never polls the bundle, so no remote action can reach it. Booting before
2074
- // the network is up (captive portal, VPN, resumed in a lift) is routine, and
2075
- // the heartbeat loop below retries within seconds.
2073
+ // the network is up (captive portal, VPN, resumed in a lift) is routine.
2074
+ // A failed first beat is handed to the heartbeat loop below as a failure, so
2075
+ // its first tick is a retry in seconds. It used to wait a full cadence
2076
+ // (5 min): after the 1.39.1 auto-update the console kept showing the old
2077
+ // version, "auto-update pending" and a stale "Online" time for that window.
2078
+ let firstHeartbeatFailed = false;
2076
2079
  await heartbeat().catch(error => {
2080
+ firstHeartbeatFailed = true;
2077
2081
  log(`First heartbeat failed: ${error?.message || String(error)}. The heartbeat loop will retry shortly.`);
2078
2082
  });
2079
2083
  // Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
@@ -2217,6 +2221,7 @@ async function runDaemon(args, config) {
2217
2221
  run: heartbeat,
2218
2222
  intervalMs: () => HEARTBEAT_INTERVAL_MS,
2219
2223
  deadlineMs: HEARTBEAT_DEADLINE_MS,
2224
+ initialFailures: firstHeartbeatFailed ? 1 : 0,
2220
2225
  onStalled: deadlineMs => {
2221
2226
  log(`Heartbeat did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; spooled events stay on disk for the next tick.`);
2222
2227
  (0, distress_1.reportDistress)('heartbeat', distress_1.DISTRESS.POLL_STALLED, `heartbeat exceeded ${deadlineMs}ms`);
@@ -274,6 +274,9 @@ function runScenario(scenario, opts) {
274
274
  // The resident daemon (if any) must not answer for the sandbox: it would
275
275
  // evaluate with the REAL home and spool real events.
276
276
  FCD_VERDICT_IPC: 'off',
277
+ // Monitor is quiet to the agent (monitorHint); this replay reports what
278
+ // a monitor machine WOULD block, so it asks the hook for that text.
279
+ FCD_MONITOR_HINTS: '1',
277
280
  FULLCOURTDEFENSE_DEVELOPER_ID: opts.developerId,
278
281
  },
279
282
  });
@@ -367,6 +370,38 @@ function renderReport(report) {
367
370
  lines.push('');
368
371
  return lines.join('\n');
369
372
  }
373
+ /**
374
+ * The last replayed hook starts a detached flush-spool child in the sandbox
375
+ * home (its cwd, and it holds the spool open). Windows refuses to remove a
376
+ * folder in use, so wait for that child (bounded), then remove with retries.
377
+ */
378
+ async function removeSandboxHome(home) {
379
+ const marker = path.join(home, '.fullcourtdefense-flush.ts');
380
+ const deadline = Date.now() + 5_000;
381
+ while (Date.now() < deadline) {
382
+ let running = false;
383
+ try {
384
+ const [, pid, state] = fs.readFileSync(marker, 'utf8').trim().split(':');
385
+ if (state !== 'done' && state !== 'fail' && Number(pid) > 0) {
386
+ try {
387
+ process.kill(Number(pid), 0);
388
+ running = true;
389
+ }
390
+ catch (error) {
391
+ running = error.code === 'EPERM';
392
+ }
393
+ }
394
+ }
395
+ catch { /* no marker: no flusher was started */ }
396
+ if (!running)
397
+ break;
398
+ await new Promise(resolve => setTimeout(resolve, 100));
399
+ }
400
+ try {
401
+ fs.rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
402
+ }
403
+ catch { /* best effort */ }
404
+ }
370
405
  async function demoActionsCommand(args, config) {
371
406
  const live = args.live === 'true';
372
407
  const json = args.json === 'true';
@@ -419,12 +454,8 @@ async function demoActionsCommand(args, config) {
419
454
  }
420
455
  }
421
456
  finally {
422
- if (cleanup) {
423
- try {
424
- fs.rmSync(cleanup, { recursive: true, force: true });
425
- }
426
- catch { /* best effort */ }
427
- }
457
+ if (cleanup)
458
+ await removeSandboxHome(cleanup);
428
459
  }
429
460
  const report = {
430
461
  mode: live ? 'live' : 'offline',
@@ -286,6 +286,14 @@ function deobfuscateShellTokens(text) {
286
286
  out = out.replace(/\\(?=[A-Za-z0-9])/g, '');
287
287
  return out.replace(/\s+/g, ' ').trim();
288
288
  }
289
+ /** Only `${IFS}` / `$IFS` and `{brace,words}` undone: quotes and backslashes keep their meaning. */
290
+ function unscrambleShellWords(text) {
291
+ let out = text.replace(/\$\{IFS[^}]*\}/g, ' ').replace(/\$IFS\b/g, ' ');
292
+ for (let i = 0; i < 3 && /\{[^{}]*,[^{}]*\}/.test(out); i++) {
293
+ out = out.replace(/\{([^{}]*,[^{}]*)\}/g, (_m, body) => body.split(',').join(' '));
294
+ }
295
+ return out;
296
+ }
289
297
  /** bash/zsh ANSI-C quoting: `$'\x72\x6d'` IS `rm`. Decode `\xHH`, `\uHHHH`,
290
298
  * octal, and single-char escapes, splicing plain text back into the command. */
291
299
  function decodeAnsiCQuoting(text) {
@@ -2025,7 +2033,39 @@ function destructiveCommandReason(value, opts = {}, options, rawValue) {
2025
2033
  return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
2026
2034
  return undefined;
2027
2035
  };
2028
- const windowsDelete = () => (on('windows_drive_delete') ? windowsRecursiveDeleteHit(rawValue === undefined ? value : rawValue) : undefined);
2036
+ const windowsDelete = () => {
2037
+ if (!on('windows_drive_delete'))
2038
+ return undefined;
2039
+ const raw = rawValue === undefined ? value : rawValue;
2040
+ const direct = windowsRecursiveDeleteHit(raw);
2041
+ if (direct)
2042
+ return direct;
2043
+ // Word disguises (${IFS}, {brace,words}, ANSI-C $'\x..'), also inside encoded
2044
+ // payloads, undone WITHOUT dequoting: quoted text stays data and backslashes
2045
+ // stay path separators, so the same target classifier still tells a drive
2046
+ // from a deep project path (1.38 parity: 306/306 disguised drive wipes).
2047
+ const seeds = [raw];
2048
+ let frontier = [raw];
2049
+ for (let depth = 0; depth < 2 && frontier.length > 0; depth++) {
2050
+ const next = [];
2051
+ for (const source of frontier)
2052
+ for (const decoded of decodeEncodedPayloads(source)) {
2053
+ seeds.push(decoded);
2054
+ next.push(decoded);
2055
+ }
2056
+ frontier = next;
2057
+ }
2058
+ for (const seed of seeds) {
2059
+ for (const variant of [unscrambleShellWords(seed), decodeAnsiCQuoting(seed), unscrambleShellWords(decodeAnsiCQuoting(seed))]) {
2060
+ if (variant === seed)
2061
+ continue;
2062
+ const hit = windowsRecursiveDeleteHit(variant);
2063
+ if (hit)
2064
+ return hit;
2065
+ }
2066
+ }
2067
+ return undefined;
2068
+ };
2029
2069
  if (!opts.literalOnly)
2030
2070
  return matchCommand(value, check) || windowsDelete();
2031
2071
  const literal = normalizeForCommand(value);
@@ -2125,7 +2165,7 @@ function windowsRecursiveDeleteHit(raw) {
2125
2165
  // Cheap gate on the verbs, past carets and quote splitting (`r^d`, `de"l"`); an
2126
2166
  // encoded payload (`powershell -e <b64>`, FromBase64String, `base64 -d`) is
2127
2167
  // decoded below however it is spelled, as commandVariants decodes it.
2128
- if (!/\b(?:del|erase|rd|rmdir)\b/i.test(raw.replace(/[\^"'`]/g, '')) && !decodeEncodedPayloads(raw).length)
2168
+ if (!/\b(?:del|erase|rd|rmdir)\b/i.test(raw.replace(/[\^"'`]|\\(?=[A-Za-z])/g, '')) && !decodeEncodedPayloads(raw).length)
2129
2169
  return undefined;
2130
2170
  let text = raw;
2131
2171
  const heredocs = heredocView(raw);
@@ -2136,7 +2176,8 @@ function windowsRecursiveDeleteHit(raw) {
2136
2176
  // Quoted text that can reach an interpreter (`… | cmd`, `powershell -c "…"`) is code.
2137
2177
  const feeds = DATA_TO_INTERPRETER.test(unquotedCode(text));
2138
2178
  const assigned = new Map();
2139
- const dequote = (token) => stripCmdCarets(token.replace(/["']/g, ''));
2179
+ // PowerShell runs an unquoted word with its backtick escapes removed (`d`e`l` is `del`).
2180
+ const dequote = (token) => stripCmdCarets(token.replace(/["']/g, '').replace(/`(.)/g, '$1'));
2140
2181
  const resolve = (operand) => {
2141
2182
  let via;
2142
2183
  let value = dequote(operand);
@@ -2181,7 +2222,10 @@ function windowsRecursiveDeleteHit(raw) {
2181
2222
  }
2182
2223
  continue;
2183
2224
  }
2184
- if (!WINDOWS_DELETE_VERBS.has(programName(dequote(token))))
2225
+ // A POSIX shell reads `\d\e\l` as `del` (Git Bash, WSL); only the verb is
2226
+ // unescaped this way — a target keeps its backslashes (`C:\dev` is a path).
2227
+ const verb = dequote(token);
2228
+ if (!WINDOWS_DELETE_VERBS.has(programName(verb)) && !WINDOWS_DELETE_VERBS.has(verb.replace(/\\([A-Za-z])/g, '$1').toLowerCase()))
2185
2229
  continue;
2186
2230
  let recurse = false, quiet = false;
2187
2231
  const targets = [];
@@ -237,6 +237,8 @@ export interface HookIo {
237
237
  viaDaemon?: boolean;
238
238
  /** Set when this evaluation's verdict is a stop still being settled (approval wait). */
239
239
  pendingStop?: string;
240
+ /** The daemon already gave the hook this event's allow (postExecutionAnswer); this evaluation only records and settles. */
241
+ answeredFirst?: boolean;
240
242
  }
241
243
  /**
242
244
  * The daemon declines a request whose verdict is a human wait (org approval,
@@ -281,7 +283,23 @@ export declare function evaluateHookRequest(args: HookArgs, stdinRaw: string, co
281
283
  stdout: string;
282
284
  stderr: string;
283
285
  exitCode: number;
286
+ afterReply?: () => Promise<void>;
284
287
  }>;
288
+ /**
289
+ * The verdict a post-execution event gets from hookCommandInner — byte for
290
+ * byte what respond(false) writes for it — or undefined for every other
291
+ * request. Every path such an event takes ends in that plain allow: the
292
+ * kill-switch branch (the action already ran; a post hook is not a
293
+ * pre-execution surface), the ignored branch (a passive tool's post event:
294
+ * it does not precede an action) and the post branch, none with a message.
295
+ * Covered: Claude-format PostToolUse (any tool), a Cursor generic postToolUse
296
+ * (any tool), and a Cursor hook whose --event the full hook reads as `post`
297
+ * (after* / post-*). Read from the names alone, the way normalizeClaudePayload
298
+ * and canonicalizeCursorPayload route them — never by mapping the tool, which
299
+ * for a file write reads the file. Parsed exactly as hookCommandInner parses;
300
+ * a payload it cannot read is not one.
301
+ */
302
+ export declare function postExecutionAnswer(args: HookArgs, stdinRaw: string): string | undefined;
285
303
  /**
286
304
  * TOP-LEVEL FAIL-OPEN WRAPPER. An unexpected exception anywhere in the hook
287
305
  * must never surface as a stack trace, a nonzero exit, or a blocked command —
@@ -50,6 +50,7 @@ exports.readBackendError = readBackendError;
50
50
  exports.humanWaitDeadline = humanWaitDeadline;
51
51
  exports.crashVerdictForProcess = crashVerdictForProcess;
52
52
  exports.evaluateHookRequest = evaluateHookRequest;
53
+ exports.postExecutionAnswer = postExecutionAnswer;
53
54
  exports.hookCommand = hookCommand;
54
55
  exports.installHookCrashHandlers = installHookCrashHandlers;
55
56
  const os = __importStar(require("os"));
@@ -1093,18 +1094,34 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
1093
1094
  * analysis) and the confirmation memory is stored. Otherwise the IDE may have approved its
1094
1095
  * own card: the run is recorded as unverified — nobody named as approver — and nothing is
1095
1096
  * remembered, so the next identical action asks again.
1097
+ *
1098
+ * A post hook whose own action has no confirmation pending (nearly every one) settles
1099
+ * nothing, and learns that from one unlocked read off the event loop
1100
+ * (pendingConfirmationKeys): no lock file, no synchronous read — and, with nothing
1101
+ * pending at all, no look at the call (a file write's diff reads the file). In the daemon
1102
+ * (`offThread`) the settle itself runs on a worker thread: its lock wait, read, write and
1103
+ * fsync never hold the verdict loop.
1096
1104
  */
1097
- function settlePostExecution(explicitEvent, payload, opts = {}) {
1105
+ async function settlePostExecution(explicitEvent, payload, opts = {}) {
1098
1106
  try {
1099
1107
  const inner = inferPostInnerEvent(explicitEvent, payload);
1100
1108
  if (inner === 'unknown' || inner === 'prompt' || inner === 'post')
1101
1109
  return;
1110
+ const pending = await devConfirm().pendingConfirmationKeys();
1111
+ if (pending && pending.size === 0)
1112
+ return;
1102
1113
  const call = buildToolCall(inner, payload);
1103
1114
  if (!call)
1104
1115
  return;
1116
+ const argsDigest = devConfirm().actionDigest(call.toolArgs);
1117
+ if (pending && !pending.has(devConfirm().pendingKey(inner, call.toolName, argsDigest)))
1118
+ return;
1105
1119
  // A suspended machine never mints session memory: the answer is recorded, nothing is remembered.
1106
1120
  // The answer is inferred from the IDE's own card (our dialog settles in its own process).
1107
- const settled = devConfirm().settleDeveloperConfirmation(inner, call.toolName, devConfirm().actionDigest(call.toolArgs), new Date(), { noMemory: opts.suspended === true, via: 'ide' });
1121
+ const settleOpts = { noMemory: opts.suspended === true, via: 'ide' };
1122
+ const settled = opts.offThread
1123
+ ? await devConfirm().settleDeveloperConfirmationOffThread(inner, call.toolName, argsDigest, new Date(), settleOpts)
1124
+ : devConfirm().settleDeveloperConfirmation(inner, call.toolName, argsDigest, new Date(), settleOpts);
1108
1125
  if (!settled)
1109
1126
  return;
1110
1127
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_settled', event: inner, tool: call.toolName, confirmLatencyMs: settled.confirmLatencyMs, remember: settled.marker.remember, humanProven: settled.humanProven, ...(opts.suspended ? { suspended: true } : {}) });
@@ -1533,6 +1550,20 @@ function processIo(stdin, stdinErr) {
1533
1550
  * `deferGateReports`: native hooks — see HookIo.deferGateReports.
1534
1551
  */
1535
1552
  async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
1553
+ // POST-EXECUTION HOOKS ARE ANSWERED FIRST (daemon, a hook waiting on the
1554
+ // pipe). The action already ran and the answer is the same plain allow
1555
+ // whatever the evaluation finds, so the hook gets it now; the evaluation
1556
+ // itself — mode, settling a developer's IDE answer, its records — runs
1557
+ // after the reply (the verdict server's afterReply queue). One slow post
1558
+ // evaluation (a laptop under memory pressure: 8-26s, past Claude Code's
1559
+ // PostToolUse cutoff) used to keep its hook waiting until it fell back to a
1560
+ // cold local copy of the same evaluation.
1561
+ if (opts.viaDaemon && !opts.exitsProcess && verdictIpc().servingWaitingClient()) {
1562
+ const answer = postExecutionAnswer(args, stdinRaw);
1563
+ if (answer !== undefined) {
1564
+ return { stdout: answer, stderr: '', exitCode: 0, afterReply: () => evaluatePostAfterReply(args, stdinRaw, config, answer) };
1565
+ }
1566
+ }
1536
1567
  // Structural shell facts (shadow by default). A missing/slow parser means
1537
1568
  // "no facts" and the regex floor decides alone — never a wait, never fail-open.
1538
1569
  // A one-shot process (`exitsProcess`) starts the parser only where it may
@@ -1543,6 +1574,63 @@ async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
1543
1574
  isTTY: false, exitsProcess: opts.exitsProcess === true, deferGateReports: opts.exitsProcess === true && opts.deferGateReports === true, viaDaemon: opts.viaDaemon,
1544
1575
  });
1545
1576
  }
1577
+ /**
1578
+ * The verdict a post-execution event gets from hookCommandInner — byte for
1579
+ * byte what respond(false) writes for it — or undefined for every other
1580
+ * request. Every path such an event takes ends in that plain allow: the
1581
+ * kill-switch branch (the action already ran; a post hook is not a
1582
+ * pre-execution surface), the ignored branch (a passive tool's post event:
1583
+ * it does not precede an action) and the post branch, none with a message.
1584
+ * Covered: Claude-format PostToolUse (any tool), a Cursor generic postToolUse
1585
+ * (any tool), and a Cursor hook whose --event the full hook reads as `post`
1586
+ * (after* / post-*). Read from the names alone, the way normalizeClaudePayload
1587
+ * and canonicalizeCursorPayload route them — never by mapping the tool, which
1588
+ * for a file write reads the file. Parsed exactly as hookCommandInner parses;
1589
+ * a payload it cannot read is not one.
1590
+ */
1591
+ function postExecutionAnswer(args, stdinRaw) {
1592
+ const cleaned = stdinRaw.replace(/^\uFEFF/, '').replace(/^[\s\uFEFF\xA0]+/, '').trim();
1593
+ if (!cleaned)
1594
+ return undefined;
1595
+ let payload;
1596
+ try {
1597
+ payload = JSON.parse(cleaned);
1598
+ }
1599
+ catch {
1600
+ return undefined;
1601
+ }
1602
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
1603
+ return undefined;
1604
+ const record = payload;
1605
+ const hookEventName = str(record.hook_event_name);
1606
+ // Claude format (PascalCase): PostToolUse is `post` or an ignored passive tool.
1607
+ if (hookEventName && !/^[a-z]/.test(hookEventName))
1608
+ return hookEventName === 'PostToolUse' ? JSON.stringify({}) : undefined;
1609
+ // Cursor generic tool events: postToolUse is `post` or ignored (canonicalizeCursorGenericToolEvent).
1610
+ if (hookEventName === 'postToolUse')
1611
+ return JSON.stringify({ permission: 'allow' });
1612
+ if (hookEventName === 'preToolUse')
1613
+ return undefined;
1614
+ return inferEvent(args.event, record) === 'post' ? JSON.stringify({ permission: 'allow' }) : undefined;
1615
+ }
1616
+ /**
1617
+ * The full evaluation of a post-execution event the daemon already answered
1618
+ * (postExecutionAnswer): the same code, its verdict discarded. Run by the
1619
+ * verdict server after the reply, never inside the hook's wait.
1620
+ */
1621
+ async function evaluatePostAfterReply(args, stdinRaw, config, answered) {
1622
+ try {
1623
+ await (0, shellAstLoader_1.ensureShellAst)();
1624
+ const result = await evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: true, answeredFirst: true });
1625
+ // The answer given first must be the one this evaluation reached.
1626
+ if (result.stdout !== answered || result.exitCode !== 0) {
1627
+ (0, hookIo_1.dbg)({ phase: 'post_answer_mismatch', event: args.event, answered, verdict: result.stdout.slice(0, 200), exitCode: result.exitCode });
1628
+ }
1629
+ }
1630
+ catch (err) {
1631
+ (0, hookIo_1.dbg)({ phase: 'post_after_reply_error', event: args.event, error: err instanceof Error ? err.message : String(err) });
1632
+ }
1633
+ }
1546
1634
  /**
1547
1635
  * Run the hook against a buffered io and return the verdict instead of writing
1548
1636
  * it. `exitsProcess: true` keeps the standalone hook's delivery contract (spool +
@@ -1562,6 +1650,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1562
1650
  exitsProcess: mode.exitsProcess,
1563
1651
  deferGateReports: mode.deferGateReports,
1564
1652
  viaDaemon: mode.viaDaemon,
1653
+ ...(mode.answeredFirst ? { answeredFirst: true } : {}),
1565
1654
  };
1566
1655
  try {
1567
1656
  await hookCommandInner(args, config, io);
@@ -2082,7 +2171,8 @@ async function hookCommandInner(args, config, io) {
2082
2171
  const ideMode = idePermissionMode(payload);
2083
2172
  decision = askDialog().attachAskEnvelope(decision, { ...askEnvelope, agentClient: client, ...(identity ? { identity } : {}), ...(ideMode ? { ideMode } : {}) });
2084
2173
  }
2085
- (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog().ASK_ENVELOPE_FIELD]: '(envelope)' } : decision });
2174
+ (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog().ASK_ENVELOPE_FIELD]: '(envelope)' } : decision,
2175
+ ...(io.answeredFirst ? { answeredFirst: true } : {}) });
2086
2176
  if (hookFormat === 'claude' && event === 'prompt' && denied) {
2087
2177
  // Claude Desktop/Code variants do not all honor the structured
2088
2178
  // UserPromptSubmit decision consistently. Exit 2 is the documented hard
@@ -2112,7 +2202,7 @@ async function hookCommandInner(args, config, io) {
2112
2202
  // would keep the agent running. A post-execution hook still closes the ask it
2113
2203
  // answers, but a suspended machine never mints session memory from it.
2114
2204
  if (event === 'post')
2115
- settlePostExecution(args.event, payload, { suspended: true });
2205
+ await settlePostExecution(args.event, payload, { suspended: true, offThread: io.viaDaemon === true });
2116
2206
  respond(false);
2117
2207
  }
2118
2208
  // Cursor's GENERIC pre-event of an action its dedicated hook also reports
@@ -2152,9 +2242,10 @@ async function hookCommandInner(args, config, io) {
2152
2242
  // Post-execution hooks: the action already ran. The only job is correlation —
2153
2243
  // a matching pending developer confirmation means the IDE prompt we raised let
2154
2244
  // it through (a person's Allow only where the client said its prompt reaches
2155
- // one). Never enforces, never needs the network.
2245
+ // one). Never enforces, never needs the network. In the daemon the hook got
2246
+ // this allow before the evaluation began (postExecutionAnswer).
2156
2247
  if (event === 'post') {
2157
- settlePostExecution(args.event, payload);
2248
+ await settlePostExecution(args.event, payload, { offThread: io.viaDaemon === true });
2158
2249
  respond(false);
2159
2250
  }
2160
2251
  // No Shield configured → allow (fail-open by design: a misconfigured machine
@@ -167,7 +167,7 @@ function buildGuardJs(nodePath, cliEntry) {
167
167
  `const RULES_URL=${JSON.stringify((0, notify_1.shellRulesConsoleUrl)())};`,
168
168
  `// Which rule (by id + origin) and where an admin changes it — printed on every block so the developer is never left guessing.`,
169
169
  `function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
170
- `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
170
+ `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',fallback:r.fallback===true,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
171
171
  `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
172
172
  `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
173
173
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
@@ -187,7 +187,7 @@ function buildGuardJs(nodePath, cliEntry) {
187
187
  `const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
188
188
  `if(!hit)process.exit(0);`,
189
189
  `// Monitor is quiet: a would-block / would-ask is recorded ('warn') and the line runs with nothing printed. A warn-only rule is a real warning (below).`,
190
- `if(mode==='monitor'&&hit.action!=='warn'){spoolEvent(hit,line,'warn');process.exit(0);}`,
190
+ `if(mode==='monitor'&&(hit.action!=='warn'||hit.fallback)){spoolEvent(hit,line,'warn');process.exit(0);}`,
191
191
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
192
192
  `if(hit.action==='ask'){`,
193
193
  ` if(agentTerminal()){`,
@@ -33,6 +33,12 @@ export interface ShellGuardRule {
33
33
  * allow. 'mask' is meaningless for a typed command and degrades to block.
34
34
  */
35
35
  action?: ShellGuardAction;
36
+ /**
37
+ * 'warn' only because no Local Safety snapshot is cached yet (the org's
38
+ * choice is unknown). A monitor machine records it silently like any
39
+ * would-block; an enforcing machine still shows the warning.
40
+ */
41
+ fallback?: boolean;
36
42
  }
37
43
  export type ShellGuardAction = 'block' | 'warn' | 'ask';
38
44
  /**
@@ -617,7 +617,9 @@ function effectiveBuiltinRules() {
617
617
  .filter(rule => !disabled.has(rule.id))
618
618
  .map(rule => {
619
619
  const action = itemActions.get(rule.id);
620
- return action ? { ...rule, action } : hasSnapshot ? rule : { ...rule, action: 'warn' };
620
+ // No snapshot yet: the org's choice is unknown, so the rule only warns —
621
+ // and `fallback` keeps a monitor machine silent about it (silent monitor).
622
+ return action ? { ...rule, action } : hasSnapshot ? rule : { ...rule, action: 'warn', fallback: true };
621
623
  });
622
624
  }
623
625
  /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
@@ -779,6 +781,7 @@ function buildGuardPs1(nodePath, cliEntry) {
779
781
  ` Reason = [string]$_.reason`,
780
782
  ` Source = [string]$_.source`,
781
783
  ` Action = $(switch ([string]$_.action) { 'warn' { 'warn' } 'ask' { 'ask' } default { 'block' } })`,
784
+ ` Fallback = ($_.fallback -eq $true)`,
782
785
  ` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
783
786
  ` }`,
784
787
  ` } catch { $null }`,
@@ -949,7 +952,7 @@ function buildGuardPs1(nodePath, cliEntry) {
949
952
  ` # BELOW the message so the warning stays in the scrollback permanently.`,
950
953
  ` # Monitor is quiet: a would-block / would-ask is recorded ('warn') and the`,
951
954
  ` # line runs with nothing printed. A warn-only rule is a real warning (below).`,
952
- ` if ($global:FcdGuardMode -eq 'monitor' -and $hit.Action -ne 'warn') {`,
955
+ ` if ($global:FcdGuardMode -eq 'monitor' -and ($hit.Action -ne 'warn' -or $hit.Fallback)) {`,
953
956
  ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
954
957
  ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
955
958
  ` return`,
@@ -1,6 +1,7 @@
1
1
  import { type AlwaysCovers, type AskSubject, type NativeDialogView } from './approvalPrompt';
2
2
  import { type CredentialGrantBinding, type CredentialRuleFacts } from './credentialGrant';
3
3
  export { isCiEnvironment, isHumanPresent } from './callerEnv';
4
+ export { CONFIRM_PENDING_TTL_MS, pendingConfirmationKeys, pendingKey, settleDeveloperConfirmation, settleDeveloperConfirmationOffThread, type SettleOptions, type SettledConfirmation, } from './devConfirmLedger';
4
5
  export type ConfirmRemember = 'none' | 'session' | '1h';
5
6
  /**
6
7
  * Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
@@ -243,8 +244,6 @@ export interface RuleGrantLimits {
243
244
  maxDays?: number | null;
244
245
  maxUses?: number | null;
245
246
  }
246
- /** A raised confirmation that received no post-execution signal in time counts as not approved. */
247
- export declare const CONFIRM_PENDING_TTL_MS: number;
248
247
  /** The hook's dialog answer only carries over to the gateway call it was given for. */
249
248
  export declare const CONFIRMED_ACTION_TTL_MS: number;
250
249
  /** "Always" is long, not forever: a standing grant lapses after 30 days unless renewed by use of the dialog. */
@@ -308,7 +307,6 @@ export declare function localSafetyRuleHash(finding: {
308
307
  action?: string;
309
308
  explanation?: string;
310
309
  }>): string;
311
- export declare function pendingKey(event: string, toolName: string, argsDigest: string): string;
312
310
  /**
313
311
  * The tool name as the MCP server sees it. The IDE hook names an MCP call
314
312
  * `mcp__<server>__<tool>` (Codex/Claude) or `<server>/<tool>`; the gateway sees `<tool>`.
@@ -333,29 +331,6 @@ export interface RememberScope {
333
331
  }
334
332
  /** Record that the IDE was asked to confirm this action. Returns the marker (for logging). */
335
333
  export declare function raiseDeveloperConfirmation(input: Omit<PendingConfirmation, 'key' | 'raisedAt'>, now?: Date): PendingConfirmation;
336
- /**
337
- * The developer's answer to this action arrived: Allow in the FullCourtDefense dialog
338
- * (askDialog, MCP gateway — the default `via: 'dialog'`), or the post-execution hook
339
- * after the IDE's own card (`via: 'ide'`). Removes the marker, records memory if the
340
- * rule asked for it, and returns the settled marker plus how long the answer took (the
341
- * rubber-stamp signal). Undefined when nothing was pending — the action was simply
342
- * allowed and this post-hook is noise.
343
- *
344
- * `humanProven` (v139 F-13): an IDE card's answer is a person's only when the asking
345
- * client said its card reaches one (`idePrompt: 'human'`). Otherwise the IDE may have
346
- * approved its own card (auto-run / bypass modes, or a client that does not say): the
347
- * marker is closed, nothing is remembered, and the caller records an unverified run.
348
- */
349
- export declare function settleDeveloperConfirmation(event: string, toolName: string, argsDigest: string, now?: Date, opts?: {
350
- /** Never store the rule's session / 1h memory for this answer (e.g. the machine is suspended). */
351
- noMemory?: boolean;
352
- /** Where the answer came from: our dialog (default) or the IDE's own card (inferred from the post hook). */
353
- via?: 'dialog' | 'ide';
354
- }): {
355
- marker: PendingConfirmation;
356
- confirmLatencyMs: number;
357
- humanProven: boolean;
358
- } | undefined;
359
334
  /**
360
335
  * Whether the IDE's own ask card reaches a person, from the permission mode the client
361
336
  * reports with its hook call (Claude Code `permission_mode`; written by the client, never