fullcourtdefense-cli 1.36.3 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/actionPolicyEngine.d.ts +19 -0
  2. package/dist/actionPolicyEngine.js +147 -0
  3. package/dist/askDialog.d.ts +1 -0
  4. package/dist/askDialog.js +20 -3
  5. package/dist/callerEnv.d.ts +29 -2
  6. package/dist/callerEnv.js +48 -2
  7. package/dist/commandNarrative.js +87 -7
  8. package/dist/commands/daemon.d.ts +10 -0
  9. package/dist/commands/daemon.js +348 -170
  10. package/dist/commands/desktopChatGuard.js +2 -0
  11. package/dist/commands/deterministicGuard.d.ts +11 -0
  12. package/dist/commands/deterministicGuard.js +445 -28
  13. package/dist/commands/discoverPaths.d.ts +12 -9
  14. package/dist/commands/discoverPaths.js +30 -0
  15. package/dist/commands/ephemeralStack.js +7 -0
  16. package/dist/commands/flushSpool.d.ts +2 -0
  17. package/dist/commands/flushSpool.js +9 -2
  18. package/dist/commands/hook.d.ts +14 -0
  19. package/dist/commands/hook.js +302 -101
  20. package/dist/commands/httpGatewayCommand.d.ts +1 -0
  21. package/dist/commands/httpGatewayCommand.js +7 -1
  22. package/dist/commands/installClaudeHook.js +32 -10
  23. package/dist/commands/installCursorHook.js +20 -9
  24. package/dist/commands/login.js +8 -1
  25. package/dist/commands/mcpGateway.d.ts +21 -0
  26. package/dist/commands/mcpGateway.js +90 -32
  27. package/dist/commands/refreshPolicyCache.d.ts +28 -0
  28. package/dist/commands/refreshPolicyCache.js +59 -0
  29. package/dist/commands/watchdog.d.ts +2 -0
  30. package/dist/commands/watchdog.js +30 -5
  31. package/dist/config.d.ts +36 -0
  32. package/dist/config.js +78 -12
  33. package/dist/daemonDetectorBoot.d.ts +1 -0
  34. package/dist/daemonDetectorBoot.js +12 -0
  35. package/dist/daemonForensics.d.ts +14 -1
  36. package/dist/daemonForensics.js +32 -0
  37. package/dist/detectorInProcess.d.ts +18 -0
  38. package/dist/detectorInProcess.js +234 -0
  39. package/dist/detectorInThread.d.ts +15 -0
  40. package/dist/detectorInThread.js +129 -0
  41. package/dist/distress.d.ts +2 -0
  42. package/dist/distress.js +2 -0
  43. package/dist/fileWriteCanon.d.ts +17 -0
  44. package/dist/fileWriteCanon.js +141 -0
  45. package/dist/gateReports.d.ts +144 -0
  46. package/dist/gateReports.js +488 -0
  47. package/dist/hookIo.d.ts +59 -0
  48. package/dist/hookIo.js +319 -9
  49. package/dist/hookSlim.d.ts +15 -8
  50. package/dist/hookSlim.js +187 -25
  51. package/dist/housekeeping.d.ts +64 -0
  52. package/dist/housekeeping.js +384 -0
  53. package/dist/housekeepingOps.d.ts +150 -0
  54. package/dist/housekeepingOps.js +218 -0
  55. package/dist/housekeepingWorker.d.ts +1 -0
  56. package/dist/housekeepingWorker.js +113 -0
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +19 -0
  59. package/dist/localDetectionData.d.ts +10 -0
  60. package/dist/localDetectionData.js +48 -0
  61. package/dist/localDetectionUpdates.d.ts +2 -2
  62. package/dist/localDetectionUpdates.js +4 -4
  63. package/dist/localSafetySnapshot.d.ts +48 -2
  64. package/dist/localSafetySnapshot.js +241 -25
  65. package/dist/machineIdentity.d.ts +7 -0
  66. package/dist/machineIdentity.js +20 -2
  67. package/dist/monitorFastPath.d.ts +191 -0
  68. package/dist/monitorFastPath.js +1198 -0
  69. package/dist/nativeHook.js +9 -2
  70. package/dist/pendingDrain.d.ts +82 -0
  71. package/dist/pendingDrain.js +215 -0
  72. package/dist/pendingDrainer.d.ts +30 -0
  73. package/dist/pendingDrainer.js +217 -0
  74. package/dist/perfSnapshot.d.ts +1 -1
  75. package/dist/perfSnapshot.js +28 -4
  76. package/dist/policyCacheFiles.d.ts +73 -0
  77. package/dist/policyCacheFiles.js +320 -0
  78. package/dist/policyGateHealth.d.ts +8 -0
  79. package/dist/policyGateHealth.js +17 -0
  80. package/dist/policyRefresh.d.ts +28 -0
  81. package/dist/policyRefresh.js +69 -0
  82. package/dist/protectionMaintenance.d.ts +15 -0
  83. package/dist/protectionMaintenance.js +43 -1
  84. package/dist/runtimeConfig.d.ts +77 -20
  85. package/dist/runtimeConfig.js +278 -57
  86. package/dist/selfTest.d.ts +6 -1
  87. package/dist/selfTest.js +11 -4
  88. package/dist/telemetry.d.ts +136 -4
  89. package/dist/telemetry.js +493 -96
  90. package/dist/verdictIpc.d.ts +60 -0
  91. package/dist/verdictIpc.js +504 -19
  92. package/dist/verdictIpcClient.d.ts +175 -18
  93. package/dist/verdictIpcClient.js +277 -59
  94. package/dist/version.json +1 -1
  95. package/package.json +2 -2
