fullcourtdefense-cli 1.35.10 → 1.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/actionPolicyEngine.d.ts +24 -0
  2. package/dist/actionPolicyEngine.js +79 -19
  3. package/dist/approvalPrompt.d.ts +15 -0
  4. package/dist/approvalPrompt.js +157 -20
  5. package/dist/askDialog.d.ts +2 -0
  6. package/dist/askDialog.js +15 -1
  7. package/dist/askHeader.d.ts +81 -0
  8. package/dist/askHeader.js +573 -0
  9. package/dist/blockExplanation.d.ts +9 -0
  10. package/dist/blockExplanation.js +14 -0
  11. package/dist/callerEnv.d.ts +1 -1
  12. package/dist/callerEnv.js +14 -0
  13. package/dist/cloudGrant.d.ts +46 -0
  14. package/dist/cloudGrant.js +420 -0
  15. package/dist/cloudGrantTools.d.ts +97 -0
  16. package/dist/cloudGrantTools.js +1112 -0
  17. package/dist/commandNarrative.d.ts +8 -0
  18. package/dist/commandNarrative.js +222 -49
  19. package/dist/commands/cmdGuard.js +3 -1
  20. package/dist/commands/daemon.js +56 -2
  21. package/dist/commands/deterministicGuard.d.ts +70 -4
  22. package/dist/commands/deterministicGuard.js +494 -63
  23. package/dist/commands/discover.js +50 -16
  24. package/dist/commands/discoverAgentFiles.d.ts +2 -0
  25. package/dist/commands/discoverAgentFiles.js +1 -0
  26. package/dist/commands/discoverPaths.d.ts +4 -0
  27. package/dist/commands/discoverPaths.js +19 -1
  28. package/dist/commands/discoverProjectRoots.d.ts +72 -0
  29. package/dist/commands/discoverProjectRoots.js +327 -0
  30. package/dist/commands/discoverProxy.d.ts +1 -1
  31. package/dist/commands/discoverProxy.js +1 -1
  32. package/dist/commands/discoverSecrets.d.ts +44 -0
  33. package/dist/commands/discoverSecrets.js +158 -14
  34. package/dist/commands/hook.js +25 -10
  35. package/dist/commands/knownMcpServers.d.ts +8 -2
  36. package/dist/commands/knownMcpServers.js +33 -2
  37. package/dist/commands/mcpGateway.js +9 -7
  38. package/dist/commands/posixShellGuard.js +1 -1
  39. package/dist/commands/shellGuard.js +86 -26
  40. package/dist/commands/uninstallAll.d.ts +1 -7
  41. package/dist/commands/uninstallAll.js +6 -45
  42. package/dist/commands/watchdog.js +17 -3
  43. package/dist/config.d.ts +1 -1
  44. package/dist/config.js +3 -3
  45. package/dist/credentialGrant.d.ts +48 -0
  46. package/dist/credentialGrant.js +29 -18
  47. package/dist/credentialStore.js +1 -1
  48. package/dist/devConfirm.d.ts +51 -3
  49. package/dist/devConfirm.js +226 -33
  50. package/dist/distress.d.ts +2 -0
  51. package/dist/distress.js +2 -0
  52. package/dist/grantTargets.d.ts +21 -0
  53. package/dist/grantTargets.js +235 -0
  54. package/dist/localSafetySnapshot.js +2 -0
  55. package/dist/msiProductCode.d.ts +10 -0
  56. package/dist/msiProductCode.js +124 -0
  57. package/dist/notify.js +1 -0
  58. package/dist/selfTest.d.ts +1 -1
  59. package/dist/selfTest.js +1 -1
  60. package/dist/selfUpdate.d.ts +108 -0
  61. package/dist/selfUpdate.js +353 -18
  62. package/dist/shellAst.d.ts +2 -0
  63. package/dist/shellAst.js +7 -1
  64. package/dist/shellAstLoader.js +14 -1
  65. package/dist/telemetry.d.ts +3 -0
  66. package/dist/telemetry.js +1 -0
  67. package/dist/version.json +1 -1
  68. package/installer/macos/guardian/ai.fullcourtdefense.guardian.plist +19 -0
  69. package/installer/macos/guardian/fcd-guardian.sh +590 -0
  70. package/installer/macos/guardian/install-guardian.sh +32 -0
  71. package/installer/macos/guardian/uninstall-guardian.sh +12 -0
  72. package/installer/windows/README.md +88 -0
  73. package/installer/windows/rescue/FullCourtDefense-Rescue.ps1 +229 -0
  74. package/package.json +11 -1
@@ -235,6 +235,29 @@ export declare function canonicalAgentClient(value: string): string;
235
235
  */
236
236
  export type SecretKind = 'github_token' | 'aws_access_key' | 'google_api_key' | 'openai_key' | 'slack_token' | 'jwt' | 'private_key' | 'assignment' | 'high_entropy' | 'none';
237
237
  export declare function detectSecretKind(raw: string): SecretKind;
238
+ /**
239
+ * gcloud read verbs. Token printers (`print-access-token`, `print-identity-token`)
240
+ * are credential dumps and are deliberately absent; `cat` streams object bytes
241
+ * and keeps its own stricter proof (isGcloudStorageCatRead). `versions` is a
242
+ * GROUP (`app versions`, `secrets versions`), never a verb: as a verb it made
243
+ * `gcloud app versions delete` a read. `access` is `secrets versions access`.
244
+ */
245
+ export declare const GCLOUD_READ_VERB_RE: RegExp;
246
+ /** gcloud verbs that change a cloud resource. Deletes are handled apart (DELETE). */
247
+ export declare const GCLOUD_WRITE_VERB_RE: RegExp;
248
+ /** gcloud verbs that remove a resource or its data (`kms keys versions destroy`, `secrets versions destroy`). */
249
+ export declare const GCLOUD_DELETE_VERB_RE: RegExp;
250
+ /**
251
+ * `gcloud [--global-flag …] [alpha|beta] <group…> <verb> …` on the masked,
252
+ * lowercased command words: the first token after the program that is not a
253
+ * flag and not a lowercase group word that names a further group. gcloud
254
+ * groups and verbs are both lowercase words, so the walk returns the FIRST
255
+ * word that is a known read/write/delete verb; a value, a redirect or an
256
+ * unknown word before any verb ends the walk with `null` (segment stays
257
+ * unknown). Nothing here reads the resource operand — `gs://…`, a service
258
+ * name, a secret name are what the verb acts ON, not where data goes.
259
+ */
260
+ export declare function gcloudVerbWord(value: string): string | null;
238
261
  export type ShellAstMode = 'off' | 'shadow' | 'primary';
