fullcourtdefense-cli 1.35.8 → 1.35.10

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 (59) hide show
  1. package/dist/actionIdentity.js +2 -1
  2. package/dist/actionPolicyEngine.d.ts +1 -0
  3. package/dist/actionPolicyEngine.js +203 -14
  4. package/dist/approvalPrompt.js +2 -2
  5. package/dist/askDialog.js +6 -6
  6. package/dist/blockExplanation.js +2 -0
  7. package/dist/callerEnv.d.ts +43 -0
  8. package/dist/callerEnv.js +107 -0
  9. package/dist/commandNarrative.d.ts +8 -0
  10. package/dist/commandNarrative.js +179 -13
  11. package/dist/commands/agentCi.js +3 -1
  12. package/dist/commands/daemon.js +7 -2
  13. package/dist/commands/deterministicGuard.d.ts +1 -1
  14. package/dist/commands/deterministicGuard.js +319 -28
  15. package/dist/commands/discover.js +23 -0
  16. package/dist/commands/discoverAgentFiles.d.ts +11 -0
  17. package/dist/commands/discoverAgentFiles.js +107 -0
  18. package/dist/commands/discoverExtensions.d.ts +64 -0
  19. package/dist/commands/discoverExtensions.js +386 -0
  20. package/dist/commands/hook.d.ts +58 -1
  21. package/dist/commands/hook.js +223 -37
  22. package/dist/commands/local-scan.js +1 -0
  23. package/dist/commands/mcpGateway.js +273 -54
  24. package/dist/commands/protectedRun.d.ts +1 -1
  25. package/dist/commands/protectedRun.js +3 -3
  26. package/dist/commands/taintLedger.js +3 -1
  27. package/dist/commands/watchdog.js +2 -0
  28. package/dist/credentialGrant.d.ts +43 -0
  29. package/dist/credentialGrant.js +504 -0
  30. package/dist/daemonForensics.d.ts +27 -0
  31. package/dist/daemonForensics.js +47 -0
  32. package/dist/detectorBaseline.d.ts +2 -2
  33. package/dist/detectorBaseline.js +2 -2
  34. package/dist/devConfirm.d.ts +76 -13
  35. package/dist/devConfirm.js +352 -144
  36. package/dist/envDiagnostics.js +4 -2
  37. package/dist/hookSlim.d.ts +1 -1
  38. package/dist/hookSlim.js +26 -5
  39. package/dist/ideExtensionRisk.d.ts +110 -0
  40. package/dist/ideExtensionRisk.js +172 -0
  41. package/dist/machineActionVerify.d.ts +34 -8
  42. package/dist/machineActionVerify.js +76 -16
  43. package/dist/mcpProtocol.d.ts +124 -0
  44. package/dist/mcpProtocol.js +302 -0
  45. package/dist/mcpToolReview.d.ts +2 -1
  46. package/dist/mcpToolReview.js +6 -4
  47. package/dist/nativeHook.d.ts +2 -1
  48. package/dist/nativeHook.js +109 -3
  49. package/dist/notify.d.ts +7 -0
  50. package/dist/notify.js +65 -0
  51. package/dist/shellQuoteMask.d.ts +25 -0
  52. package/dist/shellQuoteMask.js +319 -0
  53. package/dist/telemetry.d.ts +4 -0
  54. package/dist/telemetry.js +6 -0
  55. package/dist/verdictIpc.js +21 -2
  56. package/dist/verdictIpcClient.d.ts +43 -13
  57. package/dist/verdictIpcClient.js +66 -27
  58. package/dist/version.json +1 -1
  59. package/package.json +8 -3
@@ -2,10 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.identityPart = identityPart;
4
4
  exports.captureActionIdentity = captureActionIdentity;
5
+ const callerEnv_1 = require("./callerEnv");
5
6
  function identityPart(value) {
6
7
  return typeof value === 'string' && /^[A-Za-z0-9_.:@/-]{1,200}$/.test(value) ? value : undefined;
7
8
  }
