fullcourtdefense-cli 1.35.7 → 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.
Files changed (41) hide show
  1. package/dist/actionIdentity.js +2 -1
  2. package/dist/actionPolicyEngine.d.ts +38 -0
  3. package/dist/actionPolicyEngine.js +383 -24
  4. package/dist/approvalPrompt.js +2 -2
  5. package/dist/askDialog.js +5 -4
  6. package/dist/blockExplanation.js +2 -0
  7. package/dist/callerEnv.d.ts +33 -0
  8. package/dist/callerEnv.js +90 -0
  9. package/dist/commandNarrative.js +111 -4
  10. package/dist/commands/daemon.js +1 -1
  11. package/dist/commands/deterministicGuard.d.ts +32 -0
  12. package/dist/commands/deterministicGuard.js +214 -19
  13. package/dist/commands/hook.d.ts +34 -1
  14. package/dist/commands/hook.js +188 -31
  15. package/dist/commands/mcpGateway.js +2 -0
  16. package/dist/commands/taintLedger.js +3 -1
  17. package/dist/commands/workloadProtect.js +6 -2
  18. package/dist/devConfirm.d.ts +28 -12
  19. package/dist/devConfirm.js +134 -48
  20. package/dist/hookSlim.d.ts +1 -1
  21. package/dist/hookSlim.js +22 -2
  22. package/dist/nativeHook.d.ts +2 -1
  23. package/dist/nativeHook.js +109 -3
  24. package/dist/notify.js +4 -0
  25. package/dist/shellAst.d.ts +117 -0
  26. package/dist/shellAst.js +590 -0
  27. package/dist/shellAstLoader.d.ts +32 -0
  28. package/dist/shellAstLoader.js +271 -0
  29. package/dist/telemetry.d.ts +20 -0
  30. package/dist/telemetry.js +7 -0
  31. package/dist/vendor/tree-sitter/LICENSE.tree-sitter-bash +21 -0
  32. package/dist/vendor/tree-sitter/LICENSE.web-tree-sitter +21 -0
  33. package/dist/vendor/tree-sitter/VERSIONS.json +4 -0
  34. package/dist/vendor/tree-sitter/tree-sitter-bash.wasm +0 -0
  35. package/dist/vendor/tree-sitter/tree-sitter.cjs +4031 -0
  36. package/dist/vendor/tree-sitter/tree-sitter.wasm +0 -0
  37. package/dist/verdictIpc.js +5 -2
  38. package/dist/verdictIpcClient.d.ts +11 -1
  39. package/dist/verdictIpcClient.js +12 -2
  40. package/dist/version.json +1 -1
  41. package/package.json +9 -6
@@ -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");
@@ -69,6 +74,7 @@ const askDialog_1 = require("../askDialog");
69
74
  const hookIo_1 = require("../hookIo");
70
75
  const fileWriteCanon_1 = require("../fileWriteCanon");
71
76
  const discoverProxy_1 = require("./discoverProxy");
77
+ const shellAstLoader_1 = require("../shellAstLoader");
72
78
  function inferEvent(explicit, payload) {
73
79
  const e = (explicit || '').toLowerCase();
74
80
  if (e) {
@@ -176,10 +182,17 @@ function mapClaudeToolPayload(payload) {
176
182
  payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
177
183
  };
178
184
  }
179
- // 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
180
192
  // permission flow handle them.
181
193
  return mapBuiltinToolPayload(toolName, input, payload) ?? { event: 'ignore', payload };
182
194
  }
195
+ const BUILTIN_WEB_READ_TOOLS = new Set(['webfetch', 'fetchwebpage', 'webextract']);
183
196
  // ---------------------------------------------------------------------------
184
197
  // Built-in IDE tools — ONE taxonomy for every client.
185
198
  //
@@ -402,9 +415,10 @@ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
402
415
  * VS Code Copilot runs hooks inside the VS Code process environment.
403
416
  */
404
417
  function claudeFormatClient() {
405
- if (process.env.CLAUDECODE || process.env.CLAUDE_PROJECT_DIR)
418
+ const env = (0, callerEnv_1.callerEnv)();
419
+ if (env.CLAUDECODE || env.CLAUDE_PROJECT_DIR)
406
420
  return 'claude-code';
407
- if (process.env.VSCODE_PID || process.env.TERM_PROGRAM === 'vscode')
421
+ if (env.VSCODE_PID || env.TERM_PROGRAM === 'vscode')
408
422
  return 'vscode';
409
423
  return 'claude-code';
410
424
  }
