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.
- package/dist/actionPolicyEngine.js +10 -1
- package/dist/askHeader.d.ts +6 -0
- package/dist/askHeader.js +49 -10
- package/dist/commandNarrative.d.ts +5 -0
- package/dist/commandNarrative.js +27 -0
- package/dist/commands/daemon.js +23 -3
- package/dist/commands/demoActions.js +2 -2
- package/dist/commands/deterministicGuard.d.ts +5 -0
- package/dist/commands/deterministicGuard.js +45 -7
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/login.js +4 -0
- package/dist/identityRecovery.d.ts +50 -0
- package/dist/identityRecovery.js +99 -0
- package/dist/machineIdentity.d.ts +90 -1
- package/dist/machineIdentity.js +457 -94
- package/dist/telemetry.d.ts +9 -0
- package/dist/telemetry.js +29 -7
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -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 (
|
|
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);
|
package/dist/askHeader.d.ts
CHANGED
|
@@ -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
|
|
317
|
+
for (const m of code.matchAll(/(?:^|\s)--project(?:=|\s+)("[^"]*"|'[^']*'|[^\s;|&]+)/g))
|
|
289
318
|
add(m[1]);
|
|
290
|
-
for (const m of
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (`- …`). */
|
package/dist/commandNarrative.js
CHANGED
|
@@ -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)
|
package/dist/commands/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
180
|
-
why: '
|
|
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
|
|
1635
|
-
* every later quote of that kind; each one
|
|
1636
|
-
*
|
|
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 &&
|
|
1744
|
+
if (dq !== undefined && doubleQuotedInterpolates(body, powershell))
|
|
1707
1745
|
return whole; // $( )/`cmd` executes first
|
|
1708
1746
|
return INERT_DATA;
|
|
1709
1747
|
});
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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,
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
+
}
|