log-llm-config 1.3.61 → 1.3.63

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.
@@ -7,10 +7,13 @@
7
7
  * the same JSON line to execute_trusted_restarts (TS allowlist + spawn).
8
8
  */
9
9
  import { applyAutofixViolations, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, runLocalRemediationComplianceCheck, } from './log_config_files/runtime/compliance_check.js';
10
- import { existsSync, statSync } from 'node:fs';
10
+ import { existsSync, readFileSync, statSync } from 'node:fs';
11
11
  import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
12
12
  import { hookLogSessionBanner, hookRunLog, logRemediationApplyFailure } from './log_config_files/runtime/hook_logger.js';
13
13
  import { isThisCliModule } from './cli_invocation_match.js';
14
+ import { ensureAuthentication } from './log_config_files/auth/auth_flow.js';
15
+ import { sendConfigFile } from './log_config_files/sender/batch_sender.js';
16
+ import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid.js';
14
17
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
15
18
  function parseIde() {
16
19
  const eq = process.argv.find((a) => a.startsWith('--ide='));
@@ -87,6 +90,38 @@ function autofixDialogLine(v) {
87
90
  const short = d.length > 160 ? `${d.slice(0, 157)}…` : d;
88
91
  return `• [${v.finding_formatted_id}] ${short}`;
89
92
  }
93
+ /**
94
+ * Upload a secondary compliance file to the backend so the server can resolve the finding.
95
+ * Fire-and-forget: upload runs in background; any failure is logged but does not block the gate.
96
+ */
97
+ async function _uploadSecondaryFile(entry) {
98
+ const { uuid, config_file_path, file_type } = entry;
99
+ if (!file_type) {
100
+ hookRunLog(`secondary_upload: skipping uuid=${uuid} — no file_type on secondary group`);
101
+ return;
102
+ }
103
+ let rawContent;
104
+ try {
105
+ rawContent = JSON.parse(readFileSync(config_file_path, 'utf8'));
106
+ }
107
+ catch {
108
+ hookRunLog(`secondary_upload: could not read file uuid=${uuid} path=${config_file_path}`);
109
+ return;
110
+ }
111
+ const hw = tryResolveHardwareUuid();
112
+ if (!hw) {
113
+ hookRunLog(`secondary_upload: hardware UUID unavailable, skipping uuid=${uuid}`);
114
+ return;
115
+ }
116
+ try {
117
+ const authKey = await ensureAuthentication(hw);
118
+ const sent = await sendConfigFile({ file_type, file_path: config_file_path, raw_content: rawContent }, hw, authKey);
119
+ hookRunLog(`secondary_upload: uuid=${uuid} path=${config_file_path} sent=${sent}`);
120
+ }
121
+ catch (err) {
122
+ hookRunLog(`secondary_upload: upload failed uuid=${uuid} path=${config_file_path} err=${err instanceof Error ? err.message : String(err)}`);
123
+ }
124
+ }
90
125
  /**
91
126
  * Entry when the default npm bin (`log-llm-config` / cli.js) dispatches
92
127
  * `compliance_prompt_gate` — npx does not execute compliance_prompt_gate.js as argv[1], so
@@ -101,6 +136,11 @@ export async function runCompliancePromptGate() {
101
136
  const agent = parseAgent(ide);
102
137
  hookLogSessionBanner('compliance_prompt_gate (before submit)');
103
138
  const status = runLocalRemediationComplianceCheck(agent);
139
+ // Secondary-satisfied: primary checks failed but settings.json (or equiv) has the fix.
140
+ // Upload those files fire-and-forget so the backend can resolve the finding immediately.
141
+ for (const e of status.secondarySatisfied ?? []) {
142
+ _uploadSecondaryFile(e).catch(() => undefined);
143
+ }
104
144
  if (status.status === 'fail' && status.violations.length > 0) {
105
145
  const staleMs = getManifestStalenessMs();
106
146
  if (staleMs !== null && staleMs > MANIFEST_STALE_MS) {
@@ -4,10 +4,72 @@ import { homedir } from 'node:os';
4
4
  import { readJSONFile } from '../readers/file_readers.js';
5
5
  import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
6
6
  import { getCursorProjectsPath } from '../paths/path_constants_helpers.js';
7
+ import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
8
+ /**
9
+ * Read workspaceMetadata.entries from the Cursor global state.vscdb and emit one
10
+ * cursor_workspace_vscdb ConfigFileData per workspace entry.
11
+ *
12
+ * Cursor maintains a canonical mapping of workspace hash → display path in the global
13
+ * state.vscdb under "workspaceMetadata.entries". Each entry has:
14
+ * { workspaceId: "<hash>", displayPath: "~/my-project", folderUri: "file:///Users/..." }
15
+ *
16
+ * The per-workspace workspaceStorage/<hash>/state.vscdb holds cursor/disabledMcpServers.
17
+ * Together they give us: which servers are disabled per workspace + a human-readable label.
18
+ */
19
+ function collectWorkspaceVscdbs(spec, home = homedir()) {
20
+ if (!spec.global_vscdb_path_segments?.length || !spec.global_workspace_list_key) {
21
+ return [];
22
+ }
23
+ const globalDbPath = join(home, ...spec.global_vscdb_path_segments);
24
+ if (!existsSync(globalDbPath))
25
+ return [];
26
+ const globalResult = readVscdbItemTableJson(globalDbPath, spec.global_workspace_list_key);
27
+ if (!globalResult)
28
+ return [];
29
+ const rawValue = globalResult[spec.global_workspace_list_key];
30
+ // The stored value is {"entries": [...]}; unwrap it.
31
+ const entries = Array.isArray(rawValue)
32
+ ? rawValue
33
+ : Array.isArray(rawValue?.entries)
34
+ ? rawValue.entries
35
+ : [];
36
+ if (!entries.length)
37
+ return [];
38
+ const workspaceStoragePath = join(home, ...spec.workspace_storage_path_segments);
39
+ const output = [];
40
+ for (const entry of entries) {
41
+ if (!entry || typeof entry !== 'object')
42
+ continue;
43
+ const e = entry;
44
+ const workspaceId = typeof e.workspaceId === 'string' ? e.workspaceId : null;
45
+ if (!workspaceId)
46
+ continue;
47
+ const dbPath = join(workspaceStoragePath, workspaceId, spec.vscdb_filename);
48
+ if (!existsSync(dbPath))
49
+ continue;
50
+ const perWorkspaceResult = readVscdbItemTableJson(dbPath, spec.item_table_key);
51
+ const disabled = perWorkspaceResult?.[spec.item_table_key];
52
+ const rawContent = {
53
+ [spec.item_table_key]: Array.isArray(disabled) ? disabled : [],
54
+ };
55
+ if (typeof e.displayPath === 'string')
56
+ rawContent['displayPath'] = e.displayPath;
57
+ if (typeof e.folderUri === 'string')
58
+ rawContent['folderUri'] = e.folderUri;
59
+ rawContent['workspaceId'] = workspaceId;
60
+ output.push({
61
+ file_type: spec.file_type,
62
+ file_path: `${dbPath}#${spec.item_table_key}`,
63
+ raw_content: rawContent,
64
+ });
65
+ }
66
+ return output;
67
+ }
7
68
  function collectMcpToolFiles(pathSkipPrefixes = [], constants) {
8
69
  const result = [];
9
70
  const skipPrefixes = normalizePathSkipPrefixes(pathSkipPrefixes);
10
- const cursorProjectsPath = getCursorProjectsPath(homedir(), constants);
71
+ const home = homedir();
72
+ const cursorProjectsPath = getCursorProjectsPath(home, constants);
11
73
  try {
12
74
  if (!existsSync(cursorProjectsPath))
13
75
  return result;
@@ -34,4 +96,4 @@ function collectMcpToolFiles(pathSkipPrefixes = [], constants) {
34
96
  }
35
97
  return result;
36
98
  }
37
- export { collectMcpToolFiles };
99
+ export { collectMcpToolFiles, collectWorkspaceVscdbs };
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
4
- import { readFileCollectionVscdbContract, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
4
+ import { readFileCollectionVscdbContract, sanitizeFileCollectionVscdbContract, sanitizeReactiveStorageItemKey, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
5
5
  /**
6
6
  * ItemTable keys that store a bare JSON boolean; compliance paths use `${key}.${field}`.
7
7
  * Kept in sync with `coerceItemTableValueToObjectRoot` / `serializeItemTableValueForWrite` in remediation_sync.
@@ -52,11 +52,11 @@ export function normalizeVscdbComposerContractFromPatternsResponse(resp) {
52
52
  ((typeof c.reactive_storage_item_key === 'string' && c.reactive_storage_item_key.trim() !== '') ||
53
53
  (c.composer_shadow_keys?.length ?? 0) > 0);
54
54
  if (hasKey && c) {
55
- return {
55
+ return sanitizeFileCollectionVscdbContract({
56
56
  version: 1,
57
57
  reactive_storage_item_key: c.reactive_storage_item_key ?? undefined,
58
58
  composer_shadow_keys: [...(c.composer_shadow_keys ?? [])],
59
- };
59
+ }) ?? { version: 1, reactive_storage_item_key: undefined, composer_shadow_keys: [] };
60
60
  }
61
61
  return parseVscdbComposerContractFromReadQueries(resp.vscdb_read_queries);
62
62
  }
@@ -88,7 +88,7 @@ export function readVscdbItemTableJson(dbPath, itemKey) {
88
88
  try {
89
89
  const raw = querySqlite(dbPath, itemKey);
90
90
  const contract = readFileCollectionVscdbContract();
91
- const reactiveKey = typeof contract?.reactive_storage_item_key === 'string' ? contract.reactive_storage_item_key.trim() : '';
91
+ const reactiveKey = sanitizeReactiveStorageItemKey(contract?.reactive_storage_item_key) ?? '';
92
92
  if (itemKey === 'composerState' && (!raw || raw === '{}') && reactiveKey) {
93
93
  const reactive = querySqlite(dbPath, reactiveKey);
94
94
  if (reactive) {
@@ -106,6 +106,11 @@ export function readVscdbItemTableJson(dbPath, itemKey) {
106
106
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
107
107
  return { [itemKey]: parsed };
108
108
  }
109
+ // Bare JSON arrays (e.g. cursor/disabledMcpServers) — wrap under itemKey so ops-based
110
+ // compliance checks can locate the array via getByPath(itemKey).
111
+ if (Array.isArray(parsed)) {
112
+ return { [itemKey]: parsed };
113
+ }
109
114
  // Bare JSON primitives. Scalar toggles must be wrapped so getByPath(key.field) works in compliance checks.
110
115
  // Match coerceScalarForItemTableField in remediation_sync: Cursor may store toggles as JSON 0/1, not only booleans.
111
116
  if (typeof parsed === 'boolean' || typeof parsed === 'number' || typeof parsed === 'string') {
@@ -11,13 +11,15 @@
11
11
  * downloads the latest manifest so the gate has fresh data to act on.
12
12
  */
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
14
16
  import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
15
17
  import { readRemediationInstructionsFile, writeRemediationInstructionsFile } from './management_storage.js';
16
18
  import { resolveRemediationConfigPath } from './remediation_config_path.js';
17
19
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
18
20
  import { loadEndpointBase } from '../sender/endpoint_config.js';
19
21
  import { resolveHardwareUuid, tryResolveHardwareUuid } from './hardware_uuid.js';
20
- import { buildDeferredCursorRestartCommand, enforceRemediation, fetchSync, isTrustedRestartCommandForAutofix, remediationFixSpec, reportAutofixApplied, syncRemediations, } from './remediation_sync.js';
22
+ import { buildDeferredCursorRestartCommand, discoverAllWorkspaceVscdbs, enforceRemediation, fetchSync, isTrustedRestartCommandForAutofix, remediationFixSpec, reportAutofixApplied, syncRemediations, } from './remediation_sync.js';
21
23
  import { sendConfigFile } from '../sender/batch_sender.js';
22
24
  import { ensureAuthentication } from '../auth/auth_flow.js';
23
25
  /** Normalize manifest/env/CLI agent tokens to a known Agent, or '' if unrecognized. */
@@ -101,9 +103,34 @@ export function getByPath(obj, path) {
101
103
  }
102
104
  return current;
103
105
  }
104
- /** Deep-equal comparison via JSON serialisation (handles booleans, strings, numbers, null). */
106
+ /** Key-order-independent deep-equal comparison (handles primitives, arrays, plain objects). */
105
107
  function deepEqual(a, b) {
106
- return JSON.stringify(a) === JSON.stringify(b);
108
+ if (a === b)
109
+ return true;
110
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
111
+ return false;
112
+ if (Array.isArray(a) || Array.isArray(b)) {
113
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
114
+ return false;
115
+ for (let i = 0; i < a.length; i++) {
116
+ if (!deepEqual(a[i], b[i]))
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+ const ao = a;
122
+ const bo = b;
123
+ const aKeys = Object.keys(ao);
124
+ const bKeys = Object.keys(bo);
125
+ if (aKeys.length !== bKeys.length)
126
+ return false;
127
+ for (const k of aKeys) {
128
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
129
+ return false;
130
+ if (!deepEqual(ao[k], bo[k]))
131
+ return false;
132
+ }
133
+ return true;
107
134
  }
108
135
  function isStringArray(v) {
109
136
  return Array.isArray(v) && v.every((x) => typeof x === 'string');
@@ -131,16 +158,16 @@ function verifyOpsApplied(configJson, settingPath, ops) {
131
158
  continue;
132
159
  }
133
160
  const cur = getByPath(configJson, targetPath);
134
- const curArr = isStringArray(cur) ? cur : [];
161
+ const curArr = Array.isArray(cur) ? cur : [];
135
162
  const toAdd = add[k] ?? [];
136
163
  const toRemove = remove[k] ?? [];
137
164
  for (const item of toRemove) {
138
- if (curArr.includes(item)) {
165
+ if (curArr.some((curItem) => deepEqual(curItem, item))) {
139
166
  return { ok: false, expected: { op: 'remove', path: targetPath, value: item } };
140
167
  }
141
168
  }
142
169
  for (const item of toAdd) {
143
- if (!curArr.includes(item)) {
170
+ if (!curArr.some((curItem) => deepEqual(curItem, item))) {
144
171
  return { ok: false, expected: { op: 'add', path: targetPath, value: item } };
145
172
  }
146
173
  }
@@ -177,6 +204,25 @@ function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
177
204
  return { ok: false, reason: 'parse_error' };
178
205
  }
179
206
  }
207
+ /**
208
+ * Evaluate all checks in a secondary group against the group's config file.
209
+ * Returns true only if every check passes (ops-based). Non-ops checks are treated as failing.
210
+ */
211
+ function evaluateSecondaryGroup(group) {
212
+ const loaded = loadRemediationConfigJson(group.config_file_path, group.checks.map((c) => c.setting_path));
213
+ if (!loaded.ok) {
214
+ hookRunLog(`evaluateSecondaryGroup: could not load secondary file path=${group.config_file_path} reason=${loaded.reason}`);
215
+ return false;
216
+ }
217
+ for (const check of group.checks) {
218
+ if (!check.ops)
219
+ return false;
220
+ const { ok } = verifyOpsApplied(loaded.json, check.setting_path, check.ops);
221
+ if (!ok)
222
+ return false;
223
+ }
224
+ return true;
225
+ }
180
226
  // ---------------------------------------------------------------------------
181
227
  // Check runner — Section 6: real per-check evaluation
182
228
  // ---------------------------------------------------------------------------
@@ -194,6 +240,7 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
194
240
  }
195
241
  const uuids = entries.map((e) => e.uuid);
196
242
  const violations = [];
243
+ const secondarySatisfied = [];
197
244
  let skippedNoCompliance = 0;
198
245
  let skippedNonJson = 0;
199
246
  let skippedNoChecks = 0;
@@ -237,13 +284,63 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
237
284
  continue;
238
285
  }
239
286
  const configJson = loaded.json;
287
+ const entryViolations = [];
240
288
  for (const check of checks) {
289
+ // When the check carries a sqlite_op with apply_to_all_workspaces, the authoritative data
290
+ // lives in workspace state.vscdb files — not in config_file_path (which may be mcp.json in
291
+ // the fallback case where no workspace vscdb has been collected yet). Verify directly against
292
+ // every discovered workspace vscdb; fail as a violation if any is missing the required entry.
293
+ if (check.sqlite_op?.apply_to_all_workspaces && check.ops) {
294
+ const segments = check.sqlite_op.workspace_storage_path_segments ?? [
295
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
296
+ ];
297
+ const wsPath = join(homedir(), ...segments);
298
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
299
+ if (vscdbPaths.length === 0) {
300
+ hookRunLog(`compliance_check: apply_to_all_workspaces — no workspace vscdbs found at ${wsPath}, skipping uuid=${entry.uuid}`);
301
+ }
302
+ else {
303
+ let violated = false;
304
+ let expectedForViolation = null;
305
+ for (const dbPath of vscdbPaths) {
306
+ const wrapped = readVscdbItemTableJson(dbPath, check.sqlite_op.target_key ?? '');
307
+ if (wrapped === null) {
308
+ violated = true;
309
+ expectedForViolation = { op: 'read_failed', db: dbPath };
310
+ break;
311
+ }
312
+ const { ok, expected } = verifyOpsApplied(wrapped, check.setting_path, check.ops);
313
+ if (!ok) {
314
+ violated = true;
315
+ expectedForViolation = expected;
316
+ break;
317
+ }
318
+ }
319
+ if (violated) {
320
+ hookRunLog(`compliance_check: VIOLATION (vscdb) uuid=${entry.uuid} path=${check.setting_path} expected=${JSON.stringify(expectedForViolation)}`);
321
+ entryViolations.push({
322
+ uuid: entry.uuid,
323
+ finding_formatted_id: compliance.finding_formatted_id,
324
+ setting_path: check.setting_path,
325
+ description: check.description,
326
+ finding_title: entry.finding_title,
327
+ finding_description: entry.finding_description,
328
+ severity: compliance.severity,
329
+ autofix_allowed: compliance.autofix_allowed,
330
+ config_file_path: entry.config_file_path,
331
+ expected_value: expectedForViolation,
332
+ message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Apply remediation ops for ${check.setting_path} in workspace state.vscdb files`,
333
+ });
334
+ }
335
+ }
336
+ continue;
337
+ }
241
338
  // Prefer ops-based verification (matches delta apply semantics; doesn't require full after snapshot).
242
339
  if (check.ops) {
243
340
  const { ok, expected } = verifyOpsApplied(configJson, check.setting_path, check.ops);
244
341
  if (!ok) {
245
342
  hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} expected=${JSON.stringify(expected)}`);
246
- violations.push({
343
+ entryViolations.push({
247
344
  uuid: entry.uuid,
248
345
  finding_formatted_id: compliance.finding_formatted_id,
249
346
  setting_path: check.setting_path,
@@ -267,7 +364,7 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
267
364
  continue;
268
365
  if (!deepEqual(currentValue, expectedValue)) {
269
366
  hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} current=${JSON.stringify(currentValue)} expected=${JSON.stringify(expectedValue)}`);
270
- violations.push({
367
+ entryViolations.push({
271
368
  uuid: entry.uuid,
272
369
  finding_formatted_id: compliance.finding_formatted_id,
273
370
  setting_path: check.setting_path,
@@ -282,12 +379,30 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
282
379
  });
283
380
  }
284
381
  }
382
+ if (entryViolations.length > 0) {
383
+ const secondaryGroups = compliance.secondary_checks ?? [];
384
+ const passingGroup = secondaryGroups.length > 0
385
+ ? secondaryGroups.find((g) => evaluateSecondaryGroup(g))
386
+ : undefined;
387
+ if (passingGroup) {
388
+ hookRunLog(`compliance_check: secondary check satisfied uuid=${entry.uuid} — skipping ${entryViolations.length} primary violation(s)`);
389
+ secondarySatisfied.push({
390
+ uuid: entry.uuid,
391
+ config_file_path: passingGroup.config_file_path,
392
+ file_type: passingGroup.file_type,
393
+ });
394
+ }
395
+ else {
396
+ violations.push(...entryViolations);
397
+ }
398
+ }
285
399
  }
286
400
  const status = {
287
401
  status: violations.length > 0 ? 'fail' : 'ok',
288
402
  checked_at: new Date().toISOString(),
289
403
  manifest_uuids: uuids,
290
404
  violations,
405
+ secondarySatisfied: secondarySatisfied.length > 0 ? secondarySatisfied : undefined,
291
406
  };
292
407
  const skipParts = [];
293
408
  if (skippedNoCompliance)
@@ -527,12 +642,41 @@ export function pruneSatisfiedOneTimeRemediations(agent = 'cursor') {
527
642
  okAll = false;
528
643
  break;
529
644
  }
645
+ // apply_to_all_workspaces: verify against workspace vscdbs, not the loaded config file.
646
+ if (check.sqlite_op?.apply_to_all_workspaces) {
647
+ const segments = check.sqlite_op.workspace_storage_path_segments ?? [
648
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
649
+ ];
650
+ const wsPath = join(homedir(), ...segments);
651
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
652
+ if (vscdbPaths.length === 0) {
653
+ okAll = false;
654
+ break;
655
+ }
656
+ for (const dbPath of vscdbPaths) {
657
+ const wrapped = readVscdbItemTableJson(dbPath, check.sqlite_op.target_key ?? '');
658
+ if (wrapped === null || !verifyOpsApplied(wrapped, check.setting_path, check.ops).ok) {
659
+ okAll = false;
660
+ break;
661
+ }
662
+ }
663
+ if (!okAll)
664
+ break;
665
+ continue;
666
+ }
530
667
  const res = verifyOpsApplied(configJson, check.setting_path, check.ops);
531
668
  if (!res.ok) {
532
669
  okAll = false;
533
670
  break;
534
671
  }
535
672
  }
673
+ if (!okAll) {
674
+ const secondaryGroups = spec?.secondary_checks ?? [];
675
+ if (secondaryGroups.length > 0 && secondaryGroups.some((g) => evaluateSecondaryGroup(g))) {
676
+ okAll = true;
677
+ hookRunLog(`remediation_prune: secondary check satisfied uuid=${inst.uuid}`);
678
+ }
679
+ }
536
680
  if (okAll) {
537
681
  removed++;
538
682
  hookRunLog(`remediation_prune: satisfied one-time uuid=${inst.uuid} path=${inst.config_file_path}`);
@@ -12,6 +12,7 @@ import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
12
12
  import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
13
13
  import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
14
14
  import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, determineFileTypeFromPath } from '../collection/config_collector.js';
15
+ import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
15
16
  import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
16
17
  import { sendConfigFile, sendConfigFilesBatch, sendHookRequestCreate, sendHookRequestUpdateManifest, sendIngestSessionStart, sendIngestSessionFinish, BATCH_CHUNK_SIZE } from '../sender/batch_sender.js';
17
18
  import { canonicalCursorUserStateVscdbPath } from './remediation_config_path.js';
@@ -51,6 +52,16 @@ async function collectAllConfigFiles(endpointBase) {
51
52
  configFiles.push(m);
52
53
  }
53
54
  }
55
+ if (patternsResponse.workspace_vscdb_spec) {
56
+ hookRunLog(`scanning Cursor workspace vscdb files`);
57
+ for (const m of collectWorkspaceVscdbs(patternsResponse.workspace_vscdb_spec)) {
58
+ const key = `${m.file_type}\t${m.file_path}`;
59
+ if (!existingPaths.has(key)) {
60
+ existingPaths.add(key);
61
+ configFiles.push(m);
62
+ }
63
+ }
64
+ }
54
65
  hookRunLog(`scanning installed plugins`);
55
66
  if (!patternsResponse.client_path_constants)
56
67
  throw new Error('client_path_constants required from API response');
@@ -17,6 +17,33 @@ export const DEFERRED_VSCDB_RESTART_LOG_BASENAME = 'deferred_vscdb_restart.log';
17
17
  * of hardcoded Cursor paths.
18
18
  */
19
19
  export const FILE_COLLECTION_VSCDB_CONTRACT_BASENAME = 'file_collection_vscdb_contract.json';
20
+ export function sanitizeReactiveStorageItemKey(raw) {
21
+ if (typeof raw !== 'string')
22
+ return undefined;
23
+ const trimmed = raw.trim();
24
+ if (!trimmed || trimmed.length > 512)
25
+ return undefined;
26
+ if (/['"\\]/.test(trimmed))
27
+ return undefined;
28
+ return trimmed;
29
+ }
30
+ function sanitizeComposerShadowKeys(raw) {
31
+ if (!Array.isArray(raw))
32
+ return [];
33
+ return raw
34
+ .filter((entry) => typeof entry === 'string')
35
+ .map((entry) => entry.trim())
36
+ .filter((entry) => entry.length > 0);
37
+ }
38
+ export function sanitizeFileCollectionVscdbContract(contract) {
39
+ if (!contract || contract.version !== 1)
40
+ return null;
41
+ return {
42
+ version: 1,
43
+ reactive_storage_item_key: sanitizeReactiveStorageItemKey(contract.reactive_storage_item_key),
44
+ composer_shadow_keys: sanitizeComposerShadowKeys(contract.composer_shadow_keys),
45
+ };
46
+ }
20
47
  export function getManagementDir() {
21
48
  return join(homedir(), OPT_AI_SEC_MANAGEMENT_REL);
22
49
  }
@@ -38,16 +65,17 @@ export function readFileCollectionVscdbContract() {
38
65
  return null;
39
66
  try {
40
67
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
41
- if (parsed?.version !== 1 || !Array.isArray(parsed.composer_shadow_keys))
42
- return null;
43
- return parsed;
68
+ return sanitizeFileCollectionVscdbContract(parsed);
44
69
  }
45
70
  catch {
46
71
  return null;
47
72
  }
48
73
  }
49
74
  export function writeFileCollectionVscdbContract(contract) {
50
- atomicWriteJson(getFileCollectionVscdbContractPath(), contract);
75
+ const sanitized = sanitizeFileCollectionVscdbContract(contract);
76
+ if (!sanitized)
77
+ return;
78
+ atomicWriteJson(getFileCollectionVscdbContractPath(), sanitized);
51
79
  }
52
80
  export function atomicWriteJson(filePath, data) {
53
81
  const dir = dirname(filePath);
@@ -29,6 +29,11 @@ export function canonicalCursorUserStateVscdbPath(filePath) {
29
29
  }
30
30
  return filePath.trim();
31
31
  }
32
+ // Per-workspace vscdbs live under workspaceStorage/<hash>/state.vscdb and must NOT be
33
+ // normalized to the global path — each workspace is a distinct ConfigurationFile row.
34
+ if (lower.includes('workspacestorage')) {
35
+ return filePath.trim();
36
+ }
32
37
  const vscdb = 'state.vscdb';
33
38
  const vlen = vscdb.length;
34
39
  const slashKey = '/' + vscdb;
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
- import { delimiter, dirname } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
+ import { delimiter, dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
3
4
  import { execFileSync } from 'node:child_process';
4
5
  import { executeGet, executeBody } from '../../endpoint_client/http_transport.js';
5
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
@@ -209,6 +210,34 @@ function isPlainObject(v) {
209
210
  function isStringArray(v) {
210
211
  return Array.isArray(v) && v.every((x) => typeof x === 'string');
211
212
  }
213
+ function deepEqual(a, b) {
214
+ if (a === b)
215
+ return true;
216
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
217
+ return false;
218
+ if (Array.isArray(a) || Array.isArray(b)) {
219
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
220
+ return false;
221
+ for (let i = 0; i < a.length; i++) {
222
+ if (!deepEqual(a[i], b[i]))
223
+ return false;
224
+ }
225
+ return true;
226
+ }
227
+ const ao = a;
228
+ const bo = b;
229
+ const aKeys = Object.keys(ao);
230
+ const bKeys = Object.keys(bo);
231
+ if (aKeys.length !== bKeys.length)
232
+ return false;
233
+ for (const k of aKeys) {
234
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
235
+ return false;
236
+ if (!deepEqual(ao[k], bo[k]))
237
+ return false;
238
+ }
239
+ return true;
240
+ }
212
241
  function applyStringArrayDelta(current, before, after) {
213
242
  const cur = isStringArray(current) ? [...current] : [];
214
243
  const b = isStringArray(before) ? before : [];
@@ -253,16 +282,13 @@ function applyCheck(configJson, check) {
253
282
  continue;
254
283
  }
255
284
  const curVal = getByPath(configJson, targetPath);
256
- const cur = isStringArray(curVal) ? [...curVal] : [];
285
+ const cur = Array.isArray(curVal) ? [...curVal] : [];
257
286
  const toRemove = (remove && remove[k]) ?? [];
258
287
  const toAdd = (add && add[k]) ?? [];
259
- const rmSet = new Set(toRemove);
260
- const next = cur.filter((x) => !rmSet.has(x));
261
- const nextSet = new Set(next);
288
+ const next = cur.filter((x) => !toRemove.some((item) => deepEqual(item, x)));
262
289
  for (const x of toAdd) {
263
- if (!nextSet.has(x)) {
290
+ if (!next.some((item) => deepEqual(item, x))) {
264
291
  next.push(x);
265
- nextSet.add(x);
266
292
  }
267
293
  }
268
294
  setByPath(configJson, targetPath, next);
@@ -468,6 +494,11 @@ function coerceItemTableValueToObjectRoot(targetKey, parsed) {
468
494
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
469
495
  return parsed;
470
496
  }
497
+ // Bare JSON array (e.g. cursor/disabledMcpServers): wrap under target_key so array_union_add
498
+ // can locate and mutate it via mergeSqliteOpIntoJson.
499
+ if (Array.isArray(parsed)) {
500
+ return { [targetKey]: parsed };
501
+ }
471
502
  const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
472
503
  if (field) {
473
504
  const b = coerceScalarForItemTableField(parsed);
@@ -481,6 +512,12 @@ function coerceItemTableValueToObjectRoot(targetKey, parsed) {
481
512
  * object can be ignored or overwritten on launch; match the native primitive shape when disabling.
482
513
  */
483
514
  function serializeItemTableValueForWrite(targetKey, root) {
515
+ // Bare-array keys: root was wrapped as { [targetKey]: array } by coerceItemTableValueToObjectRoot.
516
+ // Unwrap back to a bare JSON array before writing.
517
+ const keys = Object.keys(root);
518
+ if (keys.length === 1 && keys[0] === targetKey && Array.isArray(root[targetKey])) {
519
+ return JSON.stringify(root[targetKey]);
520
+ }
484
521
  const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
485
522
  if (field) {
486
523
  const keys = Object.keys(root);
@@ -495,6 +532,22 @@ function serializeItemTableValueForWrite(targetKey, root) {
495
532
  return JSON.stringify(root);
496
533
  }
497
534
  function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
535
+ // Array union: target_key holds a bare JSON array (e.g. cursor/disabledMcpServers).
536
+ // currentJson here is the coerced object root — the raw array is stored under target_key.
537
+ if (sqliteOp.array_union_add?.length) {
538
+ const key = sqliteOp.target_key;
539
+ const existing = currentJson[key];
540
+ const arr = Array.isArray(existing) ? existing : [];
541
+ const lowerSet = new Set(arr.map((v) => String(v).toLowerCase()));
542
+ for (const item of sqliteOp.array_union_add) {
543
+ if (!lowerSet.has(item.toLowerCase())) {
544
+ arr.push(item);
545
+ lowerSet.add(item.toLowerCase());
546
+ }
547
+ }
548
+ currentJson[key] = arr;
549
+ return;
550
+ }
498
551
  const where = sqliteOp.array_item_where;
499
552
  const jp = sqliteOp.json_path ?? '';
500
553
  if (where && typeof where === 'object' && Object.keys(where).length > 0 && jp) {
@@ -769,10 +822,10 @@ export async function applyDeferredVscdbFromDisk() {
769
822
  }
770
823
  const safeJson = it.new_value_json.replace(/'/g, "''");
771
824
  const safeName = it.target_key.replace(/'/g, "''");
772
- const sql = `UPDATE ${it.table} SET ${it.value_column}='${safeJson}' WHERE ${it.key_column}='${safeName}';`;
825
+ const sql = `INSERT OR REPLACE INTO ${it.table} (${it.key_column}, ${it.value_column}) VALUES ('${safeName}', '${safeJson}');`;
773
826
  const changed = sqliteRunUpdateReturningChanges(it.dbPath, sql);
774
827
  if (changed < 1) {
775
- hookRunLog(`deferred_vscdb: UPDATE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
828
+ hookRunLog(`deferred_vscdb: INSERT OR REPLACE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
776
829
  return false;
777
830
  }
778
831
  }
@@ -1015,6 +1068,24 @@ function applyOrQueueSqliteJsonUpdate(configPath, sqliteOp, deferred) {
1015
1068
  return false;
1016
1069
  }
1017
1070
  }
1071
+ /**
1072
+ * Return paths to every state.vscdb found under workspaceStoragePath/<hash>/state.vscdb.
1073
+ * Used for the apply_to_all_workspaces fallback when no specific workspace vscdb was collected.
1074
+ * Exported so compliance_check.ts can verify workspace vscdb state during ops-based checks.
1075
+ */
1076
+ export function discoverAllWorkspaceVscdbs(workspaceStoragePath) {
1077
+ if (!existsSync(workspaceStoragePath))
1078
+ return [];
1079
+ try {
1080
+ return readdirSync(workspaceStoragePath, { withFileTypes: true })
1081
+ .filter((e) => e.isDirectory())
1082
+ .map((e) => join(workspaceStoragePath, e.name, 'state.vscdb'))
1083
+ .filter((p) => existsSync(p));
1084
+ }
1085
+ catch {
1086
+ return [];
1087
+ }
1088
+ }
1018
1089
  export function enforceRemediation(instruction) {
1019
1090
  const resolvedPath = resolveRemediationConfigPath(instruction.config_file_path);
1020
1091
  const inst = resolvedPath === instruction.config_file_path
@@ -1045,9 +1116,48 @@ export function enforceRemediation(instruction) {
1045
1116
  });
1046
1117
  if (sqliteOps.length > 0) {
1047
1118
  complianceRunnerDiag(`remediation_enforce: sqlite path uuid=${inst.uuid} checks_with_sqlite=${sqliteOps.length}`);
1119
+ const ops = sqliteOps.map((c) => c.sqlite_op);
1120
+ const firstOp = ops[0];
1121
+ // apply_to_all_workspaces: no specific vscdb was collected — discover and patch every
1122
+ // workspaceStorage/<hash>/state.vscdb on disk (fallback for mcp_config-linked findings).
1123
+ if (firstOp?.apply_to_all_workspaces) {
1124
+ const segments = firstOp.workspace_storage_path_segments ?? [
1125
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
1126
+ ];
1127
+ const wsPath = join(homedir(), ...segments);
1128
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
1129
+ hookRunLog(`remediation_enforce: apply_to_all_workspaces uuid=${inst.uuid} wsPath=${wsPath} found=${vscdbPaths.length}`);
1130
+ if (vscdbPaths.length === 0) {
1131
+ return fail(`no workspace vscdbs found at ${wsPath} (open Cursor in at least one project first)`);
1132
+ }
1133
+ let anyOk = false;
1134
+ for (const vscdbPath of vscdbPaths) {
1135
+ // Queue a post-apply upload for each workspace vscdb so that after the deferred
1136
+ // SQLite write is applied at restart time, the updated cursor/disabledMcpServers
1137
+ // value is immediately sent to the server. This closes the race condition where
1138
+ // the user re-enables the server between the enforcement write and the next
1139
+ // scheduled log-config run: the upload after the deferred apply gives the server
1140
+ // the authoritative disabled state without waiting for a full log-config cycle.
1141
+ const postApplyUpload = {
1142
+ file_path: `${vscdbPath}#${firstOp.target_key}`,
1143
+ file_type: 'cursor_workspace_vscdb',
1144
+ };
1145
+ const q = queueDeferredSqliteOpsMerged(vscdbPath, ops, postApplyUpload);
1146
+ if (q.ok) {
1147
+ anyOk = true;
1148
+ hookRunLog(`remediation_enforce: queued apply_to_all_workspaces op for ${vscdbPath}`);
1149
+ }
1150
+ else {
1151
+ hookRunLog(`remediation_enforce: skip ${vscdbPath}: ${q.reason}`);
1152
+ }
1153
+ }
1154
+ if (!anyOk) {
1155
+ return fail('all workspace vscdb deferred writes failed (see sqlite_update lines above)');
1156
+ }
1157
+ return { ok: true, deferredSqlite: true };
1158
+ }
1048
1159
  const restartRequired = !!fixSpec?.restart_required;
1049
1160
  if (restartRequired) {
1050
- const ops = sqliteOps.map((c) => c.sqlite_op);
1051
1161
  const ft = inst.file_type?.trim();
1052
1162
  const postApplyUpload = ft && inst.config_file_path.includes('#')
1053
1163
  ? { file_path: inst.config_file_path, file_type: ft }
package/dist/tofu.js CHANGED
@@ -30,7 +30,7 @@ function readEnvironment() {
30
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
31
  const isDevelopment = readEnvironment() === "development";
32
32
  // In development: resolve local optimus-tofu dist relative to this file.
33
- // dist/tofu.js → ../.. → npx_packages/production/ → optimus-tofu/dist/index.js
33
+ // dist/tofu.js → ../.. → npx_packages/staging/ → optimus-tofu/dist/index.js
34
34
  const localTofuPath = path.join(__dirname, "../../optimus-tofu/dist/index.js");
35
35
  const tofu = (isDevelopment
36
36
  ? await import(localTofuPath)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config",
3
- "version": "1.3.61",
3
+ "version": "1.3.63",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,6 +57,6 @@
57
57
  "dependencies": {
58
58
  "axios": "^1.15.0",
59
59
  "canonicalize": "^2.1.0",
60
- "optimus-tofu": "file:../optimus-tofu"
60
+ "optimus-tofu": "^0.1.14"
61
61
  }
62
62
  }