@@ -309,6 +309,25 @@ export declare function inferToolContext(toolName: string, args: Record<string,
309
309
  operation: string;
310
310
  context: Record<string, string>;
311
311
  };
312
+ /**
313
+ * What kind of site a destination host is, for the few kinds that change how an
314
+ * upload should be read. A curated list, not a reputation feed: a host is in it
315
+ * only when its PURPOSE is the category (a paste site exists to publish text; a
316
+ * request catcher exists to capture whatever is sent to it). Deliberately absent:
317
+ * general cloud storage, code hosting and gists, which are everyday developer
318
+ * destinations and would turn the category into noise.
319
+ */
320
+ export type DestinationCategory = 'paste' | 'file_drop' | 'request_capture' | 'tunnel' | 'messaging' | 'news';
321
+ export declare const DESTINATION_CATEGORY_HOSTS: Readonly<Record<DestinationCategory, readonly string[]>>;
322
+ /** Categories whose purpose is to receive data from strangers: a first upload there is high risk. */
323
+ export declare const HIGH_RISK_DESTINATION_CATEGORIES: ReadonlySet<DestinationCategory>;
324
+ /** The listed host itself or any subdomain of it (`www.pastebin.com`, `x.m.pipedream.net`); never a look-alike (`notpastebin.com`). */
325
+ export declare function destinationCategory(host: string | undefined | null): DestinationCategory | undefined;
326
+ /** A host's category and whether that category exists to receive data from anyone — the shape the anomaly scorer takes. */
327
+ export declare function classifyDestinationHost(host: string): {
328
+ category: DestinationCategory;
329
+ dataDrop: boolean;
330
+ } | undefined;
312
331
  /** Exported for reproducible detector artifacts; policy decisions are not packaged. */