@@ -552,6 +566,7 @@ function spoolCallEvent(call, rest) {
552
566
  toolName: call.toolName,
553
567
  operation: rest.operation || operation,
554
568
  evidence: rest.evidence || (0, telemetry_1.evidenceFromToolArgs)(call.toolArgs),
569
+ shellAst: rest.shellAst || (0, shellAstLoader_1.collectShellAstFacts)(),
555
570
  });
556
571
  }
557
572
  /**
@@ -826,7 +841,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
826
841
  const grantLimits = (0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)());
827
842
  const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
828
843
  if (grant) {
829
- const covers = (0, devConfirm_1.describeGrantScope)(grant.scope);
844
+ const covers = (0, devConfirm_1.describeGrantScope)(grant.scope, grant);
830
845
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_granted', event, tool: call.toolName, operation, grant: grant.key, where: source.where });
831
846
  spoolCallEvent(call, {
832
847
  decision: 'allow',
@@ -838,7 +853,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
838
853
  offlineEnforced,
839
854
  });
840
855
  (0, telemetry_1.triggerFlush)(false);
841
- 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}.` };
842
857
  }
843
858
  const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
844
859
  event,
@@ -964,18 +979,25 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
964
979
  });
965
980
  const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
966
981
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
967
- (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
982
+ (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail, astMode: (0, actionPolicyEngine_1.getShellAstMode)() });
968
983
  // Developer-scoped approvals need no backend at all — the IDE prompt IS the approver.
969
984
  if (tryDeveloperConfirmation(ctx, call, local, ctx.payload, 'offline'))
970
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;
971
990
  if (local.verdict === 'block' || local.verdict === 'require_approval') {
972
991
  const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
973
992
  if (ctx.shadow) {
974
993
  // Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
975
994
  // (which hides the would-block from the console) and never 'block'.
976
- 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 });
977
997
  (0, telemetry_1.triggerFlush)(false);
978
- 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}`);
979
1001
  }
980
1002
  const approvalNote = local.verdict === 'require_approval'
981
1003
  ? ' This action requires human approval, which is not possible while the policy service is unreachable.'
@@ -985,10 +1007,6 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
985
1007
  const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
986
1008
  ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
987
1009
  }
988
- // Monitor machines land here by design (zero synchronous network calls), not
989
- // because anything is down — never tell the developer the service is
990
- // unreachable when it is simply not consulted.
991
- const monitorPath = opts.countAsGateFailure === false;
992
1010
  if (local.verdict === 'log') {
993
1011
  // Warn-only rule, offline: still a real warning + Warned in the fleet.
994
1012
  const warning = warnByPolicy(call, local, ctx.event, ctx.exitsProcess, 'local-first');
@@ -1094,7 +1112,7 @@ function machineMetadata(client = 'cursor') {
1094
1112
  os: `${os.platform()} ${os.release()}`,
1095
1113
  username,
1096
1114
  cwd: process.cwd(),
1097
- 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(),
1098
1116
  agentClient: client,
1099
1117
  };
1100
1118
  }
@@ -1219,6 +1237,40 @@ class HookExitSignal {
1219
1237
  this.code = code;
1220
1238
  }
1221
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
+ }
1222
1274
  function processIo(stdin, stdinErr) {
1223
1275
  return {
1224
1276
  stdin,
@@ -1238,8 +1290,11 @@ function processIo(stdin, stdinErr) {
1238
1290
  * Mirrors hookCommand's fail-open contract: any unexpected exception yields
1239
1291
  * an allow verdict, never an error the thin client would surface.
1240
1292
  */
1241
- async function evaluateHookRequest(args, stdinRaw, config) {
1242
- return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false });
1293
+ async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
1294
+ // Structural shell facts (shadow by default). A missing/slow parser means
1295
+ // "no facts" and the regex floor decides alone — never a wait, never fail-open.
1296
+ await (0, shellAstLoader_1.ensureShellAst)();
1297
+ return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: opts.viaDaemon });
1243
1298
  }
