fullcourtdefense-cli 1.35.8 → 1.35.9

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.
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DaemonDeclined = void 0;
36
37
  exports.normalizeClaudePayload = normalizeClaudePayload;
37
38
  exports.canonicalBuiltinToolName = canonicalBuiltinToolName;
38
39
  exports.mapBuiltinToolPayload = mapBuiltinToolPayload;
@@ -44,13 +45,17 @@ exports.buildToolCall = buildToolCall;
44
45
  exports.classifyGateHttpFailure = classifyGateHttpFailure;
45
46
  exports.gateHttpFailureDetail = gateHttpFailureDetail;
46
47
  exports.readBackendError = readBackendError;
48
+ exports.humanWaitDeadline = humanWaitDeadline;
49
+ exports.crashVerdictForProcess = crashVerdictForProcess;
47
50
  exports.evaluateHookRequest = evaluateHookRequest;
48
51
  exports.hookCommand = hookCommand;
52
+ exports.installHookCrashHandlers = installHookCrashHandlers;
49
53
  const os = __importStar(require("os"));
50
54
  const config_1 = require("../config");
51
55
  const runtimeConfig_1 = require("../runtimeConfig");
52
56
  const telemetry_1 = require("../telemetry");
53
57
  const actionIdentity_1 = require("../actionIdentity");
58
+ const callerEnv_1 = require("../callerEnv");
54
59
  const deterministicGuard_1 = require("./deterministicGuard");
55
60
  const localSafetySnapshot_1 = require("../localSafetySnapshot");
56
61
  const taintLedger_1 = require("./taintLedger");
@@ -177,10 +182,17 @@ function mapClaudeToolPayload(payload) {
177
182
  payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
178
183
  };
179
184
  }
180
- // Glob/Grep/Task/WebFetch/etc. — no enforcement surface; let the client's own
185
+ // Built-in web readers (Claude `WebFetch`, Copilot `fetch_webpage` / `web_fetch`,
186
+ // Hermes `web_extract`) are an HTTP GET judged by host — the same MCP-shaped call
187
+ // Cursor already sends, so destination rules and Local Safety network rules apply.
188
+ if (BUILTIN_WEB_READ_TOOLS.has(canonicalBuiltinToolName(toolName))) {
189
+ return { event: 'mcp', payload: { ...payload, tool_name: toolName, tool_input: input } };
190
+ }
191
+ // Glob/Grep/Task/WebSearch/etc. — no enforcement surface; let the client's own
181
192
  // permission flow handle them.
182
193
  return mapBuiltinToolPayload(toolName, input, payload) ?? { event: 'ignore', payload };
183
194
  }
195
+ const BUILTIN_WEB_READ_TOOLS = new Set(['webfetch', 'fetchwebpage', 'webextract']);
184
196
  // ---------------------------------------------------------------------------
185
197
  // Built-in IDE tools — ONE taxonomy for every client.
186
198
  //
@@ -403,9 +415,10 @@ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
403
415
  * VS Code Copilot runs hooks inside the VS Code process environment.
404
416
  */
405
417
  function claudeFormatClient() {
406
- if (process.env.CLAUDECODE || process.env.CLAUDE_PROJECT_DIR)
418
+ const env = (0, callerEnv_1.callerEnv)();
419
+ if (env.CLAUDECODE || env.CLAUDE_PROJECT_DIR)
407
420
  return 'claude-code';
408
- if (process.env.VSCODE_PID || process.env.TERM_PROGRAM === 'vscode')
421
+ if (env.VSCODE_PID || env.TERM_PROGRAM === 'vscode')
409
422
  return 'vscode';
410
423
  return 'claude-code';
411
424
  }
@@ -828,7 +841,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
828
841
  const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
829
842
  const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