239
262
  /** Structural subset of shellAst.ts ShellFacts that the engine reads. */
240
263
  export interface ShellAstFactsLike {
@@ -268,6 +291,7 @@ export interface ShellAstOperationComparison {
268
291
  agree: boolean;
269
292
  }
270
293
  export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
294
+ export declare function setRemoteControlProvider(provider: ((command: string) => string | undefined) | undefined): void;
271
295
  export declare function setShellAstMode(mode: ShellAstMode): void;
272
296
  export declare function getShellAstMode(): ShellAstMode;
273
297
  /** Shadow telemetry: the comparison recorded by the most recent top-level shell classification (consumed once). */
@@ -24,6 +24,9 @@ __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
+ GCLOUD_DELETE_VERB_RE: () => GCLOUD_DELETE_VERB_RE,
28
+ GCLOUD_READ_VERB_RE: () => GCLOUD_READ_VERB_RE,
29
+ GCLOUD_WRITE_VERB_RE: () => GCLOUD_WRITE_VERB_RE,
27
30
  MACHINE_KINDS: () => MACHINE_KINDS,
28
31
  WORKSTATION_ASK_APPROVAL: () => WORKSTATION_ASK_APPROVAL,
29
32
  approvalExceptionError: () => approvalExceptionError,
@@ -32,6 +35,7 @@ __export(actionPolicyEngine_exports, {
32
35
  detectSecretKind: () => detectSecretKind,
33
36
  effectiveApprovalScope: () => effectiveApprovalScope,
34
37
  evaluateActionPolicies: () => evaluateActionPolicies,
38
+ gcloudVerbWord: () => gcloudVerbWord,
35
39
  getShellAstMode: () => getShellAstMode,
36
40
  inferBundledToolContext: () => inferBundledToolContext,
37
41
  inferToolContext: () => inferToolContext,
@@ -42,6 +46,7 @@ __export(actionPolicyEngine_exports, {
42
46
  resolveApprovalOptions: () => resolveApprovalOptions,
43
47
  roleExceptionError: () => roleExceptionError,
44
48
  ruleNeedsContentFact: () => ruleNeedsContentFact,
49
+ setRemoteControlProvider: () => setRemoteControlProvider,
45
50
  setShellAstMode: () => setShellAstMode,
46
51
  setShellAstProvider: () => setShellAstProvider,
47
52
  takeShellAstOperationComparison: () => takeShellAstOperationComparison,
@@ -6777,6 +6782,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
6777
6782
  "tool.status",
6778
6783
  "operation.status",
6779
6784
  "agent.baseline",
6785
+ "toolArgs.remoteControl",
6780
6786
  // Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
6781
6787
  // pollute operation matching.
6782
6788
  "toolName",
@@ -6820,6 +6826,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
6820
6826
  // its operation/capability; the words a person typed into a title are not a verb.
6821
6827
  "title",
6822
6828
  "subject",
6829
+ "topic",
6823
6830
  "description",
6824
6831
  "message",
6825
6832
  "msg",
@@ -6901,25 +6908,30 @@ function buildOperationMatchText(_toolName, context) {
6901
6908
  const identifierField = /^(?:id|ids|uuid|uuids|guid|guids)$/.test(fieldWords[fieldWords.length - 1]);
6902
6909
  if (OPERATION_MATCH_SKIP_FIELDS.has(key) || identifierField || key.startsWith("args.") || key.startsWith("request.")) continue;
6903
6910
  if (typeof value !== "string" || value.length === 0 || value.length > 2e3) continue;
6904
- const text = COMMAND_LINE_FIELDS.has(key) ? shellVerbWords(value) : value;
6911
+ const text = COMMAND_LINE_FIELDS.has(key) ? shellVerbWords(value) : SQL_STATEMENT_FIELDS.has(key) && !SQL_EXECUTES_STRING_RE.test(value) ? maskQuotedSpans(value) : value;
6905
6912
  parts.push(`${key} ${text}`);
6906
6913
  }
6907
6914
  return parts.join(" ");
6908
6915
  }
6909
6916
  var COMMAND_LINE_FIELDS = /* @__PURE__ */ new Set(["command", "cmd", "commandLine", "script", "args"]);
6917
+ var SQL_STATEMENT_FIELDS = /* @__PURE__ */ new Set(["query", "sql", "statement"]);
6918
+ var SQL_EXECUTES_STRING_RE = /\b(?:exec(?:ute)?|sp_executesql|prepare)\b/i;
6910
6919
  var CODE_EXEC_TOOL_NAME_RE = /\b(node ?repl|repl|shell|exec|execute|terminal|command|cmd|run ?code|bash|zsh|sh|powershell|pwsh)\b/i;
6911
6920
  function shellVerbWords(command) {
6912
6921
  let lead = "";
6913
6922
  let first = true;
6914
6923
  let scriptArgs = false;
6915
6924
  let previousBare = "";
6916
- return shellCommandWords(command).split(/\s+/).filter(Boolean).map((token) => {
6917
- if (token === ";") {
6925
+ let previousToken = "";
6926
+ return shellCommandWords(command).replace(/\|/g, " | ").split(/\s+/).filter(Boolean).map((token) => {
6927
+ const afterToken = previousToken;
6928
+ previousToken = token;
6929
+ if (token === ";" || token === "|") {
6918
6930
  lead = "";
6919
6931
  first = true;
6920
6932
  scriptArgs = false;
6921
6933
  previousBare = "";
6922
- return token;
6934
+ return token === "|" ? "" : token;
6923
6935
  }
6924
6936
  if (scriptArgs) return "";
6925
6937
  const flag = /^(-{1,2})([A-Za-z][A-Za-z0-9_-]*)(?:=.*)?$/.exec(token);
@@ -6931,6 +6943,14 @@ function shellVerbWords(command) {
6931
6943
  return "";
6932
6944
  }
6933
6945
  if (!flag && !lead) lead = word.toLowerCase();
6946
+ else if (!flag && PACKAGE_EXECUTORS.has(lead) && previousBare === lead) lead = word.toLowerCase();
6947
+ if (!flag && !leading && SEARCH_PROGRAMS.has(lead)) return "";
6948
+ if (!flag && !leading && TEST_RUNNERS.has(lead)) return "";
6949
+ if (!flag && PACKAGE_RUNNERS.has(lead) && (previousBare === "run" || previousBare === "run-script")) {
6950
+ scriptArgs = true;
6951
+ return "";
6952
+ }
6953
+ if (!flag && VCS_PROGRAMS.has(lead) && /^(?:-[a-z]*m|--message)$/.test(afterToken)) return "";
6934
6954
  if (flag) {
6935
6955
  if (VCS_PROGRAMS.has(lead)) return "";
6936
6956
  const segs = word.split(/[-_]+/).filter(Boolean);
@@ -6950,6 +6970,10 @@ function shellVerbWords(command) {
6950
6970
  }).filter(Boolean).join(" ");
6951
6971
  }
6952
6972
  var VCS_PROGRAMS = /* @__PURE__ */ new Set(["git", "gh", "hg", "svn", "glab"]);
6973
+ var SEARCH_PROGRAMS = /* @__PURE__ */ new Set(["grep", "egrep", "fgrep", "zgrep", "rg", "ag", "ack", "findstr", "select-string", "sls"]);
6974
+ var TEST_RUNNERS = /* @__PURE__ */ new Set(["pytest", "jest", "vitest", "mocha"]);
6975
+ var PACKAGE_RUNNERS = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun"]);
6976
+ var PACKAGE_EXECUTORS = /* @__PURE__ */ new Set(["npx", "bunx", "pnpx"]);
6953
6977
  var GH_CONFIG_RESOURCES = /* @__PURE__ */ new Set(["secret", "variable", "cache", "alias", "label", "ssh-key", "gpg-key"]);
6954
6978
  var SCRIPT_RUNNER_LEADS = /* @__PURE__ */ new Set([
6955
6979
  "bash",
@@ -7056,7 +7080,7 @@ function isHighEntropySecretToken(token, allowWordSlug = false) {
7056
7080
  return shannonEntropy(token) >= 4;
7057
7081
  }
7058
7082
  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)`;
7059
- var SECRET_ASSIGNMENT_RE = new RegExp(String.raw`(?:^|[^\w])[\w.-]*?${SECRET_NAME}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{8,})`, "gi");
7083
+ var SECRET_ASSIGNMENT_RE = new RegExp(String.raw`${SECRET_NAME}[\\"']*\s*(?:=>|[:=])\s*[\\"']*([^\s\\"',;&|)}\]<>]{8,})`, "gi");
7060
7084
  var SECRET_FLAG_RE = /(?:^|[\s"'`;(\[{,])--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)[\\"']*([^\s\\"',;&|)}\]<>-][^\s\\"',;&|)}\]<>]{7,})/gi;
7061
7085
  var SECRET_FLAG_QUOTED_RE = /(?:^|[\s"'`;(\[{,])--?(?:password|passwd|pwd|token|api-?key|secret|client-secret|access-token)(?:=|\s+)\\?(["'])([^\s"'\\-][^\s"'\\]{7,})\\?\1/gi;
7062
7086
  function isWordPhraseWithCounters(value) {
@@ -7142,7 +7166,7 @@ var AUTH_HEADER_RE = /\b(?:bearer|basic|token|apikey|x-api-key)\s+([A-Za-z0-9._~
7142
7166
  function hasSecretLikeValue(value) {
7143
7167
  return detectSecretKind(value) !== "none";
7144
7168
  }
7145
- var PII_EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
7169
+ var PII_EMAIL_RE = /(?<![A-Z0-9._%+-])[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
7146
7170
  var PII_EMAIL_IGNORE_RE = /(?:@(?:example\.(?:com|org|net)|test\.[a-z]+|localhost(?:\.[a-z]+)?|[\w.-]*\.(?:invalid|test|local|localdomain|example)|users\.noreply\.github\.com|noreply\.[\w.-]+|[\w.-]*\.gserviceaccount\.com|[\w.-]*\.svc\.id\.goog)$|^(?:no-?reply|noreply|do-?not-?reply|test\d*|example|user\d*|admin|root|foo|bar|baz|someone|somebody|you|me|name|first\.last|john\.doe|jane\.doe|email)@)/i;
7147
7171
  var PII_PHONE_RE = /(?:^|[^\w.@/:-])(\+?\(?\d{1,4}\)?[\s-]\(?\d{2,4}\)?[\s-]\d{2,4}(?:[\s-]\d{2,4}){0,2})(?![\w.@/:-])/g;
7148
7172
  function hasPiiLikeValue(raw) {
@@ -8088,7 +8112,7 @@ function classifyShellWords(masked, raw, lead, cwdScoped = false) {
8088
8112
  if (redirect && !/^\/dev\/null$|^nul$|^\$null$/i.test(redirect[1])) return "write";
8089
8113
  return "read";
8090
8114
  }
8091
- if (gcloudVerb === "delete") return "DELETE";
8115
+ if (GCLOUD_DELETE_VERB_RE.test(gcloudVerb)) return "DELETE";
8092
8116
  if (GCLOUD_WRITE_VERB_RE.test(gcloudVerb)) return pipesIntoInterpreter ? "SHELL" : "write";
8093
8117
  }
8094
8118
  if (/^(?:kubectl\s+(?:get|describe|logs|top|config\s+(?:current-context|get-contexts|view(?!\s+--raw)))|docker\s+(?:ps|images|logs|inspect|version|info)|terraform\s+(?:plan|validate|fmt|show|state\s+list|output)|aws\s+(?:s3\s+ls|sts\s+get-caller-identity)|az\s+account\s+show|npm\s+(?:ls|list|view|outdated|--version)|node\s+--version|python3?\s+--version)\b/.test(value)) return "read";
@@ -8124,15 +8148,16 @@ function classifyShellWords(masked, raw, lead, cwdScoped = false) {
8124
8148
  if (SHELL_BLOCK_STRUCTURE_RE.test(value)) return "read";
8125
8149
  return "SHELL";
8126
8150
  }
8127
- var GCLOUD_READ_VERB_RE = /^(?:describe|list|ls|get-iam-policy|get-value|get-ancestors|read|check|versions)$/;
8128
- var GCLOUD_WRITE_VERB_RE = /^(?:create|update|patch|replace|enable|disable|deploy|apply|import|export|start|stop|restart|resume|suspend|scale|promote|migrate|rollback|add-[a-z-]+|remove-[a-z-]+|set-[a-z-]+|unset|clear|activate|revoke|copy)$/;
8151
+ var GCLOUD_READ_VERB_RE = /^(?:describe|list|ls|get-iam-policy|get-value|get-ancestors|read|check|access)$/;
8152
+ var GCLOUD_WRITE_VERB_RE = /^(?:create|update|patch|replace|enable|disable|deploy|apply|import|export|start|stop|restart|resume|suspend|scale|promote|migrate|rollback|add|add-[a-z-]+|remove-[a-z-]+|set-[a-z-]+|unset|clear|activate|revoke|copy)$/;
8153
+ var GCLOUD_DELETE_VERB_RE = /^(?:delete|destroy)$/;
8129
8154
  function gcloudVerbWord(value) {
8130
8155
  const tokens = value.split("|")[0].trim().split(/\s+/);
8131
8156
  if (!/^gcloud(?:\.cmd|\.exe)?$/.test(tokens.shift() || "")) return null;
8132
8157
  let depth = 0;
8133
8158
  for (const token of tokens) {
8134
8159
  if (token.startsWith("-")) continue;
8135
- if (GCLOUD_READ_VERB_RE.test(token) || GCLOUD_WRITE_VERB_RE.test(token) || token === "delete") return token;
8160
+ if (GCLOUD_READ_VERB_RE.test(token) || GCLOUD_WRITE_VERB_RE.test(token) || GCLOUD_DELETE_VERB_RE.test(token)) return token;
8136
8161
  if (!/^[a-z][a-z0-9-]*$/.test(token) || ++depth > 6) return null;
8137
8162
  }
8138
8163
  return null;
@@ -8160,6 +8185,10 @@ var lastShellAstOperationComparison;
8160
8185
  function setShellAstProvider(provider) {
8161
8186
  shellAstProvider = provider;
8162
8187
  }
8188
+ var remoteControlProvider;
8189
+ function setRemoteControlProvider(provider) {
8190
+ remoteControlProvider = provider;
8191
+ }
8163
8192
  function setShellAstMode(mode) {
8164
8193
  shellAstMode = mode;
8165
8194
  }
@@ -8309,7 +8338,7 @@ function shellCommandTextOf(toolName, args) {
8309
8338
  return text || void 0;
8310
8339
  }
8311
8340
  function isPowerShellReadFormatter(segment) {
8312
- if (/^(?:Select-Object\s+[\w., *-]+|Format-Table(?:\s+[\w., *-]+)?|Format-List(?:\s+[\w., *-]+)?|Out-Null|ConvertFrom-Json)$/i.test(segment)) return true;
8341
+ if (/^(?:Select-Object\s+[\w., *-]+|Format-Table(?:\s+[\w., *-]+)?|Format-List(?:\s+[\w., *-]+)?|Out-Null|Out-String(?:\s+-Stream)?|ConvertFrom-Json)$/i.test(segment)) return true;
8313
8342
  const tokens = segment.trim().split(/\s+/);
8314
8343
  if (tokens.shift()?.toLowerCase() !== "convertto-json") return false;
8315
8344
  const seen = /* @__PURE__ */ new Set();
@@ -8378,10 +8407,11 @@ function provenCompoundDownload(command, detectedUrl) {
8378
8407
  return segments.every((segment) => {
8379
8408
  const statusRead = /^\(((?:Invoke-WebRequest|iwr)\s+[^\r\n]+)\)\.(?:StatusCode|StatusDescription|RawContentLength)$/i.exec(segment);
8380
8409
  if (statusRead) segment = statusRead[1];
8410
+ segment = segment.replace(/\s+(?:[12&]?>\s*(?:\/dev\/null|\$null|nul)|2>&1)(?=\s|$)/gi, "");
8381
8411
  const withoutWriteOut = segment.replace(/(\s(?:-w|--write-out)\s+)(?:"[^"\r\n$`@]*"|'[^'\r\n$`@]*')/g, '$1"fcd-write-out"');
8382
8412
  if (/[$`{}()<>;&]/.test(withoutWriteOut)) return false;
8383
8413
  const pipeline = splitShellSegments(segment, true);
8384
- if (pipeline.slice(1).some((part) => !isPowerShellReadFormatter(part))) return false;
8414
+ if (pipeline.slice(1).some((part) => !isPowerShellReadFormatter(part) && !(classifyShellSegment(part) === "read" && !/[a-z][a-z0-9+.-]*:\/\//i.test(part)))) return false;
8385
8415
  const raw = pipeline[0];
8386
8416
  if (isGcloudStorageCatRead(raw)) return true;
8387
8417
  const pieces = raw.match(/"[^"\r\n]*"|'[^'\r\n]*'|[^\s"']+/g) || [];
@@ -8425,6 +8455,13 @@ function provenCompoundDownload(command, detectedUrl) {
8425
8455
  continue;
8426
8456
  }
8427
8457
  if (/^-[sfSLI]+$|^--(?:silent|fail|location|head)$|^-UseBasicParsing$/.test(token)) continue;
8458
+ if (!isCurl && /^wget/i.test(program)) {
8459
+ if (/^-q$|^--quiet$|^-q?O-$|^--output-document=-$/.test(token)) continue;
8460
+ if (/^-q?O$|^--output-document$/.test(token) && tokens[i2 + 1] === "-") {
8461
+ i2++;
8462
+ continue;
8463
+ }
8464
+ }
8428
8465
  if (/^-TimeoutSec$/i.test(token) && /^\d+$/.test(tokens[i2 + 1] || "")) {
8429
8466
  i2++;
8430
8467
  continue;
@@ -8719,9 +8756,22 @@ function inferToolContext(toolName, args) {
8719
8756
  context["destination.type"] = context["request.destination.type"];
8720
8757
  context["destination.domain"] = context["request.destination.domain"];
8721
8758
  }
8759
+ applyRemoteControlFact(toolName, args, context);
8722
8760
  applyWebReadFacts(toolName, args, context);
8723
8761
  return { operation, context };
8724
8762
  }
8763
+ function applyRemoteControlFact(toolName, args, context) {
8764
+ delete context["toolArgs.remoteControl"];
8765
+ const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
8766
+ if (!shellText || !remoteControlProvider) return;
8767
+ let bridge;
8768
+ try {
8769
+ bridge = remoteControlProvider(shellText);
8770
+ } catch {
8771
+ bridge = void 0;
8772
+ }
8773
+ if (bridge) context["toolArgs.remoteControl"] = bridge;
8774
+ }
8725
8775
  var WEB_READ_TOOL_NAMES = /* @__PURE__ */ new Set(["webfetch", "fetchwebpage", "webextract"]);
8726
8776
  function applyWebReadFacts(toolName, args, context) {
8727
8777
  if (!WEB_READ_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_\-.\s]/g, ""))) return;
@@ -8746,6 +8796,7 @@ function applyWebReadFacts(toolName, args, context) {
8746
8796
  if (isLoopbackHost(parsed.hostname)) context["destination.loopback"] = "true";
8747
8797
  else delete context["destination.loopback"];
8748
8798
  }
8799
+ var SEARCH_PATTERN_FIELDS = ["query", "q", "pattern", "search", "searchQuery", "search_query", "term", "keyword", "keywords", "regex"];
8749
8800
  function inferBundledToolContext(toolName, args) {
8750
8801
  const context = {};
8751
8802
  let operation = toolName;
@@ -8767,16 +8818,19 @@ function inferBundledToolContext(toolName, args) {
8767
8818
  if (!reservedInputFact(key)) context[key] = String(val);
8768
8819
  }
8769
8820
  const nameWords = toolNameLower.replace(/[_\-.]/g, " ");
8770
- const isSearchTool = /\b(search|grep|find|lookup|locate)\b/.test(nameWords) && !/\b(delete|remove|drop|write|update|insert|replace|create)\b/.test(nameWords);
8771
- if (isSearchTool) {
8772
- operation = "read";
8773
- if (typeof context.query === "string") {
8774
- context["search.pattern"] = context.query;
8775
- delete context.query;
8821
+ const notMutating = !/\b(delete|remove|drop|write|update|insert|replace|create)\b/.test(nameWords);
8822
+ const isSearchTool = /\b(search|grep|find|lookup|locate)\b/.test(nameWords) && notMutating;
8823
+ const isFilterTool = isSearchTool || /\blist\b/.test(nameWords) && notMutating;
8824
+ if (isSearchTool) operation = "read";
8825
+ if (isFilterTool) {
8826
+ const patternKeys = SEARCH_PATTERN_FIELDS.filter((key) => typeof context[key] === "string");
8827
+ if (patternKeys.length) {
8828
+ context["search.pattern"] = patternKeys.map((key) => context[key]).join(" ");
8829
+ for (const key of patternKeys) delete context[key];
8776
8830
  }
8777
8831
  }
8778
8832
  const isCodeExecTool = operation === toolName && CODE_EXEC_TOOL_NAME_RE.test(nameWords);
8779
- const sqlArg = args.query || args.sql || args.statement || (isCodeExecTool ? "" : args.command || args.code || args.script || args.input) || "";
8833
+ const sqlArg = (isFilterTool ? "" : args.query) || args.sql || args.statement || (isCodeExecTool ? "" : args.command || args.code || args.script || args.input) || "";
8780
8834
  if (!isSearchTool && typeof sqlArg === "string" && sqlArg.length > 0) {
8781
8835
  const sqlMatch = sqlArg.match(/(?<!-)\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)\b(?!-)/i);
8782
8836
  if (sqlMatch) {
@@ -8855,6 +8909,7 @@ function inferBundledToolContext(toolName, args) {
8855
8909
  context["destination.type"] = isInternalHost(context["destination.domain"]) ? "internal" : "external";
8856
8910
  }
8857
8911
  }
8912
+ applyRemoteControlFact(toolName, args, context);
8858
8913
  applyWebReadFacts(toolName, args, context);
8859
8914
  return { operation, context };
8860
8915
  }
@@ -8957,6 +9012,9 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8957
9012
  ACTION_POLICY_GRANT_MAX_USES,
8958
9013
  CONTENT_FACT_FIELDS,
8959
9014
  DEFAULT_ACTION_POLICY_APPROVAL,
9015
+ GCLOUD_DELETE_VERB_RE,
9016
+ GCLOUD_READ_VERB_RE,
9017
+ GCLOUD_WRITE_VERB_RE,
8960
9018
  MACHINE_KINDS,
8961
9019
  WORKSTATION_ASK_APPROVAL,
8962
9020
  approvalExceptionError,
@@ -8965,6 +9023,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8965
9023
  detectSecretKind,
8966
9024
  effectiveApprovalScope,
8967
9025
  evaluateActionPolicies,
9026
+ gcloudVerbWord,
8968
9027
  getShellAstMode,
8969
9028
  inferBundledToolContext,
8970
9029
  inferToolContext,
@@ -8975,6 +9034,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8975
9034
  resolveApprovalOptions,
8976
9035
  roleExceptionError,
8977
9036
  ruleNeedsContentFact,
9037
+ setRemoteControlProvider,
8978
9038
  setShellAstMode,
8979
9039
  setShellAstProvider,
8980
9040
  takeShellAstOperationComparison,
@@ -1,3 +1,5 @@
1
+ import { type AskHeader, type AskSubject } from './askHeader';
2
+ export type { AskHeader, AskSubject } from './askHeader';
1
3
  export interface ApprovalMessageOptions {
2
4
  /** The dialog offers "Always allow"; the footer must say what that covers. */
3
5
  allowAlways?: boolean;
@@ -5,9 +7,20 @@ export interface ApprovalMessageOptions {
5
7
  alwaysScopeLabel?: string;
6
8
  /** How long it holds: "30 days" (default) or "7 days or 20 uses". */
7
9
  alwaysLimitsLabel?: string;
10
+ /** What / How / Where the grant covers (compact rows). Absent: the scope label is the What. */
11
+ alwaysCovers?: AlwaysCovers;
12
+ /** Two-button ask: one short line saying why "Always allow" is not offered. */
13
+ alwaysNotOffered?: string;
14
+ /** Facts from the ask envelope (grant scope, rule id) for the "Why you're asked" header. */
15
+ subject?: AskSubject;
8
16
  /** Visual variant. Ask is the interactive dialog; approval/block share the card, not extra buttons. */
9
17
  tone?: DialogTone;
10
18
  }
19
+ export interface AlwaysCovers {
20
+ what: string;
21
+ how?: string;
22
+ where?: string;
23
+ }
11
24
  /** Which policy outcome this window is showing. Icons and copy follow this; buttons do not. */
12
25
  export type DialogTone = 'ask' | 'approval' | 'block';
13
26
  export interface NativeDialogView {
@@ -15,6 +28,8 @@ export interface NativeDialogView {
15
28
  badge: string;
16
29
  headline: string;
17
30
  subtitle: string;
31
+ /** Ask dialogs: why it popped up and the facts behind it, above everything else. */
32
+ header?: AskHeader;
18
33
  kind?: string;
19
34
  /** The action: the whole script when the explanation carried it, else the short "What" line. */
20
35
  action?: string;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.nativeDialogView = nativeDialogView;
4
4
  exports.nativeApprovalTitle = nativeApprovalTitle;
5
5
  exports.nativeApprovalMessage = nativeApprovalMessage;
6
+ const askHeader_1 = require("./askHeader");
6
7
  /** Compact OS prompt. The complete decision remains in the IDE response and audit. */
7
8
  function line(value, limit) {
8
9
  const text = value.replace(/\s+/g, ' ').trim();
@@ -12,6 +13,23 @@ function line(value, limit) {
12
13
  const head = Math.ceil((limit - 3) * 0.6);
13
14
  return `${text.slice(0, head)} … ${text.slice(-(limit - head - 3))}`;
14
15
  }
16
+ /** `What: gcloud · How: change · Where: project acme-staging · 30 days, this machine`. */
17
+ function alwaysCoversParts(options) {
18
+ const covers = options.alwaysCovers || (options.alwaysScopeLabel ? { what: options.alwaysScopeLabel } : undefined);
19
+ if (!options.allowAlways || !covers)
20
+ return undefined;
21
+ return [
22
+ `What: ${line(covers.what, 90)}`,
23
+ covers.how ? `How: ${line(covers.how, 60)}` : '',
24
+ covers.where ? `Where: ${line(covers.where, 180)}` : '',
25
+ `${options.alwaysLimitsLabel || '30 days'}, this machine`,
26
+ ].filter(Boolean).join(' · ');
27
+ }
28
+ /** `Always allow covers — What: gcloud · How: change · Where: project acme-staging · 30 days, this machine`. */
29
+ function alwaysCoversLine(options) {
30
+ const parts = alwaysCoversParts(options);
31
+ return parts ? `Always allow covers — ${parts}` : undefined;
32
+ }
15
33
  /** The full script is shown in a scrolling box; this only guards against a runaway payload. */
16
34
  const MAX_DIALOG_SCRIPT = 6000;
17
35
  const CEILING_NOTE_RE = /\s*This policy is set to Block[.;]\s*(?:Because it is not marked "Hard block", your workstation requires your approval before continuing\.|on a developer workstation it asks you instead\.)?\s*(?:An admin can (?:review the matching rule and its scope in the console|mark it "Hard block" in the console to stop this without asking)\.)?/;
@@ -19,13 +37,72 @@ const CEILING_NOTE_SHORT = 'set to Block; on your workstation it asks you first.
19
37
  /** Parse the `Rule:` line the block explanation writes into the name the console lists and its scope. */
20
38
  function ruleParts(rule) {
21
39
  const policy = /^Action Policy "(.+?)" \((.+?)\)(?: — rule: (.+))?$/.exec(rule);
22
- if (policy)
23
- return { policy: policy[1], scope: [policy[2].replace(/^[^\w]+/, ''), policy[3]].filter(Boolean).join(' · ') };
40
+ if (policy) {
41
+ const scopeWords = policy[2].replace(/^[^\w]+/, '');
42
+ return { policy: policy[1], scopeWords, ...(policy[3] ? { matched: policy[3] } : {}), scope: [scopeWords, policy[3]].filter(Boolean).join(' · ') };
43
+ }
24
44
  const local = /^(built-in Local Safety rule|org custom rule) "(.+?)"(?: in Local Safety)?(?: \((.+)\))?$/.exec(rule);
25
45
  if (local)
26
- return { policy: local[2], localSafety: true, scope: [local[1] === 'org custom rule' ? 'Local Safety — org custom rule' : 'Local Safety — built-in rule', local[3]].filter(Boolean).join(' · ') };
46
+ return { policy: local[2], localSafety: true, customRule: local[1] === 'org custom rule', ...(local[3] ? { ruleDetail: local[3] } : {}), scope: [local[1] === 'org custom rule' ? 'Local Safety — org custom rule' : 'Local Safety — built-in rule', local[3]].filter(Boolean).join(' · ') };
27
47
  return { policy: rule };
28
48
  }
49
+ /** `toolArgs.command matches gcloud *` → "gcloud commands"; unknown shapes → undefined (the IDE message keeps the raw rule). */
50
+ function constraintWords(constraint) {
51
+ const m = /^(?:toolArgs\.)?([\w.]+) (equals|matches|contains|startsWith|in|notEquals|not_contains) (.+)$/.exec(constraint.trim());
52
+ if (!m)
53
+ return undefined;
54
+ const [, field, op, raw] = m;
55
+ const value = raw.trim();
56
+ if (field === 'command' || field === 'cmd') {
57
+ const lead = value.replace(/\s*\*+\s*$/, '').trim();
58
+ if ((op === 'matches' || op === 'startsWith') && /^[\w.-]+(?: [\w.-]+)?$/.test(lead))
59
+ return `${lead} commands`;
60
+ if (op === 'contains')
61
+ return `commands that contain "${value}"`;
62
+ return undefined;
63
+ }
64
+ if (field === 'destination.type' && op === 'equals')
65
+ return `actions that reach ${value === 'external' ? 'an external' : `a ${value}`} destination`;
66
+ if ((field === 'destination.domain' || field === 'destination.host') && op === 'equals')
67
+ return `actions that reach ${value}`;
68
+ if (field === 'agentClient' && op === 'equals')
69
+ return `actions by ${value}`;
70
+ return undefined;
71
+ }
72
+ const VERDICT_WORDS = { require_approval: 'asks for approval', block: 'blocks', allow: 'allows', log: 'records', warn: 'warns' };
73
+ const OPERATION_WORDS = { SHELL: 'shell commands', EXECUTE: 'commands', DELETE: 'deletes', write: 'writes', read: 'reads', delete: 'deletes' };
74
+ /**
75
+ * The rule that asked, in customer words — never engine syntax:
76
+ * `Company policy "Cloud changes need OK" asks for approval on gcloud commands, for every machine.`
77
+ * The raw matcher (`SHELL → require_approval (toolArgs.command matches gcloud *)`) stays in
78
+ * the IDE message's `Rule:` line.
79
+ */
80
+ function ruleSentence(f, tone) {
81
+ if (!f.policy || f.fallback)
82
+ return undefined;
83
+ const name = `"${line(f.policy, 80)}"`;
84
+ if (f.localSafety) {
85
+ const category = (f.ruleDetail || '').split(',').map(s => s.trim()).find(s => s && !/^id /.test(s));
86
+ const kind = `${f.customRule ? "Your organization's" : 'Built-in'} Local Safety rule ${name}${category ? ` (${category.replace(/_/g, ' ')})` : ''}`;
87
+ return `${kind} ${tone === 'block' ? 'blocks this' : tone === 'approval' ? 'needs approval for this' : 'asks before this runs'}.`;
88
+ }
89
+ const owner = /^company default/i.test(f.scopeWords || '') ? 'Company policy' : /^local rule/i.test(f.scopeWords || '') ? 'Local rule' : 'Policy';
90
+ const whom = /applies to (.+)$/i.exec(f.scopeWords || '')?.[1] || /only (.+)$/i.exec(f.scopeWords || '')?.[1];
91
+ const m = /^(\S+) → (\w+)(?: \((.*)\))?$/.exec(f.matched || '');
92
+ const verb = m ? VERDICT_WORDS[m[2]] || 'asks' : 'asks';
93
+ const on = m && m[3] ? m[3].split(/,\s*(?=[\w.]+ (?:equals|matches|contains|startsWith|in|notEquals|not_contains) )/).map(constraintWords) : [];
94
+ const what = on.length && on.every(Boolean) ? on.join(' and ')
95
+ : m && !m[3] ? unique(m[1].split('/').map(op => OPERATION_WORDS[op]).filter(Boolean)).join(' and ') || undefined
96
+ : undefined;
97
+ return `${owner} ${name} ${verb}${what ? ` on ${what}` : ''}${whom ? `, for ${whom}` : ''}.`;
98
+ }
99
+ /** The header sentence already states the fact the rule fired on (cloud, credential, a local removal). */
100
+ function saysTheFact(header) {
101
+ return header.source === 'cloud' || header.source === 'credential' || (header.source === 'operation' && Boolean(header.affects));
102
+ }
103
+ function unique(values) {
104
+ return [...new Set(values)];
105
+ }
29
106
  function promptFacts(reason) {
30
107
  const rows = reason.split(/\r?\n/).map(row => row.trim()).filter(Boolean);
31
108
  const field = (name) => rows.find(row => row.startsWith(`${name}:`))?.slice(name.length + 1).trim();
@@ -61,8 +138,10 @@ function promptFacts(reason) {
61
138
  facts.kind = kind?.[2];
62
139
  facts.action = kind ? kind[1] : what;
63
140
  }
64
- if (tool)
141
+ if (tool) {
65
142
  facts.tool = [tool, evidence].filter(Boolean).join(' · ');
143
+ facts.toolName = tool;
144
+ }
66
145
  if (why) {
67
146
  const stripped = why.replace(CEILING_NOTE_RE, ' ').replace(/\s+/g, ' ').trim();
68
147
  if (stripped !== why.trim())
@@ -74,6 +153,24 @@ function promptFacts(reason) {
74
153
  function sentences(text) {
75
154
  return text.split(/(?<=\.)\s+/).map(part => part.trim()).filter(Boolean);
76
155
  }
156
+ /** The full action text: the script when the explanation carried it, else the "What" line. */
157
+ function actionText(f) {
158
+ return f.script ? f.script.replace(/ ⏎ /g, '\n') : f.action;
159
+ }
160
+ function askHeaderFor(f, options) {
161
+ return (0, askHeader_1.buildAskHeader)({
162
+ command: actionText(f),
163
+ kind: f.kind,
164
+ tool: f.toolName,
165
+ policy: f.policy,
166
+ ruleDetail: f.ruleDetail,
167
+ ide: f.ide,
168
+ subject: options.subject,
169
+ alwaysOffered: Boolean(options.allowAlways),
170
+ alwaysCovers: alwaysCoversParts(options),
171
+ alwaysNotOffered: options.alwaysNotOffered,
172
+ });
173
+ }
77
174
  /**
78
175
  * Layout model for the branded OS dialog. Same facts as the compact prompt;
79
176
  * skips mockup-only chrome (other-outcome chips, Request approval, project tags).
@@ -81,8 +178,19 @@ function sentences(text) {
81
178
  function nativeDialogView(reason, options = {}) {
82
179
  const f = promptFacts(reason);
83
180
  const tone = options.tone || 'ask';
181
+ // Free-form text carries no facts to put in the header.
182
+ const header = tone === 'ask' && !f.fallback ? askHeaderFor(f, options) : undefined;
183
+ const because = f.because ? sentences(line(f.because, 400)) : [];
184
+ // The header already says the fact a cloud / credential rule fired on; the rule line only names the rule.
185
+ if (header && saysTheFact(header))
186
+ because.shift();
187
+ // The rule by name, its source and who it applies to — in customer words; the engine
188
+ // matcher stays in the IDE message. Then the fact it fired on.
189
+ if (because.length && /^[a-z]/.test(because[0]))
190
+ because[0] = `Because ${because[0]}`;
84
191
  const why = [
85
- ...(f.because ? sentences(line(f.because, 400)) : []),
192
+ ruleSentence(f, tone) || '',
193
+ ...because,
86
194
  f.note ? line(f.note, 180) : '',
87
195
  f.fallback ? line(f.fallback, 400) : '',
88
196
  ].filter(Boolean);
@@ -90,13 +198,14 @@ function nativeDialogView(reason, options = {}) {
90
198
  ? ['This action is stopped.', 'An admin can change the rule in the console.']
91
199
  : tone === 'approval'
92
200
  ? ['An admin or manager can approve it in the console.', 'Closing this window does not approve it.']
201
+ // With a header, what Always allow covers (or why it is not offered) is its last row.
93
202
  : [
94
- 'Allow once lets only this action continue.',
95
- 'Deny or close keeps it blocked.',
96
- options.allowAlways && options.alwaysScopeLabel
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
- : '',
203
+ 'Allow once lets only this action run; Deny or close keeps it stopped.',
204
+ header ? '' : alwaysCoversLine(options) || (!options.allowAlways && options.alwaysNotOffered ? line(options.alwaysNotOffered, 120) : ''),
99
205
  ].filter(Boolean);
206
+ // One statement shown whole in the header's Command row: the step list and script box would repeat it.
207
+ const full = actionText(f)?.replace(/\s+/g, ' ').trim();
208
+ const shownWhole = Boolean(header && full && header.command === full);
100
209
  const footer = f.policy && !f.fallback
101
210
  ? `Full details: the IDE message and this machine’s Activity. Console → Action Policies → ${f.localSafety ? 'Source: Local Safety → ' : ''}"${line(f.policy, 80)}".`
102
211
  : 'Full details are in the IDE message and this machine’s Activity in FullCourtDefense.';
@@ -109,10 +218,11 @@ function nativeDialogView(reason, options = {}) {
109
218
  : tone === 'approval'
110
219
  ? 'A security policy paused this action for an admin or manager.'
111
220
  : 'A security policy paused this action. Review the details and choose what to do next.',
221
+ ...(header ? { header } : {}),
112
222
  kind: f.kind,
113
223
  // The whole script when we have it (the box scrolls); the short line otherwise.
114
- action: f.script ? f.script.replace(/ ⏎ /g, '\n') : f.action ? line(f.action, 280) : undefined,
115
- steps: (f.steps || []).slice(0, 14).map(step => line(step, 220)),
224
+ action: shownWhole ? undefined : f.script ? actionText(f) : f.action ? line(f.action, 280) : undefined,
225
+ steps: shownWhole ? [] : (f.steps || []).slice(0, 14).map(step => line(step, 220)),
116
226
  policy: f.policy ? line(f.policy, 160) : undefined,
117
227
  scope: f.scope ? line(f.scope, 220) : undefined,
118
228
  agent: f.agent ? line(f.agent, 80) : undefined,
@@ -129,17 +239,41 @@ function nativeApprovalTitle(reason) {
129
239
  const parts = ['FullCourtDefense', f.kind ? `Approval needed: ${f.kind}` : 'Approval needed', f.policy ? line(f.policy, 60) : ''].filter(Boolean);
130
240
  return parts.join(' · ');
131
241
  }
242
+ /** The header as text rows: the sentence, then Command / Action / Where / Always allow. */
243
+ function headerLines(header) {
244
+ return [
245
+ header.who,
246
+ `Why you're asked: ${header.why}`,
247
+ `${`${header.commandLabel}:`.padEnd(9)}${header.command}`,
248
+ // One line in the compact text (the card draws Affects as its own row).
249
+ `Action: ${header.action}${header.affects ? ` — affects ${header.affects}` : ''}`,
250
+ `Where: ${header.whereText}`,
251
+ ...(header.secretSentTo ? [`${header.secretSentTo.label}: ${header.secretSentTo.value}`] : []),
252
+ `Always allow: ${header.always}`,
253
+ ];
254
+ }
132
255
  function nativeApprovalMessage(reason, options = {}) {
133
256
  const f = promptFacts(reason);
257
+ const header = !f.fallback && (options.tone || 'ask') === 'ask' ? askHeaderFor(f, options) : undefined;
258
+ // As in the card: the fact a cloud / credential rule fired on is the header's sentence, not repeated.
259
+ const because = header && saysTheFact(header) && f.because
260
+ ? sentences(f.because).slice(1).join(' ')
261
+ : f.because;
262
+ const full = actionText(f)?.replace(/\s+/g, ' ').trim();
263
+ const steps = header && full && header.command === full ? [] : f.steps || [];
134
264
  const details = f.fallback ? [line(f.fallback, 650)] : [
265
+ ...(header ? headerLines(header) : []),
135
266
  f.policy ? `Policy: ${line(f.policy, 160)}` : '',
136
- f.scope ? ` ${line(f.scope, 200)}` : '',
137
- f.action ? `Action: ${f.kind ? `${f.kind} — ` : ''}${line(f.action, 230)}` : '',
138
- ...(f.steps || []).slice(0, 6).map((step, index) => `${index === 0 ? 'Does: ' : ' '}${index + 1}. ${line(step, 160)}`),
267
+ // Where the rule comes from, in customer words (the raw matcher is on the IDE message's Rule: line).
268
+ f.policy ? ` ${line(ruleSentence(f, options.tone || 'ask') || f.scope || '', 220)}` : '',
269
+ // The header's Command row is the action; without a header the action line stays.
270
+ f.action && !header ? `Action: ${f.kind ? `${f.kind} — ` : ''}${line(f.action, 230)}` : '',
271
+ ...steps.slice(0, 6).map((step, index) => `${index === 0 ? 'Does: ' : ' '}${index + 1}. ${line(step, 160)}`),
139
272
  f.agent ? `Agent: ${line(f.agent, 80)}` : '',
140
- f.ide ? `IDE: ${line(f.ide, 40)}` : '',
273
+ // The header's first line already names the client.
274
+ f.ide && !header ? `IDE: ${line(f.ide, 40)}` : '',
141
275
  f.tool ? `Tool: ${line(f.tool, 230)}` : '',
142
- f.because ? `Because: ${line(f.because, 300)}` : '',
276
+ because ? `Because: ${line(because, 300)}` : '',
143
277
  f.note ? `Note: ${f.note}` : '',
144
278
  ].filter(Boolean);
145
279
  const where = f.policy && !f.fallback
@@ -148,15 +282,18 @@ function nativeApprovalMessage(reason, options = {}) {
148
282
  if (!options.allowAlways) {
149
283
  return [
150
284
  'Allow this action once?',
151
- '', ...details, '',
285
+ // The header's "Why you're asked" answers the question directly below it.
286
+ ...(header ? [] : ['']), ...details, '',
287
+ ...(options.alwaysNotOffered && !header ? [line(options.alwaysNotOffered, 120)] : []),
152
288
  'Allow once permits only this action. Deny or closing keeps it stopped.',
153
289
  where,
154
290
  ].join('\n');
155
291
  }
292
+ const covers = header ? undefined : alwaysCoversLine(options);
156
293
  return [
157
294
  'Allow this action?',
158
- '', ...details, '',
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.`, ''] : []),
295
+ ...(header ? [] : ['']), ...details, '',
296
+ ...(covers ? [covers, ''] : []),
160
297
  'Allow once permits only this action. Deny or closing keeps it stopped.',
161
298
  where,
162
299
  ].join('\n');
@@ -54,6 +54,8 @@ export interface AskEnvelope {
54
54
  grantLimits?: GrantLimits | null;
55
55
  /** Text for the dialog (the same explanation the IDE card shows). */
56
56
  reason: string;
57
+ /** The client the runtime event records as `agentClient` (cursor, claude-code, …): the dialog names the same agent as the console. */
58
+ agentClient?: string;
57
59
  }
58
60
  export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide';
59
61
  export interface HookVerdict {