8
- function captureActionIdentity(payload = {}, env = process.env) {
9
+ function captureActionIdentity(payload = {}, env = (0, callerEnv_1.callerEnv)()) {
9
10
  return {
10
11
  sessionId: identityPart(payload.conversation_id || payload.conversationId || payload.session_id || payload.sessionId
11
12
  || env.FCD_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || env.CODEX_THREAD_ID),
@@ -212,6 +212,7 @@ export declare function effectiveApprovalScope(approval: ActionPolicyApproval |
212
212
  */
213
213
  export declare function deriveToolOperations(toolName: string, declaredActions?: string[]): string[];
214
214
  export declare function toolCapabilities(value: string): Set<string>;
215
+ export declare function canonicalAgentClient(value: string): string;
215
216
  /**
216
217
  * "Contains a secret" means a secret VALUE is present — never that the text
217
218
  * talks about secrets. Source code, docs, grep patterns and commit messages in
@@ -27,6 +27,7 @@ __export(actionPolicyEngine_exports, {
27
27
  MACHINE_KINDS: () => MACHINE_KINDS,
28
28
  WORKSTATION_ASK_APPROVAL: () => WORKSTATION_ASK_APPROVAL,
29
29
  approvalExceptionError: () => approvalExceptionError,
30
+ canonicalAgentClient: () => canonicalAgentClient,
30
31
  deriveToolOperations: () => deriveToolOperations,
31
32
  detectSecretKind: () => detectSecretKind,
32
33
  effectiveApprovalScope: () => effectiveApprovalScope,
@@ -6043,16 +6044,93 @@ function provenJavaScriptReadUrls(code) {
6043
6044
  return [];
6044
6045
  }
6045
6046
  }
6047
+ var PY_READ_IMPORTS = /* @__PURE__ */ new Set(["urllib", "urllib.request", "urllib.parse", "urllib.error", "json", "re", "sys", "time", "datetime", "pprint", "textwrap", "collections", "itertools", "functools", "math", "string", "requests"]);
6048
+ var PY_INPUT_OR_EFFECT_RE = /__|"""|#|\b(?:open|input|stdin|argv|orig_argv|environ|getenv|getproxies\w*|proxy_bypass\w*|netrc|get_netrc_auth|get_environ_proxies|modules|exec|eval|compile|execfile|breakpoint\w*|help|codecs|importlib|getattr|setattr|delattr|globals|locals|vars|subprocess|system|popen|socket|pickle|marshal|ctypes|os|io|shutil|pathlib|glob|platform|getpass|builtins|_getframe|gi_frame|cr_frame|ag_frame|tb_frame|f_builtins|f_globals|f_locals|format|format_map|Formatter|Template|chr|post|put|patch|delete|urlretrieve|build_opener|install_opener|add_header|add_unredirected_header|add_data|Session|session|lambda|partial)\b|(?<!urllib\.)\brequest\b|\.(?:data|method|full_url|selector|host)\s*=|\b(?:urlopen|Request|get)\s*=(?!=)|\bsys\s*\.\s*(?!(?:exit|stdout|stderr)\b)\w|\.\s*sys\b|[)\]]\s*\(|"\s*\+|\+\s*"|%\s*[(\w]/;
6049
+ function maskPythonStrings(code) {
6050
+ return code.replace(/(?<!\w)([rRfF]{0,2})"((?:[^"\\\n]|\\.)*)"/g, (_m, prefix, body) => /f/i.test(prefix) ? `"${(body.match(/\{[^{}]*\}/g) || []).join(" ")}"` : '""');
6051
+ }
6052
+ var PY_REQUEST_CALL_RE = /\b(?:urllib\.request\.)?(?:urlopen|Request)\b|\brequests\.(?:get|head)\b/g;
6053
+ function pythonCallArguments(code, open) {
6054
+ const args = [];
6055
+ let depth = 0;
6056
+ let quote = "";
6057
+ let current2 = "";
6058
+ for (let i2 = open + 1; i2 < code.length; i2++) {
6059
+ const ch = code[i2];
6060
+ if (quote) {
6061
+ current2 += ch;
6062
+ if (ch === "\\") {
6063
+ current2 += code[++i2] ?? "";
6064
+ continue;
6065
+ }
6066
+ if (ch === quote) quote = "";
6067
+ continue;
6068
+ }
6069
+ if (ch === '"' || ch === "'") {
6070
+ quote = ch;
6071
+ current2 += ch;
6072
+ continue;
6073
+ }
6074
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
6075
+ if (ch === ")" || ch === "]" || ch === "}") {
6076
+ if (depth === 0) {
6077
+ if (ch !== ")") return void 0;
6078
+ args.push(current2.trim());
6079
+ return args.filter(Boolean);
6080
+ }
6081
+ depth--;
6082
+ }
6083
+ if (ch === "," && depth === 0) {
6084
+ args.push(current2.trim());
6085
+ current2 = "";
6086
+ continue;
6087
+ }
6088
+ current2 += ch;
6089
+ }
6090
+ return void 0;
6091
+ }
6092
+ function provenPythonReadUrls(code) {
6093
+ if (code.length > 16384) return [];
6094
+ const words = maskPythonStrings(code);
6095
+ if (PY_INPUT_OR_EFFECT_RE.test(words)) return [];
6096
+ for (const statement of words.matchAll(/(?:^|[\n;])[ \t]*(?:from[ \t]+([\w.]+)[ \t]+import\b([^\n;]*)|import[ \t]+([^\n;]+))/g)) {
6097
+ if (/\bas\b/.test(statement[2] ?? statement[3] ?? "")) return [];
6098
+ const modules = statement[1] !== void 0 ? [statement[1]] : statement[3].split(",").map((name) => name.trim());
6099
+ if (!modules.every((name) => PY_READ_IMPORTS.has(name))) return [];
6100
+ }
6101
+ const requestVariables = new Set([...words.matchAll(/\b([A-Za-z_]\w*)\s*=\s*(?:urllib\.request\.)?Request\s*\(/g)].map((m) => m[1]));
6102
+ const urls = [];
6103
+ for (const call of code.matchAll(PY_REQUEST_CALL_RE)) {
6104
+ const after = (call.index ?? 0) + call[0].length;
6105
+ const open = code.slice(after).search(/\S/) + after;
6106
+ if (code[open] !== "(") return [];
6107
+ const args = pythonCallArguments(code, open);
6108
+ if (!args || args.length === 0) return [];
6109
+ const [target, ...options] = args;
6110
+ if (!options.every((option) => /^timeout\s*=\s*\d+(?:\.\d+)?$/.test(option) || /^context\s*=\s*[A-Za-z_]\w*$/.test(option) || /^headers\s*=\s*\{\s*(?:"[^"\\{}]*"\s*:\s*"[^"\\{}]*"\s*,?\s*)*\}$/.test(option))) return [];
6111
+ if (/urlopen$/.test(call[0]) && requestVariables.has(target)) continue;
6112
+ const literal2 = /^[fr]?"(https?:\/\/([A-Za-z0-9.-]+)(?::\d+)?)([/?][^"\\]*)?"$/.exec(target);
6113
+ if (!literal2) return [];
6114
+ if (/^[\d.]+$/.test(literal2[2]) || isInternalHost(literal2[2])) return [];
6115
+ urls.push(literal2[1] + (literal2[3] || "/").replace(/\{[\s\S]*$/, ""));
6116
+ }
6117
+ return urls;
6118
+ }
6046
6119
  function provenInlineJavaScriptReadUrls(command) {
6047
6120
  const urls = [];
6048
6121
  for (const segment of splitShellSegments(command)) {
6049
6122
  const here = segment.match(/^\s*@'\r?\n([\s\S]*?)\r?\n'@\s*\|\s*node(?:\.exe)?(?:\s+-)?\s*$/i);
6050
6123
  const inline = segment.match(/^\s*node(?:\.exe)?\s+(?:-e|--eval)\s+'([^']*)'\s*$/i);
6124
+ const python = segment.match(/^\s*python3?\s+-c\s+'([^']*)'\s*$/);
6051
6125
  const code = here?.[1] ?? inline?.[1];
6052
6126
  if (code !== void 0) {
6053
6127
  const proven = provenJavaScriptReadUrls(code);
6054
6128
  if (!proven.length) return [];
6055
6129
  urls.push(...proven);
6130
+ } else if (python) {
6131
+ const proven = provenPythonReadUrls(python[1]);
6132
+ if (!proven.length) return [];
6133
+ urls.push(...proven);
6056
6134
  } else {
6057
6135
  const lead = shellSegmentLead(segment);
6058
6136
  if (!(SHELL_READ_PROGRAMS.has(lead) || ["git", "gh", "cd", "set-location"].includes(lead)) || classifyShellCommandOperation(segment) !== "read" || /https?:\/\//i.test(segment)) return [];
@@ -6485,14 +6563,21 @@ function toolCapabilities(value) {
6485
6563
  addCapabilityIfAny(capabilities, words, "mcp", ["mcp", "tool", "server"]);
6486
6564
  return capabilities;
6487
6565
  }
6488
- function toolNameIdentityMatches(actual, expected) {
6489
- const actualLower = actual.toLowerCase();
6490
- const expectedLower = expected.toLowerCase();
6491
- if (actualLower === expectedLower) return true;
6492
- if (actualLower.includes(expectedLower) || expectedLower.includes(actualLower)) return true;
6493
- const actualWords = normalizedWords(actual);
6494
- const expectedWords = normalizedWords(expected);
6495
- return [...expectedWords].some((word) => word.length >= 4 && actualWords.has(word));
6566
+ function canonicalToolIdentity(value) {
6567
+ const lower = value.trim().toLowerCase();
6568
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(lower);
6569
+ if (mcp) return { server: mcp[1], tool: mcp[2] };
6570
+ const slash = lower.lastIndexOf("/");
6571
+ if (slash > 0 && slash < lower.length - 1) return { server: lower.slice(0, slash).replace(/^@/, ""), tool: lower.slice(slash + 1) };
6572
+ const qualified = /^.+(?:__|\.)(.+)$/.exec(lower);
6573
+ return { tool: qualified ? qualified[1] : lower };
6574
+ }
6575
+ function toolNameIdentityMatches(actual, expected, actualServer) {
6576
+ const call = canonicalToolIdentity(actual);
6577
+ const rule = canonicalToolIdentity(expected);
6578
+ if (call.tool !== rule.tool) return false;
6579
+ const server = call.server || actualServer?.trim().toLowerCase();
6580
+ return !rule.server || !server || server === rule.server;
6496
6581
  }
6497
6582
  var IDENTITY_CONSTRAINT_FIELDS = /* @__PURE__ */ new Set(["developerName", "machineName"]);
6498
6583
  function normalizeHostPart(value) {
@@ -6509,6 +6594,11 @@ var PATH_CONSTRAINT_FIELDS = /* @__PURE__ */ new Set(["path"]);
6509
6594
  function normalizePathValue(value) {
6510
6595
  return value.replace(/\\/g, "/");
6511
6596
  }
6597
+ var AGENT_CLIENT_ALIASES = { gemini: "gemini-cli", "vs-code": "vscode" };
6598
+ function canonicalAgentClient(value) {
6599
+ const id = value.trim().toLowerCase().replace(/[\s_]+/g, "-");
6600
+ return AGENT_CLIENT_ALIASES[id] || id;
6601
+ }
6512
6602
  function evaluateConstraint(constraint, context) {
6513
6603
  let actual = context[constraint.field] || "";
6514
6604
  let expected = constraint.value;
@@ -6516,6 +6606,10 @@ function evaluateConstraint(constraint, context) {
6516
6606
  actual = normalizePathValue(actual);
6517
6607
  expected = normalizePathValue(expected);
6518
6608
  }
6609
+ if (constraint.field === "agentClient") {
6610
+ actual = canonicalAgentClient(actual);
6611
+ expected = canonicalAgentClient(expected);
6612
+ }
6519
6613
  if (COMMAND_LINE_FIELDS.has(constraint.field) && /^(?:not_)?(?:matches|contains)$/.test(constraint.operator)) {
6520
6614
  actual = shellCommandWords(actual);
6521
6615
  }
@@ -6542,9 +6636,9 @@ function evaluateConstraint(constraint, context) {
6542
6636
  if (constraint.field === "toolName") {
6543
6637
  switch (constraint.operator) {
6544
6638
  case "equals":
6545
- return toolNameIdentityMatches(actual, expected);
6639
+ return toolNameIdentityMatches(actual, expected, context.mcpServer);
6546
6640
  case "not_equals":
6547
- return actual.toLowerCase() !== expected.toLowerCase();
6641
+ return !toolNameIdentityMatches(actual, expected, context.mcpServer);
6548
6642
  case "contains":
6549
6643
  return actual.toLowerCase().includes(expected.toLowerCase());
6550
6644
  case "not_contains":
@@ -6948,7 +7042,7 @@ function isHighEntropySecretToken(token, allowWordSlug = false) {
6948
7042
  if (/^sha(?:1|256|384|512)-/i.test(token)) return false;
6949
7043
  if (/^data:/i.test(token) || /^[A-Za-z0-9+/]{200,}={0,2}$/.test(token)) return false;
6950
7044
  if (/^(?:https?|s3|gs|file):\/\//i.test(token) || /[\\/]{1}[\w.-]+[\\/]/.test(token)) return false;
6951
- if (allowWordSlug && /^[A-Za-z_][\w.-]*\/[A-Za-z_][\w.-]*\.[A-Za-z][A-Za-z0-9]{0,9}$/.test(token)) return false;
7045
+ if (allowWordSlug && /^[A-Za-z_][\w.-]*\/\w[\w.-]*\.[A-Za-z][A-Za-z0-9]{0,9}$/.test(token)) return false;
6952
7046
  if (allowWordSlug && /[/.]/.test(token) && token.split(/[/.]/).length >= 2 && token.split(/[/.]/).every((part) => /^[A-Za-z][a-z]*(?:[A-Z][a-z0-9]*)*$/.test(part))) return false;
6953
7047
  if (/^[a-z]+(?:[A-Z][a-z0-9]+)+$/.test(token) || /^[a-z]+(?:_[a-z0-9]+)+$/.test(token)) return false;
6954
7048
  if (allowWordSlug && /^[A-Z][a-z]+-(?:[A-Z][a-z]+){2,}$/.test(token)) return false;
@@ -6964,6 +7058,12 @@ function isHighEntropySecretToken(token, allowWordSlug = false) {
6964
7058
  var SECRET_NAME = String.raw`(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|bearer[_-]?token|client[_-]?secret|secret[_-]?key|secret|password|passwd|pwd|credentials?|private[_-]?key|token)`;
6965
7059
  var SECRET_ASSIGNMENT_RE = new RegExp(String.raw`(?:^|[^\w])[\w.-]*?${SECRET_NAME}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{8,})`, "gi");
6966
7060
  var SECRET_FLAG_RE = /(?:^|[\s"'`;(\[{,])--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)[\\"']*([^\s\\"',;&|)}\]<>-][^\s\\"',;&|)}\]<>]{7,})/gi;
7061
+ var SECRET_FLAG_QUOTED_RE = /(?:^|[\s"'`;(\[{,])--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)\\?(["'])([^\s"'\\-][^\s"'\\]{7,})\\?\1/gi;
7062
+ function isWordPhraseWithCounters(value) {
7063
+ const parts = value.split(/[-_.]/);
7064
+ const words = parts.filter((part) => /^(?:[a-z]+|[A-Z]+|[A-Z][a-z]+)$/.test(part));
7065
+ return parts.length >= 3 && words.length >= 2 && words.some((word) => word.length >= 3) && parts.every((part) => words.includes(part) || /^\d{1,6}(?:[a-z]+)?$/.test(part));
7066
+ }
6967
7067
  var SECRET_PLACEHOLDER_RE = /^(?:<.*>|\$\{?[A-Za-z_][\w.]*\}?|%[A-Za-z_]+%|\{\{.*\}\}|\[.*\]|\*{3,}|x{4,}|X{4,}|\.{3,}|-{3,}|_{3,}|(?:your|my|our|the|a|an)(?:[-_.][\w.-]*)?|(?:some|example|sample|dummy|fake|test|mock|placeholder|redacted|masked|hidden|changeme|change_me|replace_?me|todo|tbd|fixme|none|null|nil|undefined|true|false|required|optional|omitted|removed|unset|empty|secret|password|token|xxx+|abc+|123+)[\w.-]*)$/i;
6968
7068
  var SECRET_CODE_EXPR_RE = /^(?:[A-Za-z_$][\w$]*(?:[.\[][^\s=]*)+|[A-Za-z_$][\w$]*\(.*\)?|process\.env\.\w+|os\.environ.*|env(?:iron)?\.\w+|new\s+\w+.*|await\s+.*|require\(.*)$/;
6969
7069
  function looksLikeSecretValue(raw) {
@@ -6984,6 +7084,7 @@ function looksLikeSecretValue(raw) {
6984
7084
  if (REPEATED_CHUNK_RE.test(value)) return false;
6985
7085
  if (PLACEHOLDER_WORD_RE.test(value)) return false;
6986
7086
  if (/^[a-z]+(?:[-_.][a-z]+)+$/i.test(value)) return false;
7087
+ if (isWordPhraseWithCounters(value)) return false;
6987
7088
  if (/^(?:change|replace|your|my|our|example|sample|dummy|fake|test|mock|placeholder|not[-_]?real|do[-_]?not[-_]?use|insert|enter|put|paste)[-_]/i.test(value)) return false;
6988
7089
  let classes = 0;
6989
7090
  if (/[a-z]/.test(value)) classes++;
@@ -7022,6 +7123,10 @@ function detectSecretKind(raw) {
7022
7123
  if (looksLikeSecretValue(m[1])) return "assignment";
7023
7124
  }
7024
7125
  }
7126
+ SECRET_FLAG_QUOTED_RE.lastIndex = 0;
7127
+ for (let m = SECRET_FLAG_QUOTED_RE.exec(value); m !== null; m = SECRET_FLAG_QUOTED_RE.exec(value)) {
7128
+ if (looksLikeSecretValue(m[2])) return "assignment";
7129
+ }
7025
7130
  for (const re of [GENERIC_VALUE_RE, AUTH_HEADER_RE]) {
7026
7131
  re.lastIndex = 0;
7027
7132
  let m;
@@ -7171,6 +7276,8 @@ function withoutUnattributedJavaScriptUrls(segment) {
7171
7276
  function extractUrl(args, _argsText) {
7172
7277
  const direct = args.url || args.uri || args.endpoint || args.webhookUrl || args.callbackUrl || args.href || args.link;
7173
7278
  if (typeof direct === "string" && direct.trim()) return direct.trim();
7279
+ const listed = Array.isArray(args.urls) ? args.urls.find((url) => typeof url === "string" && url.trim()) : void 0;
7280
+ if (listed) return listed.trim();
7174
7281
  for (const field of URL_ACTION_FIELDS) {
7175
7282
  const raw = args[field];
7176
7283
  if (typeof raw !== "string") continue;
@@ -7740,6 +7847,16 @@ function cdScopeOfSegment(segment) {
7740
7847
  if (/^(?:\$\{?[a-z_][a-z0-9_]*\}?|%[a-z_][a-z0-9_]*%|\$env:[a-z_][a-z0-9_]*)\/?$/.test(lower)) return "unscoped";
7741
7848
  return lower.split("/").some((part) => part && part !== "." && part !== "..") ? "scoped" : "unscoped";
7742
7849
  }
7850
+ function isNestedScratchOrWorkPath(target) {
7851
+ const t = target.replace(/\\/g, "/").replace(/\/+$/, "");
7852
+ const named = (parts2) => parts2.every((part) => part && !part.startsWith(".") && !/[*?[\]{}$%`]/.test(part));
7853
+ const tmp = /^(?:\/private)?\/(?:tmp|var\/tmp)\/(.+)$/.exec(t);
7854
+ if (tmp) return named(tmp[1].split("/"));
7855
+ const home = /^(?:~|\$\{?HOME\}?|\/home\/[^/.$*?]+|\/Users\/[^/.$*?]+|[A-Za-z]:\/Users\/[^/.$*?]+)\/(.+)$/i.exec(t);
7856
+ if (!home) return false;
7857
+ const parts = home[1].split("/");
7858
+ return parts.length >= 2 && named(parts) && !/^(?:Library|AppData)$/i.test(parts[0]);
7859
+ }
7743
7860
  function isWorkspaceScopedRemoval(command, cwdScoped = false) {
7744
7861
  const segments = splitShellSegments(command, true);
7745
7862
  const removal = segments.find((seg) => /\b(rm|del|remove-item|rmdir)\b/i.test(seg)) || command;
@@ -7750,7 +7867,7 @@ function isWorkspaceScopedRemoval(command, cwdScoped = false) {
7750
7867
  const t = raw.replace(/^["']|["']$/g, "");
7751
7868
  if (t === "*" || /^\.\/?\*$/.test(t) || /^\.\\\*$/.test(t)) return cwdScoped;
7752
7869
  if (!t || t === "." || t === "..") return false;
7753
- if (/^[\/\\]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t)) return false;
7870
+ if (/^[\/\\]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t) || /^\$\{?HOME\}?[\/\\]/.test(t)) return isNestedScratchOrWorkPath(t);
7754
7871
  if (/\$|%|\$\{|\$\(|`/.test(t)) return false;
7755
7872
  if (/(^|[\/\\])\.\.([\/\\]|$)/.test(t)) return false;
7756
7873
  if (/^\*\*?$|^[\/\\]?\*$/.test(t)) return false;
@@ -7909,10 +8026,28 @@ function classifyShellSegment(segment, depth = 0, cwdScoped = false) {
7909
8026
  }
7910
8027
  return classifyShellWords(maskQuotedSpans(raw), raw, lead, cwdScoped);
7911
8028
  }
8029
+ var STDIN_DATA_MODULES = /* @__PURE__ */ new Set(["json.tool", "base64"]);
8030
+ var CODE_RUNS_OR_SENDS_INPUT_RE = /\b(?:exec|eval|compile|execfile|runpy|runsource|importlib|import_module|getattr|setattr|delattr|globals|locals|vars|builtins|modules|_getframe|gi_frame|f_builtins|f_globals|f_locals|Function|constructor|binding|vm|pickle|cPickle|marshal|shelve|dill|yaml|jsonpickle|require|import\s*\(|open|write|writeFile\w*|os|shutil|pathlib|fs|socket|urllib|requests|httpx|http|https|fetch|net|system|spawn|popen|Popen|subprocess|Open3|qx|readpipe|do|send|breakpoint\w*|set_trace|pdb|pty|code|interact|timeit|cProfile|profile|doctest)\b|__|(?:instance|class|module)_eval\b|[)\]]\s*\(|["']\s*\+|\+\s*["']|chr\s*\(|%x[\[{(]|`/;
8031
+ var PERL_CODE_REGEX_RE = /(?:^|[^\w$@%&])s\s*[^\w\s]|\(\?\??\{/;
8032
+ function interpreterStageReadsStdinAsData(stage) {
8033
+ const pieces = stage.trim().match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+/g) || [];
8034
+ if (pieces.join("").replace(/\s/g, "") !== stage.replace(/\s/g, "")) return false;
8035
+ const lead = pieces.shift() || "";
8036
+ if (!/^(?:python3?|perl)$/.test(lead)) return false;
8037
+ if (lead.startsWith("python") && pieces.length === 2 && pieces[0] === "-m") return STDIN_DATA_MODULES.has(pieces[1]);
8038
+ const flag = lead.startsWith("python") ? /^-c$/ : /^-[lnapF]*e$/;
8039
+ if (pieces.length !== 2 || !flag.test(pieces[0])) return false;
8040
+ const program = pieces[1];
8041
+ if (!/^'[^']*'$/.test(program) && !/^"[^"$`\\]*"$/.test(program)) return false;
8042
+ const code = program.slice(1, -1);
8043
+ if (lead === "perl" && PERL_CODE_REGEX_RE.test(code)) return false;
8044
+ return !CODE_RUNS_OR_SENDS_INPUT_RE.test(code) && !codeSpawnsProcess(code, lead);
8045
+ }
7912
8046
  function classifyShellWords(masked, raw, lead, cwdScoped = false) {
7913
8047
  const value = masked.trim().toLowerCase();
7914
8048
  if (!value) return "read";
7915
- const pipesIntoInterpreter = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:pwsh|powershell|python3?|node|perl|ruby)\b/.test(value);
8049
+ const interpreterStages = splitShellSegments(raw, true).slice(1).filter((stage) => /^(?:sudo\s+)?(?:python3?|node|perl|ruby)\b/i.test(stage));
8050
+ const pipesIntoInterpreter = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b|\|\s*(?:sudo\s+)?(?:pwsh|powershell)\b/.test(value) || /\|\s*(?:sudo\s+)?(?:python3?|node|perl|ruby)\b/.test(value) && !(interpreterStages.length > 0 && interpreterStages.every(interpreterStageReadsStdinAsData));
7916
8051
  const segment = raw;
7917
8052
  if (/\b(curl|wget|invoke-restmethod|invoke-webrequest|iwr|irm)\b/.test(value)) {
7918
8053
  const explicitMethod = /(?:\s-x|\s--request|\s-method)[\s=]+["']?(post|put|patch|delete)\b/i.test(segment);
@@ -8086,7 +8221,7 @@ function astRemovalOperation(command, inContainer) {
8086
8221
  const t = raw.trim().replace(/\\/g, "/");
8087
8222
  if (/^(?:\*|\.\/+\*)$/.test(t)) return inContainer || command.cwdScope === "scoped";
8088
8223
  if (!t || t === "." || t === "..") return false;
8089
- if (/^[\/]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t)) return false;
8224
+ if (/^[\/]/.test(t) || /^[A-Za-z]:/.test(t) || /^~/.test(t) || /^\$\{?HOME\}?\//.test(t)) return isNestedScratchOrWorkPath(t);
8090
8225
  if (/\$|%|`/.test(t)) return false;
8091
8226
  if (/(^|\/)\.\.(\/|$)/.test(t)) return false;
8092
8227
  return true;
@@ -8211,10 +8346,35 @@ function isGcloudStorageCatRead(command) {
8211
8346
  }
8212
8347
  return objects > 0;
8213
8348
  }
8349
+ function provenGitCloneOperands(tokens) {
8350
+ if (tokens.shift() !== "clone") return false;
8351
+ let urls = 0;
8352
+ let directories = 0;
8353
+ for (let i2 = 0; i2 < tokens.length; i2++) {
8354
+ const token = tokens[i2];
8355
+ if (/^(?:-q|--quiet|--single-branch|--no-single-branch|--no-tags|--no-checkout|-n|--bare|--mirror)$/.test(token)) continue;
8356
+ if (/^(?:--depth|--branch|-b|--origin|-o)$/.test(token) && /^[\w.\/-]+$/.test(tokens[i2 + 1] || "")) {
8357
+ i2++;
8358
+ continue;
8359
+ }
8360
+ if (/^--(?:depth|branch|origin|filter)=[\w.:\/-]+$/.test(token)) continue;
8361
+ if (urls === 0 && /^https?:\/\/[\w.:\/-]+$/i.test(token)) {
8362
+ urls++;
8363
+ continue;
8364
+ }
8365
+ if (urls === 1 && directories === 0 && /^[\w.~\/-]+$/.test(token) && !token.startsWith("-")) {
8366
+ directories++;
8367
+ continue;
8368
+ }
8369
+ return false;
8370
+ }
8371
+ return urls === 1;
8372
+ }
8214
8373
  function provenCompoundDownload(command, detectedUrl) {
8215
8374
  if (command.includes(detectedUrl) && provenPowerShellReadSequence(command)) return true;
8216
8375
  const segments = splitShellSegments(command).filter((segment) => /\b(?:https?|s3|gs|ftp|sftp|smb):\/\//i.test(segment));
8217
8376
  if (!segments.length || !command.includes(detectedUrl)) return false;
8377
+ if (segments.some((segment) => /^git(?:\.exe)?\s+clone\b/i.test(segment)) && splitShellSegments(command).some((segment) => !segments.includes(segment) && classifyShellSegment(segment) !== "read" && !(["rm", "rmdir", "mkdir", "cd", "pushd", "popd"].includes(shellSegmentLead(segment)) && classifyShellSegment(segment) === "write"))) return false;
8218
8378
  return segments.every((segment) => {
8219
8379
  const statusRead = /^\(((?:Invoke-WebRequest|iwr)\s+[^\r\n]+)\)\.(?:StatusCode|StatusDescription|RawContentLength)$/i.exec(segment);
8220
8380
  if (statusRead) segment = statusRead[1];
@@ -8228,6 +8388,7 @@ function provenCompoundDownload(command, detectedUrl) {
8228
8388
  if (pieces.join("").replace(/\s/g, "") !== raw.replace(/\s/g, "")) return false;
8229
8389
  const tokens = pieces.map((piece) => piece.replace(/^["']|["']$/g, ""));
8230
8390
  const program = tokens.shift() || "";
8391
+ if (/^git(?:\.exe)?$/i.test(program)) return provenGitCloneOperands(tokens);
8231
8392
  if (!/^(?:curl(?:\.exe)?|wget(?:\.exe)?|Invoke-RestMethod|Invoke-WebRequest|irm|iwr)$/i.test(program)) return false;
8232
8393
  const isCurl = /^curl(?:\.exe)?$/i.test(program);
8233
8394
  let urls = 0;
@@ -8379,6 +8540,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
8379
8540
  if (policies.length === 0) {
8380
8541
  return { allowed: true, verdict: "allow" };
8381
8542
  }
8543
+ if (!context.toolName && toolName) context = { ...context, toolName };
8382
8544
  const verdictPriority = {
8383
8545
  block: 3,
8384
8546
  require_approval: 2,
@@ -8557,8 +8719,33 @@ function inferToolContext(toolName, args) {
8557
8719
  context["destination.type"] = context["request.destination.type"];
8558
8720
  context["destination.domain"] = context["request.destination.domain"];
8559
8721
  }
8722
+ applyWebReadFacts(toolName, args, context);
8560
8723
  return { operation, context };
8561
8724
  }
8725
+ var WEB_READ_TOOL_NAMES = /* @__PURE__ */ new Set(["webfetch", "fetchwebpage", "webextract"]);
8726
+ function applyWebReadFacts(toolName, args, context) {
8727
+ if (!WEB_READ_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_\-.\s]/g, ""))) return;
8728
+ context["action.kind"] = "http";
8729
+ const url = extractUrl(args, "");
8730
+ if (!url) return;
8731
+ let parsed;
8732
+ try {
8733
+ parsed = new URL(url);
8734
+ } catch {
8735
+ context["url.risk"] = "invalid";
8736
+ return;
8737
+ }
8738
+ const hostType = isInternalHost(parsed.hostname) || isSingleLabelWebHost(parsed) ? "internal" : "external";
8739
+ context.url = url;
8740
+ context["destination.domain"] = parsed.hostname;
8741
+ context["url.risk"] = hostType;
8742
+ context["source.domain"] = parsed.hostname;
8743
+ context["source.type"] = hostType;
8744
+ if (recordHttpRequestFacts(context, args, url)) context["destination.type"] = hostType;
8745
+ else delete context["destination.type"];
8746
+ if (isLoopbackHost(parsed.hostname)) context["destination.loopback"] = "true";
8747
+ else delete context["destination.loopback"];
8748
+ }
8562
8749
  function inferBundledToolContext(toolName, args) {
8563
8750
  const context = {};
8564
8751
  let operation = toolName;
@@ -8668,6 +8855,7 @@ function inferBundledToolContext(toolName, args) {
8668
8855
  context["destination.type"] = isInternalHost(context["destination.domain"]) ? "internal" : "external";
8669
8856
  }
8670
8857
  }
8858
+ applyWebReadFacts(toolName, args, context);
8671
8859
  return { operation, context };
8672
8860
  }
8673
8861
  var RESERVED_INPUT_FACTS = /* @__PURE__ */ new Set([
@@ -8772,6 +8960,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8772
8960
  MACHINE_KINDS,
8773
8961
  WORKSTATION_ASK_APPROVAL,
8774
8962
  approvalExceptionError,
8963
+ canonicalAgentClient,
8775
8964
  deriveToolOperations,
8776
8965
  detectSecretKind,
8777
8966
  effectiveApprovalScope,
@@ -94,7 +94,7 @@ function nativeDialogView(reason, options = {}) {
94
94
  'Allow once lets only this action continue.',
95
95
  'Deny or close keeps it blocked.',
96
96
  options.allowAlways && options.alwaysScopeLabel
97
- ? `Always allow covers ${line(options.alwaysScopeLabel, 180)} for ${options.alwaysLimitsLabel || '30 days'} on this machine, under this rule as it is written today.`
97
+ ? `Always allow: ${line(options.alwaysScopeLabel, 180)} for ${options.alwaysLimitsLabel || '30 days'} on this machine, while the rule stays as it is written today.`
98
98
  : '',
99
99
  ].filter(Boolean);
100
100
  const footer = f.policy && !f.fallback
@@ -156,7 +156,7 @@ function nativeApprovalMessage(reason, options = {}) {
156
156
  return [
157
157
  'Allow this action?',
158
158
  '', ...details, '',
159
- ...(options.alwaysScopeLabel ? [`Always allow covers: ${line(options.alwaysScopeLabel, 200)} — for ${options.alwaysLimitsLabel || '30 days'} on this machine, under this rule as it is written today.`, ''] : []),
159
+ ...(options.alwaysScopeLabel ? [`Always allow: ${line(options.alwaysScopeLabel, 200)} for ${options.alwaysLimitsLabel || '30 days'} on this machine, while the rule stays as it is written today.`, ''] : []),
160
160
  'Allow once permits only this action. Deny or closing keeps it stopped.',
161
161
  where,
162
162
  ].join('\n');
package/dist/askDialog.js CHANGED
@@ -31,13 +31,14 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
31
31
  const devConfirm_1 = require("./devConfirm");
32
32
  const telemetry_1 = require("./telemetry");
33
33
  const hookIo_1 = require("./hookIo");
34
+ const callerEnv_1 = require("./callerEnv");
34
35
  /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
35
36
  exports.ASK_ENVELOPE_FIELD = 'fcdAsk';
36
37
  /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
37
38
  exports.ASK_DIALOG_TIMEOUT_MS = 55_000;
38
39
  /** Which channel asks the developer. `dialog` (default) = our OS dialog, `ide` = the IDE's own card. */
39
40
  function askChannel() {
40
- return (process.env.FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
41
+ return ((0, callerEnv_1.callerEnv)().FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
41
42
  }
42
43
  /** Attach the envelope to a decision object (core side). */
43
44
  function attachAskEnvelope(decision, envelope) {
@@ -140,9 +141,8 @@ async function resolveAskWithDialog(verdict, options = {}) {
140
141
  return { ...clean, resolution: 'ide' };
141
142
  const grantHash = envelope.ruleHash || envelope.policyHash;
142
143
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
143
- const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits)
144
- && (envelope.scope.event !== 'shell' || (envelope.scope.programs || []).length > 0);
145
- const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope) : undefined;
144
+ const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits) && (0, devConfirm_1.grantOffered)(envelope.scope);
145
+ const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope, { policyId: envelope.policyId, policyName: envelope.policyName }) : undefined;
146
146
  const limitsLabel = allowAlways && grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
147
147
  const dialog = options.dialog || devConfirm_1.confirmNativeActionDetailed;
148
148
  const reason = options.clientLabel ? `IDE: ${options.clientLabel}\n${envelope.reason}` : envelope.reason;
@@ -172,7 +172,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
172
172
  toolName: envelope.toolName,
173
173
  operation: envelope.operation,
174
174
  reason: granted
175
- ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (${limitsLabel}, this rule)${policyLabel}`
175
+ ? `developer chose Always allow in the FullCourtDefense dialog — ${scopeLabel} (${limitsLabel})${policyLabel}`
176
176
  : `developer confirmed in the FullCourtDefense dialog${policyLabel}`,
177
177
  ruleId: envelope.policyId,
178
178
  policyHash: envelope.policyHash,
@@ -182,7 +182,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
182
182
  });
183
183
  (0, telemetry_1.triggerFlush)(false);
184
184
  const agentMsg = granted
185
- ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} (${limitsLabel}) under this rule.`
185
+ ? `FullCourtDefense: the developer allowed this ${envelope.event} and chose "Always allow" for ${scopeLabel} (${limitsLabel}).`
186
186
  : `FullCourtDefense: the developer allowed this ${envelope.event} once in the FullCourtDefense dialog.`;
187
187
  return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, true, '', agentMsg)), exitCode: 0, resolution: outcome };
188
188
  }
@@ -84,6 +84,8 @@ function scopeWords(scope, developerName) {
84
84
  return '🌐 company default — applies to every machine';
85
85
  if (scope === 'machine' || scope === 'developer' || scope === 'local')
86
86
  return `📍 local rule — only ${developerName || 'this machine'}`;
87
+ if (scope === 'shield')
88
+ return '📍 shield rule — only the machines on the shield it was saved on, or the ones it names';
87
89
  return `${scope} policy`;
88
90
  }
89
91
  const FACT_WORDS = [
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The environment of the process that ASKED for a verdict (#1466).
3
+ *
4
+ * A hook answered by the resident daemon is evaluated inside the daemon's
5
+ * process, whose own environment belongs to a scheduled task — not to the IDE
6
+ * session that spawned the hook. Every fact the decision reads from the
7
+ * environment (is a human present, which CI / ephemeral markers are set, the
8
+ * session identity, the IDE's hook budget) must be the CALLER's. The thin
9
+ * client sends a snapshot of exactly these keys; the daemon evaluates the
10
+ * request with them in place of its own values, per request.
11
+ *
12
+ * Node built-ins only: this leaf is on the slim hook's warm-path module graph.
13
+ */
14
+ export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS"];
15
+ export type CallerEnvSnapshot = Partial<Record<(typeof CALLER_ENV_KEYS)[number], string>>;
16
+ /** The keys above that are set in `env` — what the thin client sends. */
17
+ export declare function snapshotCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
18
+ /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
19
+ export declare function callerEnv(): NodeJS.ProcessEnv;
20
+ /**
21
+ * Run `fn` with the caller's snapshot in place of this process's values for
22
+ * CALLER_ENV_KEYS — a key the caller did not send is UNSET, never inherited
23
+ * from the daemon. No snapshot (older client) = this process's env, as before.
24
+ */
25
+ export declare function withCallerEnv<T>(snapshot: unknown, fn: () => T): T;
26
+ /**
27
+ * How long the IDE lets an approval-capable hook run before it kills the hook
28
+ * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
29
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
30
+ */
31
+ export declare const IDE_HOOK_TIMEOUT_MS = 90000;
32
+ export declare const IDE_TIMEOUT_MARGIN_MS = 15000;
33
+ export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
34
+ /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
35
+ export declare function ideWaitCutoff(startedAt: number, ideTimeoutMs?: number): number;
36
+ /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
37
+ export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
38
+ /**
39
+ * Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
40
+ * and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
41
+ * developer-scoped approval must go to the org queue or block, never self-resolve.
42
+ */
43
+ export declare function isHumanPresent(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IDE_TIMEOUT_MARGIN_MS = exports.IDE_HOOK_TIMEOUT_MS = exports.CALLER_ENV_KEYS = void 0;
4
+ exports.snapshotCallerEnv = snapshotCallerEnv;
5
+ exports.callerEnv = callerEnv;
6
+ exports.withCallerEnv = withCallerEnv;
7
+ exports.ideHookTimeoutMs = ideHookTimeoutMs;
8
+ exports.ideWaitCutoff = ideWaitCutoff;
9
+ exports.isCiEnvironment = isCiEnvironment;
10
+ exports.isHumanPresent = isHumanPresent;
11
+ const async_hooks_1 = require("async_hooks");
12
+ /**
13
+ * The environment of the process that ASKED for a verdict (#1466).
14
+ *
15
+ * A hook answered by the resident daemon is evaluated inside the daemon's
16
+ * process, whose own environment belongs to a scheduled task — not to the IDE
17
+ * session that spawned the hook. Every fact the decision reads from the
18
+ * environment (is a human present, which CI / ephemeral markers are set, the
19
+ * session identity, the IDE's hook budget) must be the CALLER's. The thin
20
+ * client sends a snapshot of exactly these keys; the daemon evaluates the
21
+ * request with them in place of its own values, per request.
22
+ *
23
+ * Node built-ins only: this leaf is on the slim hook's warm-path module graph.
24
+ */
25
+ exports.CALLER_ENV_KEYS = [
26
+ // Human presence (isHumanPresent / isCiEnvironment).
27
+ 'FCD_HUMAN_PRESENT', 'FCD_EPHEMERAL', 'FULLCOURTDEFENSE_EPHEMERAL',
28
+ 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'TF_BUILD', 'JENKINS_URL', 'BUILDKITE', 'CIRCLECI',
29
+ // Whether a dialog can be shown in the caller's session.
30
+ 'DISPLAY', 'WAYLAND_DISPLAY', 'FCD_ASK_CHANNEL',
31
+ // Action identity (captureActionIdentity).
32
+ 'FCD_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'FCD_RUN_ID', 'GITHUB_RUN_ID', 'CI_PIPELINE_ID', 'FCD_WORKLOAD_INSTANCE_ID',
33
+ // Which client / workspace the verdict is written for.
34
+ 'CLAUDECODE', 'CLAUDE_PROJECT_DIR', 'VSCODE_PID', 'TERM_PROGRAM', 'WORKSPACE_PATH',
35
+ // Human-wait budget and approve-once switches.
36
+ 'FCD_HOOK_IDE_TIMEOUT_MS', 'FCD_TAINT_APPROVE_ONCE_DISABLED', 'FCD_TAINT_APPROVAL_TIMEOUT_MS',
37
+ ];
38
+ /** The keys above that are set in `env` — what the thin client sends. */
39
+ function snapshotCallerEnv(env = process.env) {
40
+ const snapshot = {};
41
+ for (const key of exports.CALLER_ENV_KEYS) {
42
+ const value = env[key];
43
+ if (typeof value === 'string')
44
+ snapshot[key] = value;
45
+ }
46
+ return snapshot;
47
+ }
48
+ const requestEnv = new async_hooks_1.AsyncLocalStorage();
49
+ /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
50
+ function callerEnv() {
51
+ return requestEnv.getStore() ?? process.env;
52
+ }
53
+ /**
54
+ * Run `fn` with the caller's snapshot in place of this process's values for
55
+ * CALLER_ENV_KEYS — a key the caller did not send is UNSET, never inherited
56
+ * from the daemon. No snapshot (older client) = this process's env, as before.
57
+ */
58
+ function withCallerEnv(snapshot, fn) {
59
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot))
60
+ return fn();
61
+ const env = { ...process.env };
62
+ for (const key of exports.CALLER_ENV_KEYS) {
63
+ const value = snapshot[key];
64
+ if (typeof value === 'string')
65
+ env[key] = value;
66
+ else
67
+ delete env[key];
68
+ }
69
+ return requestEnv.run(env, fn);
70
+ }
71
+ /**
72
+ * How long the IDE lets an approval-capable hook run before it kills the hook
73
+ * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
74
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
75
+ */
76
+ exports.IDE_HOOK_TIMEOUT_MS = 90_000;
77
+ exports.IDE_TIMEOUT_MARGIN_MS = 15_000;
78
+ function ideHookTimeoutMs(env = callerEnv()) {
79
+ const override = Number(env.FCD_HOOK_IDE_TIMEOUT_MS);
80
+ return override > 0 ? override : exports.IDE_HOOK_TIMEOUT_MS;
81
+ }
82
+ /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
83
+ function ideWaitCutoff(startedAt, ideTimeoutMs = ideHookTimeoutMs()) {
84
+ return startedAt + ideTimeoutMs - Math.min(exports.IDE_TIMEOUT_MARGIN_MS, ideTimeoutMs / 4);
85
+ }
86
+ /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
87
+ function isCiEnvironment(env = callerEnv()) {
88
+ return env.GITHUB_ACTIONS === 'true'
89
+ || env.GITLAB_CI === 'true'
90
+ || env.TF_BUILD === 'True' // Azure Pipelines
91
+ || !!env.JENKINS_URL
92
+ || !!env.BUILDKITE
93
+ || !!env.CIRCLECI
94
+ || env.CI === 'true';
95
+ }
96
+ /**
97
+ * Whether a developer can answer an IDE permission prompt on this machine. False on CI runners
98
+ * and on ephemeral workloads (`FCD_EPHEMERAL=1`, exported by `fcd workload-protect`) — there a
99
+ * developer-scoped approval must go to the org queue or block, never self-resolve.
100
+ */
101
+ function isHumanPresent(env = callerEnv()) {
102
+ if (env.FCD_EPHEMERAL === '1' || env.FCD_EPHEMERAL === 'true' || env.FULLCOURTDEFENSE_EPHEMERAL === '1')
103
+ return false;
104
+ if (env.FCD_HUMAN_PRESENT === '0' || env.FCD_HUMAN_PRESENT === 'false')
105
+ return false;
106
+ return !isCiEnvironment(env);
107
+ }