fullcourtdefense-cli 1.36.2 → 1.36.4

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.
@@ -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",
@@ -7461,8 +7466,17 @@ var GIT_NETWORK_SUBCOMMANDS = /* @__PURE__ */ new Set([
7461
7466
  "receive-pack",
7462
7467
  "lfs"
7463
7468
  ]);
7469
+ var POWERSHELL_LINE_RE = /\$env:|-ErrorAction\b|\bWrite-(?:Output|Host)\b|\b(?:Get|Set|New|Remove|Start|Stop|Invoke|Select|Where|ForEach|Test|Add|Format|Out|ConvertTo|ConvertFrom|Import|Export|Measure|Sort|Group|Restart|Register|Unregister|Enable|Disable|Copy|Move|Rename|Resolve|Split|Join|Read|Wait|Clear)-[A-Z][A-Za-z]+\b|@\{|@'|@"|\[(?:System\.|Security\.|IO\.|math\])|\$_\.|\$\w+\s*=\s*(?:Get|New|Read)-|\|\s*%\s|\|\s*\?\s/;
7470
+ var POWERSHELL_ONLY_SYNTAX_RE = /(?:^|[\s;{(|])\$[A-Za-z_][\w:]*\s*[+-]?=(?!=)|`"|(?:^|[\s;{])(?:if|elseif|foreach|while|switch)\s*\((?!\()|\}\s*(?:else|elseif|catch|finally)\b|\|\s*(?:where|foreach|%|\?)\s*\{/i;
7471
+ var POWERSHELL_ESCAPES_ONLY_RE = /^(?:[^`]|`[$"'`0abefnrtuv])*$/;
7472
+ function backtickMayExecute(segment) {
7473
+ if (!segment.includes("`")) return false;
7474
+ const code = maskQuotedSpans(segment);
7475
+ const powershell = POWERSHELL_LINE_RE.test(code) || POWERSHELL_ONLY_SYNTAX_RE.test(code);
7476
+ return !(powershell && POWERSHELL_ESCAPES_ONLY_RE.test(segment));
7477
+ }
7464
7478
  function segmentMayReachNetwork(segment) {
7465
- if (/\$\(|`/.test(segment) || PS_VALUE_EXECUTOR_RE.test(segment)) return true;
7479
+ if (segment.includes("$(") || backtickMayExecute(segment) || PS_VALUE_EXECUTOR_RE.test(segment)) return true;
7466
7480
  return splitShellSegments(segment, true).some((stage) => {
7467
7481
  if (/^\s*(?:\$[\w:]+|[A-Za-z_]\w*)\s*=/.test(stage)) return true;
7468
7482
  const masked = maskQuotedSpans(stage);
@@ -8758,8 +8772,145 @@ function inferToolContext(toolName, args) {
8758
8772
  }
8759
8773
  applyRemoteControlFact(toolName, args, context);
8760
8774
  applyWebReadFacts(toolName, args, context);
8775
+ applyDestinationCategoryFact(context);
8761
8776
  return { operation, context };
8762
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
+ }
8763
8914
  function applyRemoteControlFact(toolName, args, context) {
8764
8915
  delete context["toolArgs.remoteControl"];
8765
8916
  const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
@@ -8911,6 +9062,7 @@ function inferBundledToolContext(toolName, args) {
8911
9062
  }
8912
9063
  applyRemoteControlFact(toolName, args, context);
8913
9064
  applyWebReadFacts(toolName, args, context);
9065
+ applyDestinationCategoryFact(context);
8914
9066
  return { operation, context };
8915
9067
  }
8916
9068
  var RESERVED_INPUT_FACTS = /* @__PURE__ */ new Set([
@@ -9012,14 +9164,18 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
9012
9164
  ACTION_POLICY_GRANT_MAX_USES,
9013
9165
  CONTENT_FACT_FIELDS,
9014
9166
  DEFAULT_ACTION_POLICY_APPROVAL,
9167
+ DESTINATION_CATEGORY_HOSTS,
9015
9168
  GCLOUD_DELETE_VERB_RE,
9016
9169
  GCLOUD_READ_VERB_RE,
9017
9170
  GCLOUD_WRITE_VERB_RE,
9171
+ HIGH_RISK_DESTINATION_CATEGORIES,
9018
9172
  MACHINE_KINDS,
9019
9173
  WORKSTATION_ASK_APPROVAL,
9020
9174
  approvalExceptionError,
9021
9175
  canonicalAgentClient,
9176
+ classifyDestinationHost,
9022
9177
  deriveToolOperations,
9178
+ destinationCategory,
9023
9179
  detectSecretKind,
9024
9180
  effectiveApprovalScope,
9025
9181
  evaluateActionPolicies,
@@ -67,6 +67,12 @@ export interface AskHeader {
67
67
  }
68
68
  /** The client id as the console labels it; undefined for a missing or placeholder id. */
69
69
  export declare function agentClientLabel(raw?: string): string | undefined;
70
+ /**
71
+ * The guard's credential-command patterns (`deterministicGuard` CREDENTIAL_COMMAND_RULES), for
72
+ * naming a secret the line sends somewhere when ANOTHER rule asked. Copied because this module stays
73
+ * pure (the guard reads files); test-ask-dialog.js fails when the two differ.
74
+ */
75
+ export declare const LINE_CREDENTIAL_PATTERNS: Readonly<Record<string, string>>;
70
76
  /** Name tokens of a project id → environment. Deterministic, and only a hint: the dialog says "(by name)". */
71
77
  export declare function environmentTag(projectId: string): EnvironmentTag | undefined;
72
78
  interface ProjectRead {
package/dist/askHeader.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LINE_CREDENTIAL_PATTERNS = void 0;
3
4
  exports.agentClientLabel = agentClientLabel;
4
5
  exports.environmentTag = environmentTag;
5
6
  exports.projectsInCommand = projectsInCommand;
@@ -17,6 +18,7 @@ exports.buildAskHeader = buildAskHeader;
17
18
  * browser extension bundles `approvalPrompt.ts` (and so this module) for the browser.
18
19
  */
19
20
  const commandNarrative_1 = require("./commandNarrative");
21
+ const shellQuoteMask_1 = require("./shellQuoteMask");
20
22
  const MAX_COMMAND = 90;
21
23
  /** The console's client names (`frontend/src/lib/activityClient.ts`): the dialog and the console name the same agent. */
22
24
  const CLIENT_LABELS = {
@@ -186,6 +188,24 @@ const TOKEN_PRINTER_ACTIONS = [
186
188
  { re: /\bgcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+print-identity-token\b/i, action: 'Reads a cloud identity token' },
187
189
  { re: /\bgh(?:\.exe)?\s+auth\s+token\b/i, action: 'Reads the GitHub token' },
188
190
  ];
191
+ /**
192
+ * The guard's credential-command patterns (`deterministicGuard` CREDENTIAL_COMMAND_RULES), for
193
+ * naming a secret the line sends somewhere when ANOTHER rule asked. Copied because this module stays
194
+ * pure (the guard reads files); test-ask-dialog.js fails when the two differ.
195
+ */
196
+ exports.LINE_CREDENTIAL_PATTERNS = {
197
+ gh_auth_token_cmd: String.raw `\bgh\s+auth\s+token\b|\bgh\s+auth\s+status\b(?=[^\n;|&]*(?:--show-token|\s-t\b))`,
198
+ git_credential_fill: String.raw `\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b`,
199
+ macos_keychain_dump: String.raw `\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b|\bsecurity\s+dump-keychain\b`,
200
+ kubectl_config_raw: String.raw `\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b`,
201
+ aws_export_credentials: String.raw `\baws\s+configure\s+export-credentials\b|\baws\s+configure\s+get\b[^\n;|&]*secret`,
202
+ gcloud_access_token_cmd: String.raw `\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b`,
203
+ gcloud_secret_access_cmd: String.raw `\bgcloud\s+secrets\s+versions\s+access\b`,
204
+ az_access_token_cmd: String.raw `\baz\s+account\s+get-access-token\b`,
205
+ aws_secret_fetch_cmd: String.raw `\baws\s+secretsmanager\s+get-secret-value\b|\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b`,
206
+ vault_read_cmd: String.raw `\bvault\s+(?:kv\s+get|read)\b`,
207
+ };
208
+ const LINE_CREDENTIAL = Object.entries(exports.LINE_CREDENTIAL_PATTERNS).map(([item, pattern]) => ({ item, re: new RegExp(pattern, 'i') }));
189
209
  const HOW_RANK = ['delete', 'permissions', 'protected', 'change', 'read'];
190
210
  /** Name tokens of a project id → environment. Deterministic, and only a hint: the dialog says "(by name)". */
191
211
  function environmentTag(projectId) {
@@ -272,22 +292,31 @@ function oneLine(text, max = MAX_COMMAND) {
272
292
  tail = tail.slice(tailSpace + 1);
273
293
  return `${head.trimEnd()} … ${tail.trimStart()}`;
274
294
  }
295
+ /**
296
+ * The line as the shell reads it (`maskShell`, dialect from the line): quoted
297
+ * text keeps its length but loses spaces, quotes and `$`, so no flag, URL or
298
+ * program starts inside a commit message or issue body. Facts come from here.
299
+ */
300
+ function codeOf(command) {
301
+ return (0, shellQuoteMask_1.maskShell)(command, (0, shellQuoteMask_1.shellDialectOf)(command)).code;
302
+ }
275
303
  /** Literal project ids named on the command: `--project x` / `--project=x` and Google API `projects/<id>` paths. */
276
304
  function projectsInCommand(command) {
305
+ const code = codeOf(command);
277
306
  const ids = [];
278
307
  let computed = false;
279
308
  const add = (raw) => {
280
309
  const value = raw.replace(/^["']|["']$/g, '');
281
- if (/[$`(){}%<>]/.test(value) || !PROJECT_ID_RE.test(value.toLowerCase())) {
310
+ if (/[$`(){}%<>_]/.test(value) || !PROJECT_ID_RE.test(value.toLowerCase())) {
282
311
  computed = true;
283
312
  return;
284
313
  }
285
314
  if (!ids.includes(value.toLowerCase()))
286
315
  ids.push(value.toLowerCase());
287
316
  };
288
- for (const m of command.matchAll(/(?:^|\s)--project(?:=|\s+)("[^"]*"|'[^']*'|[^\s;|&]+)/g))
317
+ for (const m of code.matchAll(/(?:^|\s)--project(?:=|\s+)("[^"]*"|'[^']*'|[^\s;|&]+)/g))
289
318
  add(m[1]);
290
- for (const m of command.matchAll(/https?:\/\/[a-z0-9.-]*googleapis\.com\/[^\s"'`]*?\bprojects\/([^/\s"'`?#]+)/gi))
319
+ for (const m of code.matchAll(/(?:^|[\s=(,"'])https?:\/\/[a-z0-9.-]*googleapis\.com\/[^\s"'`]*?\bprojects\/([^/\s"'`?#]+)/gi))
291
320
  add(m[1]);
292
321
  return { ids, computed };
293
322
  }
@@ -441,15 +470,22 @@ function buildAskHeader(input) {
441
470
  const command = (input.command || '').trim();
442
471
  const shell = subject.event ? subject.event === 'shell' : /command$/.test(input.kind || '') || (!input.tool && Boolean(command));
443
472
  const noun = nounOf(input.kind, subject.event);
444
- const item = ruleItem(subject, input.ruleDetail, input.policy);
473
+ const askedItem = ruleItem(subject, input.ruleDetail, input.policy);
474
+ const code = shell ? codeOf(command) : command;
475
+ const printer = shell ? TOKEN_PRINTER_ACTIONS.find(p => p.re.test(code)) : undefined;
476
+ // Another rule asked (egress, a cloud policy), but the line reads a credential and sends it to a host:
477
+ // that is the fact the developer must see (the credential rule itself may only warn, #1837).
478
+ const lineItem = shell && !(askedItem && CREDENTIAL_ACTIONS[askedItem]) ? LINE_CREDENTIAL.find(c => c.re.test(code))?.item : undefined;
479
+ const flows = shell && (lineItem || printer) ? (0, commandNarrative_1.capturedValueDestinations)(command, s => LINE_CREDENTIAL.some(c => c.re.test(codeOf(s)))) : [];
480
+ const viaFlow = Boolean(lineItem && flows.length);
481
+ const item = viaFlow ? lineItem : askedItem;
445
482
  const credentialAction = item ? CREDENTIAL_ACTIONS[item] : undefined;
446
483
  const cloud = cloudEntries(subject.cloud);
447
484
  const how = cloud.length ? strongestHow(cloud) : undefined;
448
485
  const decisiveEntries = how ? cloud.filter(entry => entry.how === how) : [];
449
486
  const decisiveTools = unique(decisiveEntries.map(entry => entry.tool));
450
- const printer = shell ? TOKEN_PRINTER_ACTIONS.find(p => p.re.test(command)) : undefined;
451
487
  // A token read is about the token, even when the line also reaches a target not read yet (`curl` POST).
452
- const credential = Boolean(credentialAction || (printer && (!how || how === '?')));
488
+ const credential = Boolean(credentialAction || (printer && (!how || how === '?' || flows.length > 0)));
453
489
  // Where: a credential line names the project its literal Google API URL / --project acts on;
454
490
  // otherwise what the cloud entries name (each unknown part stays "unknown"), else a literal
455
491
  // project on the command, else the host the action reaches.
@@ -458,7 +494,7 @@ function buildAskHeader(input) {
458
494
  const cloudWheres = unique(cloud.map(entryWhere));
459
495
  const names = useCloud ? unique(cloud.flatMap(environmentNames)) : literal.ids;
460
496
  const host = subject.destinationDomain || (subject.credentialHosts || [])[0]
461
- || (shell ? /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?=[/:\s"'`]|$)/i.exec(command)?.[1]?.toLowerCase() : undefined);
497
+ || (shell ? /(?:^|[\s=(,"'])https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?=[/:\s"'`]|$)/i.exec(code)?.[1]?.toLowerCase() : undefined);
462
498
  const tag = names.length ? strongestTag(names) : undefined;
463
499
  const hint = addHint(input.alwaysNotOffered, unique(cloud.map(entry => entry.tool)));
464
500
  let where;
@@ -481,7 +517,10 @@ function buildAskHeader(input) {
481
517
  if (credential) {
482
518
  source = 'credential';
483
519
  action = credentialAction || printer.action;
484
- why = `This ${noun} ${action.charAt(0).toLowerCase()}${action.slice(1)}.`;
520
+ const sentTo = (subject.credentialHosts && subject.credentialHosts.length ? subject.credentialHosts : flows).slice(0, 3);
521
+ why = viaFlow && sentTo.length
522
+ ? `This ${noun} ${action.charAt(0).toLowerCase()}${action.slice(1)} and sends it to ${sentTo.join(', ')}.`
523
+ : `This ${noun} ${action.charAt(0).toLowerCase()}${action.slice(1)}.`;
485
524
  }
486
525
  else if (how) {
487
526
  source = 'cloud';
@@ -533,7 +572,7 @@ function buildAskHeader(input) {
533
572
  why = input.policy ? `Rule "${oneLine(input.policy, 80)}" asks before this ${noun} runs.` : `A policy asks before this ${noun} runs.`;
534
573
  }
535
574
  const decisive = !shell ? undefined
536
- : source === 'credential' ? matchedSegment(command, s => TOKEN_PRINTER_ACTIONS.some(p => p.re.test(s)) || (item ? new RegExp(`\\b${(CREDENTIAL_PROGRAM[item] || '\\S')}`, 'i').test(s) : false))
575
+ : source === 'credential' ? matchedSegment(command, s => TOKEN_PRINTER_ACTIONS.some(p => p.re.test(codeOf(s))) || (item ? new RegExp(`\\b${(CREDENTIAL_PROGRAM[item] || '\\S')}`, 'i').test(codeOf(s)) : false))
537
576
  : source === 'cloud' ? (matchedSegment(command, s => toolSegment(decisiveTools, how).test(s) && !/\bgcloud\s+(?:auth|config)\b/i.test(s))
538
577
  || matchedSegment(command, s => toolSegment(decisiveTools, 'any').test(s)))
539
578
  : undefined;
@@ -545,7 +584,7 @@ function buildAskHeader(input) {
545
584
  let always = alwaysRow(input);
546
585
  if (credential) {
547
586
  const bound = subject.credentialHosts || [];
548
- const followed = bound.length ? [] : (0, commandNarrative_1.tokenDestinations)(command);
587
+ const followed = bound.length ? [] : flows.length ? flows : (0, commandNarrative_1.tokenDestinations)(command);
549
588
  const label = (item && TOKEN_RULES.has(item)) || (!credentialAction && printer) ? 'Token sent to' : 'Secret sent to';
550
589
  secretSentTo = { label, value: [...bound, ...followed].slice(0, 3).join(', ') || 'unknown' };
551
590
  // A destination IS readable, but another step on the line is not: say that, not "could not be read".
@@ -39,6 +39,11 @@ export declare function splitCommandSteps(command: string): string[];
39
39
  export declare function shortCode(value: string, max?: number): string;
40
40
  /** Every host a token read on this line is sent to, as the step list names it. Empty when none is readable. */
41
41
  export declare function tokenDestinations(command: string): string[];
42
+ /**
43
+ * Every host a value stored by an assignment is sent to, when the assignment's value is one `reads`
44
+ * recognizes (a secret read the narrative does not capture), plus what `tokenDestinations` follows.
45
+ */
46
+ export declare function capturedValueDestinations(command: string, reads: (segment: string) => boolean): string[];
42
47
  /** One plain-words line per step, in order. */
43
48
  export declare function describeShellCommand(command: string): CommandStep[];
44
49
  /** The step lines as the explanation prints them (`- …`). */
@@ -18,8 +18,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.splitCommandSteps = splitCommandSteps;
19
19
  exports.shortCode = shortCode;
20
20
  exports.tokenDestinations = tokenDestinations;
21
+ exports.capturedValueDestinations = capturedValueDestinations;
21
22
  exports.describeShellCommand = describeShellCommand;
22
23
  exports.describeShellCommandLines = describeShellCommandLines;
24
+ const deterministicGuard_1 = require("./commands/deterministicGuard");
23
25
  const MAX_STEPS = 14;
24
26
  const MAX_LINE = 220;
25
27
  /** Token printers whose output can be assigned; `issuer` = who accepts the token (identity tokens go to their audience). */
@@ -642,7 +644,7 @@ function describeOne(segmentIn, captured, fns = new Map()) {
642
644
  case 'python3':
643
645
  case 'tsx':
644
646
  case 'ts-node':
645
- 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);
646
648
  case 'curl':
647
649
  case 'wget':
648
650
  case 'invoke-webrequest':
@@ -677,7 +679,10 @@ function describeOne(segmentIn, captured, fns = new Map()) {
677
679
  case 'mkdir':
678
680
  case 'new-item':
679
681
  case 'ni':
680
- 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
+ }
681
686
  case 'cat':
682
687
  case 'get-content':
683
688
  case 'gc':
@@ -685,8 +690,22 @@ function describeOne(segmentIn, captured, fns = new Map()) {
685
690
  case 'head':
686
691
  case 'tail':
687
692
  case 'less':
688
- case 'more':
689
- 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
+ }
690
709
  case 'ls':
691
710
  case 'dir':
692
711
  case 'get-childitem':
@@ -904,6 +923,32 @@ function tokenDestinations(command) {
904
923
  } });
905
924
  return [...new Set(described.flatMap((step, index) => step.captures ? tokenHosts(described, index, step.captures.variable) : []))].sort();
906
925
  }
926
+ /** `$v = …` (PowerShell) or `v=$(…)` / `export v=$(…)` (POSIX): the variable an assignment step stores. */
927
+ function assignedVariable(segment) {
928
+ return /^\s*\$([\w:]+)\s*=(?!=)/.exec(segment)?.[1] || /^\s*(?:export\s+)?([A-Za-z_]\w*)=\$\(/.exec(segment)?.[1];
929
+ }
930
+ /**
931
+ * Every host a value stored by an assignment is sent to, when the assignment's value is one `reads`
932
+ * recognizes (a secret read the narrative does not capture), plus what `tokenDestinations` follows.
933
+ */
934
+ function capturedValueDestinations(command, reads) {
935
+ const steps = splitCommandSteps(command);
936
+ const fns = localFunctions(command);
937
+ const described = steps.map(segment => { try {
938
+ return describeOne(segment, undefined, fns);
939
+ }
940
+ catch {
941
+ return { segment, text: '', known: false };
942
+ } });
943
+ const hosts = new Set();
944
+ described.forEach((step, index) => {
945
+ const variable = step.captures?.variable || (reads(step.segment) ? assignedVariable(step.segment) : undefined);
946
+ if (variable)
947
+ for (const host of tokenHosts(described, index, variable))
948
+ hosts.add(host);
949
+ });
950
+ return [...hosts].sort();
951
+ }
907
952
  function noteTokenDestinations(steps) {
908
953
  return steps.map((step, index) => {
909
954
  if (!step.captures)
@@ -921,13 +966,75 @@ function noteTokenDestinations(steps) {
921
966
  return { ...step, text: shortValue(`${step.text}, then sends it to ${described.join('; ')}`, MAX_LINE) };
922
967
  });
923
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
+ }
924
1030
  /** One plain-words line per step, in order. */
925
1031
  function describeShellCommand(command) {
926
- const steps = splitCommandSteps(command);
1032
+ const parts = stepsWithHeredocs(command);
1033
+ const steps = parts.map(part => part.segment);
927
1034
  const fns = localFunctions(command);
928
- const described = steps.map(segment => {
1035
+ const described = parts.map(({ segment, heredocs }) => {
929
1036
  try {
930
- return describeOne(segment, undefined, fns);
1037
+ return heredocs.length ? describeHeredocStep(segment, heredocs, fns) : describeOne(segment, undefined, fns);
931
1038
  }
932
1039
  catch {
933
1040
  return { segment, text: 'Runs a command the grammar does not recognise', known: false };
@@ -73,6 +73,7 @@ const discoverPaths_1 = require("./discoverPaths");
73
73
  const localSafetySnapshot_1 = require("../localSafetySnapshot");
74
74
  const policyRefresh_1 = require("../policyRefresh");
75
75
  const telemetry_1 = require("../telemetry");
76
+ const identityRecovery_1 = require("../identityRecovery");
76
77
  const notify_1 = require("../notify");
77
78
  const integrity_1 = require("../integrity");
78
79
  const protectionMaintenance_1 = require("../protectionMaintenance");
@@ -1505,6 +1506,7 @@ async function runDaemon(args, config) {
1505
1506
  // flush lock, which drops the daemon marker specifically (hook flushes do
1506
1507
  // not set `daemon: true`), so retrying soon is right in that case too.
1507
1508
  let reported = false;
1509
+ let refusal;
1508
1510
  try {
1509
1511
  let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1510
1512
  // Recovery outside our repair pass (for example a manual fix) also clears
@@ -1568,6 +1570,8 @@ async function runDaemon(args, config) {
1568
1570
  : undefined,
1569
1571
  });
1570
1572
  reported = result !== null;
1573
+ if (!reported)
1574
+ refusal = (0, telemetry_1.getLastFlushFailure)();
1571
1575
  if (result && unreportedCrash)
1572
1576
  (0, daemonForensics_1.markPostmortemReported)();
1573
1577
  if (result && result.accepted > 0)
@@ -1583,10 +1587,26 @@ async function runDaemon(args, config) {
1583
1587
  await verifyPendingUpgrade();
1584
1588
  // Surface the miss to the caller (poll loop / resume path) so it retries in
1585
1589
  // seconds. Nothing else can tell them: liveness has exactly one writer.
1586
- if (!reported)
1587
- throw new Error('heartbeat did not reach the backend — daemon liveness was not recorded');
1590
+ if (!reported) {
1591
+ // 409 MACHINE_NOT_ENROLLED: the id sent is not the one this shield is
1592
+ // enrolled under — ask the backend for it; the retry then lands.
1593
+ await (0, identityRecovery_1.recoverIdentityAfterRefusal)({ failure: refusal, apiUrl: creds.apiUrl, shieldId: creds.shieldId, shieldKey: creds.shieldKey, log })
1594
+ .catch(() => false);
1595
+ throw new Error((0, identityRecovery_1.describeHeartbeatMiss)(refusal));
1596
+ }
1588
1597
  };
1589
1598
  // --- boot ---------------------------------------------------------------
1599
+ // Settle the machine id before anything reports: the only place a pinned
1600
+ // but unconfirmed fallback id may be re-checked against the OS (#1853).
1601
+ try {
1602
+ const id = (0, machineIdentity_1.reconcileMachineIdentity)('heal');
1603
+ log(`Machine id ${(0, machineIdentity_1.shortMachineId)(id.machineId)} (${id.source}${id.confirmed ? ', backend-confirmed' : ''}${id.probed ? `, native read ${id.probeError ? `failed: ${id.probeError}` : 'ok'}` : ''}).`
1604
+ + (id.healed ? ` Healed from stale fallback id ${(0, machineIdentity_1.shortMachineId)(id.healed.from)}.` : '')
1605
+ + (id.conflict ? ` Native id ${(0, machineIdentity_1.shortMachineId)(id.conflict.native)} disagrees with the confirmed id — kept the confirmed one (machine_id_conflict).` : ''));
1606
+ }
1607
+ catch (error) {
1608
+ log(`Machine id reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
1609
+ }
1590
1610
  // Self-heal autostart + watchdog tasks: machines installed by older versions
1591
1611
  // (or where task creation failed once) must converge without a reinstall.
1592
1612
  ensureWindowsAutostartHealthy(log);
@@ -1625,7 +1645,7 @@ async function runDaemon(args, config) {
1625
1645
  // the network is up (captive portal, VPN, resumed in a lift) is routine, and
1626
1646
  // the heartbeat loop below retries within seconds.
1627
1647
  await heartbeat().catch(error => {
1628
- log(`First heartbeat did not reach the backend (${error?.message || String(error)}) — the heartbeat loop will retry shortly.`);
1648
+ log(`First heartbeat failed: ${error?.message || String(error)}. The heartbeat loop will retry shortly.`);
1629
1649
  });
1630
1650
  // Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
1631
1651
  // chat has no hook and never hits an MCP server, so it is the one machine
@@ -176,8 +176,8 @@ exports.DEMO_SCENARIOS = [
176
176
  id: 'recursive-delete',
177
177
  title: 'Recursive delete of a project folder',
178
178
  event: 'shell',
179
- payload: { command: 'rm -rf ~/projects/payments' },
180
- why: 'Destructive filesystem operations are classified as DELETE and can be governed by policy.',
179
+ payload: { command: 'rm -rf ~/projects' },
180
+ why: 'A recursive delete of a top-level home folder is classified as DELETE and can be governed by policy. Nested work paths (`~/projects/payments`) are workspace writes, not DELETE.',
181
181
  },
182
182
  ];
183
183
  function tildify(filePath) {