1244
1299
  /**
1245
1300
  * Run the hook against a buffered io and return the verdict instead of writing
@@ -1258,6 +1313,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1258
1313
  writeErr: (text) => { errOut += text; },
1259
1314
  exit: (code) => { throw new HookExitSignal(code); },
1260
1315
  exitsProcess: mode.exitsProcess,
1316
+ viaDaemon: mode.viaDaemon,
1261
1317
  };
1262
1318
  try {
1263
1319
  await hookCommandInner(args, config, io);
@@ -1269,11 +1325,13 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1269
1325
  if (err instanceof HookExitSignal) {
1270
1326
  return { stdout: out, stderr: errOut, exitCode: err.code };
1271
1327
  }
1328
+ if (err instanceof DaemonDeclined)
1329
+ throw err;
1272
1330
  const message = err instanceof Error ? err.message : String(err);
1273
1331
  // Same contract as the process-level wrapper, including the kill-switch
1274
1332
  // exception: a crash during daemon-side evaluation must not unlock a
1275
1333
  // suspended machine.
1276
- const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config);
1334
+ const outcome = failOpenOutcome(args.event, `${message} (daemon eval)`, args, config, io.pendingStop);
1277
1335
  return { stdout: outcome.stdout, stderr: '', exitCode: outcome.exitCode };
1278
1336
  }
1279
1337
  }
@@ -1293,7 +1351,27 @@ async function hookCommand(args, config) {
1293
1351
  const startedAt = Date.now();
1294
1352
  return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
1295
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
+ }
1296
1373
  async function hookCommandOuter(args, config, startedAt) {
1374
+ installHookCrashHandlers((message) => crashVerdictForProcess(args.event, message));
1297
1375
  try {
1298
1376
  let raw = '';
1299
1377
  let stdinErr = '';
@@ -1319,6 +1397,11 @@ async function hookCommandOuter(args, config, startedAt) {
1319
1397
  // machine that silently stopped using the fast path is diagnosable.
1320
1398
  (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
1321
1399
  }
1400
+ // Structural shell facts for the local evaluation (shadow by default; the
1401
+ // daemon path above initialises its own copy once). Bounded: a slow or
1402
+ // missing parser resolves to "no facts" and the regex floor decides alone.
1403
+ const astReady = await (0, shellAstLoader_1.ensureShellAst)();
1404
+ (0, hookIo_1.dbg)({ phase: 'shell_ast', ready: astReady, status: (0, shellAstLoader_1.shellAstStatus)() });
1322
1405
  // Buffered so an `ask` can be put to the developer in OUR dialog before the
1323
1406
  // IDE reads the verdict; exitsProcess stays true — this process ends right after.
1324
1407
  const result = await evaluateBuffered(args, raw, stdinErr, config, { isTTY: !!process.stdin.isTTY, exitsProcess: true });
@@ -1326,7 +1409,7 @@ async function hookCommandOuter(args, config, startedAt) {
1326
1409
  }
1327
1410
  catch (err) {
1328
1411
  const message = err instanceof Error ? err.message : String(err);
1329
- const outcome = failOpenOutcome(args.event, message, args, config);
1412
+ const outcome = failOpenOutcome(args.event, message, args, config, processPendingStop);
1330
1413
  try {
1331
1414
  process.stdout.write(outcome.stdout);
1332
1415
  }
@@ -1344,8 +1427,29 @@ async function hookCommandOuter(args, config, startedAt) {
1344
1427
  * machines still deny. An enforcing Shield with fail-closed also denies
1345
1428
  * evaluation failures; Monitor and fail-open configurations continue.
1346
1429
  */
