fullcourtdefense-cli 1.34.21 → 1.34.22
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/README.md +1 -0
- package/dist/actionPolicyEngine.d.ts +5 -0
- package/dist/actionPolicyEngine.js +23 -2
- package/dist/commands/daemon.js +1 -0
- package/dist/commands/hook.js +12 -7
- package/dist/detectionUpdates.d.ts +23 -3
- package/dist/detectionUpdates.js +45 -14
- package/dist/detectorArtifact.d.ts +17 -0
- package/dist/detectorArtifact.js +35 -0
- package/dist/detectorBaseline.d.ts +4 -0
- package/dist/detectorBaseline.js +7 -0
- package/dist/detectorModule.d.ts +9 -0
- package/dist/detectorModule.js +84 -0
- package/dist/detectorRuntime.d.ts +21 -0
- package/dist/detectorRuntime.js +49 -0
- package/dist/detectorUpdates.d.ts +21 -0
- package/dist/detectorUpdates.js +86 -0
- package/dist/frictionRollout.js +103 -0
- package/dist/index.js +8 -0
- package/dist/localDetectionUpdates.d.ts +3 -1
- package/dist/localDetectionUpdates.js +8 -1
- package/dist/telemetry.d.ts +5 -0
- package/dist/telemetry.js +8 -0
- package/dist/version.json +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ fullcourtdefense init
|
|
|
73
73
|
- `fullcourtdefense help` — shows the full onboarding flow and command reference.
|
|
74
74
|
- `fullcourtdefense onboard --token <token>` — resumable machine transaction: preflight + login + protection + optional discovery + verification. `--json true` prints final machine-readable status; exits non-zero only when a critical step fails.
|
|
75
75
|
- `fullcourtdefense doctor` — confirms outbound HTTPS to FullCourtDefense is open before scanning.
|
|
76
|
+
- `fullcourtdefense hotfix-status` — shows the loaded detector revision and whether fallback is active. Available from 1.34.22. Updates arrive through the background daemon; this command reads local status and does not force a download. Acceptance alone does not prove that a reported false alarm was corrected.
|
|
76
77
|
- `fullcourtdefense login --token <token>` — enrolls this machine with a fleet token and saves per-machine Shield credentials (no copy/paste).
|
|
77
78
|
- `fullcourtdefense install-all` — wraps every configured MCP server, installs IDE hooks and terminal guards, uploads discovery, schedules daily rescans.
|
|
78
79
|
- `fullcourtdefense configure` — legacy/manual setup: saves org API key, Shield ID, Shield key, and API URL to `.fullcourtdefense.yml` (use `login` instead when you have a fleet token).
|
|
@@ -234,5 +234,10 @@ export declare function inferToolContext(toolName: string, args: Record<string,
|
|
|
234
234
|
operation: string;
|
|
235
235
|
context: Record<string, string>;
|
|
236
236
|
};
|
|
237
|
+
/** Exported for reproducible detector artifacts; policy decisions are not packaged. */
|
|
238
|
+
export declare function inferBundledToolContext(toolName: string, args: Record<string, any>): {
|
|
239
|
+
operation: string;
|
|
240
|
+
context: Record<string, string>;
|
|
241
|
+
};
|
|
237
242
|
/** Static inventory match — same operation rules as runtime, without constraint context. */
|
|
238
243
|
export declare function inventoryToolMatchesActionPolicy(toolName: string, toolActions: string[] | undefined, policies: EngineActionPolicy[]): boolean;
|
|
@@ -29,6 +29,7 @@ __export(actionPolicyEngine_exports, {
|
|
|
29
29
|
detectSecretKind: () => detectSecretKind,
|
|
30
30
|
effectiveApprovalScope: () => effectiveApprovalScope,
|
|
31
31
|
evaluateActionPolicies: () => evaluateActionPolicies,
|
|
32
|
+
inferBundledToolContext: () => inferBundledToolContext,
|
|
32
33
|
inferToolContext: () => inferToolContext,
|
|
33
34
|
inventoryToolMatchesActionPolicy: () => inventoryToolMatchesActionPolicy,
|
|
34
35
|
isLocalFileContentAction: () => isLocalFileContentAction,
|
|
@@ -5740,6 +5741,7 @@ function parse3(input, options) {
|
|
|
5740
5741
|
|
|
5741
5742
|
// src/actionPolicyEngine.ts
|
|
5742
5743
|
var import_detectionData = require("./detectionData");
|
|
5744
|
+
var import_detectorRuntime = require("./detectorRuntime");
|
|
5743
5745
|
function isFirestoreQueryUrl(target) {
|
|
5744
5746
|
try {
|
|
5745
5747
|
const url = new URL(target);
|
|
@@ -7053,6 +7055,8 @@ function shellUrlActionText(command) {
|
|
|
7053
7055
|
if (command.length > 65536) return command;
|
|
7054
7056
|
const originalSegments = splitShellSegments(command);
|
|
7055
7057
|
const localPaths = /* @__PURE__ */ new Map();
|
|
7058
|
+
const literalValues = /* @__PURE__ */ new Map();
|
|
7059
|
+
const dataAssignments = /* @__PURE__ */ new Set();
|
|
7056
7060
|
const pathAssignments = /* @__PURE__ */ new Set();
|
|
7057
7061
|
let expandedSize = command.length;
|
|
7058
7062
|
let expansionOverflow = false;
|
|
@@ -7065,6 +7069,11 @@ function shellUrlActionText(command) {
|
|
|
7065
7069
|
return value;
|
|
7066
7070
|
};
|
|
7067
7071
|
const segments = originalSegments.map((segment, index) => {
|
|
7072
|
+
const dataAssignment = /^\$([A-Za-z_][\w]*)\s*=\s*(@'\r?\n[\s\S]*?\r?\n'@|'(?:[^']|'')*')$/.exec(segment);
|
|
7073
|
+
if (dataAssignment && !literalValues.has(dataAssignment[1].toLowerCase()) && literalValues.size < 32) {
|
|
7074
|
+
literalValues.set(dataAssignment[1].toLowerCase(), dataAssignment[2]);
|
|
7075
|
+
dataAssignments.add(index);
|
|
7076
|
+
}
|
|
7068
7077
|
const assignment = /^\$([A-Za-z_][\w]*)\s*=\s*(?:'([^']*)'|"([^"$`]*)")$/.exec(segment);
|
|
7069
7078
|
if (assignment) {
|
|
7070
7079
|
const name = assignment[1].toLowerCase();
|
|
@@ -7086,7 +7095,14 @@ function shellUrlActionText(command) {
|
|
|
7086
7095
|
const value = localPaths.get(name.toLowerCase());
|
|
7087
7096
|
return value ? expand(match, `"${value}"`) : match;
|
|
7088
7097
|
});
|
|
7089
|
-
|
|
7098
|
+
const expanded = prefix + tail;
|
|
7099
|
+
return expanded.replace(
|
|
7100
|
+
/^(Set-Content|Add-Content|Out-File)(\s+[\s\S]*?\s+-(?:Value|InputObject)\s+)\$([A-Za-z_][\w]*)\s*$/i,
|
|
7101
|
+
(match, writer, options, name) => {
|
|
7102
|
+
const value = literalValues.get(name.toLowerCase());
|
|
7103
|
+
return value ? expand(match, writer + options + value) : match;
|
|
7104
|
+
}
|
|
7105
|
+
);
|
|
7090
7106
|
});
|
|
7091
7107
|
if (expansionOverflow) return command;
|
|
7092
7108
|
const filtered = segments.map((segment) => {
|
|
@@ -7121,6 +7137,7 @@ function shellUrlActionText(command) {
|
|
|
7121
7137
|
return tail;
|
|
7122
7138
|
});
|
|
7123
7139
|
const onlyStoredData = segments.every((segment, index) => {
|
|
7140
|
+
if (dataAssignments.has(index)) return true;
|
|
7124
7141
|
if (pathAssignments.has(index)) return true;
|
|
7125
7142
|
if (filtered[index] !== segment) return true;
|
|
7126
7143
|
const directory = /^New-Item\s+-ItemType\s+Directory\s+(?:-Force\s+)?-(?:LiteralPath|Path)\s+(?:'([^']*)'|"([^"$`]*)")\s*(?:\|\s*Out-Null)?$/i.exec(segment);
|
|
@@ -7129,7 +7146,7 @@ function shellUrlActionText(command) {
|
|
|
7129
7146
|
if (!/[$`|;&(){}<>]/.test(segment) && /^(?:(?:cd|pushd|set-location)\s+[^\r\n]+|(?:pwd|popd|get-location)|git\s+status(?:\s+[^\r\n]+)?)$/i.test(segment)) return true;
|
|
7130
7147
|
return !/[$`]/.test(segment) && /^\(Get-Content\s+(?:[A-Za-z]:[\\/])?[\w./\\-]+\s+-Raw\)\.Replace\("",\s*""\)\s*\|\s*Set-Content\s+(?:[A-Za-z]:[\\/])?[\w./\\-]+\s+-Encoding\s+utf8\s*$/i.test(words);
|
|
7131
7148
|
});
|
|
7132
|
-
return onlyStoredData && filtered.some((value, index) => value !== segments[index]) ? filtered.join("\n") : command;
|
|
7149
|
+
return onlyStoredData && filtered.some((value, index) => value !== segments[index]) ? filtered.filter((_, index) => !dataAssignments.has(index)).join("\n") : command;
|
|
7133
7150
|
}
|
|
7134
7151
|
function splitShellSegments(command, alsoOnPipe = false) {
|
|
7135
7152
|
const out = [];
|
|
@@ -7723,6 +7740,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
|
|
|
7723
7740
|
return worstResult;
|
|
7724
7741
|
}
|
|
7725
7742
|
function inferToolContext(toolName, args) {
|
|
7743
|
+
return (0, import_detectorRuntime.updatedDetectorFacts)(toolName, args) || inferBundledToolContext(toolName, args);
|
|
7744
|
+
}
|
|
7745
|
+
function inferBundledToolContext(toolName, args) {
|
|
7726
7746
|
const context = {};
|
|
7727
7747
|
let operation = toolName;
|
|
7728
7748
|
const toolNameLower = toolName.toLowerCase();
|
|
@@ -7863,6 +7883,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
|
|
|
7863
7883
|
detectSecretKind,
|
|
7864
7884
|
effectiveApprovalScope,
|
|
7865
7885
|
evaluateActionPolicies,
|
|
7886
|
+
inferBundledToolContext,
|
|
7866
7887
|
inferToolContext,
|
|
7867
7888
|
inventoryToolMatchesActionPolicy,
|
|
7868
7889
|
isLocalFileContentAction,
|
package/dist/commands/daemon.js
CHANGED
|
@@ -1206,6 +1206,7 @@ async function runDaemon(args, config) {
|
|
|
1206
1206
|
if (!creds.shieldId)
|
|
1207
1207
|
return;
|
|
1208
1208
|
void localDetectionUpdates_1.localDetectionUpdates.refresh();
|
|
1209
|
+
void localDetectionUpdates_1.localDetectorUpdates.refresh();
|
|
1209
1210
|
if (!creds.shieldKey) {
|
|
1210
1211
|
// Credential-broken machine: stay reachable via the key-less signed-
|
|
1211
1212
|
// action channel while credential recovery keeps retrying.
|
package/dist/commands/hook.js
CHANGED
|
@@ -757,6 +757,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
|
|
|
757
757
|
if (ctx.shadow)
|
|
758
758
|
return false; // monitor machines never prompt — the caller records would-approve
|
|
759
759
|
const prepared = prepareDeveloperConfirmation(ctx.event, call, payload, {
|
|
760
|
+
policyHash: ctx.effectivePolicyHash,
|
|
760
761
|
policyId: verdict.policyId,
|
|
761
762
|
policyName: verdict.policyName,
|
|
762
763
|
matchedRule: verdict.matchedRule,
|
|
@@ -800,6 +801,8 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
800
801
|
spoolCallEvent(call, {
|
|
801
802
|
decision: 'allow',
|
|
802
803
|
reason: `developer confirmed earlier (${remembered.remember} memory)${policyLabel}`,
|
|
804
|
+
ruleId: source.policyId || source.localFinding?.ruleId,
|
|
805
|
+
policyHash: source.policyHash || source.localFinding?.policyHash,
|
|
803
806
|
approvalScope: 'developer',
|
|
804
807
|
approvedBy: 'developer',
|
|
805
808
|
offlineEnforced,
|
|
@@ -822,6 +825,8 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
822
825
|
(0, hookIo_1.dbg)({ phase: 'dev_confirm_ask', event, tool: call.toolName, operation, key: marker.key, remember: source.remember, where: source.where });
|
|
823
826
|
spoolCallEvent(call, {
|
|
824
827
|
decision: 'approval',
|
|
828
|
+
ruleId: source.policyId || source.localFinding?.ruleId,
|
|
829
|
+
policyHash: source.policyHash || source.localFinding?.policyHash,
|
|
825
830
|
reason: `developer confirmation requested via IDE prompt${policyLabel}${source.reason ? `: ${source.reason}` : ''}`,
|
|
826
831
|
approvalScope: 'developer',
|
|
827
832
|
offlineEnforced,
|
|
@@ -916,14 +921,14 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
916
921
|
if (ctx.shadow) {
|
|
917
922
|
// Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
|
|
918
923
|
// (which hides the would-block from the console) and never 'block'.
|
|
919
|
-
spoolCallEvent(call, { decision: 'warn', reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
|
|
924
|
+
spoolCallEvent(call, { decision: 'warn', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
|
|
920
925
|
(0, telemetry_1.triggerFlush)(false);
|
|
921
926
|
ctx.respond(false, undefined, `[FullCourtDefense shadow] would ${local.verdict === 'block' ? 'block' : 'require approval for'} ${call.toolName} (offline, locally cached policy): ${reason}`);
|
|
922
927
|
}
|
|
923
928
|
const approvalNote = local.verdict === 'require_approval'
|
|
924
929
|
? ' This action requires human approval, which is not possible while the policy service is unreachable.'
|
|
925
930
|
: '';
|
|
926
|
-
spoolCallEvent(call, { decision: 'block', reason: `local policy (offline): ${reason}`, offlineEnforced: true });
|
|
931
|
+
spoolCallEvent(call, { decision: 'block', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `local policy (offline): ${reason}`, offlineEnforced: true });
|
|
927
932
|
(0, telemetry_1.triggerFlush)(true);
|
|
928
933
|
const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
|
|
929
934
|
ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
|
|
@@ -939,7 +944,7 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
939
944
|
? warning.agentMsg
|
|
940
945
|
: `${warning.agentMsg} (policy service unreachable — evaluated from the locally cached org policies).`);
|
|
941
946
|
}
|
|
942
|
-
spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (${monitorPath ? 'monitor' : 'offline'}): ${detail}`, offlineEnforced: true });
|
|
947
|
+
spoolCallEvent(call, { decision: 'allow', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `local policy allow (${monitorPath ? 'monitor' : 'offline'}): ${detail}`, offlineEnforced: true });
|
|
943
948
|
(0, telemetry_1.triggerFlush)(false);
|
|
944
949
|
ctx.respond(false, undefined, monitorPath
|
|
945
950
|
? undefined
|
|
@@ -1947,7 +1952,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1947
1952
|
// read "N blocked of N recent" with every allow missing. Same delivery
|
|
1948
1953
|
// as a block: spool (sync file append) + detached flusher that outlives
|
|
1949
1954
|
// this process. The IDE is not kept waiting on the network.
|
|
1950
|
-
spoolCallEvent(call, { decision: 'allow', reason: 'local policy allow (block mode, local-first)' });
|
|
1955
|
+
spoolCallEvent(call, { decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first)' });
|
|
1951
1956
|
(0, telemetry_1.triggerFlush)(true);
|
|
1952
1957
|
respond(false);
|
|
1953
1958
|
return;
|
|
@@ -1958,7 +1963,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1958
1963
|
void fetch(gateUrl, { method: 'POST', headers, body: gateBody, signal: AbortSignal.timeout(4000) })
|
|
1959
1964
|
.then(() => (0, policyGateHealth_1.recordGateSuccess)())
|
|
1960
1965
|
.catch(() => {
|
|
1961
|
-
spoolCallEvent(call, { decision: 'allow', reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
|
|
1966
|
+
spoolCallEvent(call, { decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
|
|
1962
1967
|
(0, telemetry_1.triggerFlush)(false);
|
|
1963
1968
|
});
|
|
1964
1969
|
respond(false);
|
|
@@ -1966,7 +1971,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1966
1971
|
}
|
|
1967
1972
|
if (localVerdict.verdict === 'block') {
|
|
1968
1973
|
const reason = localVerdict.reason || `${call.toolName}: blocked by org Action Policy`;
|
|
1969
|
-
spoolCallEvent(call, { decision: 'block', reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
|
|
1974
|
+
spoolCallEvent(call, { decision: 'block', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
|
|
1970
1975
|
(0, telemetry_1.triggerFlush)(true);
|
|
1971
1976
|
const explained = explainPolicyStop('block', event, call, localVerdict, ctx.localPolicies);
|
|
1972
1977
|
respond(true, explained.userMessage, explained.agentMessage);
|
|
@@ -1980,7 +1985,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1980
1985
|
return;
|
|
1981
1986
|
}
|
|
1982
1987
|
// require_approval, developer scope + human present → IDE-native ask (no org queue).
|
|
1983
|
-
if (tryDeveloperConfirmation({ event, respond, shadow, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
|
|
1988
|
+
if (tryDeveloperConfirmation({ event, respond, shadow, effectivePolicyHash, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
|
|
1984
1989
|
return;
|
|
1985
1990
|
// require_approval (org scope) → synchronous gate below creates the approval action.
|
|
1986
1991
|
}
|
|
@@ -1,12 +1,31 @@
|
|
|
1
|
+
import { verifyDetectionEnvelope } from './detectionData';
|
|
2
|
+
export interface SignedUpdateAdapter<T extends {
|
|
3
|
+
revision: number;
|
|
4
|
+
}> {
|
|
5
|
+
url: string;
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
bundledRevision: number;
|
|
8
|
+
current(): Readonly<T>;
|
|
9
|
+
verify(raw: string, now: number, key: string): T;
|
|
10
|
+
activate(data: T): boolean;
|
|
11
|
+
eligible?(data: T): boolean;
|
|
12
|
+
/** Runs only for eligible, newer content, before it replaces the cache. */
|
|
13
|
+
prepare?(data: T): {
|
|
14
|
+
activate(): boolean;
|
|
15
|
+
dispose(): void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
1
18
|
export declare const DETECTION_UPDATE_URL = "https://storage.googleapis.com/fullcourtdefense-cli-releases/detection/stable.json";
|
|
2
19
|
export interface DetectionUpdateStatus {
|
|
3
20
|
revision: number;
|
|
4
21
|
bundledRevision: number;
|
|
5
22
|
source: 'bundled' | 'update';
|
|
6
23
|
lastCheckedAt?: string;
|
|
7
|
-
state: 'bundled' | 'current' | 'unavailable' | 'rejected';
|
|
24
|
+
state: 'bundled' | 'current' | 'unavailable' | 'rejected' | 'not_targeted';
|
|
8
25
|
}
|
|
9
|
-
export declare class DetectionUpdates {
|
|
26
|
+
export declare class DetectionUpdates<T extends {
|
|
27
|
+
revision: number;
|
|
28
|
+
} = ReturnType<typeof verifyDetectionEnvelope>> {
|
|
10
29
|
private readonly cachePath;
|
|
11
30
|
private readonly publicKey;
|
|
12
31
|
private readonly channel;
|
|
@@ -14,7 +33,8 @@ export declare class DetectionUpdates {
|
|
|
14
33
|
private nextLocalRead;
|
|
15
34
|
private pending?;
|
|
16
35
|
private status;
|
|
17
|
-
|
|
36
|
+
private readonly adapter;
|
|
37
|
+
constructor(cachePath: string, publicKey?: string, channel?: 'stable' | 'staging', adapter?: SignedUpdateAdapter<T>);
|
|
18
38
|
getStatus(): DetectionUpdateStatus;
|
|
19
39
|
private readCache;
|
|
20
40
|
private cachedData;
|
package/dist/detectionUpdates.js
CHANGED
|
@@ -49,18 +49,23 @@ class DetectionUpdates {
|
|
|
49
49
|
pending;
|
|
50
50
|
status = { revision: detectionData_1.BUNDLED_DETECTION_DATA.revision,
|
|
51
51
|
bundledRevision: detectionData_1.BUNDLED_DETECTION_DATA.revision, source: 'bundled', state: 'bundled' };
|
|
52
|
-
|
|
52
|
+
adapter;
|
|
53
|
+
constructor(cachePath, publicKey = detectionData_1.DETECTION_PUBLIC_KEY, channel = 'stable', adapter) {
|
|
53
54
|
this.cachePath = cachePath;
|
|
54
55
|
this.publicKey = publicKey;
|
|
55
56
|
this.channel = channel;
|
|
57
|
+
this.adapter = adapter || { url: exports.DETECTION_UPDATE_URL, maxBytes: detectionData_1.MAX_DETECTION_BYTES,
|
|
58
|
+
bundledRevision: detectionData_1.BUNDLED_DETECTION_DATA.revision, current: detectionData_1.getDetectionData,
|
|
59
|
+
verify: detectionData_1.verifyDetectionEnvelope, activate: detectionData_1.activateDetectionData };
|
|
60
|
+
this.status.revision = this.status.bundledRevision = this.adapter.bundledRevision;
|
|
56
61
|
}
|
|
57
62
|
getStatus() {
|
|
58
|
-
const revision =
|
|
59
|
-
return { ...this.status, revision, source: revision >
|
|
63
|
+
const revision = this.adapter.current().revision;
|
|
64
|
+
return { ...this.status, revision, source: revision > this.adapter.bundledRevision ? 'update' : 'bundled' };
|
|
60
65
|
}
|
|
61
66
|
readCache() {
|
|
62
67
|
try {
|
|
63
|
-
if (fs.statSync(this.cachePath).size >
|
|
68
|
+
if (fs.statSync(this.cachePath).size > this.adapter.maxBytes)
|
|
64
69
|
return undefined;
|
|
65
70
|
return fs.readFileSync(this.cachePath, 'utf8');
|
|
66
71
|
}
|
|
@@ -72,7 +77,7 @@ class DetectionUpdates {
|
|
|
72
77
|
// Expiration gates first acceptance, not continued offline use. Authenticate
|
|
73
78
|
// the signed validity period before using the last verified cache after restart.
|
|
74
79
|
const payload = JSON.parse(JSON.parse(raw).payload);
|
|
75
|
-
return
|
|
80
|
+
return this.adapter.verify(raw, Math.min(Date.now(), payload.issuedAt), this.publicKey);
|
|
76
81
|
}
|
|
77
82
|
loadLocal() {
|
|
78
83
|
if (Date.now() < this.nextLocalRead)
|
|
@@ -82,7 +87,24 @@ class DetectionUpdates {
|
|
|
82
87
|
if (!raw)
|
|
83
88
|
return;
|
|
84
89
|
try {
|
|
85
|
-
|
|
90
|
+
const data = this.cachedData(raw);
|
|
91
|
+
if (this.adapter.eligible && !this.adapter.eligible(data)) {
|
|
92
|
+
this.status.state = 'not_targeted';
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (data.revision === this.adapter.current().revision && JSON.stringify(data) !== JSON.stringify(this.adapter.current()))
|
|
96
|
+
throw new Error('Cached revision reused for different content');
|
|
97
|
+
if (data.revision > this.adapter.current().revision) {
|
|
98
|
+
const prepared = this.adapter.prepare?.(data);
|
|
99
|
+
try {
|
|
100
|
+
prepared ? prepared.activate() : this.adapter.activate(data);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
prepared?.dispose();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (data.revision === this.adapter.current().revision)
|
|
107
|
+
this.status.state = 'current';
|
|
86
108
|
}
|
|
87
109
|
catch {
|
|
88
110
|
this.status.state = 'rejected';
|
|
@@ -102,7 +124,7 @@ class DetectionUpdates {
|
|
|
102
124
|
this.status.lastCheckedAt = new Date().toISOString();
|
|
103
125
|
let raw;
|
|
104
126
|
try {
|
|
105
|
-
const response = await fetch(this.channel === 'staging' ?
|
|
127
|
+
const response = await fetch(this.channel === 'staging' ? this.adapter.url.replace('/stable.json', '/staging.json') : this.adapter.url, { redirect: 'error', signal: AbortSignal.timeout(3000) });
|
|
106
128
|
if (!response.ok || !response.body)
|
|
107
129
|
throw new Error('Unavailable');
|
|
108
130
|
const reader = response.body.getReader();
|
|
@@ -114,7 +136,7 @@ class DetectionUpdates {
|
|
|
114
136
|
if (done)
|
|
115
137
|
break;
|
|
116
138
|
size += value.byteLength;
|
|
117
|
-
if (size >
|
|
139
|
+
if (size > this.adapter.maxBytes)
|
|
118
140
|
throw new Error('Oversized');
|
|
119
141
|
chunks.push(value);
|
|
120
142
|
}
|
|
@@ -131,8 +153,13 @@ class DetectionUpdates {
|
|
|
131
153
|
}
|
|
132
154
|
let lock;
|
|
133
155
|
let temporary;
|
|
156
|
+
let prepared;
|
|
134
157
|
try {
|
|
135
|
-
const data =
|
|
158
|
+
const data = this.adapter.verify(raw, Date.now(), this.publicKey);
|
|
159
|
+
if (this.adapter.eligible && !this.adapter.eligible(data)) {
|
|
160
|
+
this.status.state = 'not_targeted';
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
136
163
|
fs.mkdirSync(path.dirname(this.cachePath), { recursive: true });
|
|
137
164
|
const lockPath = this.cachePath + '.lock';
|
|
138
165
|
try {
|
|
@@ -160,14 +187,15 @@ class DetectionUpdates {
|
|
|
160
187
|
}
|
|
161
188
|
catch { /* recover corrupt cache */ }
|
|
162
189
|
const cachedRevision = cached?.revision ?? 0;
|
|
163
|
-
const currentRevision = Math.max(cachedRevision,
|
|
190
|
+
const currentRevision = Math.max(cachedRevision, this.adapter.current().revision);
|
|
164
191
|
if (data.revision < currentRevision)
|
|
165
192
|
throw new Error('Revision downgrade');
|
|
166
193
|
if (cached && data.revision === cached.revision && JSON.stringify(data) !== JSON.stringify(cached))
|
|
167
194
|
throw new Error('Cached revision reused for different content');
|
|
168
|
-
if (data.revision ===
|
|
195
|
+
if (data.revision === this.adapter.current().revision && JSON.stringify(data) !== JSON.stringify(this.adapter.current()))
|
|
169
196
|
throw new Error('Revision reused for different content');
|
|
170
197
|
if (data.revision > currentRevision) {
|
|
198
|
+
prepared = this.adapter.prepare?.(data);
|
|
171
199
|
temporary = this.cachePath + '.' + (0, crypto_1.randomUUID)() + '.tmp';
|
|
172
200
|
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
173
201
|
try {
|
|
@@ -179,10 +207,12 @@ class DetectionUpdates {
|
|
|
179
207
|
}
|
|
180
208
|
fs.renameSync(temporary, this.cachePath);
|
|
181
209
|
temporary = undefined;
|
|
182
|
-
(
|
|
210
|
+
prepared ? prepared.activate() : this.adapter.activate(data);
|
|
211
|
+
}
|
|
212
|
+
else if (cached && cached.revision > this.adapter.current().revision) {
|
|
213
|
+
prepared = this.adapter.prepare?.(cached);
|
|
214
|
+
prepared ? prepared.activate() : this.adapter.activate(cached);
|
|
183
215
|
}
|
|
184
|
-
else if (cached)
|
|
185
|
-
(0, detectionData_1.activateDetectionData)(cached);
|
|
186
216
|
this.status.state = 'current';
|
|
187
217
|
}
|
|
188
218
|
catch {
|
|
@@ -190,6 +220,7 @@ class DetectionUpdates {
|
|
|
190
220
|
this.nextCheck = Date.now() + 5 * 60_000;
|
|
191
221
|
}
|
|
192
222
|
finally {
|
|
223
|
+
prepared?.dispose();
|
|
193
224
|
if (temporary) {
|
|
194
225
|
try {
|
|
195
226
|
fs.unlinkSync(temporary);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const DETECTOR_ABI = 1;
|
|
2
|
+
export declare const MAX_DETECTOR_BYTES: number;
|
|
3
|
+
export declare const DETECTOR_DOMAIN = "fullcourtdefense/reviewed-detector/v1\n";
|
|
4
|
+
export interface DetectorArtifact {
|
|
5
|
+
abi: 1;
|
|
6
|
+
revision: number;
|
|
7
|
+
issuedAt: number;
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
sourceCommit: string;
|
|
10
|
+
issueNumber: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
code: string;
|
|
13
|
+
/** null = approved fleet-wide rollout; otherwise explicit enrolled machine IDs. */
|
|
14
|
+
machineIds: string[] | null;
|
|
15
|
+
}
|
|
16
|
+
/** Official reviewed code, not user-supplied rules or tool content. */
|
|
17
|
+
export declare function verifyDetectorArtifact(raw: string, now?: number, publicKey?: string): DetectorArtifact;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DETECTOR_DOMAIN = exports.MAX_DETECTOR_BYTES = exports.DETECTOR_ABI = void 0;
|
|
4
|
+
exports.verifyDetectorArtifact = verifyDetectorArtifact;
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const detectionData_1 = require("./detectionData");
|
|
7
|
+
exports.DETECTOR_ABI = 1;
|
|
8
|
+
exports.MAX_DETECTOR_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
exports.DETECTOR_DOMAIN = 'fullcourtdefense/reviewed-detector/v1\n';
|
|
10
|
+
/** Official reviewed code, not user-supplied rules or tool content. */
|
|
11
|
+
function verifyDetectorArtifact(raw, now = Date.now(), publicKey = detectionData_1.DETECTION_PUBLIC_KEY) {
|
|
12
|
+
if (Buffer.byteLength(raw) > exports.MAX_DETECTOR_BYTES)
|
|
13
|
+
throw new Error('Detector artifact oversized');
|
|
14
|
+
const envelope = JSON.parse(raw);
|
|
15
|
+
if (!envelope || Object.keys(envelope).sort().join(',') !== 'payload,signature' || typeof envelope.payload !== 'string'
|
|
16
|
+
|| typeof envelope.signature !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(envelope.signature))
|
|
17
|
+
throw new Error('Invalid detector envelope');
|
|
18
|
+
if (!(0, crypto_1.verify)(null, Buffer.from(exports.DETECTOR_DOMAIN + envelope.payload), (0, crypto_1.createPublicKey)(publicKey), Buffer.from(envelope.signature, 'base64')))
|
|
19
|
+
throw new Error('Invalid detector signature');
|
|
20
|
+
const data = JSON.parse(envelope.payload);
|
|
21
|
+
if (!data || Object.keys(data).sort().join(',') !== 'abi,code,expiresAt,issueNumber,issuedAt,machineIds,revision,sha256,sourceCommit'
|
|
22
|
+
|| data.abi !== exports.DETECTOR_ABI || !Number.isSafeInteger(data.revision) || data.revision < 1
|
|
23
|
+
|| !Number.isSafeInteger(data.issueNumber) || data.issueNumber < 1
|
|
24
|
+
|| typeof data.sourceCommit !== 'string' || !/^[a-f0-9]{40}$/.test(data.sourceCommit) || typeof data.code !== 'string' || !data.code
|
|
25
|
+
|| typeof data.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(data.sha256)
|
|
26
|
+
|| (0, crypto_1.createHash)('sha256').update(data.code).digest('hex') !== data.sha256
|
|
27
|
+
|| !Number.isSafeInteger(data.issuedAt) || !Number.isSafeInteger(data.expiresAt)
|
|
28
|
+
|| data.issuedAt > now + 60_000 || data.expiresAt <= now || data.expiresAt <= data.issuedAt
|
|
29
|
+
|| data.expiresAt - data.issuedAt > 90 * 86400_000)
|
|
30
|
+
throw new Error('Invalid detector artifact or compatibility');
|
|
31
|
+
if (data.machineIds !== null && (!Array.isArray(data.machineIds) || data.machineIds.length < 1 || data.machineIds.length > 64
|
|
32
|
+
|| data.machineIds.some(id => typeof id !== 'string' || !/^[a-zA-Z0-9_.:-]{1,64}$/.test(id)) || new Set(data.machineIds).size !== data.machineIds.length))
|
|
33
|
+
throw new Error('Invalid detector rollout cohort');
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BUNDLED_DETECTOR_ENVELOPE = void 0;
|
|
4
|
+
/** Updated by the baseline incorporation command after a stable release.
|
|
5
|
+
* Empty only for the first bootstrap. The signature is retained in the CLI.
|
|
6
|
+
*/
|
|
7
|
+
exports.BUNDLED_DETECTOR_ENVELOPE = '';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DetectorArtifact } from './detectorArtifact';
|
|
2
|
+
import { type DetectorFacts } from './detectorRuntime';
|
|
3
|
+
/** Authenticated first-party code only. This is not a hostile-code sandbox:
|
|
4
|
+
* signatures/review are the trust boundary. Separate V8 heap, bounded output
|
|
5
|
+
* and wall time contain ordinary detector bugs. Tool text remains JSON data.
|
|
6
|
+
*/
|
|
7
|
+
export declare function compileDetector(artifact: DetectorArtifact): ((tool: string, args: Record<string, any>) => DetectorFacts) & {
|
|
8
|
+
dispose(): void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.compileDetector = compileDetector;
|
|
4
|
+
const worker_threads_1 = require("worker_threads");
|
|
5
|
+
const detectorRuntime_1 = require("./detectorRuntime");
|
|
6
|
+
const detectionData_1 = require("./detectionData");
|
|
7
|
+
/** Authenticated first-party code only. This is not a hostile-code sandbox:
|
|
8
|
+
* signatures/review are the trust boundary. Separate V8 heap, bounded output
|
|
9
|
+
* and wall time contain ordinary detector bugs. Tool text remains JSON data.
|
|
10
|
+
*/
|
|
11
|
+
function compileDetector(artifact) {
|
|
12
|
+
const readyBuffer = new SharedArrayBuffer(8);
|
|
13
|
+
const ready = new Int32Array(readyBuffer);
|
|
14
|
+
const worker = new worker_threads_1.Worker(`
|
|
15
|
+
const {workerData,parentPort}=require('worker_threads');
|
|
16
|
+
const {Script,createContext}=require('vm');
|
|
17
|
+
const ready=new Int32Array(workerData.ready);
|
|
18
|
+
let context,evaluate;
|
|
19
|
+
try {
|
|
20
|
+
context=createContext(Object.create(null),{codeGeneration:{strings:false,wasm:false}});
|
|
21
|
+
// Expose parsed scalar fields, not Node's host URL constructor or native
|
|
22
|
+
// exceptions. The wrapper and all values visible to code belong to the VM.
|
|
23
|
+
const makeUrl=new Script('(parse => class URL { constructor(input, base) { const fields=JSON.parse(parse(String(input), base===undefined?undefined:String(base))); if(fields.error)throw new TypeError("Invalid URL"); Object.assign(this,fields); } toString(){return this.href;} })').runInContext(context,{timeout:1000});
|
|
24
|
+
context.URL=makeUrl((input,base)=>{
|
|
25
|
+
try {
|
|
26
|
+
if(typeof input!=='string'||input.length>256*1024||(base!==undefined&&(typeof base!=='string'||base.length>256*1024)))return '{"error":true}';
|
|
27
|
+
const value=new URL(input,base),fields={};
|
|
28
|
+
for(const key of ['href','origin','protocol','username','password','host','hostname','port','pathname','search','hash'])fields[key]=value[key];
|
|
29
|
+
return JSON.stringify(fields);
|
|
30
|
+
} catch { return '{"error":true}'; }
|
|
31
|
+
});
|
|
32
|
+
new Script(workerData.code).runInContext(context,{timeout:1000});
|
|
33
|
+
new Script('if(typeof FcdDetector?.inferToolContext!=="function")throw new Error("Missing export")').runInContext(context,{timeout:1000});
|
|
34
|
+
evaluate=new Script('JSON.stringify(FcdDetector.inferToolContext(...JSON.parse(inputJson)))');
|
|
35
|
+
Atomics.store(ready,0,1);
|
|
36
|
+
} catch { Atomics.store(ready,0,-1); }
|
|
37
|
+
Atomics.notify(ready,0);
|
|
38
|
+
parentPort.on('message',({input,buffer})=>{
|
|
39
|
+
const state=new Int32Array(buffer,0,2),output=new Uint8Array(buffer,8);
|
|
40
|
+
try {
|
|
41
|
+
context.inputJson=input;
|
|
42
|
+
const text=evaluate.runInContext(context,{timeout:250});
|
|
43
|
+
if(typeof text!=='string')throw new Error('Invalid output');
|
|
44
|
+
const bytes=Buffer.from(text);
|
|
45
|
+
if(bytes.length>output.length)throw new Error('Output too large');
|
|
46
|
+
output.set(bytes); Atomics.store(state,1,bytes.length); Atomics.store(state,0,1);
|
|
47
|
+
} catch { Atomics.store(state,0,-1); }
|
|
48
|
+
finally { if(context)delete context.inputJson; Atomics.notify(state,0); }
|
|
49
|
+
});
|
|
50
|
+
`, { eval: true, workerData: { code: artifact.code, ready: readyBuffer },
|
|
51
|
+
resourceLimits: { maxOldGenerationSizeMb: 48, maxYoungGenerationSizeMb: 8, stackSizeMb: 4 } });
|
|
52
|
+
let stopped = false;
|
|
53
|
+
const dispose = () => { if (!stopped) {
|
|
54
|
+
stopped = true;
|
|
55
|
+
void worker.terminate();
|
|
56
|
+
} };
|
|
57
|
+
worker.on('error', () => { stopped = true; });
|
|
58
|
+
worker.on('exit', () => { stopped = true; });
|
|
59
|
+
worker.unref();
|
|
60
|
+
Atomics.wait(ready, 0, 0, 4000);
|
|
61
|
+
if (Atomics.load(ready, 0) !== 1) {
|
|
62
|
+
dispose();
|
|
63
|
+
throw new Error('Detector initialization failed');
|
|
64
|
+
}
|
|
65
|
+
const invoke = (tool, args) => {
|
|
66
|
+
if (stopped)
|
|
67
|
+
throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector worker unavailable');
|
|
68
|
+
const input = JSON.stringify([tool, args, (0, detectionData_1.getDetectionData)()]);
|
|
69
|
+
if (Buffer.byteLength(input) > 256 * 1024)
|
|
70
|
+
throw new Error('Detector input exceeds bound');
|
|
71
|
+
const buffer = new SharedArrayBuffer(512 * 1024 + 8);
|
|
72
|
+
const state = new Int32Array(buffer, 0, 2);
|
|
73
|
+
worker.postMessage({ input, buffer });
|
|
74
|
+
// VM deadlines count wall time, including time descheduled by Windows.
|
|
75
|
+
// Allow scheduling headroom while retaining a hard half-second fallback.
|
|
76
|
+
Atomics.wait(state, 0, 0, 500);
|
|
77
|
+
if (Atomics.load(state, 0) !== 1) {
|
|
78
|
+
dispose();
|
|
79
|
+
throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector timed out or failed');
|
|
80
|
+
}
|
|
81
|
+
return JSON.parse(Buffer.from(new Uint8Array(buffer, 8, Atomics.load(state, 1))).toString('utf8'));
|
|
82
|
+
};
|
|
83
|
+
return Object.assign(invoke, { dispose });
|
|
84
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** In-memory detector contract. No downloads, file reads or model calls here. */
|
|
2
|
+
export interface DetectorFacts {
|
|
3
|
+
operation: string;
|
|
4
|
+
context: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
/** A terminated worker cannot recover on the next action. */
|
|
7
|
+
export declare class DetectorWorkerUnavailable extends Error {
|
|
8
|
+
}
|
|
9
|
+
type Detector = ((tool: string, args: Record<string, any>) => DetectorFacts) & {
|
|
10
|
+
dispose?(): void;
|
|
11
|
+
};
|
|
12
|
+
export declare function detectorRuntimeStatus(): {
|
|
13
|
+
revision: number;
|
|
14
|
+
failures: number;
|
|
15
|
+
active: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function installDetector(next: Detector, nextRevision: number): void;
|
|
18
|
+
export declare function updatedDetectorFacts(tool: string, args: Record<string, any>): DetectorFacts | undefined;
|
|
19
|
+
/** Shared validation for smoke tests and every actual inference result. */
|
|
20
|
+
export declare function validateDetectorFacts(result: DetectorFacts): DetectorFacts;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DetectorWorkerUnavailable = void 0;
|
|
4
|
+
exports.detectorRuntimeStatus = detectorRuntimeStatus;
|
|
5
|
+
exports.installDetector = installDetector;
|
|
6
|
+
exports.updatedDetectorFacts = updatedDetectorFacts;
|
|
7
|
+
exports.validateDetectorFacts = validateDetectorFacts;
|
|
8
|
+
/** A terminated worker cannot recover on the next action. */
|
|
9
|
+
class DetectorWorkerUnavailable extends Error {
|
|
10
|
+
}
|
|
11
|
+
exports.DetectorWorkerUnavailable = DetectorWorkerUnavailable;
|
|
12
|
+
let detector;
|
|
13
|
+
let revision = 0;
|
|
14
|
+
let failures = 0;
|
|
15
|
+
function detectorRuntimeStatus() { return { revision, failures, active: Boolean(detector) }; }
|
|
16
|
+
function installDetector(next, nextRevision) {
|
|
17
|
+
if (!Number.isSafeInteger(nextRevision) || nextRevision <= revision)
|
|
18
|
+
throw new Error('Detector revision must increase');
|
|
19
|
+
detector?.dispose?.();
|
|
20
|
+
detector = next;
|
|
21
|
+
revision = nextRevision;
|
|
22
|
+
failures = 0;
|
|
23
|
+
}
|
|
24
|
+
function updatedDetectorFacts(tool, args) {
|
|
25
|
+
if (!detector)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
return validateDetectorFacts(detector(tool, args));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
failures++;
|
|
32
|
+
if (error instanceof DetectorWorkerUnavailable || failures >= 3) {
|
|
33
|
+
detector?.dispose?.();
|
|
34
|
+
detector = undefined;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Shared validation for smoke tests and every actual inference result. */
|
|
40
|
+
function validateDetectorFacts(result) {
|
|
41
|
+
if (!result || typeof result.operation !== 'string' || !result.operation || result.operation.length > 100
|
|
42
|
+
|| !result.context || typeof result.context !== 'object' || Array.isArray(result.context))
|
|
43
|
+
throw new Error('Invalid detector result');
|
|
44
|
+
const entries = Object.entries(result.context);
|
|
45
|
+
if (entries.length > 200 || entries.some(([key, value]) => key.length > 160 || typeof value !== 'string' || value.length > 100_000
|
|
46
|
+
|| /^(?:__proto__|constructor|prototype|verdict|decision|approval)$/i.test(key)))
|
|
47
|
+
throw new Error('Invalid detector facts');
|
|
48
|
+
return { operation: result.operation, context: Object.fromEntries(entries) };
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DetectionUpdates } from './detectionUpdates';
|
|
2
|
+
import { type DetectorArtifact } from './detectorArtifact';
|
|
3
|
+
type Snapshot = DetectorArtifact | {
|
|
4
|
+
revision: 0;
|
|
5
|
+
};
|
|
6
|
+
export declare class DetectorUpdates extends DetectionUpdates<Snapshot> {
|
|
7
|
+
constructor(cachePath: string, publicKey?: string, channel?: 'stable' | 'staging', machineId?: string);
|
|
8
|
+
getDetectorStatus(): {
|
|
9
|
+
active: boolean;
|
|
10
|
+
failures: number;
|
|
11
|
+
baselineRejected: boolean;
|
|
12
|
+
sha256: string | undefined;
|
|
13
|
+
sourceCommit: string | undefined;
|
|
14
|
+
revision: number;
|
|
15
|
+
bundledRevision: number;
|
|
16
|
+
source: "bundled" | "update";
|
|
17
|
+
lastCheckedAt?: string;
|
|
18
|
+
state: "bundled" | "current" | "unavailable" | "rejected" | "not_targeted";
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DetectorUpdates = void 0;
|
|
4
|
+
const detectionUpdates_1 = require("./detectionUpdates");
|
|
5
|
+
const detectorArtifact_1 = require("./detectorArtifact");
|
|
6
|
+
const detectorModule_1 = require("./detectorModule");
|
|
7
|
+
const detectorRuntime_1 = require("./detectorRuntime");
|
|
8
|
+
const detectorBaseline_1 = require("./detectorBaseline");
|
|
9
|
+
let current = { revision: 0 };
|
|
10
|
+
let baselineRejected = false;
|
|
11
|
+
/** Compile once: the smoke-tested worker is the worker adopted after persistence. */
|
|
12
|
+
function prepareDetector(data) {
|
|
13
|
+
if (!('code' in data))
|
|
14
|
+
throw new Error('Missing detector code');
|
|
15
|
+
const infer = (0, detectorModule_1.compileDetector)(data);
|
|
16
|
+
let adopted = false;
|
|
17
|
+
try {
|
|
18
|
+
(0, detectorRuntime_1.validateDetectorFacts)(infer('read_file', { path: 'sample.txt' }));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
infer.dispose();
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
activate() {
|
|
26
|
+
if (data.revision <= current.revision)
|
|
27
|
+
return false;
|
|
28
|
+
(0, detectorRuntime_1.installDetector)(infer, data.revision);
|
|
29
|
+
adopted = true;
|
|
30
|
+
current = Object.freeze({ ...data });
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
dispose() { if (!adopted)
|
|
34
|
+
infer.dispose(); },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (detectorBaseline_1.BUNDLED_DETECTOR_ENVELOPE) {
|
|
38
|
+
try {
|
|
39
|
+
const issuedAt = JSON.parse(JSON.parse(detectorBaseline_1.BUNDLED_DETECTOR_ENVELOPE).payload).issuedAt;
|
|
40
|
+
const bundled = (0, detectorArtifact_1.verifyDetectorArtifact)(detectorBaseline_1.BUNDLED_DETECTOR_ENVELOPE, Math.min(Date.now(), issuedAt));
|
|
41
|
+
if (bundled.machineIds !== null)
|
|
42
|
+
throw new Error('CLI baseline must be approved for the full fleet');
|
|
43
|
+
const prepared = prepareDetector(bundled);
|
|
44
|
+
try {
|
|
45
|
+
prepared.activate();
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
prepared.dispose();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
baselineRejected = true;
|
|
53
|
+
} // Preserve compiled-in inference and CLI startup.
|
|
54
|
+
}
|
|
55
|
+
const bundledRevision = current.revision;
|
|
56
|
+
const adapter = {
|
|
57
|
+
url: 'https://storage.googleapis.com/fullcourtdefense-cli-releases/detectors/v1/stable.json',
|
|
58
|
+
maxBytes: detectorArtifact_1.MAX_DETECTOR_BYTES, bundledRevision,
|
|
59
|
+
current: () => current,
|
|
60
|
+
verify: detectorArtifact_1.verifyDetectorArtifact,
|
|
61
|
+
prepare: prepareDetector,
|
|
62
|
+
activate: data => {
|
|
63
|
+
if (data.revision <= current.revision)
|
|
64
|
+
return false;
|
|
65
|
+
const prepared = prepareDetector(data);
|
|
66
|
+
try {
|
|
67
|
+
return prepared.activate();
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
prepared.dispose();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
class DetectorUpdates extends detectionUpdates_1.DetectionUpdates {
|
|
75
|
+
constructor(cachePath, publicKey, channel = 'stable', machineId) {
|
|
76
|
+
super(cachePath, publicKey, channel, { ...adapter,
|
|
77
|
+
eligible: data => !('machineIds' in data) || data.machineIds === null || Boolean(machineId && data.machineIds.includes(machineId)) });
|
|
78
|
+
}
|
|
79
|
+
getDetectorStatus() {
|
|
80
|
+
const { active, failures } = (0, detectorRuntime_1.detectorRuntimeStatus)();
|
|
81
|
+
return { ...this.getStatus(), active, failures, baselineRejected,
|
|
82
|
+
sha256: 'sha256' in current ? current.sha256 : undefined,
|
|
83
|
+
sourceCommit: 'sourceCommit' in current ? current.sourceCommit : undefined };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.DetectorUpdates = DetectorUpdates;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// ../../backend/src/modules/discovery/friction-rollout.ts
|
|
21
|
+
var friction_rollout_exports = {};
|
|
22
|
+
__export(friction_rollout_exports, {
|
|
23
|
+
assessFrictionRollout: () => assessFrictionRollout
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(friction_rollout_exports);
|
|
26
|
+
function assessFrictionRollout(input) {
|
|
27
|
+
const value = input;
|
|
28
|
+
if (!value || typeof value.baselineVersion !== "string" || typeof value.candidateVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(value.baselineVersion) || !/^\d+\.\d+\.\d+$/.test(value.candidateVersion) || !["replay", "real_hook", "real_ide", "production"].includes(String(value.evidenceLevel)) || !Array.isArray(value.observations) || value.observations.length > 2e3) throw new Error("Invalid paired evidence");
|
|
29
|
+
const artifact = (raw, bundled = false) => {
|
|
30
|
+
const item = raw;
|
|
31
|
+
if (!item || !Number.isSafeInteger(item.revision) || Number(item.revision) < (bundled ? 0 : 1) || typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)) throw new Error("Invalid detector artifact identity");
|
|
32
|
+
return { revision: Number(item.revision), sha256: item.sha256 };
|
|
33
|
+
};
|
|
34
|
+
const hasDetection = value.baselineDetection !== void 0 || value.candidateDetection !== void 0;
|
|
35
|
+
const baselineDetection = hasDetection ? artifact(value.baselineDetection, true) : void 0;
|
|
36
|
+
const candidateDetection = hasDetection ? artifact(value.candidateDetection) : void 0;
|
|
37
|
+
const sameCli = value.baselineVersion === value.candidateVersion;
|
|
38
|
+
if (baselineDetection && candidateDetection) {
|
|
39
|
+
if (candidateDetection.revision < baselineDetection.revision || candidateDetection.revision === baselineDetection.revision && candidateDetection.sha256 !== baselineDetection.sha256) {
|
|
40
|
+
throw new Error("Detector revision downgrade or reuse");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (sameCli && (!baselineDetection || !candidateDetection || candidateDetection.revision <= baselineDetection.revision || candidateDetection.sha256 === baselineDetection.sha256)) throw new Error("Same CLI requires distinct, increasing detector artifacts");
|
|
44
|
+
const verdicts = ["allow", "block", "require_approval"];
|
|
45
|
+
const seen = /* @__PURE__ */ new Set();
|
|
46
|
+
let fixed = 0, regressed = 0, unchanged = 0, missingEffects = 0, remainingErrors = 0;
|
|
47
|
+
let benign = 0, guarded = 0, baselineInterruptions = 0, candidateInterruptions = 0;
|
|
48
|
+
const families = /* @__PURE__ */ new Set();
|
|
49
|
+
const machines = /* @__PURE__ */ new Set();
|
|
50
|
+
for (const row of value.observations) {
|
|
51
|
+
if (!row || !["caseId", "family", "machineId", "policyHash"].every((key2) => typeof row[key2] === "string" && /^[a-zA-Z0-9_.:-]{1,100}$/.test(row[key2])) || ![row.expected, row.baseline, row.candidate].every((v) => verdicts.includes(v)) || typeof row.sideEffectVerified !== "boolean") throw new Error("Invalid observation");
|
|
52
|
+
const key = JSON.stringify([row.machineId, row.caseId, row.policyHash]);
|
|
53
|
+
if (seen.has(key)) throw new Error("Duplicate paired observation");
|
|
54
|
+
seen.add(key);
|
|
55
|
+
families.add(row.family);
|
|
56
|
+
machines.add(row.machineId);
|
|
57
|
+
const before = row.baseline === row.expected, after = row.candidate === row.expected;
|
|
58
|
+
if (!before && after) fixed++;
|
|
59
|
+
else if (before && !after) regressed++;
|
|
60
|
+
else unchanged++;
|
|
61
|
+
if (!after) remainingErrors++;
|
|
62
|
+
if (!row.sideEffectVerified) missingEffects++;
|
|
63
|
+
if (row.expected === "allow") {
|
|
64
|
+
benign++;
|
|
65
|
+
if (row.baseline !== "allow") baselineInterruptions++;
|
|
66
|
+
if (row.candidate !== "allow") candidateInterruptions++;
|
|
67
|
+
} else guarded++;
|
|
68
|
+
}
|
|
69
|
+
const reasons = [];
|
|
70
|
+
if (!benign || !guarded) reasons.push("Both benign actions and prohibited/approval counterparts are required");
|
|
71
|
+
if (!fixed) reasons.push("No measured correction");
|
|
72
|
+
if (regressed || remainingErrors) reasons.push("Candidate has regressions or unresolved mismatches");
|
|
73
|
+
if (missingEffects) reasons.push("Actual side-effect verification is missing");
|
|
74
|
+
if (value.evidenceLevel !== "production") reasons.push("This evidence cannot establish production improvement");
|
|
75
|
+
return {
|
|
76
|
+
schema: "fcd.paired-feedback.v1",
|
|
77
|
+
baselineVersion: value.baselineVersion,
|
|
78
|
+
candidateVersion: value.candidateVersion,
|
|
79
|
+
...hasDetection ? { baselineDetection, candidateDetection } : {},
|
|
80
|
+
changeKind: sameCli ? "detector_only" : hasDetection && baselineDetection.sha256 !== candidateDetection.sha256 ? "cli_and_detector" : "cli",
|
|
81
|
+
evidenceLevel: value.evidenceLevel,
|
|
82
|
+
cases: seen.size,
|
|
83
|
+
families: families.size,
|
|
84
|
+
machines: machines.size,
|
|
85
|
+
fixed,
|
|
86
|
+
regressed,
|
|
87
|
+
unchanged,
|
|
88
|
+
remainingErrors,
|
|
89
|
+
missingEffects,
|
|
90
|
+
benign,
|
|
91
|
+
guarded,
|
|
92
|
+
baselineInterruptions,
|
|
93
|
+
candidateInterruptions,
|
|
94
|
+
eligibleForOwnerReview: reasons.length === 0,
|
|
95
|
+
reasons,
|
|
96
|
+
automaticPublication: false,
|
|
97
|
+
note: "Labels and side effects are reviewer-supplied evidence, not independently verified by this gate. Publication requires reviewed signed release evidence."
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
101
|
+
0 && (module.exports = {
|
|
102
|
+
assessFrictionRollout
|
|
103
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -137,6 +137,7 @@ function printHelp() {
|
|
|
137
137
|
Exits 1 when remnants are found. Works via npx after the CLI
|
|
138
138
|
itself is uninstalled.
|
|
139
139
|
doctor First step. Checks outbound HTTPS access to FullCourtDefense.
|
|
140
|
+
hotfix-status Show loaded detector revision, update state and fallback status.
|
|
140
141
|
--perf measures this machine's real per-event overhead
|
|
141
142
|
(hook latency, spawn floor, cmd-guard cost, disk footprint).
|
|
142
143
|
--health prints an mdatp-health-style per-subsystem status
|
|
@@ -553,6 +554,13 @@ async function main() {
|
|
|
553
554
|
await doctorCommand(args, config);
|
|
554
555
|
break;
|
|
555
556
|
}
|
|
557
|
+
case 'hotfix-status': {
|
|
558
|
+
const { localDetectorUpdates } = await Promise.resolve().then(() => __importStar(require('./localDetectionUpdates')));
|
|
559
|
+
const { cliVersion } = await Promise.resolve().then(() => __importStar(require('./cliVersion')));
|
|
560
|
+
console.log(JSON.stringify({ cliVersion: cliVersion(), detector: localDetectorUpdates.getDetectorStatus(),
|
|
561
|
+
note: 'Acceptance is not proof of correction. Compare the reported action and prohibited counterparts under the same policy.' }, null, 2));
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
556
564
|
case 'demo-actions': {
|
|
557
565
|
const { demoActionsCommand } = await Promise.resolve().then(() => __importStar(require('./commands/demoActions')));
|
|
558
566
|
await demoActionsCommand({ live: flags.live, json: flags.json, demoPolicies: flags['demo-policies'], timeout: flags.timeout }, config);
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import { DetectionUpdates } from './detectionUpdates';
|
|
2
|
-
|
|
2
|
+
import { DetectorUpdates } from './detectorUpdates';
|
|
3
|
+
export declare const localDetectionUpdates: DetectionUpdates<import("./detectionData").DetectionData>;
|
|
4
|
+
export declare const localDetectorUpdates: DetectorUpdates;
|
|
@@ -33,9 +33,16 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.localDetectionUpdates = void 0;
|
|
36
|
+
exports.localDetectorUpdates = exports.localDetectionUpdates = void 0;
|
|
37
37
|
const os = __importStar(require("os"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const detectionUpdates_1 = require("./detectionUpdates");
|
|
40
|
+
const detectorUpdates_1 = require("./detectorUpdates");
|
|
41
|
+
const machineIdentity_1 = require("./machineIdentity");
|
|
40
42
|
exports.localDetectionUpdates = new detectionUpdates_1.DetectionUpdates(path.join(os.homedir(), '.fullcourtdefense', 'detection-update.json'));
|
|
41
43
|
exports.localDetectionUpdates.loadLocal();
|
|
44
|
+
exports.localDetectorUpdates = new detectorUpdates_1.DetectorUpdates(path.join(os.homedir(), '.fullcourtdefense', 'detector-update-v1.json'), undefined, 'stable', (0, machineIdentity_1.getMachineIdentity)().machineId);
|
|
45
|
+
exports.localDetectorUpdates.loadLocal();
|
|
46
|
+
// Long-running gateways observe the daemon's accepted cache without a restart.
|
|
47
|
+
// This timer is background-only and never keeps a short-lived hook alive.
|
|
48
|
+
setInterval(() => { exports.localDetectionUpdates.loadLocal(); exports.localDetectorUpdates.loadLocal(); }, 30_000).unref();
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { type ActionIdentity } from './actionIdentity';
|
|
2
2
|
export interface SpoolEvent extends ActionIdentity {
|
|
3
|
+
cliVersion?: string;
|
|
4
|
+
detectorRevision?: number;
|
|
5
|
+
detectorSha256?: string;
|
|
6
|
+
/** Loaded module state when recording, not proof this module decided the action. */
|
|
7
|
+
detectorActive?: boolean;
|
|
3
8
|
agentClient?: string;
|
|
4
9
|
eventId: string;
|
|
5
10
|
type: 'verdict';
|
package/dist/telemetry.js
CHANGED
|
@@ -52,6 +52,8 @@ const path = __importStar(require("path"));
|
|
|
52
52
|
const machineIdentity_1 = require("./machineIdentity");
|
|
53
53
|
const actionIdentity_1 = require("./actionIdentity");
|
|
54
54
|
const localDetectionUpdates_1 = require("./localDetectionUpdates");
|
|
55
|
+
const cliVersion_1 = require("./cliVersion");
|
|
56
|
+
const installedCliVersion = (0, cliVersion_1.cliVersion)();
|
|
55
57
|
/**
|
|
56
58
|
* Local-first telemetry: every enforcement decision is appended to an on-disk
|
|
57
59
|
* spool (instant, offline-safe) and flushed to the backend in batches. Critical
|
|
@@ -194,7 +196,12 @@ function spoolEvent(event) {
|
|
|
194
196
|
// took. Absent when a caller spools outside a hook evaluation (guards,
|
|
195
197
|
// onboard) — the backend treats missing fields as "no sample".
|
|
196
198
|
const timing = verdictTiming.getStore();
|
|
199
|
+
const detector = localDetectionUpdates_1.localDetectorUpdates.getDetectorStatus();
|
|
197
200
|
const full = {
|
|
201
|
+
cliVersion: installedCliVersion,
|
|
202
|
+
detectorRevision: detector.revision,
|
|
203
|
+
detectorSha256: detector.sha256,
|
|
204
|
+
detectorActive: detector.active,
|
|
198
205
|
...(0, actionIdentity_1.captureActionIdentity)(),
|
|
199
206
|
...timing?.identity,
|
|
200
207
|
eventId: crypto.randomUUID(),
|
|
@@ -366,6 +373,7 @@ async function flushSpoolLocked(input) {
|
|
|
366
373
|
? {
|
|
367
374
|
agentVersion: input.agentVersion,
|
|
368
375
|
detectionUpdates: localDetectionUpdates_1.localDetectionUpdates.getStatus(),
|
|
376
|
+
detectorUpdates: localDetectionUpdates_1.localDetectorUpdates.getDetectorStatus(),
|
|
369
377
|
integrityOk: input.integrityOk,
|
|
370
378
|
integrityReasons: input.integrityReasons,
|
|
371
379
|
integrityCheckedAt: input.integrityCheckedAt,
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.22",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"README.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
+
"test:detector-updates": "npm run build && node scripts/test-detector-artifacts.js && node scripts/test-detector-publication.js && node scripts/test-detection-updates.js && node scripts/test-detection-cache.js",
|
|
16
17
|
"test:native-hooks": "npm run build && node scripts/test-codex-activation.js && node scripts/test-native-hook-launch.js && node scripts/test-native-hook-edges.js && node scripts/test-client-telemetry.js && node scripts/test-native-hook-clients.js",
|
|
17
18
|
"test:endpoint-approvals": "npm run build && node scripts/audit-endpoint-mcp-shell.js",
|
|
18
19
|
"test:endpoint-clients": "npm run build && node scripts/test-endpoint-client-matrix.js && node scripts/test-copilot-hook-install.js",
|
|
@@ -96,7 +97,7 @@
|
|
|
96
97
|
"test:ide-fp-corpus:hook": "npm run build && node scripts/test-ide-fp-corpus.js --hook",
|
|
97
98
|
"test:msi-payload-deps": "npm run build && node scripts/test-msi-payload-deps.js",
|
|
98
99
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
99
|
-
"prepublishOnly": "npm run build && node scripts/check-detection-baseline.js && node scripts/test-detection-updates.js && node scripts/test-detection-cache.js && node scripts/test-msi-payload-deps.js && node scripts/test-shell-parity.js && node scripts/test-terminal-mcp-ask.js"
|
|
100
|
+
"prepublishOnly": "npm run build && node scripts/check-detection-baseline.js && node scripts/check-detector-baseline.js && node scripts/test-detector-artifacts.js && node scripts/test-detector-publication.js && node scripts/test-detection-updates.js && node scripts/test-detection-cache.js && node scripts/test-msi-payload-deps.js && node scripts/test-shell-parity.js && node scripts/test-terminal-mcp-ask.js"
|
|
100
101
|
},
|
|
101
102
|
"keywords": [
|
|
102
103
|
"llm",
|