fullcourtdefense-cli 1.36.2 → 1.36.3

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.
@@ -7461,8 +7461,17 @@ var GIT_NETWORK_SUBCOMMANDS = /* @__PURE__ */ new Set([
7461
7461
  "receive-pack",
7462
7462
  "lfs"
7463
7463
  ]);
7464
+ 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/;
7465
+ 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;
7466
+ var POWERSHELL_ESCAPES_ONLY_RE = /^(?:[^`]|`[$"'`0abefnrtuv])*$/;
7467
+ function backtickMayExecute(segment) {
7468
+ if (!segment.includes("`")) return false;
7469
+ const code = maskQuotedSpans(segment);
7470
+ const powershell = POWERSHELL_LINE_RE.test(code) || POWERSHELL_ONLY_SYNTAX_RE.test(code);
7471
+ return !(powershell && POWERSHELL_ESCAPES_ONLY_RE.test(segment));
7472
+ }
7464
7473
  function segmentMayReachNetwork(segment) {
7465
- if (/\$\(|`/.test(segment) || PS_VALUE_EXECUTOR_RE.test(segment)) return true;
7474
+ if (segment.includes("$(") || backtickMayExecute(segment) || PS_VALUE_EXECUTOR_RE.test(segment)) return true;
7466
7475
  return splitShellSegments(segment, true).some((stage) => {
7467
7476
  if (/^\s*(?:\$[\w:]+|[A-Za-z_]\w*)\s*=/.test(stage)) return true;
7468
7477
  const masked = maskQuotedSpans(stage);
@@ -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,6 +18,7 @@ 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;
23
24
  const MAX_STEPS = 14;
@@ -904,6 +905,32 @@ function tokenDestinations(command) {
904
905
  } });
905
906
  return [...new Set(described.flatMap((step, index) => step.captures ? tokenHosts(described, index, step.captures.variable) : []))].sort();
906
907
  }
908
+ /** `$v = …` (PowerShell) or `v=$(…)` / `export v=$(…)` (POSIX): the variable an assignment step stores. */
909
+ function assignedVariable(segment) {
910
+ return /^\s*\$([\w:]+)\s*=(?!=)/.exec(segment)?.[1] || /^\s*(?:export\s+)?([A-Za-z_]\w*)=\$\(/.exec(segment)?.[1];
911
+ }
912
+ /**
913
+ * Every host a value stored by an assignment is sent to, when the assignment's value is one `reads`
914
+ * recognizes (a secret read the narrative does not capture), plus what `tokenDestinations` follows.
915
+ */
916
+ function capturedValueDestinations(command, reads) {
917
+ const steps = splitCommandSteps(command);
918
+ const fns = localFunctions(command);
919
+ const described = steps.map(segment => { try {
920
+ return describeOne(segment, undefined, fns);
921
+ }
922
+ catch {
923
+ return { segment, text: '', known: false };
924
+ } });
925
+ const hosts = new Set();
926
+ described.forEach((step, index) => {
927
+ const variable = step.captures?.variable || (reads(step.segment) ? assignedVariable(step.segment) : undefined);
928
+ if (variable)
929
+ for (const host of tokenHosts(described, index, variable))
930
+ hosts.add(host);
931
+ });
932
+ return [...hosts].sort();
933
+ }
907
934
  function noteTokenDestinations(steps) {
908
935
  return steps.map((step, index) => {
909
936
  if (!step.captures)
@@ -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) {
@@ -204,6 +204,11 @@ export declare function setShellAstMode(mode: ShellAstMode): void;
204
204
  export declare function getShellAstMode(): ShellAstMode;
205
205
  /** Shadow telemetry: the comparison recorded by the most recent shell scan (consumed once). */
206
206
  export declare function takeShellAstComparison(): ShellAstComparison | undefined;
207
+ /**
208
+ * PowerShell line, judged on its CODE only (`unquotedCode`): a cmdlet named
209
+ * inside a commit message or issue body does not change how the shell reads it.
210
+ */
211
+ export declare function isPowerShellLine(text: string): boolean;
207
212
  /**
208
213
  * Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
209
214
  * space. Detectors that ask "does this EXECUTE" must look here; the raw line
@@ -46,6 +46,7 @@ exports.setShellAstProvider = setShellAstProvider;
46
46
  exports.setShellAstMode = setShellAstMode;
47
47
  exports.getShellAstMode = getShellAstMode;
48
48
  exports.takeShellAstComparison = takeShellAstComparison;
49
+ exports.isPowerShellLine = isPowerShellLine;
49
50
  exports.unquotedCode = unquotedCode;
50
51
  exports.stripInertDataSegments = stripInertDataSegments;
51
52
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
@@ -1510,7 +1511,9 @@ function isSensitivePathContext(toolName, candidate) {
1510
1511
  // (`wsl -e bash -c ""r""m"…`) that per-span reasoning cannot survive.
1511
1512
  // 2. A double-quoted span containing $( … ) or real backtick command
1512
1513
  // substitution is kept — substitution executes BEFORE the outer command.
1513
- // A single-char backtick pair (`n, `t) is a PowerShell ESCAPE, not code.
1514
+ // A single-char backtick pair (`n, `t) is a PowerShell ESCAPE, not code;
1515
+ // on a PowerShell line (`isPowerShellLine`) so is every `$ `" `` `n in
1516
+ // a body that uses the backtick only that way. `""` is an escaped quote.
1514
1517
  // 3. Single-token quoted spans (rm -rf "/") are argv, not prose — kept.
1515
1518
  // 4. Quoted spans in a CODE POSITION (interpreter -c/-Command/-e flags, or a
1516
1519
  // remote executor earlier in the same statement) are kept.
@@ -1571,6 +1574,31 @@ function interpreterFileArguments(text) {
1571
1574
  // $( … ) always executes; backtick pairs only with ≥2 chars of content
1572
1575
  // (PowerShell escapes like `n / `t are single-char and inert).
1573
1576
  const INTERPOLATES = /\$\(|`[^`]{2,}`/;
1577
+ // Same signals as shellDialectOf (shellQuoteMask.ts / shellAst.ts looksLikePowerShell).
1578
+ const POWERSHELL_LINE = /\$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/;
1579
+ const POWERSHELL_ONLY_SYNTAX = /(?:^|[\s;{(|])\$[A-Za-z_][\w:]*\s*[+-]?=(?!=)|`"|(?:^|[\s;{])(?:if|elseif|foreach|while|switch)\s*\((?!\()|\}\s*(?:else|elseif|catch|finally)\b|\|\s*(?:where|foreach|%|\?)\s*\{/i;
1580
+ /**
1581
+ * PowerShell line, judged on its CODE only (`unquotedCode`): a cmdlet named
1582
+ * inside a commit message or issue body does not change how the shell reads it.
1583
+ */
1584
+ function isPowerShellLine(text) {
1585
+ const code = unquotedCode(text);
1586
+ return POWERSHELL_LINE.test(code) || POWERSHELL_ONLY_SYNTAX.test(code);
1587
+ }
1588
+ // Every backtick is a canonical PowerShell escape (`$ `" `' `` `n `t `r `0 …).
1589
+ const POWERSHELL_ESCAPES_ONLY = /^(?:[^`]|`[$"'`0abefnrtuv])*$/;
1590
+ /**
1591
+ * Does a double-quoted body run code before its command? `$( … )` always does.
1592
+ * A backtick pair is POSIX command substitution, except on a PowerShell line
1593
+ * whose body uses the backtick only as PowerShell's escape character there.
1594
+ */
1595
+ function doubleQuotedInterpolates(body, powershell) {
1596
+ if (body.includes('$('))
1597
+ return true;
1598
+ if (!/`[^`]{2,}`/.test(body))
1599
+ return false;
1600
+ return !(powershell && POWERSHELL_ESCAPES_ONLY.test(body));
1601
+ }
1574
1602
  const INERT_DATA = ' fcd_inert_data ';
1575
1603
  // Code position immediately before a quoted span: interpreter code flags or a
1576
1604
  // remote/nested executor earlier in the same statement (no ; & | between).
@@ -1596,6 +1624,11 @@ function unquotedCode(text) {
1596
1624
  i += 2;
1597
1625
  continue;
1598
1626
  }
1627
+ // `""` inside "…" is PowerShell's escaped quote; POSIX reads close+reopen, text either way.
1628
+ if (q === '"' && text[i] === '"' && text[i + 1] === '"') {
1629
+ i += 2;
1630
+ continue;
1631
+ }
1599
1632
  if (text[i] === q)
1600
1633
  break;
1601
1634
  i += 1;
@@ -1629,12 +1662,12 @@ function replaceDelimited(text, open, close, fn) {
1629
1662
  }
1630
1663
  const LINE_TERMINATOR = new Set(['\n', '\r', '\u2028', '\u2029']);
1631
1664
  /**
1632
- * `src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, fn)` in one
1665
+ * `src.replace(/"((?:[^"\\`]|""|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, fn)` in one
1633
1666
  * pass, same matches and callback arguments (a PowerShell backtick escapes any
1634
- * character inside double quotes). After an unclosed quote the regex retried
1635
- * every later quote of that kind; each one before the dead end is the tail of an
1636
- * escape pair and walks the same path to the same dead end (quadratic on a body
1637
- * of `\"`).
1667
+ * character inside double quotes; `""` is an escaped quote there). After an
1668
+ * unclosed quote the regex retried every later quote of that kind; each one
1669
+ * before the dead end is the tail of an escape pair and walks the same path to
1670
+ * the same dead end (quadratic on a body of `\"`).
1638
1671
  */
1639
1672
  function replaceQuotedSpans(src, fn) {
1640
1673
  const deadUntil = { '"': -1, "'": -1 };
@@ -1651,6 +1684,10 @@ function replaceQuotedSpans(src, fn) {
1651
1684
  let closed = -1;
1652
1685
  while (j < src.length) {
1653
1686
  const c = src[j];
1687
+ if (c === '"' && quote === '"' && src[j + 1] === '"') {
1688
+ j += 2;
1689
+ continue;
1690
+ }
1654
1691
  if (c === quote) {
1655
1692
  closed = j;
1656
1693
  break;
@@ -1693,6 +1730,7 @@ function stripInertDataSegments(text) {
1693
1730
  // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
1694
1731
  // unless the span sits in a code position (invariants 2–4 above).
1695
1732
  const src = out;
1733
+ const powershell = isPowerShellLine(src);
1696
1734
  out = replaceQuotedSpans(src, (whole, dq, sq, offset) => {
1697
1735
  const body = dq !== undefined ? dq : (sq || '');
1698
1736
  if (!/\s/.test(body))
@@ -1703,7 +1741,7 @@ function stripInertDataSegments(text) {
1703
1741
  return interpreterCodeSpawnsProcess(before, body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
1704
1742
  if (CODE_CONTEXT_BEFORE.test(before))
1705
1743
  return whole; // code position: executes
1706
- if (dq !== undefined && INTERPOLATES.test(body))
1744
+ if (dq !== undefined && doubleQuotedInterpolates(body, powershell))
1707
1745
  return whole; // $( )/`cmd` executes first
1708
1746
  return INERT_DATA;
1709
1747
  });
@@ -165,6 +165,7 @@ async function healthCheck(apiUrl, config) {
165
165
  }
166
166
  console.log('');
167
167
  const identity = getMachineIdentity();
168
+ console.log(`machine id: ${identity.machineId.slice(0, 8)} (source: ${identity.idSource || 'unknown'}${identity.idConfirmed ? ', backend-confirmed' : ', not yet confirmed by the backend'})`);
168
169
  const report = await runDeepSelfTest({
169
170
  creds,
170
171
  developerName: identity.developerName,
@@ -20,6 +20,9 @@ async function loginCommand(args, config) {
20
20
  throw new Error('A fleet enrollment token is required. Pass --token <token> or set FCD_ENROLL_TOKEN.\n'
21
21
  + 'An org admin issues one from the AI Fleet console (Settings → Fleet enrollment token).');
22
22
  }
23
+ // Enrollment binds the fleet record to this id for good: an unconfirmed
24
+ // fallback pin gets one native re-check first, throttle or not.
25
+ (0, machineIdentity_1.reconcileMachineIdentity)('force');
23
26
  const identity = (0, machineIdentity_1.getMachineIdentity)();
24
27
  const shieldName = (0, machineIdentity_1.suggestedShieldName)(identity);
25
28
  const nickname = (args.nickname || process.env.FCD_MACHINE_NICKNAME || '').trim().slice(0, 80) || undefined;
@@ -51,6 +54,7 @@ async function loginCommand(args, config) {
51
54
  throw new Error(data.error || `Enrollment failed (HTTP ${resp.status})`);
52
55
  }
53
56
  const result = data.data;
57
+ (0, machineIdentity_1.confirmMachineIdentity)(identity.machineId, 'enrollment');
54
58
  // Clear any leftover runtime cache (previous enrollment, failed/partial
55
59
  // install) so a stale enforcing bundle can never keep blocking on a freshly
56
60
  // enrolled machine. The next hook/gateway call fetches the real bundle.
@@ -0,0 +1,50 @@
1
+ import type { FlushFailure } from './telemetry';
2
+ /**
3
+ * Server-assisted identity recovery (#1853).
4
+ *
5
+ * When events:batch answers 409 MACHINE_NOT_ENROLLED, the id this machine
6
+ * sends is not the one its shield is enrolled under (a stale fallback id
7
+ * after a blocked native read). The daemon asks the backend which machine its
8
+ * per-machine shield is bound to. The backend answers only for EXACTLY one
9
+ * live machine record bound to this shield, in the shield's organization,
10
+ * with this hostname, and only to a caller holding the shield key — it never
11
+ * lets the caller choose an id. The answer is pinned as `recovered`.
12
+ */
13
+ export declare const MACHINE_NOT_ENROLLED = "MACHINE_NOT_ENROLLED";
14
+ /** Daemon log wording for a heartbeat that did not land: refused by the backend vs never reached it. */
15
+ export declare function describeHeartbeatMiss(failure: FlushFailure | undefined): string;
16
+ export type RecoveryResult = {
17
+ ok: true;
18
+ machineId: string;
19
+ } | {
20
+ ok: false;
21
+ status?: number;
22
+ code?: string;
23
+ error: string;
24
+ };
25
+ /** POST /api/cli/machines/identity-recovery. Requires the shield key; never throws. */
26
+ export declare function requestIdentityRecovery(input: {
27
+ apiUrl: string;
28
+ shieldId: string;
29
+ shieldKey?: string;
30
+ hostname: string;
31
+ offeredIds: string[];
32
+ timeoutMs?: number;
33
+ fetchImpl?: typeof fetch;
34
+ }): Promise<RecoveryResult>;
35
+ /**
36
+ * Daemon hook: after a refused heartbeat, recover the enrolled id when the
37
+ * refusal is MACHINE_NOT_ENROLLED. At most once per 15 min per process.
38
+ * Returns true when the machine now uses a different (recovered) id.
39
+ */
40
+ export declare function recoverIdentityAfterRefusal(input: {
41
+ failure: FlushFailure | undefined;
42
+ apiUrl: string;
43
+ shieldId?: string;
44
+ shieldKey?: string;
45
+ log: (line: string) => void;
46
+ now?: number;
47
+ fetchImpl?: typeof fetch;
48
+ }): Promise<boolean>;
49
+ /** Test seam: forget the recovery throttle. */
50
+ export declare function resetIdentityRecoveryThrottle(): void;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MACHINE_NOT_ENROLLED = void 0;
4
+ exports.describeHeartbeatMiss = describeHeartbeatMiss;
5
+ exports.requestIdentityRecovery = requestIdentityRecovery;
6
+ exports.recoverIdentityAfterRefusal = recoverIdentityAfterRefusal;
7
+ exports.resetIdentityRecoveryThrottle = resetIdentityRecoveryThrottle;
8
+ const machineIdentity_1 = require("./machineIdentity");
9
+ /**
10
+ * Server-assisted identity recovery (#1853).
11
+ *
12
+ * When events:batch answers 409 MACHINE_NOT_ENROLLED, the id this machine
13
+ * sends is not the one its shield is enrolled under (a stale fallback id
14
+ * after a blocked native read). The daemon asks the backend which machine its
15
+ * per-machine shield is bound to. The backend answers only for EXACTLY one
16
+ * live machine record bound to this shield, in the shield's organization,
17
+ * with this hostname, and only to a caller holding the shield key — it never
18
+ * lets the caller choose an id. The answer is pinned as `recovered`.
19
+ */
20
+ exports.MACHINE_NOT_ENROLLED = 'MACHINE_NOT_ENROLLED';
21
+ const RECOVERY_RETRY_MS = 15 * 60_000;
22
+ let lastAttemptAt = 0;
23
+ /** Daemon log wording for a heartbeat that did not land: refused by the backend vs never reached it. */
24
+ function describeHeartbeatMiss(failure) {
25
+ if (failure?.kind === 'http' && failure.status) {
26
+ const what = failure.code === exports.MACHINE_NOT_ENROLLED
27
+ ? 'machine not enrolled under this id'
28
+ : failure.code || failure.error || 'request refused';
29
+ return `backend refused the heartbeat (HTTP ${failure.status}: ${what}) — daemon liveness was not recorded`;
30
+ }
31
+ if (failure?.kind === 'locked')
32
+ return 'heartbeat skipped: another flusher held the spool lock — daemon liveness was not recorded';
33
+ const detail = failure?.error ? ` (${failure.error})` : '';
34
+ return `heartbeat did not reach the backend${detail} — daemon liveness was not recorded`;
35
+ }
36
+ /** POST /api/cli/machines/identity-recovery. Requires the shield key; never throws. */
37
+ async function requestIdentityRecovery(input) {
38
+ if (!input.shieldKey)
39
+ return { ok: false, error: 'no shield key on this machine — recovery needs it' };
40
+ try {
41
+ const resp = await (input.fetchImpl || fetch)(`${input.apiUrl}/api/cli/machines/identity-recovery`, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json', 'x-shield-key': input.shieldKey },
44
+ body: JSON.stringify({ shieldId: input.shieldId, hostname: input.hostname, offeredIds: input.offeredIds }),
45
+ signal: AbortSignal.timeout(input.timeoutMs ?? 8000),
46
+ });
47
+ const body = await resp.json().catch(() => ({}));
48
+ const machineId = typeof body.data?.machineId === 'string' ? body.data.machineId.toLowerCase() : '';
49
+ if (resp.ok && body.success && /^[0-9a-f]{16,64}$/.test(machineId))
50
+ return { ok: true, machineId };
51
+ return { ok: false, status: resp.status, code: body.code, error: body.error || `HTTP ${resp.status}` };
52
+ }
53
+ catch (error) {
54
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
55
+ }
56
+ }
57
+ /**
58
+ * Daemon hook: after a refused heartbeat, recover the enrolled id when the
59
+ * refusal is MACHINE_NOT_ENROLLED. At most once per 15 min per process.
60
+ * Returns true when the machine now uses a different (recovered) id.
61
+ */
62
+ async function recoverIdentityAfterRefusal(input) {
63
+ if (input.failure?.code !== exports.MACHINE_NOT_ENROLLED || !input.shieldId)
64
+ return false;
65
+ if ((process.env.FCD_MACHINE_ID || '').trim())
66
+ return false;
67
+ const now = input.now ?? Date.now();
68
+ if (now - lastAttemptAt < RECOVERY_RETRY_MS)
69
+ return false;
70
+ lastAttemptAt = now;
71
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
72
+ const pin = (0, machineIdentity_1.readPinnedIdentity)();
73
+ const offeredIds = [...new Set([identity.machineId, pin?.machineId].filter((id) => !!id).map(machineIdentity_1.shortMachineId))];
74
+ const result = await requestIdentityRecovery({
75
+ apiUrl: input.apiUrl,
76
+ shieldId: input.shieldId,
77
+ shieldKey: input.shieldKey,
78
+ hostname: identity.hostname,
79
+ offeredIds,
80
+ fetchImpl: input.fetchImpl,
81
+ });
82
+ if (!result.ok) {
83
+ input.log(`Identity recovery: the backend did not name an enrolled machine for this shield (${result.code || result.error}) — this machine keeps id ${(0, machineIdentity_1.shortMachineId)(identity.machineId)}; re-enroll with \`fullcourtdefense onboard --token <token>\` if it persists.`);
84
+ return false;
85
+ }
86
+ if (result.machineId === identity.machineId)
87
+ return false;
88
+ const outcome = (0, machineIdentity_1.confirmMachineIdentity)(result.machineId, 'recovered');
89
+ if (outcome === 'conflict') {
90
+ input.log(`Identity recovery: the backend names ${(0, machineIdentity_1.shortMachineId)(result.machineId)} but this machine holds a confirmed id ${(0, machineIdentity_1.shortMachineId)(identity.machineId)} — kept; reported as machine_id_conflict.`);
91
+ return false;
92
+ }
93
+ input.log(`Identity recovery: this machine was sending id ${(0, machineIdentity_1.shortMachineId)(identity.machineId)}; its shield is enrolled as ${(0, machineIdentity_1.shortMachineId)(result.machineId)} — pinned (${outcome}).`);
94
+ return (0, machineIdentity_1.getMachineIdentity)().machineId === result.machineId;
95
+ }
96
+ /** Test seam: forget the recovery throttle. */
97
+ function resetIdentityRecoveryThrottle() {
98
+ lastAttemptAt = 0;
99
+ }