313
332
  export declare function inferBundledToolContext(toolName: string, args: Record<string, any>): {
314
333
  operation: string;
@@ -24,14 +24,18 @@ __export(actionPolicyEngine_exports, {
24
24
  ACTION_POLICY_GRANT_MAX_USES: () => ACTION_POLICY_GRANT_MAX_USES,
25
25
  CONTENT_FACT_FIELDS: () => CONTENT_FACT_FIELDS,
26
26
  DEFAULT_ACTION_POLICY_APPROVAL: () => DEFAULT_ACTION_POLICY_APPROVAL,
27
+ DESTINATION_CATEGORY_HOSTS: () => DESTINATION_CATEGORY_HOSTS,
27
28
  GCLOUD_DELETE_VERB_RE: () => GCLOUD_DELETE_VERB_RE,
28
29
  GCLOUD_READ_VERB_RE: () => GCLOUD_READ_VERB_RE,
29
30
  GCLOUD_WRITE_VERB_RE: () => GCLOUD_WRITE_VERB_RE,
31
+ HIGH_RISK_DESTINATION_CATEGORIES: () => HIGH_RISK_DESTINATION_CATEGORIES,
30
32
  MACHINE_KINDS: () => MACHINE_KINDS,
31
33
  WORKSTATION_ASK_APPROVAL: () => WORKSTATION_ASK_APPROVAL,
32
34
  approvalExceptionError: () => approvalExceptionError,
33
35
  canonicalAgentClient: () => canonicalAgentClient,
36
+ classifyDestinationHost: () => classifyDestinationHost,
34
37
  deriveToolOperations: () => deriveToolOperations,
38
+ destinationCategory: () => destinationCategory,
35
39
  detectSecretKind: () => detectSecretKind,
36
40
  effectiveApprovalScope: () => effectiveApprovalScope,
37
41
  evaluateActionPolicies: () => evaluateActionPolicies,
@@ -6888,6 +6892,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
6888
6892
  "destination.host",
6889
6893
  "destination.type",
6890
6894
  "destination.repository",
6895
+ "destination.category",
6891
6896
  "destinationHost",
6892
6897
  "host",
6893
6898
  "hostname",
@@ -8767,8 +8772,145 @@ function inferToolContext(toolName, args) {
8767
8772
  }
8768
8773
  applyRemoteControlFact(toolName, args, context);
8769
8774
  applyWebReadFacts(toolName, args, context);
8775
+ applyDestinationCategoryFact(context);
8770
8776
  return { operation, context };
8771
8777
  }
8778
+ var DESTINATION_CATEGORY_HOSTS = {
8779
+ paste: [
8780
+ "pastebin.com",
8781
+ "paste.ee",
8782
+ "hastebin.com",
8783
+ "rentry.co",
8784
+ "rentry.org",
8785
+ "dpaste.org",
8786
+ "dpaste.com",
8787
+ "paste.rs",
8788
+ "termbin.com",
8789
+ "controlc.com",
8790
+ "justpaste.it",
8791
+ "privatebin.net",
8792
+ "ix.io",
8793
+ "sprunge.us",
8794
+ "pastes.io",
8795
+ "paste.mozilla.org"
8796
+ ],
8797
+ file_drop: [
8798
+ "transfer.sh",
8799
+ "file.io",
8800
+ "0x0.st",
8801
+ "temp.sh",
8802
+ "bashupload.com",
8803
+ "gofile.io",
8804
+ "catbox.moe",
8805
+ "uguu.se",
8806
+ "tmpfiles.org",
8807
+ "filebin.net",
8808
+ "wetransfer.com",
8809
+ "we.tl",
8810
+ "sendspace.com",
8811
+ "mediafire.com",
8812
+ "mega.nz",
8813
+ "anonfiles.com",
8814
+ "oshi.at",
8815
+ "pixeldrain.com",
8816
+ "ufile.io",
8817
+ "keep.sh",
8818
+ "filetransfer.io"
8819
+ ],
8820
+ request_capture: [
8821
+ "webhook.site",
8822
+ "pipedream.net",
8823
+ "beeceptor.com",
8824
+ "hookbin.com",
8825
+ "requestcatcher.com",
8826
+ "interact.sh",
8827
+ "oast.fun",
8828
+ "oast.pro",
8829
+ "oast.live",
8830
+ "oast.site",
8831
+ "oast.online",
8832
+ "oast.me",
8833
+ "oastify.com",
8834
+ "burpcollaborator.net",
8835
+ "dnslog.cn",
8836
+ "ceye.io"
8837
+ ],
8838
+ tunnel: [
8839
+ "ngrok.io",
8840
+ "ngrok.app",
8841
+ "ngrok.dev",
8842
+ "ngrok-free.app",
8843
+ "ngrok-free.dev",
8844
+ "trycloudflare.com",
8845
+ "loca.lt",
8846
+ "localtunnel.me",
8847
+ "serveo.net",
8848
+ "localhost.run",
8849
+ "lhr.life",
8850
+ "pinggy.io",
8851
+ "pinggy.link",
8852
+ "bore.pub"
8853
+ ],
8854
+ messaging: ["discord.com", "discordapp.com", "api.telegram.org", "hooks.slack.com"],
8855
+ news: [
8856
+ "ynet.co.il",
8857
+ "ynetnews.com",
8858
+ "walla.co.il",
8859
+ "mako.co.il",
8860
+ "n12.co.il",
8861
+ "haaretz.co.il",
8862
+ "haaretz.com",
8863
+ "themarker.com",
8864
+ "maariv.co.il",
8865
+ "globes.co.il",
8866
+ "calcalist.co.il",
8867
+ "israelhayom.co.il",
8868
+ "kan.org.il",
8869
+ "timesofisrael.com",
8870
+ "jpost.com",
8871
+ "i24news.tv",
8872
+ "bbc.com",
8873
+ "bbc.co.uk",
8874
+ "cnn.com",
8875
+ "nytimes.com",
8876
+ "reuters.com",
8877
+ "apnews.com",
8878
+ "theguardian.com",
8879
+ "washingtonpost.com",
8880
+ "wsj.com",
8881
+ "bloomberg.com",
8882
+ "ft.com",
8883
+ "foxnews.com",
8884
+ "nbcnews.com",
8885
+ "cbsnews.com",
8886
+ "npr.org",
8887
+ "aljazeera.com"
8888
+ ]
8889
+ };
8890
+ var HIGH_RISK_DESTINATION_CATEGORIES = /* @__PURE__ */ new Set(["paste", "file_drop", "request_capture", "tunnel"]);
8891
+ var DESTINATION_CATEGORY_BY_HOST = new Map(
8892
+ Object.entries(DESTINATION_CATEGORY_HOSTS).flatMap(([category, hosts]) => hosts.map((host) => [host, category]))
8893
+ );
8894
+ function destinationCategory(host) {
8895
+ let candidate = String(host || "").trim().toLowerCase().replace(/\.$/, "");
8896
+ while (candidate) {
8897
+ const category = DESTINATION_CATEGORY_BY_HOST.get(candidate);
8898
+ if (category) return category;
8899
+ const dot = candidate.indexOf(".");
8900
+ if (dot < 0) return void 0;
8901
+ candidate = candidate.slice(dot + 1);
8902
+ }
8903
+ return void 0;
8904
+ }
8905
+ function classifyDestinationHost(host) {
8906
+ const category = destinationCategory(host);
8907
+ return category ? { category, dataDrop: HIGH_RISK_DESTINATION_CATEGORIES.has(category) } : void 0;
8908
+ }
8909
+ function applyDestinationCategoryFact(context) {
8910
+ delete context["destination.category"];
8911
+ const category = destinationCategory(context["destination.domain"]);
8912
+ if (category) context["destination.category"] = category;
8913
+ }
8772
8914
  function applyRemoteControlFact(toolName, args, context) {
8773
8915
  delete context["toolArgs.remoteControl"];
8774
8916
  const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
@@ -8920,6 +9062,7 @@ function inferBundledToolContext(toolName, args) {
8920
9062
  }
8921
9063
  applyRemoteControlFact(toolName, args, context);
8922
9064
  applyWebReadFacts(toolName, args, context);
9065
+ applyDestinationCategoryFact(context);
8923
9066
  return { operation, context };
8924
9067
  }
8925
9068
  var RESERVED_INPUT_FACTS = /* @__PURE__ */ new Set([
@@ -9021,14 +9164,18 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
9021
9164
  ACTION_POLICY_GRANT_MAX_USES,
9022
9165
  CONTENT_FACT_FIELDS,
9023
9166
  DEFAULT_ACTION_POLICY_APPROVAL,
9167
+ DESTINATION_CATEGORY_HOSTS,
9024
9168
  GCLOUD_DELETE_VERB_RE,
9025
9169
  GCLOUD_READ_VERB_RE,
9026
9170
  GCLOUD_WRITE_VERB_RE,
9171
+ HIGH_RISK_DESTINATION_CATEGORIES,
9027
9172
  MACHINE_KINDS,
9028
9173
  WORKSTATION_ASK_APPROVAL,
9029
9174
  approvalExceptionError,
9030
9175
  canonicalAgentClient,
9176
+ classifyDestinationHost,
9031
9177
  deriveToolOperations,
9178
+ destinationCategory,
9032
9179
  detectSecretKind,
9033
9180
  effectiveApprovalScope,
9034
9181
  evaluateActionPolicies,
@@ -96,6 +96,7 @@ export declare function resolveAskWithDialog(verdict: HookVerdict, options?: {
96
96
  clientLabel?: string;
97
97
  timeoutMs?: number;
98
98
  dialog?: DialogFn;
99
+ deadlineAt?: number;
99
100
  }): Promise<HookVerdict & {
100
101
  resolution?: AskResolution;
101
102
  envelope?: AskEnvelope;
package/dist/askDialog.js CHANGED
@@ -36,6 +36,8 @@ const callerEnv_1 = require("./callerEnv");
36
36
  exports.ASK_ENVELOPE_FIELD = 'fcdAsk';
37
37
  /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
38
38
  exports.ASK_DIALOG_TIMEOUT_MS = 55_000;
39
+ /** Less time than this before the IDE's cutoff is not enough to read and answer a dialog. */
40
+ const MIN_DIALOG_TIMEOUT_MS = 5_000;
39
41
  /** Which channel asks the developer. `dialog` (default) = our OS dialog, `ide` = the IDE's own card. */
40
42
  function askChannel() {
41
43
  return ((0, callerEnv_1.callerEnv)().FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
@@ -97,9 +99,20 @@ function rewriteDecision(decision, allowed, userMsg, agentMsg) {
97
99
  * written as-is with the envelope removed. Then the process exits.
98
100
  */
99
101
  async function emitVerdictForIde(args, result, entry) {
102
+ // This process ends with the verdict below: an upload the dialog's outcome
103
+ // asks for (allow, decline, timeout) starts after stdout, at exit — never
104
+ // while the IDE still waits for the answer.
105
+ try {
106
+ (0, telemetry_1.deferFlushUntilExit)();
107
+ }
108
+ catch { /* best-effort */ }
100
109
  let final = result;
101
110
  try {
102
- const resolved = await resolveAskWithDialog(result, { clientLabel: args.agentClient });
111
+ // This is the per-event process the IDE started: its clock started with
112
+ // the process, so the dialog must end before the IDE's hook timeout does
113
+ // (a killed hook runs the action without anyone's answer).
114
+ const processStartedAt = Date.now() - Math.round(process.uptime() * 1000);
115
+ const resolved = await resolveAskWithDialog(result, { clientLabel: args.agentClient, deadlineAt: (0, callerEnv_1.ideWaitCutoff)(processStartedAt) });
103
116
  if (resolved.resolution)
104
117
  (0, hookIo_1.dbg)({ phase: 'ask_dialog', event: args.event, entry, resolution: resolved.resolution, tool: resolved.envelope?.toolName });
105
118
  final = resolved;
@@ -137,7 +150,11 @@ async function resolveAskWithDialog(verdict, options = {}) {
137
150
  const clean = { ...verdict, stdout: taken.stdout, envelope };
138
151
  // An injected dialog (tests, embedding hosts) only needs a person; the OS dialog also needs a display.
139
152
  const canAsk = options.dialog ? (0, devConfirm_1.isHumanPresent)() : (0, devConfirm_1.nativeDialogAvailable)();
140
- if (askChannel() === 'ide' || !canAsk)
153
+ // Wait = min(configured, time left before the IDE's cutoff). Too little time
154
+ // left for a person to read and answer: the IDE's own ask card takes the
155
+ // question instead, so the hook still answers inside the IDE's window.
156
+ const dialogTimeoutMs = Math.min(options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, options.deadlineAt === undefined ? Number.POSITIVE_INFINITY : options.deadlineAt - Date.now());
157
+ if (askChannel() === 'ide' || !canAsk || dialogTimeoutMs < MIN_DIALOG_TIMEOUT_MS)
141
158
  return { ...clean, resolution: 'ide' };
142
159
  const grantHash = envelope.ruleHash || envelope.policyHash;
143
160
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
@@ -163,7 +180,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
163
180
  let outcome = 'unavailable';
164
181
  let latencyMs = 0;
165
182
  try {
166
- ({ outcome, latencyMs } = await dialog(reason, options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, { allowAlways, alwaysScopeLabel: scopeLabel, alwaysLimitsLabel: limitsLabel, alwaysCovers, alwaysNotOffered, subject }));
183
+ ({ outcome, latencyMs } = await dialog(reason, dialogTimeoutMs, { allowAlways, alwaysScopeLabel: scopeLabel, alwaysLimitsLabel: limitsLabel, alwaysCovers, alwaysNotOffered, subject }));
167
184
  }
168
185
  catch {
169
186
  outcome = 'unavailable';
@@ -11,10 +11,23 @@
11
11
  *
12
12
  * Node built-ins only: this leaf is on the slim hook's warm-path module graph.
13
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", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
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", "FCD_HOOK_DEBUG", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
15
15
  export type CallerEnvSnapshot = Partial<Record<(typeof CALLER_ENV_KEYS)[number], string>>;
16
+ /**
17
+ * Keys a decision reads only for WHETHER they are set (cloudGrantTools: static
18
+ * AWS keys beat every profile; a helm API server / token / impersonation makes
19
+ * the target unknown) — and some are credentials (HELM_KUBETOKEN is a bearer
20
+ * token). A snapshot that is written to disk keeps only that they are set.
21
+ */
22
+ export declare const PRESENCE_ONLY_CALLER_ENV_KEYS: readonly (typeof CALLER_ENV_KEYS)[number][];
16
23
  /** The keys above that are set in `env` — what the thin client sends. */
17
24
  export declare function snapshotCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
25
+ /**
26
+ * snapshotCallerEnv for a snapshot that is STORED (the monitor fast path's
27
+ * local queue): the same decision facts, without credential values — a
28
+ * presence-only key keeps '1' when set (non-blank), '' when blank.
29
+ */
30
+ export declare function storableCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
18
31
  /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
19
32
  export declare function callerEnv(): NodeJS.ProcessEnv;
20
33
  /**
@@ -26,11 +39,25 @@ export declare function withCallerEnv<T>(snapshot: unknown, fn: () => T): T;
26
39
  /**
27
40
  * How long the IDE lets an approval-capable hook run before it kills the hook
28
41
  * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
29
- * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
42
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it — per hook entry (applyIdeTimeoutArg) and
43
+ * for drills.
30
44
  */
31
45
  export declare const IDE_HOOK_TIMEOUT_MS = 90000;
32
46
  export declare const IDE_TIMEOUT_MARGIN_MS = 15000;
33
47
  export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
48
+ /**
49
+ * The installer writes each hook entry's real IDE timeout into its command
50
+ * (`--ide-timeout <seconds>`, the same number as the entry's `timeout`): Claude
51
+ * Code PostToolUse 10s, UserPromptSubmit 30s, PreToolUse 90s; Cursor per event.
52
+ * A hook the IDE kills runs the action, so every wait must end before THAT
53
+ * timeout, not before a fixed 90s. Applied, in the hook's own process only, as
54
+ * its FCD_HOOK_IDE_TIMEOUT_MS — a caller-env key, so the daemon evaluating the
55
+ * call applies it too (older daemons already honour it). It only ever narrows
56
+ * the 90s default: an entry configured longer keeps today's waits. An explicit
57
+ * FCD_HOOK_IDE_TIMEOUT_MS (drills) wins; a command line without the argument
58
+ * (older installs) keeps the default.
59
+ */
60
+ export declare function applyIdeTimeoutArg(seconds: string | undefined, env?: NodeJS.ProcessEnv): void;
34
61
  /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
35
62
  export declare function ideWaitCutoff(startedAt: number, ideTimeoutMs?: number): number;
36
63
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
package/dist/callerEnv.js CHANGED
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.IDE_TIMEOUT_MARGIN_MS = exports.IDE_HOOK_TIMEOUT_MS = exports.CALLER_ENV_KEYS = void 0;
3
+ exports.IDE_TIMEOUT_MARGIN_MS = exports.IDE_HOOK_TIMEOUT_MS = exports.PRESENCE_ONLY_CALLER_ENV_KEYS = exports.CALLER_ENV_KEYS = void 0;
4
4
  exports.snapshotCallerEnv = snapshotCallerEnv;
5
+ exports.storableCallerEnv = storableCallerEnv;
5
6
  exports.callerEnv = callerEnv;
6
7
  exports.withCallerEnv = withCallerEnv;
7
8
  exports.ideHookTimeoutMs = ideHookTimeoutMs;
9
+ exports.applyIdeTimeoutArg = applyIdeTimeoutArg;
8
10
  exports.ideWaitCutoff = ideWaitCutoff;
9
11
  exports.isCiEnvironment = isCiEnvironment;
10
12
  exports.isHumanPresent = isHumanPresent;
@@ -34,6 +36,8 @@ exports.CALLER_ENV_KEYS = [
34
36
  'CLAUDECODE', 'CLAUDE_PROJECT_DIR', 'VSCODE_PID', 'TERM_PROGRAM', 'WORKSPACE_PATH',
35
37
  // Human-wait budget and approve-once switches.
36
38
  'FCD_HOOK_IDE_TIMEOUT_MS', 'FCD_TAINT_APPROVE_ONCE_DISABLED', 'FCD_TAINT_APPROVAL_TIMEOUT_MS',
39
+ // Support diagnostics: per-phase hook.log lines for this caller's verdicts (never a decision fact).
40
+ 'FCD_HOOK_DEBUG',
37
41
  // Which GCP project a gcloud line targets when it names none (cloudGrant.activeGcloudProject).
38
42
  'CLOUDSDK_CORE_PROJECT', 'CLOUDSDK_ACTIVE_CONFIG_NAME', 'CLOUDSDK_CONFIG', 'APPDATA',
39
43
  // Which aws profile / region and which kube context an aws / kubectl / helm line targets
@@ -49,6 +53,15 @@ exports.CALLER_ENV_KEYS = [
49
53
  // Which database host / name a psql / mysql line falls back to (cloudGrantTools).
50
54
  'PGHOST', 'PGDATABASE', 'PGSERVICE', 'MYSQL_HOST',
51
55
  ];
56
+ /**
57
+ * Keys a decision reads only for WHETHER they are set (cloudGrantTools: static
58
+ * AWS keys beat every profile; a helm API server / token / impersonation makes
59
+ * the target unknown) — and some are credentials (HELM_KUBETOKEN is a bearer
60
+ * token). A snapshot that is written to disk keeps only that they are set.
61
+ */
62
+ exports.PRESENCE_ONLY_CALLER_ENV_KEYS = [
63
+ 'AWS_ACCESS_KEY_ID', 'HELM_KUBEAPISERVER', 'HELM_KUBETOKEN', 'HELM_KUBEASUSER',
64
+ ];
52
65
  /** The keys above that are set in `env` — what the thin client sends. */
53
66
  function snapshotCallerEnv(env = process.env) {
54
67
  const snapshot = {};
@@ -59,6 +72,20 @@ function snapshotCallerEnv(env = process.env) {
59
72
  }
60
73
  return snapshot;
61
74
  }
75
+ /**
76
+ * snapshotCallerEnv for a snapshot that is STORED (the monitor fast path's
77
+ * local queue): the same decision facts, without credential values — a
78
+ * presence-only key keeps '1' when set (non-blank), '' when blank.
79
+ */
80
+ function storableCallerEnv(env = process.env) {
81
+ const snapshot = snapshotCallerEnv(env);
82
+ for (const key of exports.PRESENCE_ONLY_CALLER_ENV_KEYS) {
83
+ const value = snapshot[key];
84
+ if (typeof value === 'string')
85
+ snapshot[key] = value.trim() ? '1' : '';
86
+ }
87
+ return snapshot;
88
+ }
62
89
  const requestEnv = new async_hooks_1.AsyncLocalStorage();
63
90
  /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
64
91
  function callerEnv() {
@@ -85,7 +112,8 @@ function withCallerEnv(snapshot, fn) {
85
112
  /**
86
113
  * How long the IDE lets an approval-capable hook run before it kills the hook
87
114
  * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
88
- * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
115
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it — per hook entry (applyIdeTimeoutArg) and
116
+ * for drills.
89
117
  */
90
118
  exports.IDE_HOOK_TIMEOUT_MS = 90_000;
91
119
  exports.IDE_TIMEOUT_MARGIN_MS = 15_000;
@@ -93,6 +121,24 @@ function ideHookTimeoutMs(env = callerEnv()) {
93
121
  const override = Number(env.FCD_HOOK_IDE_TIMEOUT_MS);
94
122
  return override > 0 ? override : exports.IDE_HOOK_TIMEOUT_MS;
95
123
  }
124
+ /**
125
+ * The installer writes each hook entry's real IDE timeout into its command
126
+ * (`--ide-timeout <seconds>`, the same number as the entry's `timeout`): Claude
127
+ * Code PostToolUse 10s, UserPromptSubmit 30s, PreToolUse 90s; Cursor per event.
128
+ * A hook the IDE kills runs the action, so every wait must end before THAT
129
+ * timeout, not before a fixed 90s. Applied, in the hook's own process only, as
130
+ * its FCD_HOOK_IDE_TIMEOUT_MS — a caller-env key, so the daemon evaluating the
131
+ * call applies it too (older daemons already honour it). It only ever narrows
132
+ * the 90s default: an entry configured longer keeps today's waits. An explicit
133
+ * FCD_HOOK_IDE_TIMEOUT_MS (drills) wins; a command line without the argument
134
+ * (older installs) keeps the default.
135
+ */
136
+ function applyIdeTimeoutArg(seconds, env = process.env) {
137
+ const ms = Math.round(Number(seconds) * 1000);
138
+ if (!Number.isFinite(ms) || ms <= 0 || Number(env.FCD_HOOK_IDE_TIMEOUT_MS) > 0)
139
+ return;
140
+ env.FCD_HOOK_IDE_TIMEOUT_MS = String(Math.min(ms, exports.IDE_HOOK_TIMEOUT_MS));
141
+ }
96
142
  /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
97
143
  function ideWaitCutoff(startedAt, ideTimeoutMs = ideHookTimeoutMs()) {
98
144
  return startedAt + ideTimeoutMs - Math.min(exports.IDE_TIMEOUT_MARGIN_MS, ideTimeoutMs / 4);
@@ -21,6 +21,7 @@ exports.tokenDestinations = tokenDestinations;
21
21
  exports.capturedValueDestinations = capturedValueDestinations;
22
22
  exports.describeShellCommand = describeShellCommand;
23
23
  exports.describeShellCommandLines = describeShellCommandLines;
24
+ const deterministicGuard_1 = require("./commands/deterministicGuard");
24
25
  const MAX_STEPS = 14;
25
26
  const MAX_LINE = 220;
26
27
  /** Token printers whose output can be assigned; `issuer` = who accepts the token (identity tokens go to their audience). */
@@ -643,7 +644,7 @@ function describeOne(segmentIn, captured, fns = new Map()) {
643
644
  case 'python3':
644
645
  case 'tsx':
645
646
  case 'ts-node':
646
- return step(`Runs ${program} ${rest[1] ? `(${shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
647
+ return step(`Runs ${program} ${rest[1] ? `(${/[\\/]/.test(rest[1]) && !rest[1].startsWith('-') ? [shortPath(rest[1]), ...rest.slice(2)].join(' ').slice(0, 80) : shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
647
648
  case 'curl':
648
649
  case 'wget':
649
650
  case 'invoke-webrequest':
@@ -678,7 +679,10 @@ function describeOne(segmentIn, captured, fns = new Map()) {
678
679
  case 'mkdir':
679
680
  case 'new-item':
680
681
  case 'ni':
681
- case 'touch': return step(`Creates ${shortValue(rest.slice(1).filter(t => !t.startsWith('-')).join(', '), 60)}`);
682
+ case 'touch': {
683
+ const made = rest.slice(1).filter(t => !t.startsWith('-'));
684
+ return step(`Creates ${made.length === 1 ? shortPath(made[0]) : shortValue(made.join(', '), 60)}`);
685
+ }
682
686
  case 'cat':
683
687
  case 'get-content':
684
688
  case 'gc':
@@ -686,8 +690,22 @@ function describeOne(segmentIn, captured, fns = new Map()) {
686
690
  case 'head':
687
691
  case 'tail':
688
692
  case 'less':
689
- case 'more':
690
- return step(`Reads the file ${shortValue(rest.slice(1).filter(t => !t.startsWith('-'))[0] || '', 60)}${redirectNote}`);
693
+ case 'more': {
694
+ // `cat > f` / `cat 2>/dev/null f`: redirections and their targets are not files read.
695
+ const operands = [];
696
+ for (let k = 1; k < rest.length; k++) {
697
+ if (/^(?:\d*>{1,2}|&>|<)$/.test(rest[k])) {
698
+ k++;
699
+ continue;
700
+ }
701
+ if (/^(?:\d*>|&>|<)/.test(rest[k]) || rest[k].startsWith('-'))
702
+ continue;
703
+ operands.push(rest[k]);
704
+ }
705
+ if (!operands.length && redirectTo)
706
+ return step(`Writes its input to ${shortValue(redirectTo, 60)}`);
707
+ return step(`Reads the file ${shortValue(operands[0] || '', 60)}${redirectNote}`);
708
+ }
691
709
  case 'ls':
692
710
  case 'dir':
693
711
  case 'get-childitem':
@@ -948,13 +966,75 @@ function noteTokenDestinations(steps) {
948
966
  return { ...step, text: shortValue(`${step.text}, then sends it to ${described.join('; ')}`, MAX_LINE) };
949
967
  });
950
968
  }
969
+ const HEREDOC_OPENER = /(?:^|[^<])<<-?[ \t]*(?:'[^'\n]*'|"[^"\n]*"|\\?[^\s;&|<>()'"]+)/g;
970
+ /**
971
+ * A heredoc body is the input of the step that opens it, not steps of its own:
972
+ * drop every body (and its delimiter line) and hand each opener step its body.
973
+ */
974
+ function stepsWithHeredocs(command) {
975
+ const heredocs = (0, deterministicGuard_1.posixHeredocs)(command);
976
+ if (!heredocs.length)
977
+ return splitCommandSteps(command).map(segment => ({ segment, heredocs: [] }));
978
+ let code = '';
979
+ let at = 0;
980
+ for (const heredoc of heredocs) {
981
+ code += command.slice(at, heredoc.bodyStart);
982
+ at = Math.min(command.length, heredoc.end + 1);
983
+ }
984
+ code += command.slice(at);
985
+ let next = 0;
986
+ return splitCommandSteps(code).map(segment => {
987
+ const opened = segment.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => (/<</.test(m) ? m : '""')).match(HEREDOC_OPENER) || [];
988
+ const mine = heredocs.slice(next, next + opened.length);
989
+ next += opened.length;
990
+ return { segment, heredocs: mine };
991
+ });
992
+ }
993
+ /** A long path shortened from the front: the file name is what the reader needs. */
994
+ function shortPath(path, max = 60) {
995
+ if (path.length <= max)
996
+ return path;
997
+ const parts = path.split(/[\\/]/);
998
+ let out = parts.pop() || '';
999
+ while (parts.length && out.length + parts[parts.length - 1].length + 3 <= max)
1000
+ out = `${parts.pop()}/${out}`;
1001
+ return `…/${out}`.slice(0, max + 2);
1002
+ }
1003
+ function lineCount(body) {
1004
+ return body ? body.split('\n').length : 0;
1005
+ }
1006
+ /** What a step does with the heredoc bodies it opens. */
1007
+ function describeHeredocStep(segment, heredocs, fns) {
1008
+ const [heredoc] = heredocs;
1009
+ const lines = lineCount(heredoc.body);
1010
+ const size = `${lines}-line`;
1011
+ const masked = segment.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => m.replace(/[>|]/g, '_'));
1012
+ const target = /(?:^|[^>0-9-])>{1,2}\s*("[^"]*"|'[^']*'|[^\s&|;<]+)/.exec(masked);
1013
+ const file = target ? unquote(segment.slice(target.index + target[0].length - target[1].length, target.index + target[0].length)) : '';
1014
+ const words = tokens(segment).filter(t => !/^<</.test(t));
1015
+ const program = (words[0] || '').toLowerCase().replace(/^.*[\\/]/, '');
1016
+ const { kind, program: reader } = heredoc.receiver;
1017
+ const into = program === 'cat' ? file : program === 'tee' ? (file || words.slice(1).find(t => !t.startsWith('-')) || '') : '';
1018
+ // Writing the body to a file is this step; a later step that runs the file says so itself.
1019
+ if (into && !/\|/.test(masked)) {
1020
+ const what = kind === 'shell' ? `a ${size} shell script` : kind === 'language' ? `a ${size} ${reader} script` : `${lines} line${lines === 1 ? '' : 's'} of text`;
1021
+ return { segment, text: shortValue(`Writes ${what} to ${shortPath(into)}`, MAX_LINE), known: true };
1022
+ }
1023
+ if (kind === 'shell')
1024
+ return { segment, text: shortValue(`Runs a ${size} script with ${reader || program}`, MAX_LINE), known: true };
1025
+ if (kind === 'language')
1026
+ return { segment, text: shortValue(`Runs a ${size} ${reader} script`, MAX_LINE), known: true };
1027
+ const base = describeOne(segment.replace(HEREDOC_OPENER, m => m.replace(/<<[\s\S]*$/, '')), undefined, fns);
1028
+ return { ...base, segment, text: shortValue(`${base.text} (given ${lines} line${lines === 1 ? '' : 's'} of input text)`, MAX_LINE) };
1029
+ }
951
1030
  /** One plain-words line per step, in order. */
952
1031
  function describeShellCommand(command) {
953
- const steps = splitCommandSteps(command);
1032
+ const parts = stepsWithHeredocs(command);
1033
+ const steps = parts.map(part => part.segment);
954
1034
  const fns = localFunctions(command);
955
- const described = steps.map(segment => {
1035
+ const described = parts.map(({ segment, heredocs }) => {
956
1036
  try {
957
- return describeOne(segment, undefined, fns);
1037
+ return heredocs.length ? describeHeredocStep(segment, heredocs, fns) : describeOne(segment, undefined, fns);
958
1038
  }
959
1039
  catch {
960
1040
  return { segment, text: 'Runs a command the grammar does not recognise', known: false };
@@ -1,5 +1,6 @@
1
1
  import { BotGuardConfig } from '../config';
2
2
  import { ProtectAllArgs } from './mcpGateway';
3
+ import '../askDialog';
3
4
  import { windowsTaskRunsInteractive, windowsTaskRunsVisibly } from '../windowsTaskState';
4
5
  export interface DaemonArgs extends ProtectAllArgs {
5
6
  install?: string;
@@ -97,6 +98,15 @@ export declare function registerHiddenTask(taskName: string, xml: string | strin
97
98
  * (the pre-1.22.1 VBS launcher) — the migration trigger. */
98
99
  export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
99
100
  export { windowsTaskRunsInteractive, windowsTaskRunsVisibly };
101
+ /**
102
+ * Boot-time self-heal for the Windows autostart chain. Recreates a missing
103
+ * daemon logon entry, refreshes the launcher scripts (paths move on upgrade),
104
+ * and re-points the watchdog task at the `watchdog` launcher (older versions
105
+ * pointed it at daemon.vbs, which restarts but never beacons). Creating
106
+ * per-user MINUTE tasks needs no elevation; ONLOGON does — fall back to the
107
+ * per-user Run key exactly like installWindows().
108
+ */
109
+ export declare function ensureWindowsAutostartHealthy(logFn: (message: string) => void): void;
100
110
  /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
101
111
  export interface DaemonRuntimeState {
102
112
  alive: boolean;