830
843
  if (grant) {
831
- const covers = (0, devConfirm_1.describeGrantScope)(grant.scope);
844
+ const covers = (0, devConfirm_1.describeGrantScope)(grant.scope, grant);
832
845
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_granted', event, tool: call.toolName, operation, grant: grant.key, where: source.where });
833
846
  spoolCallEvent(call, {
834
847
  decision: 'allow',
@@ -840,7 +853,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
840
853
  offlineEnforced,
841
854
  });
842
855
  (0, telemetry_1.triggerFlush)(false);
843
- return { outcome: 'allow', agentMsg: `FullCourtDefense: the developer has a standing "Always allow" for ${covers} under this rule.` };
856
+ return { outcome: 'allow', agentMsg: `FullCourtDefense: the developer has a standing "Always allow" for ${covers}.` };
844
857
  }
845
858
  const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
846
859
  event,
@@ -970,14 +983,21 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
970
983
  // Developer-scoped approvals need no backend at all — the IDE prompt IS the approver.
971
984
  if (tryDeveloperConfirmation(ctx, call, local, ctx.payload, 'offline'))
972
985
  return true;
986
+ // Monitor machines land here by design (zero synchronous network calls), not
987
+ // because anything is down — never tell the developer the service is
988
+ // unreachable when it is simply not consulted.
989
+ const monitorPath = opts.countAsGateFailure === false;
973
990
  if (local.verdict === 'block' || local.verdict === 'require_approval') {
974
991
  const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
975
992
  if (ctx.shadow) {
976
993
  // Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
977
994
  // (which hides the would-block from the console) and never 'block'.
978
- spoolCallEvent(call, { decision: 'warn', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
995
+ const would = local.verdict === 'block' ? 'block' : 'require approval for';
996
+ spoolCallEvent(call, { decision: 'warn', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `[monitor] local policy would ${local.verdict}${monitorPath ? '' : ' (offline)'}: ${reason}`, offlineEnforced: true });
979
997
  (0, telemetry_1.triggerFlush)(false);
980
- ctx.respond(false, undefined, `[FullCourtDefense shadow] would ${local.verdict === 'block' ? 'block' : 'require approval for'} ${call.toolName} (offline, locally cached policy): ${reason}`);
998
+ ctx.respond(false, undefined, monitorPath
999
+ ? `[FullCourtDefense shadow] would ${would} ${call.toolName}${local.policyName ? ` (${local.policyName})` : ''}: ${reason} — this machine is in Monitor mode, so nothing was blocked.`
1000
+ : `[FullCourtDefense shadow] would ${would} ${call.toolName} (offline, locally cached policy): ${reason}`);
981
1001
  }
982
1002
  const approvalNote = local.verdict === 'require_approval'
983
1003
  ? ' This action requires human approval, which is not possible while the policy service is unreachable.'
@@ -987,10 +1007,6 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
987
1007
  const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
988
1008
  ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
989
1009
  }
990
- // Monitor machines land here by design (zero synchronous network calls), not
991
- // because anything is down — never tell the developer the service is
992
- // unreachable when it is simply not consulted.
993
- const monitorPath = opts.countAsGateFailure === false;
994
1010
  if (local.verdict === 'log') {
995
1011
  // Warn-only rule, offline: still a real warning + Warned in the fleet.
996
1012
  const warning = warnByPolicy(call, local, ctx.event, ctx.exitsProcess, 'local-first');
@@ -1096,7 +1112,7 @@ function machineMetadata(client = 'cursor') {
1096
1112
  os: `${os.platform()} ${os.release()}`,
1097
1113
  username,
1098
1114
  cwd: process.cwd(),
1099
- workspacePath: process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd(),
1115
+ workspacePath: (0, callerEnv_1.callerEnv)().WORKSPACE_PATH || (0, callerEnv_1.callerEnv)().CLAUDE_PROJECT_DIR || process.cwd(),
1100
1116
  agentClient: client,
1101
1117
  };
1102
1118
  }
@@ -1221,6 +1237,40 @@ class HookExitSignal {
1221
1237
  this.code = code;
1222
1238
  }
1223
1239
  }
1240
+ /**
1241
+ * The daemon declines a request whose verdict is a human wait (org approval,
1242
+ * taint approve-once). The thin client stops listening after ~2.5s and
1243
+ * re-evaluates locally, so a daemon-side wait only opened a second approval
1244
+ * request nobody would ever read. Declining sends the client to its local
1245
+ * evaluation at once, where the one wait runs in the developer's own process.
1246
+ */
1247
+ class DaemonDeclined extends Error {
1248
+ }
1249
+ exports.DaemonDeclined = DaemonDeclined;
1250
+ /**
1251
+ * The shortest pre-tool hook timeout any installer writes for an
1252
+ * approval-capable event (Claude Code / Copilot / VS Code and Cursor: 90s; the
1253
+ * native adapter's own process deadline is 110s). When a client kills a hook on
1254
+ * timeout it runs the action — so every human wait must END, with a deny,
1255
+ * before this. FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
1256
+ */
1257
+ const IDE_HOOK_TIMEOUT_MS = 90_000;
1258
+ const IDE_TIMEOUT_MARGIN_MS = 15_000;
1259
+ /** Absolute time a human wait must end by: its own cap, and never past the IDE's timeout. */
1260
+ function humanWaitDeadline(waitMs, now = Date.now(), startedAt = (0, telemetry_1.verdictStartedAt)() ?? now, ideTimeoutMs = Number((0, callerEnv_1.callerEnv)().FCD_HOOK_IDE_TIMEOUT_MS) > 0 ? Number((0, callerEnv_1.callerEnv)().FCD_HOOK_IDE_TIMEOUT_MS) : IDE_HOOK_TIMEOUT_MS) {
1261
+ return Math.min(now + waitMs, startedAt + ideTimeoutMs - Math.min(IDE_TIMEOUT_MARGIN_MS, ideTimeoutMs / 4));
1262
+ }
1263
+ /**
1264
+ * Per-event hook process only (full, slim and native entries): set once the
1265
+ * verdict is a stop (approval pending). A crash after this point must deny —
1266
+ * the fail-open wrapper would otherwise turn a pending approval into an allow.
1267
+ * Never set in the daemon, which evaluates many requests in one process.
1268
+ */
1269
+ let processPendingStop;
1270
+ /** Verdict for an unexpected error that escaped the evaluation (process-level handlers). */
1271
+ function crashVerdictForProcess(event, message) {
1272
+ return failOpenOutcome(event, message, undefined, undefined, processPendingStop);
1273
+ }
1224
1274
  function processIo(stdin, stdinErr) {
1225
1275
  return {
1226
1276
  stdin,
@@ -1240,11 +1290,11 @@ function processIo(stdin, stdinErr) {
1240
1290
  * Mirrors hookCommand's fail-open contract: any unexpected exception yields
1241
1291
  * an allow verdict, never an error the thin client would surface.
1242
1292
  */
1243
- async function evaluateHookRequest(args, stdinRaw, config) {
1293
+ async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
1244
1294
  // Structural shell facts (shadow by default). A missing/slow parser means
1245
1295
  // "no facts" and the regex floor decides alone — never a wait, never fail-open.
1246
1296
  await (0, shellAstLoader_1.ensureShellAst)();
1247
- return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false });
1297
+ return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: opts.viaDaemon });
1248
1298
  }
