fullcourtdefense-cli 1.35.9 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actionPolicyEngine.d.ts +1 -0
- package/dist/actionPolicyEngine.js +21 -0
- package/dist/askDialog.js +1 -2
- package/dist/callerEnv.d.ts +10 -0
- package/dist/callerEnv.js +18 -1
- package/dist/commandNarrative.d.ts +8 -0
- package/dist/commandNarrative.js +69 -10
- package/dist/commands/agentCi.js +3 -1
- package/dist/commands/cmdGuard.js +2 -0
- package/dist/commands/daemon.js +48 -3
- package/dist/commands/deterministicGuard.d.ts +62 -2
- package/dist/commands/deterministicGuard.js +583 -30
- package/dist/commands/discover.js +36 -2
- package/dist/commands/discoverAgentFiles.d.ts +11 -0
- package/dist/commands/discoverAgentFiles.js +107 -0
- package/dist/commands/discoverExtensions.d.ts +64 -0
- package/dist/commands/discoverExtensions.js +386 -0
- package/dist/commands/hook.d.ts +28 -4
- package/dist/commands/hook.js +76 -19
- package/dist/commands/knownMcpServers.d.ts +8 -2
- package/dist/commands/knownMcpServers.js +33 -2
- package/dist/commands/local-scan.js +1 -0
- package/dist/commands/mcpGateway.js +273 -54
- package/dist/commands/protectedRun.d.ts +1 -1
- package/dist/commands/protectedRun.js +3 -3
- package/dist/commands/shellGuard.js +16 -1
- package/dist/commands/watchdog.js +19 -3
- package/dist/credentialGrant.d.ts +43 -0
- package/dist/credentialGrant.js +504 -0
- package/dist/daemonForensics.d.ts +27 -0
- package/dist/daemonForensics.js +47 -0
- package/dist/detectorBaseline.d.ts +2 -2
- package/dist/detectorBaseline.js +2 -2
- package/dist/devConfirm.d.ts +55 -1
- package/dist/devConfirm.js +291 -112
- package/dist/envDiagnostics.js +4 -2
- package/dist/hookSlim.js +4 -3
- package/dist/ideExtensionRisk.d.ts +110 -0
- package/dist/ideExtensionRisk.js +172 -0
- package/dist/machineActionVerify.d.ts +34 -8
- package/dist/machineActionVerify.js +76 -16
- package/dist/mcpProtocol.d.ts +124 -0
- package/dist/mcpProtocol.js +302 -0
- package/dist/mcpToolReview.d.ts +2 -1
- package/dist/mcpToolReview.js +6 -4
- package/dist/notify.d.ts +7 -0
- package/dist/notify.js +61 -0
- package/dist/selfUpdate.d.ts +59 -0
- package/dist/selfUpdate.js +138 -1
- package/dist/shellAstLoader.js +2 -0
- package/dist/shellQuoteMask.d.ts +25 -0
- package/dist/shellQuoteMask.js +319 -0
- package/dist/telemetry.d.ts +5 -0
- package/dist/telemetry.js +1 -0
- package/dist/verdictIpc.js +16 -0
- package/dist/verdictIpcClient.d.ts +32 -12
- package/dist/verdictIpcClient.js +55 -26
- package/dist/version.json +1 -1
- package/installer/macos/guardian/ai.fullcourtdefense.guardian.plist +19 -0
- package/installer/macos/guardian/fcd-guardian.sh +590 -0
- package/installer/macos/guardian/install-guardian.sh +32 -0
- package/installer/macos/guardian/uninstall-guardian.sh +12 -0
- package/installer/windows/README.md +88 -0
- package/installer/windows/rescue/FullCourtDefense-Rescue.ps1 +229 -0
- package/package.json +12 -2
|
@@ -268,6 +268,7 @@ export interface ShellAstOperationComparison {
|
|
|
268
268
|
agree: boolean;
|
|
269
269
|
}
|
|
270
270
|
export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
|
|
271
|
+
export declare function setRemoteControlProvider(provider: ((command: string) => string | undefined) | undefined): void;
|
|
271
272
|
export declare function setShellAstMode(mode: ShellAstMode): void;
|
|
272
273
|
export declare function getShellAstMode(): ShellAstMode;
|
|
273
274
|
/** Shadow telemetry: the comparison recorded by the most recent top-level shell classification (consumed once). */
|
|
@@ -42,6 +42,7 @@ __export(actionPolicyEngine_exports, {
|
|
|
42
42
|
resolveApprovalOptions: () => resolveApprovalOptions,
|
|
43
43
|
roleExceptionError: () => roleExceptionError,
|
|
44
44
|
ruleNeedsContentFact: () => ruleNeedsContentFact,
|
|
45
|
+
setRemoteControlProvider: () => setRemoteControlProvider,
|
|
45
46
|
setShellAstMode: () => setShellAstMode,
|
|
46
47
|
setShellAstProvider: () => setShellAstProvider,
|
|
47
48
|
takeShellAstOperationComparison: () => takeShellAstOperationComparison,
|
|
@@ -6777,6 +6778,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
6777
6778
|
"tool.status",
|
|
6778
6779
|
"operation.status",
|
|
6779
6780
|
"agent.baseline",
|
|
6781
|
+
"toolArgs.remoteControl",
|
|
6780
6782
|
// Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
|
|
6781
6783
|
// pollute operation matching.
|
|
6782
6784
|
"toolName",
|
|
@@ -8160,6 +8162,10 @@ var lastShellAstOperationComparison;
|
|
|
8160
8162
|
function setShellAstProvider(provider) {
|
|
8161
8163
|
shellAstProvider = provider;
|
|
8162
8164
|
}
|
|
8165
|
+
var remoteControlProvider;
|
|
8166
|
+
function setRemoteControlProvider(provider) {
|
|
8167
|
+
remoteControlProvider = provider;
|
|
8168
|
+
}
|
|
8163
8169
|
function setShellAstMode(mode) {
|
|
8164
8170
|
shellAstMode = mode;
|
|
8165
8171
|
}
|
|
@@ -8719,9 +8725,22 @@ function inferToolContext(toolName, args) {
|
|
|
8719
8725
|
context["destination.type"] = context["request.destination.type"];
|
|
8720
8726
|
context["destination.domain"] = context["request.destination.domain"];
|
|
8721
8727
|
}
|
|
8728
|
+
applyRemoteControlFact(toolName, args, context);
|
|
8722
8729
|
applyWebReadFacts(toolName, args, context);
|
|
8723
8730
|
return { operation, context };
|
|
8724
8731
|
}
|
|
8732
|
+
function applyRemoteControlFact(toolName, args, context) {
|
|
8733
|
+
delete context["toolArgs.remoteControl"];
|
|
8734
|
+
const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
|
|
8735
|
+
if (!shellText || !remoteControlProvider) return;
|
|
8736
|
+
let bridge;
|
|
8737
|
+
try {
|
|
8738
|
+
bridge = remoteControlProvider(shellText);
|
|
8739
|
+
} catch {
|
|
8740
|
+
bridge = void 0;
|
|
8741
|
+
}
|
|
8742
|
+
if (bridge) context["toolArgs.remoteControl"] = bridge;
|
|
8743
|
+
}
|
|
8725
8744
|
var WEB_READ_TOOL_NAMES = /* @__PURE__ */ new Set(["webfetch", "fetchwebpage", "webextract"]);
|
|
8726
8745
|
function applyWebReadFacts(toolName, args, context) {
|
|
8727
8746
|
if (!WEB_READ_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_\-.\s]/g, ""))) return;
|
|
@@ -8855,6 +8874,7 @@ function inferBundledToolContext(toolName, args) {
|
|
|
8855
8874
|
context["destination.type"] = isInternalHost(context["destination.domain"]) ? "internal" : "external";
|
|
8856
8875
|
}
|
|
8857
8876
|
}
|
|
8877
|
+
applyRemoteControlFact(toolName, args, context);
|
|
8858
8878
|
applyWebReadFacts(toolName, args, context);
|
|
8859
8879
|
return { operation, context };
|
|
8860
8880
|
}
|
|
@@ -8975,6 +8995,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
|
|
|
8975
8995
|
resolveApprovalOptions,
|
|
8976
8996
|
roleExceptionError,
|
|
8977
8997
|
ruleNeedsContentFact,
|
|
8998
|
+
setRemoteControlProvider,
|
|
8978
8999
|
setShellAstMode,
|
|
8979
9000
|
setShellAstProvider,
|
|
8980
9001
|
takeShellAstOperationComparison,
|
package/dist/askDialog.js
CHANGED
|
@@ -141,8 +141,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
|
|
|
141
141
|
return { ...clean, resolution: 'ide' };
|
|
142
142
|
const grantHash = envelope.ruleHash || envelope.policyHash;
|
|
143
143
|
const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
|
|
144
|
-
const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits)
|
|
145
|
-
&& (envelope.scope.event !== 'shell' || (envelope.scope.programs || []).length > 0);
|
|
144
|
+
const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits) && (0, devConfirm_1.grantOffered)(envelope.scope);
|
|
146
145
|
const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope, { policyId: envelope.policyId, policyName: envelope.policyName }) : undefined;
|
|
147
146
|
const limitsLabel = allowAlways && grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
|
|
148
147
|
const dialog = options.dialog || devConfirm_1.confirmNativeActionDetailed;
|
package/dist/callerEnv.d.ts
CHANGED
|
@@ -23,6 +23,16 @@ export declare function callerEnv(): NodeJS.ProcessEnv;
|
|
|
23
23
|
* from the daemon. No snapshot (older client) = this process's env, as before.
|
|
24
24
|
*/
|
|
25
25
|
export declare function withCallerEnv<T>(snapshot: unknown, fn: () => T): T;
|
|
26
|
+
/**
|
|
27
|
+
* How long the IDE lets an approval-capable hook run before it kills the hook
|
|
28
|
+
* (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
|
|
29
|
+
* FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
|
|
30
|
+
*/
|
|
31
|
+
export declare const IDE_HOOK_TIMEOUT_MS = 90000;
|
|
32
|
+
export declare const IDE_TIMEOUT_MARGIN_MS = 15000;
|
|
33
|
+
export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
|
|
34
|
+
/** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
|
|
35
|
+
export declare function ideWaitCutoff(startedAt: number, ideTimeoutMs?: number): number;
|
|
26
36
|
/** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
|
|
27
37
|
export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
|
|
28
38
|
/**
|
package/dist/callerEnv.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CALLER_ENV_KEYS = void 0;
|
|
3
|
+
exports.IDE_TIMEOUT_MARGIN_MS = exports.IDE_HOOK_TIMEOUT_MS = exports.CALLER_ENV_KEYS = void 0;
|
|
4
4
|
exports.snapshotCallerEnv = snapshotCallerEnv;
|
|
5
5
|
exports.callerEnv = callerEnv;
|
|
6
6
|
exports.withCallerEnv = withCallerEnv;
|
|
7
|
+
exports.ideHookTimeoutMs = ideHookTimeoutMs;
|
|
8
|
+
exports.ideWaitCutoff = ideWaitCutoff;
|
|
7
9
|
exports.isCiEnvironment = isCiEnvironment;
|
|
8
10
|
exports.isHumanPresent = isHumanPresent;
|
|
9
11
|
const async_hooks_1 = require("async_hooks");
|
|
@@ -66,6 +68,21 @@ function withCallerEnv(snapshot, fn) {
|
|
|
66
68
|
}
|
|
67
69
|
return requestEnv.run(env, fn);
|
|
68
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* How long the IDE lets an approval-capable hook run before it kills the hook
|
|
73
|
+
* (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
|
|
74
|
+
* FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
|
|
75
|
+
*/
|
|
76
|
+
exports.IDE_HOOK_TIMEOUT_MS = 90_000;
|
|
77
|
+
exports.IDE_TIMEOUT_MARGIN_MS = 15_000;
|
|
78
|
+
function ideHookTimeoutMs(env = callerEnv()) {
|
|
79
|
+
const override = Number(env.FCD_HOOK_IDE_TIMEOUT_MS);
|
|
80
|
+
return override > 0 ? override : exports.IDE_HOOK_TIMEOUT_MS;
|
|
81
|
+
}
|
|
82
|
+
/** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
|
|
83
|
+
function ideWaitCutoff(startedAt, ideTimeoutMs = ideHookTimeoutMs()) {
|
|
84
|
+
return startedAt + ideTimeoutMs - Math.min(exports.IDE_TIMEOUT_MARGIN_MS, ideTimeoutMs / 4);
|
|
85
|
+
}
|
|
69
86
|
/** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
|
|
70
87
|
function isCiEnvironment(env = callerEnv()) {
|
|
71
88
|
return env.GITHUB_ACTIONS === 'true'
|
|
@@ -20,6 +20,14 @@ export interface CommandStep {
|
|
|
20
20
|
text: string;
|
|
21
21
|
/** The grammar recognised the program and its verb. */
|
|
22
22
|
known: boolean;
|
|
23
|
+
/** A credential stored in a variable by this step (`$tok = gcloud auth print-access-token`). */
|
|
24
|
+
captures?: {
|
|
25
|
+
variable: string;
|
|
26
|
+
issuer?: {
|
|
27
|
+
name: string;
|
|
28
|
+
domains: string[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
23
31
|
}
|
|
24
32
|
/** Quote-aware split on `;`, `&&`, `||`, newlines. Pipes stay inside a step. */
|
|
25
33
|
export declare function splitCommandSteps(command: string): string[];
|
package/dist/commandNarrative.js
CHANGED
|
@@ -20,6 +20,12 @@ exports.describeShellCommand = describeShellCommand;
|
|
|
20
20
|
exports.describeShellCommandLines = describeShellCommandLines;
|
|
21
21
|
const MAX_STEPS = 14;
|
|
22
22
|
const MAX_LINE = 220;
|
|
23
|
+
/** Token printers whose output can be assigned; `issuer` = who accepts the token (identity tokens go to their audience). */
|
|
24
|
+
const TOKEN_PRINTERS = [
|
|
25
|
+
{ re: /^gcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+(?:application-default\s+)?print-access-token\b/i, issuer: { name: 'Google', domains: ['googleapis.com'] } },
|
|
26
|
+
{ re: /^gcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+print-identity-token\b/i },
|
|
27
|
+
{ re: /^gh(?:\.exe)?\s+auth\s+token\b/i, issuer: { name: 'GitHub', domains: ['github.com', 'githubusercontent.com'] } },
|
|
28
|
+
];
|
|
23
29
|
/** Quote-aware split on `;`, `&&`, `||`, newlines. Pipes stay inside a step. */
|
|
24
30
|
function splitCommandSteps(command) {
|
|
25
31
|
const steps = [];
|
|
@@ -158,7 +164,11 @@ function describeSecretVersions(toks, verbAt, where) {
|
|
|
158
164
|
return `${state[verb]} ${version}${secret(flagSecret)} in Secret Manager${where}`;
|
|
159
165
|
return `Runs \`gcloud secrets versions ${verb}\`${where}`;
|
|
160
166
|
}
|
|
161
|
-
|
|
167
|
+
/**
|
|
168
|
+
* `captured` = the variable the command's output is assigned to (`$tok = gcloud …`,
|
|
169
|
+
* `TOKEN=$(gcloud …)`): the output never reaches the terminal.
|
|
170
|
+
*/
|
|
171
|
+
function describeGcloud(toks, captured) {
|
|
162
172
|
let i = 1;
|
|
163
173
|
while (i < toks.length && toks[i].startsWith('-'))
|
|
164
174
|
i++;
|
|
@@ -210,8 +220,10 @@ function describeGcloud(toks) {
|
|
|
210
220
|
return `Reads the contents of ${isStorageUri(first) ? `an object in storage bucket ${bucketOf(first)}` : shortValue(first, 60)}`;
|
|
211
221
|
if (verb === 'rm')
|
|
212
222
|
return `Deletes ${named(first)}`;
|
|
213
|
-
if (/^print-(?:access|identity)-token$/.test(verb))
|
|
214
|
-
|
|
223
|
+
if (/^print-(?:access|identity)-token$/.test(verb)) {
|
|
224
|
+
const kind = verb === 'print-identity-token' ? 'identity' : 'access';
|
|
225
|
+
return captured ? `Stores a cloud ${kind} token in variable ${captured}` : `Prints a cloud ${kind} token to the terminal`;
|
|
226
|
+
}
|
|
215
227
|
if (verb === 'login')
|
|
216
228
|
return 'Signs in to Google Cloud';
|
|
217
229
|
if (verb === 'add-iam-policy-binding' || verb === 'remove-iam-policy-binding') {
|
|
@@ -334,7 +346,7 @@ function peelGroups(text) {
|
|
|
334
346
|
}
|
|
335
347
|
return value;
|
|
336
348
|
}
|
|
337
|
-
function describeOne(segmentIn) {
|
|
349
|
+
function describeOne(segmentIn, captured) {
|
|
338
350
|
const segment = segmentIn.trim();
|
|
339
351
|
const [head, ...pipe] = splitPipe(segment);
|
|
340
352
|
const tail = describePipeTail(pipe);
|
|
@@ -370,8 +382,13 @@ function describeOne(segmentIn) {
|
|
|
370
382
|
}
|
|
371
383
|
return step(`Sets variable ${name}${interpolates ? ' from other values' : ` to ${shortValue(bare, 60)}`}`);
|
|
372
384
|
}
|
|
373
|
-
const
|
|
374
|
-
const
|
|
385
|
+
const substituted = /^\$\((.*)\)$/s.exec(rhs) || /^`(.*)`$/s.exec(rhs);
|
|
386
|
+
const inner = substituted ? substituted[1].trim() : rhs.startsWith('(') ? peelGroups(rhs) : rhs.replace(/\)\s*(?:\.\w+\(\))*$/, '');
|
|
387
|
+
const innerStep = describeOne(inner, name);
|
|
388
|
+
const printer = TOKEN_PRINTERS.find(p => p.re.test(inner));
|
|
389
|
+
if (printer) {
|
|
390
|
+
return { segment, text: shortValue(`${innerStep.text}${tail}`, MAX_LINE), known: innerStep.known, captures: { variable: name, ...(printer.issuer ? { issuer: printer.issuer } : {}) } };
|
|
391
|
+
}
|
|
375
392
|
return { segment, text: shortValue(`Stores in ${name}: ${innerStep.text.charAt(0).toLowerCase()}${innerStep.text.slice(1)}${tail}`, MAX_LINE), known: innerStep.known };
|
|
376
393
|
}
|
|
377
394
|
// A bare string statement: PowerShell writes it to the output (`"len=$($t.Length)"`).
|
|
@@ -412,7 +429,7 @@ function describeOne(segmentIn) {
|
|
|
412
429
|
case 'exit':
|
|
413
430
|
case 'return': return step('Ends the script');
|
|
414
431
|
case 'gcloud': {
|
|
415
|
-
const text = describeGcloud(rest);
|
|
432
|
+
const text = describeGcloud(rest, captured);
|
|
416
433
|
return text ? step(`${text}${redirectNote}`) : step(`Runs \`gcloud\``, false);
|
|
417
434
|
}
|
|
418
435
|
case 'gsutil': {
|
|
@@ -475,7 +492,7 @@ function describeOne(segmentIn) {
|
|
|
475
492
|
if (/\b(?:create|edit|merge|close|delete|comment)\b/.test(sub))
|
|
476
493
|
return step(`Changes GitHub ${sub.split(' ')[0]} (${sub.split(' ')[1]})`);
|
|
477
494
|
if (/^auth token\b/.test(sub))
|
|
478
|
-
return step('Prints the GitHub token');
|
|
495
|
+
return step(captured ? `Stores the GitHub token in variable ${captured}` : 'Prints the GitHub token');
|
|
479
496
|
return step(`Runs \`gh ${sub}\``, false);
|
|
480
497
|
}
|
|
481
498
|
case 'npm':
|
|
@@ -691,17 +708,59 @@ function resolveStorageVariables(steps) {
|
|
|
691
708
|
return { ...step, text };
|
|
692
709
|
});
|
|
693
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* A token stored in a variable, then used later on the line: name the host(s)
|
|
713
|
+
* that receive it and whether each is the token's issuer. Hosts are literal
|
|
714
|
+
* URLs in the using step, or a variable assigned a literal URL earlier.
|
|
715
|
+
*/
|
|
716
|
+
function noteTokenDestinations(steps) {
|
|
717
|
+
const urlVars = new Map();
|
|
718
|
+
for (const step of steps) {
|
|
719
|
+
const assign = /^\$?([\w:]+)\s*=\s*["']?https?:\/\/([^/\s"'`$]+)/i.exec(step.segment);
|
|
720
|
+
if (assign)
|
|
721
|
+
urlVars.set(assign[1].toLowerCase(), assign[2].toLowerCase());
|
|
722
|
+
}
|
|
723
|
+
return steps.map((step, index) => {
|
|
724
|
+
if (!step.captures)
|
|
725
|
+
return step;
|
|
726
|
+
const { variable, issuer } = step.captures;
|
|
727
|
+
const ref = new RegExp(`\\$\\{?${variable.replace(/[^\w:]/g, '')}\\}?(?![\\w])`, 'i');
|
|
728
|
+
const hosts = new Set();
|
|
729
|
+
for (const later of steps.slice(index + 1)) {
|
|
730
|
+
if (!ref.test(later.segment))
|
|
731
|
+
continue;
|
|
732
|
+
for (const m of later.segment.matchAll(/https?:\/\/([^/\s"'`<>]+)/gi))
|
|
733
|
+
if (!/[$({]/.test(m[1]))
|
|
734
|
+
hosts.add(m[1].toLowerCase().replace(/:\d+$/, ''));
|
|
735
|
+
for (const m of later.segment.matchAll(/\$\{?([\w:]+)\}?/g)) {
|
|
736
|
+
const host = urlVars.get(m[1].toLowerCase());
|
|
737
|
+
if (host)
|
|
738
|
+
hosts.add(host);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!hosts.size)
|
|
742
|
+
return step;
|
|
743
|
+
const described = [...hosts].sort().map(host => {
|
|
744
|
+
if (!issuer)
|
|
745
|
+
return host;
|
|
746
|
+
const own = issuer.domains.some(d => host === d || host.endsWith(`.${d}`));
|
|
747
|
+
return own ? `${host} (${issuer.name}, the token's issuer)` : `${host}, which is not ${issuer.name}`;
|
|
748
|
+
});
|
|
749
|
+
return { ...step, text: shortValue(`${step.text}, then sends it to ${described.join('; ')}`, MAX_LINE) };
|
|
750
|
+
});
|
|
751
|
+
}
|
|
694
752
|
/** One plain-words line per step, in order. */
|
|
695
753
|
function describeShellCommand(command) {
|
|
696
754
|
const steps = splitCommandSteps(command);
|
|
697
|
-
const
|
|
755
|
+
const described = steps.map(segment => {
|
|
698
756
|
try {
|
|
699
757
|
return describeOne(segment);
|
|
700
758
|
}
|
|
701
759
|
catch {
|
|
702
760
|
return { segment, text: 'Runs a command the grammar does not recognise', known: false };
|
|
703
761
|
}
|
|
704
|
-
})
|
|
762
|
+
});
|
|
763
|
+
const out = resolveStorageVariables(noteTokenDestinations(described).slice(0, MAX_STEPS));
|
|
705
764
|
if (steps.length > MAX_STEPS)
|
|
706
765
|
out.push({ segment: '', text: `… and ${steps.length - MAX_STEPS} more step${steps.length - MAX_STEPS === 1 ? '' : 's'}`, known: false });
|
|
707
766
|
return out;
|
package/dist/commands/agentCi.js
CHANGED
|
@@ -216,6 +216,8 @@ function agentFileRemediation(tags) {
|
|
|
216
216
|
fixes.push('remove inline secret/password/token references and use a managed secret store');
|
|
217
217
|
if (tags.includes('jailbreak_hint'))
|
|
218
218
|
fixes.push('remove jailbreak/bypass instructions and add hierarchy language that system and security policies win');
|
|
219
|
+
if (tags.includes('hooks_disabled'))
|
|
220
|
+
fixes.push('remove "disableAllHooks": true (or a managed "allowManagedHooksOnly": true) so Claude Code runs the Full Court Defense hook again');
|
|
219
221
|
if (tags.includes('no_runtime_hook'))
|
|
220
222
|
fixes.push('install the Full Court Defense hook/gateway so risky tool calls are checked before execution');
|
|
221
223
|
if (fixes.length === 0)
|
|
@@ -266,7 +268,7 @@ function evaluateAgentGate(args) {
|
|
|
266
268
|
}
|
|
267
269
|
for (const file of agentFiles.filter(item => item.riskTags.length > 0)) {
|
|
268
270
|
findings.push(finding({
|
|
269
|
-
severity: file.riskTags.includes('allows_shell') || file.riskTags.includes('broad_filesystem') ? 'high' : 'medium',
|
|
271
|
+
severity: file.riskTags.includes('allows_shell') || file.riskTags.includes('broad_filesystem') || file.riskTags.includes('hooks_disabled') ? 'high' : 'medium',
|
|
270
272
|
category: 'agent-file',
|
|
271
273
|
title: `Risky agent instruction: ${file.title}`,
|
|
272
274
|
detail: `${file.filePath} contains agent risk tags: ${file.riskTags.join(', ')}.`,
|
|
@@ -106,6 +106,8 @@ const FIXED_INTERCEPTED_COMMANDS = [
|
|
|
106
106
|
const CMD_HIGH_FREQUENCY_COMMANDS = new Set([
|
|
107
107
|
'docker', 'git', 'kubectl', 'aws', 'gcloud', 'az', 'terraform', 'pulumi', 'wsl',
|
|
108
108
|
'curl', 'wget', 'taskkill', 'robocopy', 'xcopy', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
|
|
109
|
+
// Package managers: `npm exec` / `pnpm dlx` bridges are covered in PowerShell and the IDE; cmd keeps `npx`.
|
|
110
|
+
'npm', 'pnpm', 'yarn', 'bun', 'uv',
|
|
109
111
|
]);
|
|
110
112
|
/**
|
|
111
113
|
* Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
|
package/dist/commands/daemon.js
CHANGED
|
@@ -466,7 +466,26 @@ function hookConfigFiles() {
|
|
|
466
466
|
// ---------------------------------------------------------------------------
|
|
467
467
|
// The resident loop
|
|
468
468
|
// ---------------------------------------------------------------------------
|
|
469
|
+
/**
|
|
470
|
+
* A daemon started while the updater replaces the install (logon trigger, a
|
|
471
|
+
* pre-fix watchdog, a manual start) would put the old build — and its own
|
|
472
|
+
* update kick — into the middle of msiexec. Wait for the run instead. Bounded
|
|
473
|
+
* by the lock's max age; stage 2 relaunches the daemon when it finishes.
|
|
474
|
+
*/
|
|
475
|
+
async function waitForActiveUpdaterRun() {
|
|
476
|
+
let run = (0, selfUpdate_1.readActiveUpdaterRun)();
|
|
477
|
+
if (!run)
|
|
478
|
+
return;
|
|
479
|
+
log(`An updater run is installing${run.targetVersion ? ` ${run.targetVersion}` : ''} (${run.stage || 'running'}, since ${run.startedAt || 'unknown'}) — daemon start waits for it to finish.`);
|
|
480
|
+
const deadline = Date.now() + (0, selfUpdate_1.updaterLockMaxAgeMs)();
|
|
481
|
+
while (run && Date.now() < deadline) {
|
|
482
|
+
await new Promise(resolve => setTimeout(resolve, 5_000));
|
|
483
|
+
run = (0, selfUpdate_1.readActiveUpdaterRun)();
|
|
484
|
+
}
|
|
485
|
+
log('Updater run finished — continuing daemon start.');
|
|
486
|
+
}
|
|
469
487
|
async function runDaemon(args, config) {
|
|
488
|
+
await waitForActiveUpdaterRun();
|
|
470
489
|
if (!acquirePidLock(await detectRecycledPidOwner())) {
|
|
471
490
|
console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
|
|
472
491
|
return;
|
|
@@ -518,7 +537,11 @@ async function runDaemon(args, config) {
|
|
|
518
537
|
log(`Shield key UNAVAILABLE${ph && !ph.spawnOk ? ' — powershell.exe could not start (EDR/policy block?), so the DPAPI-protected key cannot be decrypted' : ''}. Telemetry/control-plane sync disabled; retrying periodically.`);
|
|
519
538
|
}
|
|
520
539
|
// Crash forensics: was the previous daemon killed without a clean shutdown?
|
|
540
|
+
const previousMarker = (0, daemonForensics_1.readAliveMarker)();
|
|
521
541
|
const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
|
|
542
|
+
if (previousMarker?.cleanExit?.reason === 'stopped-for-update' && previousMarker.pid !== process.pid) {
|
|
543
|
+
log(`Previous daemon (v${previousMarker.version || '?'}) was stopped for update by the updater (${previousMarker.cleanExit.at}) — expected, not a crash.`);
|
|
544
|
+
}
|
|
522
545
|
if (postmortem) {
|
|
523
546
|
const current = cliVersion();
|
|
524
547
|
if (postmortem.version && current && postmortem.version !== current) {
|
|
@@ -526,6 +549,16 @@ async function runDaemon(args, config) {
|
|
|
526
549
|
// of an OLDER version is the upgrade itself, not something to alarm on.
|
|
527
550
|
log(`Previous daemon (v${postmortem.version}) was replaced by this upgrade to v${current} — the updater's forced stop is expected, not a crash. Post-mortem recorded for the timeline.`);
|
|
528
551
|
}
|
|
552
|
+
else if (postmortem.cause === 'machine_restart') {
|
|
553
|
+
log(`Previous daemon (v${postmortem.version || '?'}) stopped when the machine restarted (booted ${postmortem.machineBootedAt || 'after its last heartbeat'}; last alive ${postmortem.lastAliveAt || 'unknown'}). Expected, not a crash. Post-mortem recorded for the timeline.`);
|
|
554
|
+
}
|
|
555
|
+
else if ((0, selfUpdate_1.deathDuringUpdaterRun)(postmortem.lastAliveAt, (0, selfUpdate_1.readActiveUpdaterRun)() || (0, selfUpdate_1.readLastUpdaterResult)())) {
|
|
556
|
+
// Same version, but an updater run was replacing the install when it
|
|
557
|
+
// died (an update that failed, or a run whose marker stamp was lost):
|
|
558
|
+
// stopped for update — never an EDR/AV kill in the console.
|
|
559
|
+
(0, daemonForensics_1.markPostmortemReported)();
|
|
560
|
+
log(`Previous daemon (v${postmortem.version || '?'}) stopped for update — an updater run was replacing the install when it died (last alive ${postmortem.lastAliveAt || 'unknown'}). Not a crash; not reported as one.`);
|
|
561
|
+
}
|
|
529
562
|
else {
|
|
530
563
|
log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
|
|
531
564
|
}
|
|
@@ -1064,13 +1097,23 @@ async function runDaemon(args, config) {
|
|
|
1064
1097
|
return;
|
|
1065
1098
|
}
|
|
1066
1099
|
const startedAt = marker.startedAt ? Date.parse(marker.startedAt) : NaN;
|
|
1067
|
-
|
|
1100
|
+
// The install is still running: its own outcome settles this, not a timer.
|
|
1101
|
+
if ((0, selfUpdate_1.readActiveUpdaterRun)())
|
|
1102
|
+
return;
|
|
1103
|
+
// The updater recorded a failed run for this target after the kick —
|
|
1104
|
+
// report its reason now instead of waiting out the stale timer.
|
|
1105
|
+
const lastRun = (0, selfUpdate_1.readLastUpdaterResult)();
|
|
1106
|
+
const runFailed = lastRun?.outcome === 'failed' && lastRun.targetVersion === marker.target
|
|
1107
|
+
&& Number.isFinite(startedAt) && Date.parse(lastRun.finishedAt || '') >= startedAt;
|
|
1108
|
+
if (runFailed || !Number.isFinite(startedAt) || Date.now() - startedAt > PENDING_UPGRADE_STALE_MS) {
|
|
1068
1109
|
try {
|
|
1069
1110
|
fs.unlinkSync(pendingUpgradeFile());
|
|
1070
1111
|
}
|
|
1071
1112
|
catch { /* ignore */ }
|
|
1072
1113
|
await reportMachineAction(marker.actionId, 'failed', {
|
|
1073
|
-
error:
|
|
1114
|
+
error: runFailed
|
|
1115
|
+
? `The updater could not install ${marker.target}: ${lastRun?.error || 'unknown error'} The machine stays on ${current || 'unknown'}; see %ProgramData%\\FullCourtDefense\\updater.log.`
|
|
1116
|
+
: `The updater was triggered but the CLI still reports ${current || 'unknown'} after ${Math.round(PENDING_UPGRADE_STALE_MS / 60_000)} minutes (target ${marker.target}). Check %ProgramData%\\FullCourtDefense\\updater.log on the machine.`,
|
|
1074
1117
|
}, marker.signatureProof);
|
|
1075
1118
|
log(`Upgrade verification failed: still on ${current || 'unknown'}, target was ${marker.target} (action ${marker.actionId}).`);
|
|
1076
1119
|
await uploadLogTail();
|
|
@@ -1097,7 +1140,7 @@ async function runDaemon(args, config) {
|
|
|
1097
1140
|
// whose enrolled fingerprint drifted can set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1
|
|
1098
1141
|
// locally while they re-enroll.
|
|
1099
1142
|
const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
|
|
1100
|
-
const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
|
|
1143
|
+
const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId, apiUrl: creds.apiUrl });
|
|
1101
1144
|
if (!verdict.ok) {
|
|
1102
1145
|
log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
|
|
1103
1146
|
await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` }, action.signature?.signature);
|
|
@@ -1505,6 +1548,8 @@ async function runDaemon(args, config) {
|
|
|
1505
1548
|
lastAliveAt: unreportedCrash.lastAliveAt,
|
|
1506
1549
|
detectedAt: unreportedCrash.detectedAt,
|
|
1507
1550
|
detectedBy: unreportedCrash.detectedBy,
|
|
1551
|
+
cause: unreportedCrash.cause,
|
|
1552
|
+
machineBootedAt: unreportedCrash.machineBootedAt,
|
|
1508
1553
|
}
|
|
1509
1554
|
: undefined,
|
|
1510
1555
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type DeterministicDirection = 'request' | 'response';
|
|
2
|
-
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot';
|
|
2
|
+
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'remote_control' | 'honeypot' | 'agent_guard_tamper';
|
|
3
3
|
/**
|
|
4
4
|
* Per-rule enforcement action (org-configured, shipped in the Local Safety
|
|
5
5
|
* snapshot). Absent everywhere = 'block' — the only behavior older CLIs and
|
|
@@ -34,6 +34,8 @@ export interface DeterministicFinding {
|
|
|
34
34
|
* findings — the seam 'mask' uses to redact tool responses. Never spooled.
|
|
35
35
|
*/
|
|
36
36
|
matchedSecret?: string;
|
|
37
|
+
/** Catalog facts for remote_control findings (bridge id, mode, launcher) — no argument text. */
|
|
38
|
+
remoteControl?: RemoteControlFacts;
|
|
37
39
|
}
|
|
38
40
|
export interface LocalSafetyCustomBlock {
|
|
39
41
|
id: string;
|
|
@@ -90,7 +92,7 @@ export declare const DEFAULT_DISABLED_BUILT_INS: ReadonlySet<string>;
|
|
|
90
92
|
export interface SharedCommandRule {
|
|
91
93
|
itemId: string;
|
|
92
94
|
/** Catalog category id (local-safety-catalog.ts). */
|
|
93
|
-
categoryId: 'credential_commands';
|
|
95
|
+
categoryId: 'credential_commands' | 'remote_control';
|
|
94
96
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
95
97
|
pattern: string;
|
|
96
98
|
reason: string;
|
|
@@ -106,6 +108,64 @@ export interface SharedCommandRule {
|
|
|
106
108
|
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
107
109
|
*/
|
|
108
110
|
export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
111
|
+
export declare const REMOTE_CONTROL_CATALOG_VERSION = "2026-09-26.1";
|
|
112
|
+
export interface RemoteControlFacts {
|
|
113
|
+
/** Catalog entry id. */
|
|
114
|
+
bridge: string;
|
|
115
|
+
/** Catalog mode label: 'remote' | 'serve' | 'connect'. */
|
|
116
|
+
mode: string;
|
|
117
|
+
/** How it was started: 'npx', 'npm exec', 'pnpm dlx', …, or 'direct' for an installed binary. */
|
|
118
|
+
launcher: string;
|
|
119
|
+
}
|
|
120
|
+
export interface RemoteControlBridge {
|
|
121
|
+
id: string;
|
|
122
|
+
title: string;
|
|
123
|
+
kind: 'remote_control' | 'remote_connector';
|
|
124
|
+
/** Registry package names (npm / PyPI), as typed after a launcher. */
|
|
125
|
+
packages: string[];
|
|
126
|
+
/** Executable names when installed globally or run from node_modules/.bin. */
|
|
127
|
+
bins: string[];
|
|
128
|
+
mode: 'remote' | 'serve' | 'connect';
|
|
129
|
+
/** Regex source applied right after the package token (JS + .NET, IgnoreCase). */
|
|
130
|
+
modePattern: string;
|
|
131
|
+
/** Short label for the console and evidence. */
|
|
132
|
+
label: string;
|
|
133
|
+
/** Decision Card sentence — what starting it means, in plain words. */
|
|
134
|
+
card: string;
|
|
135
|
+
}
|
|
136
|
+
export declare const REMOTE_CONTROL_BRIDGES: readonly RemoteControlBridge[];
|
|
137
|
+
/** The positional pattern for one catalog entry: program at a statement start, package, then its mode. */
|
|
138
|
+
export declare function remoteControlBridgePattern(bridge: RemoteControlBridge): string;
|
|
139
|
+
/** Shared command rules for the terminal guards: one per remote-control entry, same Local Safety item. */
|
|
140
|
+
export declare const REMOTE_CONTROL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
141
|
+
export interface RemoteControlMatch {
|
|
142
|
+
bridge: RemoteControlBridge;
|
|
143
|
+
facts: RemoteControlFacts;
|
|
144
|
+
}
|
|
145
|
+
/** A shell line that starts a remote-control bridge (any statement, any de-obfuscated variant). */
|
|
146
|
+
export declare function remoteControlBridgeInCommand(command: string): RemoteControlMatch | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* An MCP server launch (config `command` + `args`) that is a catalogued bridge:
|
|
149
|
+
* a remote-control bridge first, else a remote connector. Arguments with
|
|
150
|
+
* spaces are re-quoted so a `--stdio "npx server"` value stays one argument.
|
|
151
|
+
*/
|
|
152
|
+
export declare function classifyRemoteMcpLaunch(command: string | undefined, args?: readonly string[]): RemoteControlMatch | undefined;
|
|
153
|
+
/** Wire-supplied facts constrained to the catalog vocabulary (backend ingest); anything else is dropped. */
|
|
154
|
+
export declare function sanitizeRemoteControlFacts(raw: unknown): RemoteControlFacts | undefined;
|
|
155
|
+
/** The engine fact provider: the catalog id of the remote-control bridge this line starts. */
|
|
156
|
+
export declare function remoteControlFactFor(command: string): string | undefined;
|
|
157
|
+
/** What an "Always allow" on a bridge is bound to: the package and its mode, never the launcher that ran it. */
|
|
158
|
+
export declare function remoteControlGrantKey(bridge: RemoteControlBridge): string;
|
|
159
|
+
/**
|
|
160
|
+
* Every distinct remote-control bridge a line starts (as grant keys), plus the
|
|
161
|
+
* line with each verbatim launch cut out — statement separators kept — so the
|
|
162
|
+
* caller can still read the OTHER programs on the line. A launch seen only in a
|
|
163
|
+
* de-obfuscated variant is not cut: the rest is over-counted, never under-counted.
|
|
164
|
+
*/
|
|
165
|
+
export declare function remoteControlLaunches(command: string): {
|
|
166
|
+
bridges: string[];
|
|
167
|
+
rest: string;
|
|
168
|
+
};
|
|
109
169
|
export type ShellAstMode = 'off' | 'shadow' | 'primary';
|
|
110
170
|
/** Structural subset of shellAst.ts ShellFacts that the guard reads. */
|
|
111
171
|
export interface ShellAstFactsLike {
|