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 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
- return prefix + tail;
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,
@@ -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.
@@ -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
- constructor(cachePath: string, publicKey?: string, channel?: 'stable' | 'staging');
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;
@@ -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
- constructor(cachePath, publicKey = detectionData_1.DETECTION_PUBLIC_KEY, channel = 'stable') {
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 = (0, detectionData_1.getDetectionData)().revision;
59
- return { ...this.status, revision, source: revision > detectionData_1.BUNDLED_DETECTION_DATA.revision ? 'update' : 'bundled' };
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 > detectionData_1.MAX_DETECTION_BYTES)
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 (0, detectionData_1.verifyDetectionEnvelope)(raw, Math.min(Date.now(), payload.issuedAt), this.publicKey);
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
- (0, detectionData_1.activateDetectionData)(this.cachedData(raw));
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' ? exports.DETECTION_UPDATE_URL.replace('/stable.json', '/staging.json') : exports.DETECTION_UPDATE_URL, { redirect: 'error', signal: AbortSignal.timeout(3000) });
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 > detectionData_1.MAX_DETECTION_BYTES)
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 = (0, detectionData_1.verifyDetectionEnvelope)(raw, Date.now(), this.publicKey);
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, (0, detectionData_1.getDetectionData)().revision);
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 === (0, detectionData_1.getDetectionData)().revision && JSON.stringify(data) !== JSON.stringify((0, detectionData_1.getDetectionData)()))
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
- (0, detectionData_1.activateDetectionData)(data);
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,4 @@
1
+ /** Updated by the baseline incorporation command after a stable release.
2
+ * Empty only for the first bootstrap. The signature is retained in the CLI.
3
+ */
4
+ export declare const BUNDLED_DETECTOR_ENVELOPE = "";
@@ -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
- export declare const localDetectionUpdates: DetectionUpdates;
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();
@@ -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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.34.21"
2
+ "version": "1.34.22"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.34.21",
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",