1249
1299
  /**
1250
1300
  * Run the hook against a buffered io and return the verdict instead of writing
@@ -1263,6 +1313,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1263
1313
  writeErr: (text) => { errOut += text; },
1264
1314
  exit: (code) => { throw new HookExitSignal(code); },
1265
1315
  exitsProcess: mode.exitsProcess,
1316
+ viaDaemon: mode.viaDaemon,
1266
1317
  };
1267
1318
  try {
1268
1319
  await hookCommandInner(args, config, io);
@@ -1274,11 +1325,13 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1274
1325
  if (err instanceof HookExitSignal) {
1275
1326
  return { stdout: out, stderr: errOut, exitCode: err.code };
1276
1327
  }
1328
+ if (err instanceof DaemonDeclined)
1329
+ throw err;
1277
1330
  const message = err instanceof Error ? err.message : String(err);
1278
1331
  // Same contract as the process-level wrapper, including the kill-switch
1279
1332
  // exception: a crash during daemon-side evaluation must not unlock a
1280
1333
  // suspended machine.
1281
- const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config);
1334
+ const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config, io.pendingStop);
1282
1335
  return { stdout: outcome.stdout, stderr: '', exitCode: outcome.exitCode };
1283
1336
  }
1284
1337
  }
@@ -1298,7 +1351,27 @@ async function hookCommand(args, config) {
1298
1351
  const startedAt = Date.now();
1299
1352
  return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
1300
1353
  }
1354
+ /**
1355
+ * Errors that escape every await (an async 'error' event from a child process,
1356
+ * a stray rejection) never reach the try/catch around the evaluation. Without
1357
+ * these handlers Node prints a stack and exits 1 with NO verdict, which every
1358
+ * client reads as a non-blocking hook error — the action runs. Route them
1359
+ * through the same contract as a caught crash instead.
1360
+ */
1361
+ function installHookCrashHandlers(verdict) {
1362
+ const onCrash = (err) => {
1363
+ const outcome = verdict(err instanceof Error ? err.message : String(err));
1364
+ try {
1365
+ process.stdout.write(outcome.stdout);
1366
+ }
1367
+ catch { /* stdout gone */ }
1368
+ process.exit(outcome.exitCode);
1369
+ };
1370
+ process.on('uncaughtException', onCrash);
1371
+ process.on('unhandledRejection', onCrash);
1372
+ }
1301
1373
  async function hookCommandOuter(args, config, startedAt) {
1374
+ installHookCrashHandlers((message) => crashVerdictForProcess(args.event, message));
1302
1375
  try {
1303
1376
  let raw = '';
1304
1377
  let stdinErr = '';
@@ -1336,7 +1409,7 @@ async function hookCommandOuter(args, config, startedAt) {
1336
1409
  }
1337
1410
  catch (err) {
1338
1411
  const message = err instanceof Error ? err.message : String(err);
1339
- const outcome = failOpenOutcome(args.event, message, args, config);
1412
+ const outcome = failOpenOutcome(args.event, message, args, config, processPendingStop);
1340
1413
  try {
1341
1414
  process.stdout.write(outcome.stdout);
1342
1415
  }
@@ -1354,8 +1427,29 @@ async function hookCommandOuter(args, config, startedAt) {
1354
1427
  * machines still deny. An enforcing Shield with fail-closed also denies
1355
1428
  * evaluation failures; Monitor and fail-open configurations continue.
1356
1429
  */
1357
- function failOpenOutcome(event, message, args, config) {
1430
+ function failOpenOutcome(event, message, args, config, pendingStop) {
1358
1431
  const toolName = event || 'unknown';
1432
+ if (pendingStop) {
1433
+ // The verdict was already a stop (approval requested, not granted) when the
1434
+ // error hit — it stays a stop. The request remains pending in the console.
1435
+ try {
1436
+ (0, telemetry_1.setVerdictPath)('fail_open');
1437
+ }
1438
+ catch { /* never throw */ }
1439
+ try {
1440
+ (0, hookIo_1.dbg)({ phase: 'hook_error_pending_stop', event, error: message });
1441
+ }
1442
+ catch { /* never throw */ }
1443
+ try {
1444
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: `${toolName}:hook_error`, reason: `Hook error while ${pendingStop} — denied: ${message.slice(0, 200)}`, offlineEnforced: true });
1445
+ (0, telemetry_1.triggerFlush)(true);
1446
+ }
1447
+ catch { /* never throw */ }
1448
+ const reason = `FullCourtDefense stopped this action: ${pendingStop}, and it was not approved before the check ended. The request stays pending in the console; re-run the action after it is approved.`;
1449
+ return { stdout: JSON.stringify({ continue: false, permission: 'deny', user_message: reason, agent_message: `${reason} Do not retry until a human approves it.`,
1450
+ permissionDecision: 'deny', permissionDecisionReason: reason,
1451
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } }) + '\n', exitCode: 0 };
1452
+ }
1359
1453
  // Label the spooled event below as a crash-allow: a rising fail_open rate is
1360
1454
  // the fleet's earliest signal that the guard itself is broken on a machine.
1361
1455
  try {
@@ -1555,7 +1649,7 @@ async function hookCommandInner(args, config, io) {
1555
1649
  payload = cursor.payload;
1556
1650
  ignoreEvent = cursor.ignore;
1557
1651
  }
1558
- (0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
1652
+ (0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid, viaDaemon: Boolean(io.viaDaemon),
1559
1653
  isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
1560
1654
  argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
1561
1655
  // Emit the verdict in the shape THIS client expects, then exit.
@@ -1593,7 +1687,7 @@ async function hookCommandInner(args, config, io) {
1593
1687
  const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
1594
1688
  const respond = (blocked, userMsg, agentMsg, askEnvelope) => {
1595
1689
  let ask = blocked === 'ask' && event !== 'prompt';
1596
- const denied = blocked === true;
1690
+ let denied = blocked === true;
1597
1691
  if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
1598
1692
  const { finding, call, ruleHash } = pendingLocalAsk;
1599
1693
  pendingLocalAsk = undefined;
@@ -1614,6 +1708,22 @@ async function hookCommandInner(args, config, io) {
1614
1708
  }
1615
1709
  agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
1616
1710
  }
1711
+ if (ask && !(0, devConfirm_1.isHumanPresent)()) {
1712
+ // Nobody can answer an IDE ask here, and an agent in an auto-approve mode
1713
+ // (Claude Code bypass, Gemini CLI yolo) runs the action on an ask.
1714
+ (0, hookIo_1.dbg)({ phase: 'ask_without_human_denied', event, policyId: askEnvelope?.policyId });
1715
+ (0, telemetry_1.spoolEvent)({
1716
+ decision: 'block', toolName: askEnvelope?.toolName || event, operation: askEnvelope?.operation || event,
1717
+ reason: `Confirmation required and no human is present — denied${askEnvelope?.policyName ? ` (${askEnvelope.policyName})` : ''}`,
1718
+ ruleId: askEnvelope?.policyId, policyHash: askEnvelope?.policyHash, offlineEnforced: true,
1719
+ });
1720
+ (0, telemetry_1.triggerFlush)(true);
1721
+ ask = false;
1722
+ denied = true;
1723
+ askEnvelope = undefined;
1724
+ userMsg = `FullCourtDefense stopped this ${event}: a rule asks a person to confirm it, and no one is present on this machine (CI, ephemeral workload or FCD_HUMAN_PRESENT=0), so it was not run.`;
1725
+ agentMsg = `${userMsg} Do not retry or work around it.`;
1726
+ }
1617
1727
  if (!denied && pendingLocalWarnings.length > 0) {
1618
1728
  spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
1619
1729
  }
@@ -1707,7 +1817,12 @@ async function hookCommandInner(args, config, io) {
1707
1817
  await enforceActionPolicy({
1708
1818
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1709
1819
  shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
1710
- effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess,
1820
+ effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess, viaDaemon: Boolean(io.viaDaemon),
1821
+ notePendingStop: (what) => {
1822
+ io.pendingStop = what;
1823
+ if (!io.viaDaemon)
1824
+ processPendingStop = what;
1825
+ },
1711
1826
  });
1712
1827
  return;
1713
1828
  }
@@ -1765,6 +1880,13 @@ async function hookCommandInner(args, config, io) {
1765
1880
  // Unknown event → nothing to enforce.
1766
1881
  respond(false);
1767
1882
  }
1883
+ /** A human wait belongs in the developer's hook process, never in the daemon. */
1884
+ function declineInDaemon(ctx, what) {
1885
+ if (!ctx.viaDaemon)
1886
+ return;
1887
+ (0, hookIo_1.dbg)({ phase: 'daemon_declined', what });
1888
+ throw new DaemonDeclined(`${what}: runs in the hook process`);
1889
+ }
1768
1890
  /**
1769
1891
  * Approve-once for a taint-guard block: hold the action, alert the human at
1770
1892
  * the keyboard, and poll for their one-time decision from their OWN terminal
@@ -1786,11 +1908,17 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1786
1908
  respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry. ${explained.agentMessage}`);
1787
1909
  return false;
1788
1910
  };
1789
- const approveOnceEnabled = process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
1790
- && process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== '1'
1911
+ const env = (0, callerEnv_1.callerEnv)();
1912
+ const approveOnceEnabled = env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
1913
+ && env.FCD_TAINT_APPROVE_ONCE_DISABLED !== '1'
1791
1914
  && ctx.approvalMode !== 'block';
1792
1915
  if (!approveOnceEnabled)
1793
1916
  return hardBlock();
1917
+ // Approve-once is answered from the developer's own terminal: with nobody
1918
+ // there, the wait could only end in the IDE's timeout.
1919
+ if (!(0, devConfirm_1.isHumanPresent)())
1920
+ return hardBlock();
1921
+ declineInDaemon(ctx, 'taint approve-once');
1794
1922
  const approval = (0, taintLedger_1.createTaintApproval)({
1795
1923
  sessionId: sessId,
1796
1924
  event,
@@ -1801,8 +1929,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1801
1929
  });
1802
1930
  if (!approval)
1803
1931
  return hardBlock();
1804
- const timeoutMs = Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
1805
- ? Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS)
1932
+ const timeoutMs = Number(env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
1933
+ ? Number(env.FCD_TAINT_APPROVAL_TIMEOUT_MS)
1806
1934
  : 120000; // local developer decision — short window, they are at the keyboard
1807
1935
  const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
1808
1936
  // forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
@@ -1815,10 +1943,13 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1815
1943
  + `Deny: fullcourtdefense approve ${approval.id} --deny\n`
1816
1944
  + `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
1817
1945
  });
1818
- (0, hookIo_1.dbg)({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
1819
- const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, timeoutMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
1946
+ const waitMs = Math.max(0, humanWaitDeadline(timeoutMs) - Date.now());
1947
+ (0, hookIo_1.dbg)({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs, waitMs });
1948
+ ctx.notePendingStop('the action is held for a one-time developer approval');
1949
+ const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, waitMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
1820
1950
  (0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
1821
1951
  if (outcome === 'approved') {
1952
+ ctx.notePendingStop('');
1822
1953
  spoolCallEvent(call, {
1823
1954
  decision: 'allow',
1824
1955
  reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
@@ -2066,6 +2197,9 @@ async function enforceActionPolicy(ctx) {
2066
2197
  if (tryDeveloperConfirmation({ event, respond, shadow, effectivePolicyHash, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
2067
2198
  return;
2068
2199
  // require_approval (org scope) → synchronous gate below creates the approval action.
2200
+ if (localVerdict.verdict === 'require_approval' && ctx.approvalMode === 'wait' && !shadow) {
2201
+ declineInDaemon(ctx, 'org approval');
2202
+ }
2069
2203
  }
2070
2204
  // PROXY RESILIENCE (fast probe): when the gate failed within the last 60s
2071
2205
  // (bank proxy without the api hostname allowlisted, backend outage), skip
@@ -2176,6 +2310,11 @@ async function enforceActionPolicy(ctx) {
2176
2310
  respond(true, explained.userMessage, `This ${event} requires human approval per Action Policy. Stop and ask the developer to approve it in the FullCourtDefense console: ${queueUrl}. ${explained.agentMessage}`);
2177
2311
  return;
2178
2312
  }
2313
+ // No cached policies, so the daemon only learned it from the gate: the
2314
+ // client re-asks the gate, which answers with this same pending request
2315
+ // (one request per action — see approvalRequestKey), and waits on it.
2316
+ declineInDaemon(ctx, 'org approval');
2317
+ ctx.notePendingStop('it needs human approval');
2179
2318
  // Make the wait visible on the machine: the hook process has no UI and its
2180
2319
  // stderr is invisible inside IDEs, so a native toast is the only reliable
2181
2320
  // signal that a human needs to act NOW (the approval window is minutes).
@@ -2186,9 +2325,10 @@ async function enforceActionPolicy(ctx) {
2186
2325
  });
2187
2326
  const outcome = await waitForApproval({
2188
2327
  apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
2189
- timeoutMs: ctx.approvalTimeoutMs, pollMs: ctx.approvalPollMs,
2328
+ deadline: humanWaitDeadline(ctx.approvalTimeoutMs), pollMs: ctx.approvalPollMs,
2190
2329
  });
2191
2330
  if (outcome.status === 'approved') {
2331
+ ctx.notePendingStop('');
2192
2332
  (0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved', message: `${call.toolName} was approved and will continue.` });
2193
2333
  respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
2194
2334
  return;
@@ -2218,11 +2358,12 @@ async function waitForApproval(input) {
2218
2358
  if (input.shieldKey)
2219
2359
  headers['x-shield-key'] = input.shieldKey;
2220
2360
  const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
2221
- const deadline = Date.now() + input.timeoutMs;
2222
- (0, hookIo_1.dbg)({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
2361
+ const { deadline } = input;
2362
+ (0, hookIo_1.dbg)({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: Math.max(0, deadline - Date.now()) });
2223
2363
  while (Date.now() <= deadline) {
2224
2364
  try {
2225
- const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15000) });
2365
+ // A poll never outlives the deadline — the deadline is what keeps the verdict inside the IDE's timeout.
2366
+ const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(Math.max(1000, Math.min(15000, deadline - Date.now()))) });
2226
2367
  if (resp.ok) {
2227
2368
  const body = await resp.json().catch(() => ({}));
2228
2369
  const approval = body.data?.approval;
@@ -53,6 +53,7 @@ const os = __importStar(require("os"));
53
53
  const path = __importStar(require("path"));
54
54
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
55
55
  const deterministicGuard_1 = require("./deterministicGuard");
56
+ const callerEnv_1 = require("../callerEnv");
56
57
  /**
57
58
  * Deterministic taint tracking for local IDE coding agents.
58
59
  *
@@ -240,7 +241,8 @@ function isLocalHost(host) {
240
241
  || host.endsWith('.local');
241
242
  }
242
243
  function workspaceRoot(workspacePath) {
243
- return workspacePath || process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd();
244
+ const env = (0, callerEnv_1.callerEnv)();
245
+ return workspacePath || env.WORKSPACE_PATH || env.CLAUDE_PROJECT_DIR || process.cwd();
244
246
  }
245
247
  function isExternalFile(filePath, workspacePath) {
246
248
  try {
@@ -1,3 +1,4 @@
1
+ export { isCiEnvironment, isHumanPresent } from './callerEnv';
1
2
  export type ConfirmRemember = 'none' | 'session' | '1h';
2
3
  /**
3
4
  * Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
@@ -191,14 +192,6 @@ export declare function resolveGrantLimits(rule?: RuleGrantLimits | null, org?:
191
192
  export declare function describeGrantLimits(limits: GrantLimits): string;
192
193
  /** Uses left on a grant, or undefined when it is only time-limited. */
193
194
  export declare function grantRemainingUses(grant: Pick<DeveloperGrant, 'uses' | 'maxUses'>): number | undefined;
194
- /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
195
- export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
196
- /**
197
- * Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
198
- * and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
199
- * developer-scoped approval must go to the org queue or block, never self-resolve.
200
- */
201
- export declare function isHumanPresent(env?: NodeJS.ProcessEnv): boolean;
202
195
  /** Canonical JSON action, including cwd and every argument. Invalid inputs cannot reuse consent. */
203
196
  export declare function actionDigest(toolArgs: Record<string, unknown>): string;
204
197
  /**
@@ -299,11 +292,20 @@ export declare function sweepExpiredConfirmations(now?: Date, ttlMs?: number): P
299
292
  * (`out-string).trim()`) is stripped. Sorted and unique.
300
293
  */
301
294
  export declare function shellPrograms(command: string): string[];
295
+ /**
296
+ * `shellPrograms` narrowed to what a rule that declares its programs is about:
297
+ * the rule's programs plus every other program that could move data off the
298
+ * machine or run code (`curl`, `node`, `Invoke-RestMethod`, anything unknown).
299
+ * Only local file-structure changes and local `git` reads/commits drop out.
300
+ * Without declared programs, or when none of them is on the line, the full set.
301
+ */
302
+ export declare function shellProgramsForRule(command: string, ruleCommands?: readonly string[]): string[];
302
303
  /** Local Safety item ids joined as `a+b` when two ask-rules fire in one dialog. */
303
304
  export declare function splitLocalSafetyRuleIds(matchedRule?: string): string[] | undefined;
304
305
  /**
305
- * Does a live grant cover this action under this rule? Combined Local Safety
306
- * asks (`a+b`) match a grant for `a` or `b`. Shell programs match by INCLUSION:
306
+ * Does a live grant cover this action under this rule? A combined Local Safety
307
+ * ask (`a+b`) needs a grant for `a` AND one for `b` (or one for `a+b`); a grant
308
+ * for `a` alone never silences `b`. Shell programs match by INCLUSION:
307
309
  * a grant for `{gh, git}` covers a line that only runs `gh`, never a line that
308
310
  * also runs `curl`. Generic shells and pipeline cmdlets are not in the set.
309
311
  * Touch counts a use.
@@ -332,8 +334,14 @@ export declare function recordDeveloperGrant(scope: GrantScope, rule: {
332
334
  export declare function listDeveloperGrants(now?: Date): DeveloperGrant[];
333
335
  /** Remove one grant by key, or every grant when `key` is omitted. Returns how many were removed. */
334
336
  export declare function revokeDeveloperGrants(key?: string): number;
335
- /** One line a person can read on the button and in the grant list: what "Always allow" covers. */
336
- export declare function describeGrantScope(scope: GrantScope): string;
337
+ /**
338
+ * One line a person can read on the button and in the grant list: what "Always allow" covers.
339
+ * With `rule`, names the rule the grant is under (`… under rule gcloud_secret_access_cmd`).
340
+ */
341
+ export declare function describeGrantScope(scope: GrantScope, rule?: {
342
+ policyId?: string;
343
+ policyName?: string;
344
+ }): string;
337
345
  /**
338
346
  * Which line-level facts the RULE that asked actually depends on. A grant binds
339
347
  * the facts the rule fired on and nothing else: `gcloud_access_token_cmd` fired
@@ -346,6 +354,12 @@ export declare function describeGrantScope(scope: GrantScope): string;
346
354
  */
347
355
  export interface GrantRuleFacts {
348
356
  bindsDestination: boolean;
357
+ /**
358
+ * Programs the rule itself is about (a Local Safety item's `commands`). When
359
+ * set, local-only helpers on the line do not partition the grant
360
+ * (`shellProgramsForRule`). Absent = every program on the line.
361
+ */
362
+ ruleCommands?: string[];
349
363
  }
350
364
  /**
351
365
  * Derive `GrantRuleFacts` from the source of the ask. Unknown = bound (the
@@ -354,6 +368,8 @@ export interface GrantRuleFacts {
354
368
  export declare function grantRuleFactsFor(source: {
355
369
  localFinding?: {
356
370
  category?: string;
371
+ itemId?: string;
372
+ source?: string;
357
373
  };
358
374
  policyId?: string;
359
375
  policies?: Array<{