1347
- function failOpenOutcome(event, message, args, config) {
1430
+ function failOpenOutcome(event, message, args, config, pendingStop) {
1348
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
+ }
1349
1453
  // Label the spooled event below as a crash-allow: a rising fail_open rate is
1350
1454
  // the fleet's earliest signal that the guard itself is broken on a machine.
1351
1455
  try {
@@ -1456,9 +1560,11 @@ async function hookCommandInner(args, config, io) {
1456
1560
  // this is instant when fresh and bounded (~1.5s) only when stale.
1457
1561
  let machineSuspended = false;
1458
1562
  let modeResolved = false;
1563
+ let runtimeBundleFlags = {};
1459
1564
  if (shieldId) {
1460
1565
  try {
1461
1566
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
1567
+ runtimeBundleFlags = bundle;
1462
1568
  // Kill-switch holds even on a 'default' bundle: with the cache missing
1463
1569
  // but a signed last-known-suspended marker standing, getRuntimeBundle
1464
1570
  // stamps suspended on the default — deleting the cache file must not
@@ -1486,6 +1592,10 @@ async function hookCommandInner(args, config, io) {
1486
1592
  }
1487
1593
  catch { /* keep the local default */ }
1488
1594
  }
1595
+ // Shell AST mode: FCD_SHELL_AST_MODE env, else the bundle's `shellAstMode`
1596
+ // (server-controlled rollout), else shadow — facts recorded, verdicts unchanged.
1597
+ (0, shellAstLoader_1.applyShellAstMode)((0, shellAstLoader_1.resolveShellAstMode)(process.env.FCD_SHELL_AST_MODE, runtimeBundleFlags.shellAstMode));
1598
+ (0, hookIo_1.dbg)({ phase: 'shell_ast_mode', mode: (0, actionPolicyEngine_1.getShellAstMode)(), fromEnv: process.env.FCD_SHELL_AST_MODE, fromBundle: runtimeBundleFlags.shellAstMode });
1489
1599
  // Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
1490
1600
  if (!modeResolved && !forceEnforce) {
1491
1601
  shadow = true;
@@ -1539,7 +1649,7 @@ async function hookCommandInner(args, config, io) {
1539
1649
  payload = cursor.payload;
1540
1650
  ignoreEvent = cursor.ignore;
1541
1651
  }
1542
- (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),
1543
1653
  isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
1544
1654
  argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
1545
1655
  // Emit the verdict in the shape THIS client expects, then exit.
@@ -1577,7 +1687,7 @@ async function hookCommandInner(args, config, io) {
1577
1687
  const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
1578
1688
  const respond = (blocked, userMsg, agentMsg, askEnvelope) => {
1579
1689
  let ask = blocked === 'ask' && event !== 'prompt';
1580
- const denied = blocked === true;
1690
+ let denied = blocked === true;
1581
1691
  if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
1582
1692
  const { finding, call, ruleHash } = pendingLocalAsk;
1583
1693
  pendingLocalAsk = undefined;
@@ -1598,6 +1708,22 @@ async function hookCommandInner(args, config, io) {
1598
1708
  }
1599
1709
  agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
1600
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
+ }
1601
1727
  if (!denied && pendingLocalWarnings.length > 0) {
1602
1728
  spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
1603
1729
  }
@@ -1691,7 +1817,12 @@ async function hookCommandInner(args, config, io) {
1691
1817
  await enforceActionPolicy({
1692
1818
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1693
1819
  shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
1694
- 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
+ },
1695
1826
  });
1696
1827
  return;
1697
1828
  }
@@ -1749,6 +1880,13 @@ async function hookCommandInner(args, config, io) {
1749
1880
  // Unknown event → nothing to enforce.
1750
1881
  respond(false);
1751
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
+ }
1752
1890
  /**
1753
1891
  * Approve-once for a taint-guard block: hold the action, alert the human at
1754
1892
  * the keyboard, and poll for their one-time decision from their OWN terminal
@@ -1770,11 +1908,17 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1770
1908
  respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry. ${explained.agentMessage}`);
1771
1909
  return false;
1772
1910
  };
1773
- const approveOnceEnabled = process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
1774
- && 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'
1775
1914
  && ctx.approvalMode !== 'block';
1776
1915
  if (!approveOnceEnabled)
1777
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');
1778
1922
  const approval = (0, taintLedger_1.createTaintApproval)({
1779
1923
  sessionId: sessId,
1780
1924
  event,
@@ -1785,8 +1929,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1785
1929
  });
1786
1930
  if (!approval)
1787
1931
  return hardBlock();
1788
- const timeoutMs = Number(process.env.FCD_TAINT_APPROVAL_TIMEOUT_MS) > 0
1789
- ? 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)
1790
1934
  : 120000; // local developer decision — short window, they are at the keyboard
1791
1935
  const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
1792
1936
  // forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
@@ -1799,10 +1943,13 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1799
1943
  + `Deny: fullcourtdefense approve ${approval.id} --deny\n`
1800
1944
  + `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
1801
1945
  });
1802
- (0, hookIo_1.dbg)({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
1803
- 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)));
1804
1950
  (0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
1805
1951
  if (outcome === 'approved') {
1952
+ ctx.notePendingStop('');
1806
1953
  spoolCallEvent(call, {
1807
1954
  decision: 'allow',
1808
1955
  reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
@@ -2050,6 +2197,9 @@ async function enforceActionPolicy(ctx) {
2050
2197
  if (tryDeveloperConfirmation({ event, respond, shadow, effectivePolicyHash, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
2051
2198
  return;
2052
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
+ }
2053
2203
  }
2054
2204
  // PROXY RESILIENCE (fast probe): when the gate failed within the last 60s
2055
2205
  // (bank proxy without the api hostname allowlisted, backend outage), skip
@@ -2160,6 +2310,11 @@ async function enforceActionPolicy(ctx) {
2160
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}`);
2161
2311
  return;
2162
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');
2163
2318
  // Make the wait visible on the machine: the hook process has no UI and its
2164
2319
  // stderr is invisible inside IDEs, so a native toast is the only reliable
2165
2320
  // signal that a human needs to act NOW (the approval window is minutes).
@@ -2170,9 +2325,10 @@ async function enforceActionPolicy(ctx) {
2170
2325
  });
2171
2326
  const outcome = await waitForApproval({
2172
2327
  apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
2173
- timeoutMs: ctx.approvalTimeoutMs, pollMs: ctx.approvalPollMs,
2328
+ deadline: humanWaitDeadline(ctx.approvalTimeoutMs), pollMs: ctx.approvalPollMs,
2174
2329
  });
2175
2330
  if (outcome.status === 'approved') {
2331
+ ctx.notePendingStop('');
2176
2332
  (0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved', message: `${call.toolName} was approved and will continue.` });
2177
2333
  respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
2178
2334
  return;
@@ -2202,11 +2358,12 @@ async function waitForApproval(input) {
2202
2358
  if (input.shieldKey)
2203
2359
  headers['x-shield-key'] = input.shieldKey;
2204
2360
  const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
2205
- const deadline = Date.now() + input.timeoutMs;
2206
- (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()) });
2207
2363
  while (Date.now() <= deadline) {
2208
2364
  try {
2209
- 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()))) });
2210
2367
  if (resp.ok) {
2211
2368
  const body = await resp.json().catch(() => ({}));
2212
2369
  const approval = body.data?.approval;
@@ -74,6 +74,7 @@ const policyGateHealth_1 = require("../policyGateHealth");
74
74
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
75
75
  const machineIdentity_1 = require("../machineIdentity");
76
76
  const backupManifest_1 = require("../backupManifest");
77
+ const shellAstLoader_1 = require("../shellAstLoader");
77
78
  const devConfirm_1 = require("../devConfirm");
78
79
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
79
80
  const MANAGED_SERVER_NAME = 'agentguard-gateway';
@@ -2148,6 +2149,7 @@ class McpGatewayServer {
2148
2149
  }
2149
2150
  }
2150
2151
  async function mcpGatewayCommand(args, config) {
2152
+ await (0, shellAstLoader_1.ensureShellAstReady)(); // resident process: load the parser once, before the first tool call (shadow by default)
2151
2153
  // The gateway is a resident proxy in front of the IDE's MCP servers — if it
2152
2154
  // crashes, EVERY protected tool goes down at once. Any escaped exception
2153
2155
  // (detached flush, timer, transport edge case) logs one line and the proxy
@@ -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 {
@@ -66,7 +66,7 @@ function assertWorkloadSubstrate(kind, rt, options = {}) {
66
66
  + 'or pass --force true if this really is a disposable workload host.');
67
67
  }
68
68
  /** Connections the console has a section for; anything else is the generic snippet. */
69
- const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'snippet']);
69
+ const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'azure', 'snippet']);
70
70
  function resolveEnrolledVia(explicit, env, detected) {
71
71
  for (const candidate of [explicit, env.FCD_ENROLLED_VIA, detected]) {
72
72
  const clean = (candidate || '').trim().toLowerCase();
@@ -105,7 +105,11 @@ function detectWorkloadContext(env = process.env, fsProbe = fs) {
105
105
  }
106
106
  if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_SITE_NAME) {
107
107
  // Azure Functions
108
- return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image };
108
+ return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image, enrolledVia: 'azure' };
109
+ }
110
+ if (env.CONTAINER_APP_NAME && env.CONTAINER_APP_REVISION) {
111
+ // Azure Container Apps: the app name is the stable identity, the replica the instance.
112
+ return { kind: 'container', name: explicitName || env.CONTAINER_APP_NAME, environment, instanceId: env.CONTAINER_APP_REPLICA_NAME || env.HOSTNAME, image, enrolledVia: 'azure' };
109
113
  }
110
114
  if (env.ECS_CONTAINER_METADATA_URI_V4 || env.ECS_CONTAINER_METADATA_URI) {
111
115
  // ECS / Fargate task: the service name comes from the task definition env.
@@ -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<{