@optimuslabs/harness-map-staging 1.5.16

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.
Files changed (88) hide show
  1. package/README.md +46 -0
  2. package/dist/apply_deferred_vscdb.js +38 -0
  3. package/dist/bootstrap_constants.js +15 -0
  4. package/dist/cli/bash_script_generator.js +95 -0
  5. package/dist/cli.js +144 -0
  6. package/dist/cli_invocation_match.js +30 -0
  7. package/dist/compliance_check_runner.js +57 -0
  8. package/dist/compliance_prompt_gate.js +396 -0
  9. package/dist/dialog_prefs_cli.js +109 -0
  10. package/dist/endpoint_client/compress_payload.js +25 -0
  11. package/dist/endpoint_client/http_transport.js +90 -0
  12. package/dist/endpoint_client/index.js +3 -0
  13. package/dist/endpoint_client/registry_api.js +41 -0
  14. package/dist/endpoint_client/startup_api.js +44 -0
  15. package/dist/endpoint_client/types.js +4 -0
  16. package/dist/execute_trusted_restarts.js +60 -0
  17. package/dist/log_config_files/auth/auth_flow.js +22 -0
  18. package/dist/log_config_files/auth/auth_key_store.js +14 -0
  19. package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +248 -0
  20. package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
  21. package/dist/log_config_files/collection/claude_token_usage_collector.js +203 -0
  22. package/dist/log_config_files/collection/codex_token_usage_collector.js +342 -0
  23. package/dist/log_config_files/collection/config_collector.js +245 -0
  24. package/dist/log_config_files/collection/copilot_token_usage_collector.js +167 -0
  25. package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
  26. package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
  27. package/dist/log_config_files/collection/cursor_project_mcp_collector.js +230 -0
  28. package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
  29. package/dist/log_config_files/collection/cursor_token_usage_collector.js +192 -0
  30. package/dist/log_config_files/collection/directory_collector.js +126 -0
  31. package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
  32. package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -0
  33. package/dist/log_config_files/collection/file_type_rules.js +47 -0
  34. package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
  35. package/dist/log_config_files/collection/hermes_token_usage_collector.js +241 -0
  36. package/dist/log_config_files/collection/mcp_tool_collector.js +106 -0
  37. package/dist/log_config_files/collection/metadata_merge.js +43 -0
  38. package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
  39. package/dist/log_config_files/collection/openclaw_token_usage_collector.js +223 -0
  40. package/dist/log_config_files/collection/opencode_token_usage_collector.js +177 -0
  41. package/dist/log_config_files/collection/pi_token_usage_collector.js +239 -0
  42. package/dist/log_config_files/collection/plugin_collector.js +223 -0
  43. package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
  44. package/dist/log_config_files/collection/skills_cli_collector.js +162 -0
  45. package/dist/log_config_files/index.js +19 -0
  46. package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
  47. package/dist/log_config_files/paths/pattern_resolver.js +326 -0
  48. package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
  49. package/dist/log_config_files/readers/cursor_shadow_merge_ruleset.js +46 -0
  50. package/dist/log_config_files/readers/file_readers.js +196 -0
  51. package/dist/log_config_files/readers/vscdb_config_builder.js +148 -0
  52. package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
  53. package/dist/log_config_files/readers/vscdb_reader.js +337 -0
  54. package/dist/log_config_files/runtime/client_event_reporter.js +347 -0
  55. package/dist/log_config_files/runtime/compliance_check.js +1013 -0
  56. package/dist/log_config_files/runtime/compliance_session_log.js +378 -0
  57. package/dist/log_config_files/runtime/dialog_preferences.js +31 -0
  58. package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
  59. package/dist/log_config_files/runtime/hook_logger.js +267 -0
  60. package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
  61. package/dist/log_config_files/runtime/log_metadata.js +122 -0
  62. package/dist/log_config_files/runtime/main_runner.js +376 -0
  63. package/dist/log_config_files/runtime/management_storage.js +144 -0
  64. package/dist/log_config_files/runtime/ops_target_path.js +18 -0
  65. package/dist/log_config_files/runtime/remediation_apply_tracking.js +152 -0
  66. package/dist/log_config_files/runtime/remediation_config_path.js +140 -0
  67. package/dist/log_config_files/runtime/remediation_sync.js +1705 -0
  68. package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
  69. package/dist/log_config_files/runtime/sqlite_binary.js +124 -0
  70. package/dist/log_config_files/runtime/trusted_restarts.js +113 -0
  71. package/dist/log_config_files/runtime/workspace_repo.js +170 -0
  72. package/dist/log_config_files/runtime/worktree_absent.js +50 -0
  73. package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
  74. package/dist/log_config_files/sender/batch_sender.js +283 -0
  75. package/dist/log_config_files/sender/endpoint_config.js +24 -0
  76. package/dist/log_config_files/sender/signing.js +1 -0
  77. package/dist/log_sensitive_paths_audit.js +89 -0
  78. package/dist/log_uuid/auth_key_store.js +37 -0
  79. package/dist/log_uuid/hardware_uuid.js +35 -0
  80. package/dist/log_uuid/index.js +11 -0
  81. package/dist/log_uuid/log_uuid_helper.js +30 -0
  82. package/dist/log_uuid/startup_sender.js +131 -0
  83. package/dist/log_uuid/user_profile.js +192 -0
  84. package/dist/remediation_change_preview.js +182 -0
  85. package/dist/tofu.js +22 -0
  86. package/dist/tofu_environment.js +36 -0
  87. package/dist/types/config_file_types.js +1 -0
  88. package/package.json +67 -0
@@ -0,0 +1,1705 @@
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';
4
+ import { executeBody } from '../../endpoint_client/http_transport.js';
5
+ import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
6
+ import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
8
+ import { readStoredAuthKey } from '../auth/auth_key_store.js';
9
+ import { ensureAuthentication } from '../auth/auth_flow.js';
10
+ import { createSignature } from '../sender/signing.js';
11
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
12
+ import { tryResolveHardwareUuid } from './hardware_uuid.js';
13
+ import { clearRemediationApplyQuarantine, readRemediationApplyTrackingFile, writeRemediationApplyTrackingFile, } from './remediation_apply_tracking.js';
14
+ import { CURSOR_SCALAR_ITEMTABLE_FIELDS, buildVscdbPostApplyRawContent, persistVscdbComposerContractFromPatternsResponse, } from '../readers/vscdb_reader.js';
15
+ import { sendConfigFile } from '../sender/batch_sender.js';
16
+ import { parseWorktreeRootFromPath } from './worktree_scanner.js';
17
+ import { reportAbsentWorktrees } from './worktree_absent.js';
18
+ import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
19
+ import { resolveRemediationConfigPath } from './remediation_config_path.js';
20
+ import { resolveOpsTargetPath } from './ops_target_path.js';
21
+ import { execSqlite3, resolveSqlite3Binary } from './sqlite_binary.js';
22
+ import { CURSOR_COMPOSER_SHADOW_KEYS } from '../readers/cursor_shadow_merge_ruleset.js';
23
+ import { REACTIVE_STORAGE_ITEM_KEY_SUFFIX, composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from '../readers/vscdb_reactive_storage.js';
24
+ /** Best-effort detail from execFileSync failures (stderr, exit code, errno). */
25
+ function formatNodeChildException(err) {
26
+ if (!(err instanceof Error)) {
27
+ const s = String(err);
28
+ return { short: s, long: s };
29
+ }
30
+ const e = err;
31
+ const bits = [e.message];
32
+ if (e.code)
33
+ bits.push(`errno_code=${e.code}`);
34
+ if (typeof e.status === 'number')
35
+ bits.push(`exit_status=${e.status}`);
36
+ let stderr = '';
37
+ if (e.stderr != null) {
38
+ stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf8');
39
+ const t = stderr.trim();
40
+ if (t)
41
+ bits.push(`stderr=${t.slice(0, 1200)}`);
42
+ }
43
+ const long = bits.join(' | ');
44
+ return { short: bits.slice(0, 2).join(' | '), long };
45
+ }
46
+ function composerShadowKeySetFromContract() {
47
+ return new Set(composerShadowKeysFromContract());
48
+ }
49
+ /** Resolve fix payload from API or legacy `compliance` key in local JSON. */
50
+ export function remediationFixSpec(inst) {
51
+ return inst.fix ?? inst.compliance ?? null;
52
+ }
53
+ /** True when this row should be replaced from GET manifest (missing or empty fix checks). */
54
+ function needsManifestRefetch(inst) {
55
+ if (!inst)
56
+ return true;
57
+ const spec = remediationFixSpec(inst);
58
+ if (!spec)
59
+ return true;
60
+ const checks = spec.checks;
61
+ return !Array.isArray(checks) || checks.length === 0;
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Persistence (single file: remediation_instructions.json)
65
+ // ---------------------------------------------------------------------------
66
+ function readInstructions() {
67
+ const { remediations } = readRemediationInstructionsFile();
68
+ return { remediations: remediations };
69
+ }
70
+ function writeInstructions(file) {
71
+ writeRemediationInstructionsFile(file);
72
+ }
73
+ /**
74
+ * Merge persisted rows with API overlay; GET manifest for enforced UUIDs still missing rows.
75
+ */
76
+ async function ensureInstructionsForUuids(endpointBase, machineUuid, want, overlayFromApi) {
77
+ const byUuid = new Map();
78
+ for (const inst of readInstructions().remediations) {
79
+ if (want.has(inst.uuid))
80
+ byUuid.set(inst.uuid, inst);
81
+ }
82
+ for (const inst of overlayFromApi ?? []) {
83
+ if (want.has(inst.uuid))
84
+ byUuid.set(inst.uuid, inst);
85
+ }
86
+ // Refetch rows that are missing entirely or have no usable checks (stale/incomplete local cache).
87
+ const missing = [...want].filter((u) => needsManifestRefetch(byUuid.get(u)));
88
+ if (missing.length > 0) {
89
+ try {
90
+ const fetched = await fetchManifest(endpointBase, machineUuid, missing);
91
+ if (fetched) {
92
+ for (const inst of fetched) {
93
+ if (want.has(inst.uuid))
94
+ byUuid.set(inst.uuid, inst);
95
+ }
96
+ }
97
+ }
98
+ catch (err) {
99
+ hookRunLog(`remediation_manifest_backfill_error: ${err instanceof Error ? err.message : String(err)}`);
100
+ }
101
+ const stillMissing = [...want].filter((u) => !byUuid.has(u));
102
+ if (stillMissing.length > 0) {
103
+ hookRunLog(`remediation_manifest_backfill_incomplete: uuids=${stillMissing.join(',')}`);
104
+ }
105
+ }
106
+ const remediations = [...byUuid.values()].sort((a, b) => a.uuid.localeCompare(b.uuid));
107
+ const payload = { remediations };
108
+ const nextJson = JSON.stringify(payload, null, 2);
109
+ const path = getRemediationInstructionsPath();
110
+ let prev = '';
111
+ if (existsSync(path)) {
112
+ try {
113
+ prev = readFileSync(path, 'utf8');
114
+ }
115
+ catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ if (prev !== nextJson) {
120
+ writeInstructions(payload);
121
+ }
122
+ }
123
+ function applyClearApplyTrackingUuids(uuids) {
124
+ if (!uuids?.length)
125
+ return;
126
+ for (const uuid of uuids) {
127
+ clearRemediationApplyQuarantine(uuid);
128
+ hookRunLog(`remediation_sync: cleared apply quarantine uuid=${uuid}`);
129
+ }
130
+ }
131
+ /** When the server redeploys enforcement under a new UUID, keep local quarantine state. */
132
+ function transferQuarantineForRetiredUuids(removed, added, retiredInstructions, addedInstructions) {
133
+ if (removed.length === 0 || added.length === 0 || addedInstructions.length === 0)
134
+ return;
135
+ const file = readRemediationApplyTrackingFile();
136
+ let changed = false;
137
+ for (const removedUuid of removed) {
138
+ const prev = file.entries[removedUuid];
139
+ if (!prev?.quarantined)
140
+ continue;
141
+ const retired = retiredInstructions.find((r) => r.uuid === removedUuid);
142
+ const retiredPath = retired?.config_file_path ?? '';
143
+ for (const inst of addedInstructions) {
144
+ if (!added.includes(inst.uuid))
145
+ continue;
146
+ if (retiredPath && inst.config_file_path !== retiredPath)
147
+ continue;
148
+ file.entries[inst.uuid] = {
149
+ ...prev,
150
+ pending_post_restart_verify: false,
151
+ };
152
+ changed = true;
153
+ hookRunLog(`remediation_sync: transferred quarantine ${removedUuid} -> ${inst.uuid} path=${inst.config_file_path}`);
154
+ }
155
+ delete file.entries[removedUuid];
156
+ changed = true;
157
+ }
158
+ if (changed)
159
+ writeRemediationApplyTrackingFile(file);
160
+ }
161
+ async function ensureAuthKeyForMachine(endpointBase, machineUuid) {
162
+ try {
163
+ return await ensureAuthentication(machineUuid, endpointBase);
164
+ }
165
+ catch (err) {
166
+ hookRunLog(`remediation_sync: ensureAuthentication failed: ${err instanceof Error ? err.message : String(err)}`);
167
+ complianceRunnerDiag(`remediation_sync: ensureAuthentication failed: ${err instanceof Error ? err.message : String(err)}`);
168
+ return null;
169
+ }
170
+ }
171
+ function buildSignedMachinePostBody(machineUuid, fields, authKey) {
172
+ const payload = { machine_uuid: machineUuid, ...fields };
173
+ const signature = createSignature(payload, authKey.key);
174
+ return JSON.stringify({ ...payload, signature });
175
+ }
176
+ export async function fetchSync(endpointBase, machineUuid, activeUuids, timeoutMs = 8000) {
177
+ const sortedUuids = [...new Set(activeUuids.map((u) => u.trim()).filter(Boolean))].sort();
178
+ const authKey = await ensureAuthKeyForMachine(endpointBase, machineUuid);
179
+ if (!authKey) {
180
+ hookRunLog('remediation_sync_post: no auth key, skipping');
181
+ complianceRunnerDiag('remediation_sync_post: no auth key');
182
+ return null;
183
+ }
184
+ const url = buildApiUrl(endpointBase, '/api/findings/remediations/sync/');
185
+ const body = buildSignedMachinePostBody(machineUuid, { active_uuids: sortedUuids }, authKey);
186
+ let statusCode = 0;
187
+ let responseBody = '';
188
+ try {
189
+ const result = await executeBody(url, 'POST', body, timeoutMs);
190
+ statusCode = result.statusCode;
191
+ responseBody = result.body;
192
+ }
193
+ catch (err) {
194
+ const line = `remediation_sync_post: url=${url} error=${err instanceof Error ? err.message : String(err)}`;
195
+ hookRunLog(line);
196
+ complianceRunnerDiag(line);
197
+ return null;
198
+ }
199
+ if (statusCode !== 200 || !responseBody) {
200
+ const bodyHint = responseBody && responseBody.length < 500
201
+ ? ` body=${responseBody.replace(/\s+/g, ' ').trim()}`
202
+ : '';
203
+ const line = `remediation_sync_post: url=${url} status=${statusCode} bytes=${responseBody?.length ?? 0}${bodyHint}`;
204
+ hookRunLog(line);
205
+ complianceRunnerDiag(line);
206
+ return null;
207
+ }
208
+ try {
209
+ return JSON.parse(responseBody);
210
+ }
211
+ catch {
212
+ hookRunLog('remediation_sync_post: invalid JSON body');
213
+ complianceRunnerDiag('remediation_sync_post: invalid JSON body');
214
+ return null;
215
+ }
216
+ }
217
+ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000) {
218
+ const sortedUuids = [...new Set(uuids.map((u) => u.trim()).filter(Boolean))].sort();
219
+ const authKey = await ensureAuthKeyForMachine(endpointBase, machineUuid);
220
+ if (!authKey) {
221
+ hookRunLog('remediation_manifest_post: no auth key, skipping');
222
+ complianceRunnerDiag('remediation_manifest_post: no auth key');
223
+ return null;
224
+ }
225
+ const url = buildApiUrl(endpointBase, '/api/findings/remediations/manifest/');
226
+ const body = buildSignedMachinePostBody(machineUuid, { uuids: sortedUuids }, authKey);
227
+ let statusCode = 0;
228
+ let responseBody = '';
229
+ try {
230
+ const result = await executeBody(url, 'POST', body, timeoutMs);
231
+ statusCode = result.statusCode;
232
+ responseBody = result.body;
233
+ }
234
+ catch (err) {
235
+ const line = `remediation_manifest_post: url=${url} error=${err instanceof Error ? err.message : String(err)}`;
236
+ hookRunLog(line);
237
+ complianceRunnerDiag(line);
238
+ return null;
239
+ }
240
+ if (statusCode !== 200 || !responseBody) {
241
+ const line = `remediation_manifest_post: url=${url} status=${statusCode} bytes=${responseBody?.length ?? 0}`;
242
+ hookRunLog(line);
243
+ complianceRunnerDiag(line);
244
+ return null;
245
+ }
246
+ try {
247
+ const parsed = JSON.parse(responseBody);
248
+ if (!Array.isArray(parsed.remediations)) {
249
+ complianceRunnerDiag('remediation_manifest_post: JSON remediations is not an array');
250
+ return null;
251
+ }
252
+ return parsed.remediations.map((raw) => {
253
+ const { compliance, ...rest } = raw;
254
+ const fix = rest.fix ?? compliance ?? null;
255
+ return { ...rest, fix };
256
+ });
257
+ }
258
+ catch {
259
+ complianceRunnerDiag('remediation_manifest_post: invalid JSON body');
260
+ return null;
261
+ }
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // Enforce / remove
265
+ // ---------------------------------------------------------------------------
266
+ function setByPath(obj, path, value) {
267
+ const parts = path.split('.');
268
+ let current = obj;
269
+ for (let i = 0; i < parts.length - 1; i++) {
270
+ const part = parts[i];
271
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
272
+ current[part] = {};
273
+ }
274
+ current = current[part];
275
+ }
276
+ current[parts[parts.length - 1]] = value;
277
+ }
278
+ /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
279
+ function getByPath(obj, path) {
280
+ const parts = path.split('.');
281
+ let current = obj;
282
+ for (const part of parts) {
283
+ if (current == null || typeof current !== 'object')
284
+ return undefined;
285
+ current = current[part];
286
+ }
287
+ return current;
288
+ }
289
+ /** Recursively set a value at a dot-notation path in a JSON object. */
290
+ function setByPathNested(obj, path, value) {
291
+ const parts = path.split('.');
292
+ let current = obj;
293
+ for (let i = 0; i < parts.length - 1; i++) {
294
+ const part = parts[i];
295
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
296
+ current[part] = {};
297
+ }
298
+ current = current[part];
299
+ }
300
+ current[parts[parts.length - 1]] = value;
301
+ }
302
+ function isPlainObject(v) {
303
+ return v != null && typeof v === 'object' && !Array.isArray(v);
304
+ }
305
+ function isStringArray(v) {
306
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
307
+ }
308
+ function deepEqual(a, b) {
309
+ if (a === b)
310
+ return true;
311
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
312
+ return false;
313
+ if (Array.isArray(a) || Array.isArray(b)) {
314
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
315
+ return false;
316
+ for (let i = 0; i < a.length; i++) {
317
+ if (!deepEqual(a[i], b[i]))
318
+ return false;
319
+ }
320
+ return true;
321
+ }
322
+ const ao = a;
323
+ const bo = b;
324
+ const aKeys = Object.keys(ao);
325
+ const bKeys = Object.keys(bo);
326
+ if (aKeys.length !== bKeys.length)
327
+ return false;
328
+ for (const k of aKeys) {
329
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
330
+ return false;
331
+ if (!deepEqual(ao[k], bo[k]))
332
+ return false;
333
+ }
334
+ return true;
335
+ }
336
+ function applyStringArrayDelta(current, before, after) {
337
+ const cur = isStringArray(current) ? [...current] : [];
338
+ const b = isStringArray(before) ? before : [];
339
+ const a = isStringArray(after) ? after : [];
340
+ const bSet = new Set(b);
341
+ const aSet = new Set(a);
342
+ const removed = b.filter((x) => !aSet.has(x));
343
+ const added = a.filter((x) => !bSet.has(x));
344
+ // Remove any items that were removed by the remediation (preserve other local additions).
345
+ const removedSet = new Set(removed);
346
+ const next = cur.filter((x) => !removedSet.has(x));
347
+ // Append items that were added by the remediation (avoid duplicates).
348
+ const nextSet = new Set(next);
349
+ for (const x of added) {
350
+ if (!nextSet.has(x)) {
351
+ next.push(x);
352
+ nextSet.add(x);
353
+ }
354
+ }
355
+ return next;
356
+ }
357
+ export const GLOBAL_CURSOR_IGNORE_SETTING_PATH = 'cursor.general.globalCursorIgnoreList';
358
+ export function isGlobalCursorIgnoreRemediationViolation(v) {
359
+ return (v.setting_path ?? '').trim() === GLOBAL_CURSOR_IGNORE_SETTING_PATH;
360
+ }
361
+ /** Match server scan: flat VS Code key wins when present (including []). */
362
+ export function globalCursorIgnoreListFromSettings(configJson) {
363
+ const flat = configJson[GLOBAL_CURSOR_IGNORE_SETTING_PATH];
364
+ if (Array.isArray(flat))
365
+ return flat;
366
+ const nested = getByPath(configJson, GLOBAL_CURSOR_IGNORE_SETTING_PATH);
367
+ if (Array.isArray(nested))
368
+ return nested;
369
+ return [];
370
+ }
371
+ export function persistGlobalCursorIgnoreList(configJson, list) {
372
+ configJson[GLOBAL_CURSOR_IGNORE_SETTING_PATH] = list;
373
+ setByPathNested(configJson, GLOBAL_CURSOR_IGNORE_SETTING_PATH, list);
374
+ }
375
+ /** Keep flat and nested global ignore lists aligned; prefer flat when it is an array. */
376
+ function syncFlatGlobalCursorIgnoreListKey(configJson) {
377
+ const flat = configJson[GLOBAL_CURSOR_IGNORE_SETTING_PATH];
378
+ if (Array.isArray(flat)) {
379
+ setByPathNested(configJson, GLOBAL_CURSOR_IGNORE_SETTING_PATH, flat);
380
+ return;
381
+ }
382
+ const nested = getByPath(configJson, GLOBAL_CURSOR_IGNORE_SETTING_PATH);
383
+ if (Array.isArray(nested)) {
384
+ configJson[GLOBAL_CURSOR_IGNORE_SETTING_PATH] = nested;
385
+ }
386
+ }
387
+ function listForComplianceTarget(configJson, targetPath) {
388
+ if (targetPath === GLOBAL_CURSOR_IGNORE_SETTING_PATH) {
389
+ return [...globalCursorIgnoreListFromSettings(configJson)];
390
+ }
391
+ const curVal = getByPath(configJson, targetPath);
392
+ return Array.isArray(curVal) ? [...curVal] : [];
393
+ }
394
+ function writeListAtComplianceTarget(configJson, targetPath, next) {
395
+ if (targetPath === GLOBAL_CURSOR_IGNORE_SETTING_PATH) {
396
+ persistGlobalCursorIgnoreList(configJson, next);
397
+ return;
398
+ }
399
+ setByPath(configJson, targetPath, next);
400
+ }
401
+ function applyCheck(configJson, check) {
402
+ const parts = check.setting_path.split('.');
403
+ const leafKey = parts[parts.length - 1] ?? '';
404
+ const parentPath = parts.slice(0, -1).join('.');
405
+ // Prefer explicit ops when provided by server (safest: avoids clobbering local edits).
406
+ if (check.ops && typeof check.ops === 'object') {
407
+ const { set, add, remove } = check.ops;
408
+ const keys = new Set([
409
+ ...Object.keys(set ?? {}),
410
+ ...Object.keys(add ?? {}),
411
+ ...Object.keys(remove ?? {}),
412
+ ]);
413
+ for (const k of keys) {
414
+ const targetPath = resolveOpsTargetPath(check.setting_path, k);
415
+ if (set && Object.prototype.hasOwnProperty.call(set, k)) {
416
+ const value = set[k];
417
+ if (targetPath === GLOBAL_CURSOR_IGNORE_SETTING_PATH && Array.isArray(value)) {
418
+ persistGlobalCursorIgnoreList(configJson, value);
419
+ }
420
+ else {
421
+ setByPath(configJson, targetPath, value);
422
+ }
423
+ continue;
424
+ }
425
+ const cur = listForComplianceTarget(configJson, targetPath);
426
+ const toRemove = (remove && remove[k]) ?? [];
427
+ const toAdd = (add && add[k]) ?? [];
428
+ const next = cur.filter((x) => !toRemove.some((item) => deepEqual(item, x)));
429
+ for (const x of toAdd) {
430
+ if (!next.some((item) => deepEqual(item, x))) {
431
+ next.push(x);
432
+ }
433
+ }
434
+ writeListAtComplianceTarget(configJson, targetPath, next);
435
+ }
436
+ return;
437
+ }
438
+ // Normal v2 shape: after[leafKey] is the intended leaf value.
439
+ // Some remediations may include multi-key objects (e.g. {allow, deny}) while the path points at one leaf.
440
+ // In that case, treat the payload as a patch for the parent object and apply per-key deltas.
441
+ if (isPlainObject(check.before) && isPlainObject(check.after)) {
442
+ const afterKeys = Object.keys(check.after);
443
+ const beforeKeys = Object.keys(check.before);
444
+ const looksMultiKey = afterKeys.length > 1 || (afterKeys.length > 0 && !Object.prototype.hasOwnProperty.call(check.after, leafKey));
445
+ if (looksMultiKey) {
446
+ for (const k of afterKeys) {
447
+ const targetPath = parentPath ? `${parentPath}.${k}` : k;
448
+ const curVal = getByPath(configJson, targetPath);
449
+ const bVal = check.before[k];
450
+ const aVal = check.after[k];
451
+ if (isStringArray(bVal) && isStringArray(aVal)) {
452
+ setByPath(configJson, targetPath, applyStringArrayDelta(curVal, bVal, aVal));
453
+ }
454
+ else {
455
+ setByPath(configJson, targetPath, aVal);
456
+ }
457
+ }
458
+ return;
459
+ }
460
+ }
461
+ let value = undefined;
462
+ if (isPlainObject(check.after) && Object.prototype.hasOwnProperty.call(check.after, leafKey)) {
463
+ value = check.after[leafKey];
464
+ }
465
+ else {
466
+ value = check.after;
467
+ }
468
+ // If the leaf is a string[] and we have before/after snapshots, apply delta not replacement.
469
+ if (isPlainObject(check.before) && isPlainObject(check.after)) {
470
+ const b = check.before[leafKey];
471
+ const a = check.after[leafKey];
472
+ if (isStringArray(b) && isStringArray(a)) {
473
+ const cur = getByPath(configJson, check.setting_path);
474
+ setByPath(configJson, check.setting_path, applyStringArrayDelta(cur, b, a));
475
+ return;
476
+ }
477
+ }
478
+ setByPath(configJson, check.setting_path, value);
479
+ }
480
+ // ---------------------------------------------------------------------------
481
+ // SQLite remediation support
482
+ // ---------------------------------------------------------------------------
483
+ function escapeRegExpForAllowlist(s) {
484
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
485
+ }
486
+ function allowlistEntryMatchesRemoveToken(entry, token) {
487
+ const valStr = String(entry).toLowerCase();
488
+ const t = token.toLowerCase().trim();
489
+ if (!t)
490
+ return false;
491
+ const first = valStr.trim().split(/\s+/)[0] ?? '';
492
+ if (first === t || valStr === t)
493
+ return true;
494
+ const pattern = new RegExp(`(^|[^a-z0-9_|])${escapeRegExpForAllowlist(t)}([^a-z0-9_|]|$)`);
495
+ return pattern.test(valStr);
496
+ }
497
+ function mergePostApplyUploadIntoPayload(payload, hint) {
498
+ if (!hint)
499
+ return;
500
+ const ft = hint.file_type?.trim();
501
+ if (!ft || !hint.file_path.includes('#'))
502
+ return;
503
+ if (!payload.post_apply_uploads)
504
+ payload.post_apply_uploads = [];
505
+ if (payload.post_apply_uploads.some((u) => u.file_path === hint.file_path))
506
+ return;
507
+ payload.post_apply_uploads.push({ file_path: hint.file_path, file_type: ft });
508
+ }
509
+ function assertSqlite3Available() {
510
+ const bin = resolveSqlite3Binary();
511
+ if (bin) {
512
+ complianceRunnerDiag(`sqlite_update: using sqlite3 binary ${bin}`);
513
+ return true;
514
+ }
515
+ const pathEnv = process.env.PATH ?? '';
516
+ const n = pathEnv ? pathEnv.split(delimiter).filter(Boolean).length : 0;
517
+ const line = 'sqlite_update: sqlite3 command not found';
518
+ hookRunLog(line);
519
+ hookRunLog(`sqlite_update: no sqlite3 resolved after checking env OPTIMUS_SQLITE3/SQLITE3_PATH, common paths, and PATH (${n} entries)`);
520
+ hookRunLog('sqlite_update: hint set OPTIMUS_SQLITE3 to the full path (e.g. /usr/bin/sqlite3 on macOS) if sqlite3 is outside PATH');
521
+ complianceRunnerDiag(`${line} PATH_entry_count=${n}`);
522
+ return false;
523
+ }
524
+ /**
525
+ * Unquoted SQLite identifiers must be [A-Za-z_][A-Za-z0-9_]* so values read from
526
+ * deferred_vscdb_apply.json cannot inject SQL via bogus table/column names.
527
+ */
528
+ function isSafeSqliteIdentifier(ident) {
529
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(ident);
530
+ }
531
+ function assertSafeSqliteIdentifiersForItemTable(table, keyColumn, valueColumn) {
532
+ if (isSafeSqliteIdentifier(table) && isSafeSqliteIdentifier(keyColumn) && isSafeSqliteIdentifier(valueColumn)) {
533
+ return true;
534
+ }
535
+ hookRunLog(`sqlite_update: rejected unsafe SQL identifier(s) table=${table} key_column=${keyColumn} value_column=${valueColumn}`);
536
+ complianceRunnerDiag('sqlite_update: rejected unsafe SQL identifier(s)');
537
+ return false;
538
+ }
539
+ /**
540
+ * Canonical Cursor restart_command strings — single source for buildDeferredCursorRestartCommand + allowlist.
541
+ *
542
+ * - **SQLite / state.vscdb (deferred):** autofix queued ItemTable writes; restart runs `apply_deferred_vscdb` then reopens Cursor.
543
+ * Routing matches shell hooks (`optimus-compliance-check.sh`): read `~/.optimuslabs/management/optimus_dev.env`
544
+ * (then `OPTIMUS_ENVIRONMENT`), use `npx=` base for local `dist` when `environment=development`,
545
+ * `@optimuslabs/harness-map-staging@latest` when `environment=staging`, else production package.
546
+ * - **JSON settings files:** autofix wrote normal config JSON; restart is kill + reopen only (no deferred apply).
547
+ *
548
+ * Exact-match only: `spawn('sh', ['-c', cmd])` runs the whole string; substring checks would allow
549
+ * appending `; arbitrary shell` after a trusted prefix.
550
+ */
551
+ const TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND = 'REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT && ' +
552
+ 'CURSOR_PROJECT="${CURSOR_PROJECT_DIR:-$REPO_ROOT}" && export CURSOR_PROJECT && ' +
553
+ 'OPTIMUS_DEFERRED_LOG="${HOME}/.optimuslabs/management/vscdb/deferred_vscdb_restart.log" && mkdir -p "$(dirname "$OPTIMUS_DEFERRED_LOG")" && export OPTIMUS_DEFERRED_LOG && ' +
554
+ "nohup bash -c 'exec >>\"\$OPTIMUS_DEFERRED_LOG\" 2>&1; echo deferred_restart:begin ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ) REPO_ROOT=\"\$REPO_ROOT\" CURSOR_PROJECT=\"\$CURSOR_PROJECT\"; sleep 2; STATIC_DEV_ENV=\"\$HOME/.optimuslabs/management/optimus_dev.env\"; ENV_LABEL=\"\"; if [ -f \"\$STATIC_DEV_ENV\" ]; then ENV_LABEL=\$(grep -E \"^(environment|ENVIRONMENT|OPTIMUS_ENVIRONMENT)=\" \"\$STATIC_DEV_ENV\" 2>/dev/null | head -1 | cut -d\"=\" -f2- | tr \"[:upper:]\" \"[:lower:]\" | xargs); fi; if [ -z \"\$ENV_LABEL\" ] && [ -n \"\${OPTIMUS_ENVIRONMENT:-}\" ]; then ENV_LABEL=\$(printf \"%s\" \"\$OPTIMUS_ENVIRONMENT\" | tr \"[:upper:]\" \"[:lower:]\" | xargs); fi; [ -z \"\$ENV_LABEL\" ] && ENV_LABEL=\"production\"; NPX_BASE=\"\"; if [ -f \"\$STATIC_DEV_ENV\" ]; then _nb=\$(grep -E \"^npx=\" \"\$STATIC_DEV_ENV\" 2>/dev/null | head -1 | cut -d\"=\" -f2- | xargs); [ -n \"\$_nb\" ] && [ -d \"\$_nb\" ] && NPX_BASE=\"\$_nb\"; fi; p=\"\${npm_config_prefix:-\${NPM_CONFIG_PREFIX:-}}\"; gp=\"\${npm_config_global_prefix:-}\"; gc=\"\${npm_config_globalconfig:-}\"; if [[ \"\$p\" == *\"/Applications/Cursor.app/\"* ]] || [[ \"\$gp\" == *\"/Applications/Cursor.app/\"* ]] || [[ \"\$gc\" == *\"/Applications/Cursor.app/\"* ]]; then unset npm_config_prefix NPM_CONFIG_PREFIX npm_config_global_prefix npm_config_globalconfig npm_node_execpath 2>/dev/null; fi; APPLY_EC=0; if [ -f \"\$REPO_ROOT/dev_npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\" ]; then echo deferred_restart:apply_via_dev_npx_packages; node \"\$REPO_ROOT/dev_npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\"; APPLY_EC=\$?; elif [ \"\$ENV_LABEL\" = \"development\" ] && [ -n \"\$NPX_BASE\" ] && [ -f \"\$NPX_BASE/optimuslabs/log-llm-config/dist/apply_deferred_vscdb.js\" ]; then echo deferred_restart:apply_via_optimus_npx_base; node \"\$NPX_BASE/optimuslabs/log-llm-config/dist/apply_deferred_vscdb.js\"; APPLY_EC=\$?; elif command -v npx >/dev/null 2>&1; then echo deferred_restart:apply_via_npx env=\"\$ENV_LABEL\"; cd \"\$REPO_ROOT\" || true; if [ \"\$ENV_LABEL\" = \"staging\" ]; then npx --yes --package=@optimuslabs/harness-map-staging@latest apply-deferred-vscdb-staging; APPLY_EC=\$?; else npx --yes --package=@optimuslabs/harness-map@latest apply-deferred-vscdb; APPLY_EC=\$?; fi; else echo deferred_restart:no_npx; APPLY_EC=127; fi; echo deferred_restart:apply_exit=\$APPLY_EC; if [ \$APPLY_EC -ne 0 ]; then echo deferred_restart:APPLY_FAILED_see_messages_above; fi; echo deferred_restart:open_cursor; env -u npm_config_package -u npm_lifecycle_event -u npm_lifecycle_script -u npm_config_local_prefix open -a Cursor \"\$CURSOR_PROJECT\"; echo deferred_restart:open_exit=\$?; echo deferred_restart:end ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ)' >/dev/null 2>&1 & killall -9 Cursor";
555
+ /** Legacy manifests; hooks may run with cwd under `.cursor` where `pwd` is wrong. */
556
+ const TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND_LEGACY = 'CURSOR_PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export CURSOR_PROJECT && nohup bash -c \'sleep 2 && open -a Cursor "$CURSOR_PROJECT"\' >/dev/null 2>&1 & killall -9 Cursor';
557
+ const TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND = 'REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT && ' +
558
+ 'CURSOR_PROJECT="${CURSOR_PROJECT_DIR:-$REPO_ROOT}" && export CURSOR_PROJECT && ' +
559
+ "nohup bash -c 'sleep 2 && open -a Cursor \"$CURSOR_PROJECT\"' >/dev/null 2>&1 & killall -9 Cursor";
560
+ const TRUSTED_CLAUDE_RESTART_COMMAND = "nohup bash -c 'sleep 2 && open -a Claude' >/dev/null 2>&1 & pkill -x 'Claude'";
561
+ export function isClaudeRestartCommand(cmd) {
562
+ return cmd.trim() === TRUSTED_CLAUDE_RESTART_COMMAND;
563
+ }
564
+ export function isCursorRestartCommand(cmd) {
565
+ const t = cmd.trim();
566
+ return (t === TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND ||
567
+ t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND ||
568
+ t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND_LEGACY);
569
+ }
570
+ /**
571
+ * Autofix restart_command allowlist: manifest strings are attacker-controlled if JSON is tampered.
572
+ * SQLite-deferred Cursor path always uses {@link buildDeferredCursorRestartCommand}; manifests may still
573
+ * embed the JSON-settings-only Cursor template when `restart_required` applies to file-based autofix.
574
+ */
575
+ export function isTrustedRestartCommandForAutofix(cmd) {
576
+ const t = cmd.trim();
577
+ if (!t)
578
+ return false;
579
+ return (t === TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND ||
580
+ t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND ||
581
+ t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND_LEGACY ||
582
+ t === TRUSTED_CLAUDE_RESTART_COMMAND);
583
+ }
584
+ /** Legacy Cursor: dedicated ItemTable row `composerState`. Current Cursor: nested under reactive `applicationUser` blob. */
585
+ function cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp) {
586
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, 'composerState').trim();
587
+ if (!raw || raw === '{}')
588
+ return false;
589
+ try {
590
+ const o = JSON.parse(raw);
591
+ return typeof o === 'object' && o !== null && !Array.isArray(o) && Object.keys(o).length > 0;
592
+ }
593
+ catch {
594
+ return false;
595
+ }
596
+ }
597
+ function resolveCursorComposerSqliteOp(dbPath, sqliteOp) {
598
+ if (sqliteOp.target_key !== 'composerState')
599
+ return sqliteOp;
600
+ const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath);
601
+ /** Merge into applicationUser (or equivalent) JSON: inner `composerState` or nested path under it. */
602
+ const resolveToReactive = () => {
603
+ if (!reactiveKey)
604
+ return sqliteOp;
605
+ const jp = (sqliteOp.json_path ?? '').trim();
606
+ if (!jp || jp === 'composerState') {
607
+ return {
608
+ ...sqliteOp,
609
+ target_key: reactiveKey,
610
+ json_path: 'composerState',
611
+ };
612
+ }
613
+ const nestedPath = jp.startsWith('composerState.') ? jp : `composerState.${jp}`;
614
+ return {
615
+ ...sqliteOp,
616
+ target_key: reactiveKey,
617
+ json_path: nestedPath,
618
+ };
619
+ };
620
+ // Prefer the reactive storage blob when it exists and is non-empty. Cursor reads web/composer
621
+ // toggles from there; a legacy ItemTable `composerState` row may still hold stale JSON — writing
622
+ // only that row leaves the UI unchanged (user sees "remediation did nothing" after restart).
623
+ if (reactiveKey) {
624
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, reactiveKey).trim();
625
+ const reactiveHasBlob = raw !== '' && raw !== '{}';
626
+ if (reactiveHasBlob) {
627
+ return resolveToReactive();
628
+ }
629
+ }
630
+ if (cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp))
631
+ return sqliteOp;
632
+ if (reactiveKey) {
633
+ return resolveToReactive();
634
+ }
635
+ hookRunLog('vscdb_contract: missing file_collection_vscdb_contract.json (reactive_storage_item_key); run log-config or remediation sync against API');
636
+ return sqliteOp;
637
+ }
638
+ /** Apply sqlite merge: dot-path, or array match where `json_path` is `…container.arrayKey` (e.g. `modes4` or `composerState.modes4`). */
639
+ function coerceScalarForItemTableField(parsed) {
640
+ if (typeof parsed === 'boolean')
641
+ return parsed;
642
+ if (typeof parsed === 'string') {
643
+ const lower = parsed.trim().toLowerCase();
644
+ if (lower === 'true' || lower === '1' || lower === 'yes')
645
+ return true;
646
+ return false;
647
+ }
648
+ if (typeof parsed === 'number' && !Number.isNaN(parsed))
649
+ return parsed !== 0;
650
+ return undefined;
651
+ }
652
+ /**
653
+ * ItemTable values are sometimes bare JSON booleans for toggle keys; sqlite merge expects an object root.
654
+ * Maps known scalar roots to the policy-shaped object before applying updates.
655
+ */
656
+ function coerceItemTableValueToObjectRoot(targetKey, parsed) {
657
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
658
+ return parsed;
659
+ }
660
+ // Bare JSON array (e.g. cursor/disabledMcpServers): wrap under target_key so array_union_add
661
+ // can locate and mutate it via mergeSqliteOpIntoJson.
662
+ if (Array.isArray(parsed)) {
663
+ return { [targetKey]: parsed };
664
+ }
665
+ const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
666
+ if (field) {
667
+ const b = coerceScalarForItemTableField(parsed);
668
+ if (b !== undefined)
669
+ return { [field]: b };
670
+ }
671
+ return {};
672
+ }
673
+ /**
674
+ * Cursor stores some ItemTable toggles as bare JSON booleans (`true`/`false` text). Writing a wrapper
675
+ * object can be ignored or overwritten on launch; match the native primitive shape when disabling.
676
+ */
677
+ function serializeItemTableValueForWrite(targetKey, root) {
678
+ // Bare-array keys: root was wrapped as { [targetKey]: array } by coerceItemTableValueToObjectRoot.
679
+ // Unwrap back to a bare JSON array before writing.
680
+ const keys = Object.keys(root);
681
+ if (keys.length === 1 && keys[0] === targetKey && Array.isArray(root[targetKey])) {
682
+ return JSON.stringify(root[targetKey]);
683
+ }
684
+ const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
685
+ if (field) {
686
+ const keys = Object.keys(root);
687
+ if (keys.length === 1 && keys[0] === field) {
688
+ const v = root[field];
689
+ if (typeof v === 'boolean')
690
+ return v ? 'true' : 'false';
691
+ if (typeof v === 'number' && !Number.isNaN(v))
692
+ return v !== 0 ? 'true' : 'false';
693
+ }
694
+ }
695
+ return JSON.stringify(root);
696
+ }
697
+ function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
698
+ // Array union: target_key holds a bare JSON array (e.g. cursor/disabledMcpServers).
699
+ // currentJson here is the coerced object root — the raw array is stored under target_key.
700
+ if (sqliteOp.array_union_add?.length) {
701
+ const key = sqliteOp.target_key;
702
+ const existing = currentJson[key];
703
+ const arr = Array.isArray(existing) ? existing : [];
704
+ const lowerSet = new Set(arr.map((v) => String(v).toLowerCase()));
705
+ for (const item of sqliteOp.array_union_add) {
706
+ if (!lowerSet.has(item.toLowerCase())) {
707
+ arr.push(item);
708
+ lowerSet.add(item.toLowerCase());
709
+ }
710
+ }
711
+ currentJson[key] = arr;
712
+ return;
713
+ }
714
+ if (sqliteOp.array_remove?.length) {
715
+ const jp = (sqliteOp.json_path ?? '').trim();
716
+ const removeSet = new Set(sqliteOp.array_remove.map((v) => String(v)));
717
+ if (!jp) {
718
+ const key = sqliteOp.target_key;
719
+ const existing = currentJson[key];
720
+ const arr = Array.isArray(existing) ? [...existing] : [];
721
+ currentJson[key] = arr.filter((x) => !removeSet.has(String(x)));
722
+ return;
723
+ }
724
+ const parts = jp.split('.').filter(Boolean);
725
+ if (parts.length === 0)
726
+ return;
727
+ let node = currentJson;
728
+ for (const p of parts.slice(0, -1)) {
729
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
730
+ return;
731
+ node = node[p];
732
+ }
733
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
734
+ return;
735
+ const container = node;
736
+ const arrayKey = parts[parts.length - 1];
737
+ const existing = container[arrayKey];
738
+ if (!Array.isArray(existing))
739
+ return;
740
+ container[arrayKey] = existing.filter((x) => !removeSet.has(String(x)));
741
+ return;
742
+ }
743
+ if (sqliteOp.remove_commands?.length &&
744
+ (sqliteOp.json_path ?? '').includes('yoloCommandAllowlist') &&
745
+ !sqliteOp.array_remove?.length) {
746
+ const jp = (sqliteOp.json_path ?? '').trim();
747
+ const parts = jp.split('.').filter(Boolean);
748
+ if (parts.length === 0)
749
+ return;
750
+ let node = currentJson;
751
+ for (const p of parts.slice(0, -1)) {
752
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
753
+ return;
754
+ node = node[p];
755
+ }
756
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
757
+ return;
758
+ const container = node;
759
+ const arrayKey = parts[parts.length - 1];
760
+ const existing = container[arrayKey];
761
+ if (!Array.isArray(existing))
762
+ return;
763
+ const tokens = sqliteOp.remove_commands.map((t) => String(t).trim()).filter(Boolean);
764
+ container[arrayKey] = existing.filter((entry) => !tokens.some((t) => allowlistEntryMatchesRemoveToken(entry, t)));
765
+ return;
766
+ }
767
+ const where = sqliteOp.array_item_where;
768
+ const jp = sqliteOp.json_path ?? '';
769
+ if (where && typeof where === 'object' && Object.keys(where).length > 0 && jp) {
770
+ const parts = jp.split('.').filter(Boolean);
771
+ if (parts.length < 1) {
772
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
773
+ return;
774
+ }
775
+ const arrayKey = parts[parts.length - 1];
776
+ const parentParts = parts.slice(0, -1);
777
+ let node = currentJson;
778
+ for (const p of parentParts) {
779
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
780
+ return;
781
+ const rec = node;
782
+ const child = rec[p];
783
+ if (child == null) {
784
+ rec[p] = {};
785
+ node = rec[p];
786
+ }
787
+ else if (typeof child === 'object' && !Array.isArray(child)) {
788
+ node = child;
789
+ }
790
+ else {
791
+ return;
792
+ }
793
+ }
794
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
795
+ return;
796
+ const container = node;
797
+ const existing = container[arrayKey];
798
+ let arr;
799
+ if (Array.isArray(existing)) {
800
+ arr = existing;
801
+ }
802
+ else if (existing == null) {
803
+ arr = [];
804
+ container[arrayKey] = arr;
805
+ }
806
+ else {
807
+ return;
808
+ }
809
+ const idx = arr.findIndex((item) => {
810
+ if (item === null || typeof item !== 'object')
811
+ return false;
812
+ const o = item;
813
+ return Object.entries(where).every(([k, v]) => o[k] === v);
814
+ });
815
+ if (idx >= 0) {
816
+ Object.assign(arr[idx], sqliteOp.updates);
817
+ return;
818
+ }
819
+ const newItem = { ...where };
820
+ Object.assign(newItem, sqliteOp.updates);
821
+ arr.push(newItem);
822
+ return;
823
+ }
824
+ const updates = sqliteOp.updates ?? {};
825
+ let jsonPath = sqliteOp.json_path ?? '';
826
+ const shadowKeys = new Set(CURSOR_COMPOSER_SHADOW_KEYS);
827
+ if (!jsonPath.trim() && Object.keys(updates).some((k) => shadowKeys.has(k))) {
828
+ const nested = currentJson.composerState;
829
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
830
+ jsonPath = 'composerState';
831
+ }
832
+ }
833
+ mergeJsonAtSqlitePath(currentJson, jsonPath, updates);
834
+ const opForMirror = jsonPath === (sqliteOp.json_path ?? '') ? sqliteOp : { ...sqliteOp, json_path: jsonPath };
835
+ mirrorComposerShadowKeysToReactiveRoot(currentJson, opForMirror);
836
+ }
837
+ /**
838
+ * Cursor remediations historically used json_path `composerState.` (trailing dot). That yields an empty
839
+ * segment and mergeJsonAtSqlitePath wrote under composerState[''] instead of composerState — the real
840
+ * isWebFetchToolEnabled stayed true. Strip that spurious object if present.
841
+ */
842
+ function repairComposerStateEmptySegmentBug(root) {
843
+ const cs = root.composerState;
844
+ if (!cs || typeof cs !== 'object' || Array.isArray(cs))
845
+ return;
846
+ const o = cs;
847
+ if (!('' in o))
848
+ return;
849
+ delete o[''];
850
+ }
851
+ /**
852
+ * Cursor sometimes reads web-tool toggles from the reactive blob root; policy/reads merge those into
853
+ * `composerState` for scan. After patching `composerState` directly (resolved json_path exactly
854
+ * `composerState`), copy whitelisted keys to the root using cached
855
+ * `composer_shadow_keys` from the file-patterns API.
856
+ * Skips array-target ops (`modes4`, etc.).
857
+ */
858
+ function mirrorComposerShadowKeysToReactiveRoot(root, resolvedOp) {
859
+ const targetKey = resolvedOp.target_key;
860
+ const contractKey = reactiveStorageItemKeyForVscdb();
861
+ const reactiveTarget = (contractKey && targetKey === contractKey) ||
862
+ (typeof targetKey === 'string' &&
863
+ targetKey.includes(REACTIVE_STORAGE_ITEM_KEY_SUFFIX) &&
864
+ !/['"\\]/.test(targetKey));
865
+ if (!reactiveTarget)
866
+ return;
867
+ const where = resolvedOp.array_item_where;
868
+ if (where && typeof where === 'object' && Object.keys(where).length > 0)
869
+ return;
870
+ const jp = (resolvedOp.json_path ?? '').split('.').filter(Boolean);
871
+ if (jp.length !== 1 || jp[0] !== 'composerState')
872
+ return;
873
+ const updates = resolvedOp.updates;
874
+ if (!updates || typeof updates !== 'object')
875
+ return;
876
+ const shadow = composerShadowKeySetFromContract();
877
+ for (const k of Object.keys(updates)) {
878
+ if (shadow.has(k))
879
+ root[k] = updates[k];
880
+ }
881
+ }
882
+ /** Merge `updates` into JSON at dot-path `json_path` (same rules as server-generated ops). */
883
+ function mergeJsonAtSqlitePath(currentJson, json_path, updates) {
884
+ if (json_path?.trim()) {
885
+ const pathParts = json_path.split('.').filter(Boolean);
886
+ if (pathParts.length === 0) {
887
+ Object.assign(currentJson, updates);
888
+ return;
889
+ }
890
+ let current = currentJson;
891
+ for (let i = 0; i < pathParts.length - 1; i++) {
892
+ const part = pathParts[i];
893
+ const idx = parseInt(part, 10);
894
+ if (!isNaN(idx) && Array.isArray(current)) {
895
+ if (!current[idx])
896
+ current[idx] = {};
897
+ current = current[idx];
898
+ }
899
+ else {
900
+ if (!current[part])
901
+ current[part] = {};
902
+ current = current[part];
903
+ }
904
+ }
905
+ const lastPart = pathParts[pathParts.length - 1];
906
+ const lastIdx = parseInt(lastPart, 10);
907
+ if (!isNaN(lastIdx) && Array.isArray(current)) {
908
+ if (!current[lastIdx])
909
+ current[lastIdx] = {};
910
+ const target = current[lastIdx];
911
+ Object.assign(target, updates);
912
+ }
913
+ else {
914
+ if (!current[lastPart])
915
+ current[lastPart] = {};
916
+ const target = current[lastPart];
917
+ Object.assign(target, updates);
918
+ }
919
+ }
920
+ else {
921
+ Object.assign(currentJson, updates);
922
+ }
923
+ }
924
+ function sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key) {
925
+ if (!assertSafeSqliteIdentifiersForItemTable(table, key_column, value_column)) {
926
+ return '';
927
+ }
928
+ const sqlite3 = resolveSqlite3Binary();
929
+ if (!sqlite3) {
930
+ const line = 'sqlite_update: sqliteSelectValueCell called with no resolved sqlite3 binary (logic error: assertSqlite3Available should run first)';
931
+ hookRunLog(line);
932
+ complianceRunnerDiag(line);
933
+ return '';
934
+ }
935
+ const safeName = target_key.replace(/'/g, "''");
936
+ const script = `.timeout 60000\nSELECT ${value_column} FROM ${table} WHERE ${key_column}='${safeName}';\n`;
937
+ try {
938
+ return execSqlite3(sqlite3, ['-noheader', dbPath], script).trim();
939
+ }
940
+ catch (err) {
941
+ const { short, long } = formatNodeChildException(err);
942
+ hookRunLog(`sqlite_update: SELECT failed binary=${sqlite3} db=${dbPath} table=${table} target_key=${target_key} ${long}`);
943
+ complianceRunnerDiag(`sqlite_update: SELECT failed target_key=${target_key} ${long.slice(0, 1800)}`);
944
+ throw new Error(`sqlite3 SELECT failed: ${short}`);
945
+ }
946
+ }
947
+ function sqliteExecWithTimeout(dbPath, sqlBody) {
948
+ const sqlite3 = resolveSqlite3Binary();
949
+ if (!sqlite3)
950
+ throw new Error('sqlite3 not found');
951
+ const script = `.timeout 60000\n${sqlBody}\n`;
952
+ try {
953
+ execSqlite3(sqlite3, [dbPath], script);
954
+ }
955
+ catch (err) {
956
+ const { long } = formatNodeChildException(err);
957
+ hookRunLog(`sqlite_update: UPDATE/exec failed binary=${sqlite3} db=${dbPath} ${long}`);
958
+ complianceRunnerDiag(`sqlite_update: UPDATE/exec failed ${long.slice(0, 1800)}`);
959
+ throw err;
960
+ }
961
+ }
962
+ /** Runs a single UPDATE (or other SQL) and returns sqlite `changes()` for the last statement. */
963
+ function sqliteRunUpdateReturningChanges(dbPath, updateSql) {
964
+ const sqlite3 = resolveSqlite3Binary();
965
+ if (!sqlite3)
966
+ throw new Error('sqlite3 not found');
967
+ const script = `.timeout 60000\n${updateSql}\nSELECT changes();\n`;
968
+ let out;
969
+ try {
970
+ out = execSqlite3(sqlite3, [dbPath], script).trim();
971
+ }
972
+ catch (err) {
973
+ const { long } = formatNodeChildException(err);
974
+ hookRunLog(`sqlite_update: batch UPDATE/changes failed binary=${sqlite3} db=${dbPath} ${long}`);
975
+ complianceRunnerDiag(`sqlite_update: batch UPDATE failed ${long.slice(0, 1800)}`);
976
+ throw err;
977
+ }
978
+ const lines = out.split(/\r?\n/).filter((l) => l.length > 0);
979
+ const last = lines[lines.length - 1] ?? '0';
980
+ return parseInt(last, 10) || 0;
981
+ }
982
+ function queueDeferredVscdbItem(item, postApplyUpload) {
983
+ const path = getDeferredVscdbApplyPath();
984
+ let payload = { version: 1, items: [] };
985
+ if (existsSync(path)) {
986
+ try {
987
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
988
+ if (parsed?.items && Array.isArray(parsed.items)) {
989
+ payload = parsed;
990
+ if (!Array.isArray(payload.post_apply_uploads))
991
+ payload.post_apply_uploads = [];
992
+ }
993
+ }
994
+ catch {
995
+ /* replace corrupt file */
996
+ }
997
+ }
998
+ payload.items.push(item);
999
+ mergePostApplyUploadIntoPayload(payload, postApplyUpload);
1000
+ atomicWriteJson(path, payload);
1001
+ }
1002
+ /**
1003
+ * After Cursor exits: apply queued ItemTable JSON updates, optional log-config-file uploads (re-read sqlite),
1004
+ * then delete the queue file.
1005
+ * No-op (returns true) if there is nothing pending so `open -a Cursor` still runs.
1006
+ */
1007
+ export async function applyDeferredVscdbFromDisk() {
1008
+ const path = getDeferredVscdbApplyPath();
1009
+ hookRunLog(`deferred_vscdb: applyDeferredVscdbFromDisk queue_path=${path} exists=${existsSync(path)}`);
1010
+ complianceRunnerDiag(`deferred_vscdb: applyDeferredVscdbFromDisk start exists=${existsSync(path)}`);
1011
+ if (!existsSync(path))
1012
+ return true;
1013
+ if (!assertSqlite3Available())
1014
+ return false;
1015
+ let payload;
1016
+ try {
1017
+ payload = JSON.parse(readFileSync(path, 'utf8'));
1018
+ }
1019
+ catch {
1020
+ hookRunLog('deferred_vscdb: could not parse queue file');
1021
+ return false;
1022
+ }
1023
+ const postApplyUploads = [...(payload.post_apply_uploads ?? [])];
1024
+ if (!payload.items?.length) {
1025
+ try {
1026
+ unlinkSync(path);
1027
+ }
1028
+ catch {
1029
+ /* ignore */
1030
+ }
1031
+ return true;
1032
+ }
1033
+ try {
1034
+ let applied = 0;
1035
+ let skippedMissing = 0;
1036
+ for (const it of payload.items) {
1037
+ // Workspace storage can disappear between queue and restart (Cursor GC). Skip those
1038
+ // items instead of aborting the whole batch — other workspaces still need the write.
1039
+ if (!existsSync(it.dbPath)) {
1040
+ hookRunLog(`deferred_vscdb: database missing — skipping ${it.dbPath}`);
1041
+ skippedMissing++;
1042
+ continue;
1043
+ }
1044
+ if (!assertSafeSqliteIdentifiersForItemTable(it.table, it.key_column, it.value_column)) {
1045
+ return false;
1046
+ }
1047
+ const safeJson = it.new_value_json.replace(/'/g, "''");
1048
+ const safeName = it.target_key.replace(/'/g, "''");
1049
+ const sql = `INSERT OR REPLACE INTO ${it.table} (${it.key_column}, ${it.value_column}) VALUES ('${safeName}', '${safeJson}');`;
1050
+ const changed = sqliteRunUpdateReturningChanges(it.dbPath, sql);
1051
+ if (changed < 1) {
1052
+ hookRunLog(`deferred_vscdb: INSERT OR REPLACE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
1053
+ return false;
1054
+ }
1055
+ applied++;
1056
+ }
1057
+ hookRunLog(`deferred_vscdb: applied ${applied} queued update(s) skipped_missing=${skippedMissing}`);
1058
+ const authKey = readStoredAuthKey();
1059
+ for (const u of postApplyUploads) {
1060
+ if (!authKey) {
1061
+ hookRunLog(`deferred_vscdb: skip post-apply upload (no auth) path=${u.file_path}`);
1062
+ continue;
1063
+ }
1064
+ const hi = u.file_path.indexOf('#');
1065
+ if (hi < 0)
1066
+ continue;
1067
+ const dbPath = u.file_path.slice(0, hi);
1068
+ const itemKey = u.file_path.slice(hi + 1).trim();
1069
+ if (!itemKey)
1070
+ continue;
1071
+ const rawContent = buildVscdbPostApplyRawContent(dbPath, itemKey);
1072
+ if (rawContent === null) {
1073
+ hookRunLog(`deferred_vscdb: post-apply read failed path=${u.file_path}`);
1074
+ continue;
1075
+ }
1076
+ const hw = tryResolveHardwareUuid();
1077
+ if (!hw) {
1078
+ hookRunLog(`deferred_vscdb: skip post-apply upload (hardware UUID unavailable) path=${u.file_path}`);
1079
+ continue;
1080
+ }
1081
+ const sent = await sendConfigFile({ file_type: u.file_type, file_path: u.file_path, raw_content: rawContent }, hw, authKey);
1082
+ hookRunLog(`deferred_vscdb: post-apply upload path=${u.file_path} ok=${sent}`);
1083
+ }
1084
+ unlinkSync(path);
1085
+ return true;
1086
+ }
1087
+ catch (e) {
1088
+ hookRunLog(`deferred_vscdb: apply failed: ${e instanceof Error ? e.message : String(e)}`);
1089
+ return false;
1090
+ }
1091
+ }
1092
+ /**
1093
+ * macOS Cursor: after deferred **SQLite / state.vscdb** autofix — apply queued writes, SIGKILL, reopen project.
1094
+ *
1095
+ * When autofix used the deferred vscdb path, `applyAutofixViolations` replaces any manifest `restart_command`
1096
+ * with this string (see compliance_check.ts). For **JSON settings-file** remediations, use
1097
+ * {@link buildCursorJsonSettingsRestartCommand} (kill + reopen).
1098
+ */
1099
+ export function buildDeferredCursorRestartCommand() {
1100
+ // Prefer monorepo path when hooks run from optimus-secure-fdn; otherwise `npx --yes --package=@optimuslabs/harness-map@latest apply-deferred-vscdb`
1101
+ // (package bin) so published installs work without a local dev_npx_packages copy.
1102
+ return TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND;
1103
+ }
1104
+ export function buildCursorJsonSettingsRestartCommand() {
1105
+ return TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND;
1106
+ }
1107
+ /**
1108
+ * Cursor JSON settings are written synchronously; when local recheck passes, report verified before
1109
+ * restart (restart only reloads disk into the running app). Deferred state.vscdb must wait until
1110
+ * apply_deferred_vscdb runs in the trusted restart script, then the next compliance gate.
1111
+ */
1112
+ export function cursorAutofixDefersInlineVerify(ide, restartCommandCount, deferredSqlitePending, recheckOk) {
1113
+ if (ide !== 'cursor' || restartCommandCount === 0)
1114
+ return false;
1115
+ if (deferredSqlitePending)
1116
+ return true;
1117
+ if (recheckOk)
1118
+ return false;
1119
+ return true;
1120
+ }
1121
+ function sqliteRowGroupKey(dbPath, op) {
1122
+ return `${dbPath}|${op.table}|${op.key_column}|${op.value_column}|${op.target_key}`;
1123
+ }
1124
+ /**
1125
+ * Deferred sqlite path: one read per logical row, apply every `sqlite_op` merge in memory, then one queue
1126
+ * entry per row. Without this, two ops on the same `composerState` row each read the stale DB and queue
1127
+ * two full JSON blobs — the second UPDATE overwrites the first (e.g. only one of autoRun/fullAutoRun sticks).
1128
+ */
1129
+ /** ItemTable keys must not contain SQL-quote garbage (bad vscdb #fragment or corrupted manifest). */
1130
+ function assertSafeDeferredItemTableKey(targetKey) {
1131
+ if (!targetKey || targetKey.length > 512)
1132
+ return false;
1133
+ if (/['"\\]/.test(targetKey))
1134
+ return false;
1135
+ return true;
1136
+ }
1137
+ function queueDeferredSqliteOpsMerged(configPath, sqliteOps, postApplyUpload) {
1138
+ const dbPath = configPath.split('#')[0];
1139
+ hookRunLog(`sqlite_update: deferred_queue begin configPath=${configPath} dbPath=${dbPath} dbExists=${existsSync(dbPath)} sqliteOps=${sqliteOps.length}`);
1140
+ complianceRunnerDiag(`sqlite_update: deferred_queue begin dbExists=${existsSync(dbPath)} ops=${sqliteOps.length}`);
1141
+ if (!existsSync(dbPath)) {
1142
+ const line = `sqlite_update: database not found at ${dbPath}`;
1143
+ hookRunLog(line);
1144
+ hookRunLog(`sqlite_update: parent dir exists=${existsSync(dirname(dbPath))} (if false, wrong Cursor profile path or never launched Cursor)`);
1145
+ complianceRunnerDiag(line);
1146
+ return {
1147
+ ok: false,
1148
+ reason: `state.vscdb not on disk at ${dbPath} (Cursor may not have created globalStorage yet — open Cursor once).`,
1149
+ };
1150
+ }
1151
+ if (!assertSqlite3Available()) {
1152
+ return {
1153
+ ok: false,
1154
+ reason: 'sqlite3 CLI not found (IDE hooks often have a minimal PATH). On macOS try OPTIMUS_SQLITE3=/usr/bin/sqlite3 or install sqlite3 and fix PATH.',
1155
+ };
1156
+ }
1157
+ const resolvedOps = sqliteOps.map((op) => resolveCursorComposerSqliteOp(dbPath, op));
1158
+ const groups = new Map();
1159
+ for (const op of resolvedOps) {
1160
+ const k = sqliteRowGroupKey(dbPath, op);
1161
+ const arr = groups.get(k);
1162
+ if (arr)
1163
+ arr.push(op);
1164
+ else
1165
+ groups.set(k, [op]);
1166
+ }
1167
+ try {
1168
+ for (const ops of groups.values()) {
1169
+ const first = ops[0];
1170
+ if (!assertSafeDeferredItemTableKey(first.target_key)) {
1171
+ const line = `sqlite_update: rejected unsafe or empty target_key for deferred queue (refusing to write state.vscdb)`;
1172
+ hookRunLog(line);
1173
+ complianceRunnerDiag(`${line} target_key_preview=${first.target_key.slice(0, 80)}`);
1174
+ return { ok: false, reason: 'unsafe or empty ItemTable target_key (see hook_log).' };
1175
+ }
1176
+ complianceRunnerDiag(`sqlite_update: deferred merge db=${dbPath} target_key=${first.target_key} operations=${ops.length}`);
1177
+ let currentJson = {};
1178
+ let rawSelectLength = 0;
1179
+ try {
1180
+ const result = sqliteSelectValueCell(dbPath, first.table, first.key_column, first.value_column, first.target_key);
1181
+ rawSelectLength = result.length;
1182
+ if (result) {
1183
+ const parsed = JSON.parse(result);
1184
+ currentJson = coerceItemTableValueToObjectRoot(first.target_key, parsed);
1185
+ }
1186
+ }
1187
+ catch (e) {
1188
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
1189
+ hookRunLog(line);
1190
+ complianceRunnerDiag(line);
1191
+ return {
1192
+ ok: false,
1193
+ reason: `sqlite read/parse failed: ${e instanceof Error ? e.message : String(e)}`,
1194
+ };
1195
+ }
1196
+ for (const op of ops) {
1197
+ mergeSqliteOpIntoJson(currentJson, op);
1198
+ }
1199
+ repairComposerStateEmptySegmentBug(currentJson);
1200
+ const updatedJson = serializeItemTableValueForWrite(first.target_key, currentJson);
1201
+ if (updatedJson === '{}' && Object.keys(currentJson).length === 0) {
1202
+ const opSummary = ops
1203
+ .map((o) => `path=${o.json_path ?? ''} updates=${JSON.stringify(o.updates ?? {})}`)
1204
+ .join(' || ')
1205
+ .slice(0, 900);
1206
+ const line = `sqlite_update: deferred merge produced empty JSON — refusing to queue (would wipe ItemTable row) target_key=${first.target_key} raw_cell_len=${rawSelectLength} ops=${opSummary}`;
1207
+ hookRunLog(line);
1208
+ complianceRunnerDiag(line.slice(0, 2000));
1209
+ return {
1210
+ ok: false,
1211
+ reason: `merge produced empty JSON after ops (target_key=${first.target_key}, raw_cell_len=${rawSelectLength}). Check sqlite_update lines above for SELECT errors or bad sqlite_op merge.`,
1212
+ };
1213
+ }
1214
+ queueDeferredVscdbItem({
1215
+ dbPath,
1216
+ table: first.table,
1217
+ key_column: first.key_column,
1218
+ value_column: first.value_column,
1219
+ target_key: first.target_key,
1220
+ new_value_json: updatedJson,
1221
+ }, postApplyUpload);
1222
+ const okLine = `sqlite_update: queued deferred write (merged ${ops.length} op(s)) for ${first.target_key} in ${dbPath}`;
1223
+ hookRunLog(okLine);
1224
+ complianceRunnerDiag(okLine);
1225
+ }
1226
+ return { ok: true };
1227
+ }
1228
+ catch (err) {
1229
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
1230
+ hookRunLog(line);
1231
+ complianceRunnerDiag(line);
1232
+ return {
1233
+ ok: false,
1234
+ reason: `unexpected: ${err instanceof Error ? err.message : String(err)}`,
1235
+ };
1236
+ }
1237
+ }
1238
+ /**
1239
+ * Read + merge JSON for a sqlite op; when `deferred`, queue UPDATE for after IDE exit (state.vscdb lock).
1240
+ */
1241
+ function applyOrQueueSqliteJsonUpdate(configPath, sqliteOp, deferred) {
1242
+ try {
1243
+ const dbPath = configPath.split('#')[0];
1244
+ complianceRunnerDiag(`sqlite_update: attempt db=${dbPath} target_key=${sqliteOp.target_key} json_path=${sqliteOp.json_path} deferred=${deferred}`);
1245
+ if (!existsSync(dbPath)) {
1246
+ const line = `sqlite_update: database not found at ${dbPath}`;
1247
+ hookRunLog(line);
1248
+ complianceRunnerDiag(line);
1249
+ return false;
1250
+ }
1251
+ if (!assertSqlite3Available())
1252
+ return false;
1253
+ sqliteOp = resolveCursorComposerSqliteOp(dbPath, sqliteOp);
1254
+ const { table, key_column, value_column, target_key } = sqliteOp;
1255
+ if (!assertSafeSqliteIdentifiersForItemTable(table, key_column, value_column)) {
1256
+ return false;
1257
+ }
1258
+ const safeName = target_key.replace(/'/g, "''");
1259
+ let currentJson = {};
1260
+ try {
1261
+ const result = sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key);
1262
+ if (result) {
1263
+ const parsed = JSON.parse(result);
1264
+ currentJson = coerceItemTableValueToObjectRoot(target_key, parsed);
1265
+ }
1266
+ }
1267
+ catch (e) {
1268
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
1269
+ hookRunLog(line);
1270
+ complianceRunnerDiag(line);
1271
+ return false;
1272
+ }
1273
+ mergeSqliteOpIntoJson(currentJson, sqliteOp);
1274
+ repairComposerStateEmptySegmentBug(currentJson);
1275
+ const updatedJson = serializeItemTableValueForWrite(target_key, currentJson);
1276
+ if (deferred) {
1277
+ queueDeferredVscdbItem({
1278
+ dbPath,
1279
+ table,
1280
+ key_column,
1281
+ value_column,
1282
+ target_key,
1283
+ new_value_json: updatedJson,
1284
+ });
1285
+ const okLine = `sqlite_update: queued deferred write for ${target_key} in ${dbPath}`;
1286
+ hookRunLog(okLine);
1287
+ complianceRunnerDiag(okLine);
1288
+ return true;
1289
+ }
1290
+ const safeJson = updatedJson.replace(/'/g, "''");
1291
+ const updateSql = `UPDATE ${table} SET ${value_column}='${safeJson}' WHERE ${key_column}='${safeName}';`;
1292
+ try {
1293
+ sqliteExecWithTimeout(dbPath, updateSql);
1294
+ const okLine = `sqlite_update: successfully updated ${target_key} in ${dbPath}`;
1295
+ hookRunLog(okLine);
1296
+ complianceRunnerDiag(okLine);
1297
+ return true;
1298
+ }
1299
+ catch (e) {
1300
+ const line = `sqlite_update: error updating database: ${e instanceof Error ? e.message : String(e)}`;
1301
+ hookRunLog(line);
1302
+ complianceRunnerDiag(line);
1303
+ return false;
1304
+ }
1305
+ }
1306
+ catch (err) {
1307
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
1308
+ hookRunLog(line);
1309
+ complianceRunnerDiag(line);
1310
+ return false;
1311
+ }
1312
+ }
1313
+ /**
1314
+ * Return paths to every state.vscdb found under workspaceStoragePath/<hash>/state.vscdb.
1315
+ * Used for the apply_to_all_workspaces fallback when no specific workspace vscdb was collected.
1316
+ * Exported so compliance_check.ts can verify workspace vscdb state during ops-based checks.
1317
+ */
1318
+ export function discoverAllWorkspaceVscdbs(workspaceStoragePath) {
1319
+ if (!existsSync(workspaceStoragePath))
1320
+ return [];
1321
+ try {
1322
+ return readdirSync(workspaceStoragePath, { withFileTypes: true })
1323
+ .filter((e) => e.isDirectory())
1324
+ .map((e) => join(workspaceStoragePath, e.name, 'state.vscdb'))
1325
+ .filter((p) => existsSync(p));
1326
+ }
1327
+ catch {
1328
+ return [];
1329
+ }
1330
+ }
1331
+ export function enforceRemediation(instruction) {
1332
+ const resolvedPath = resolveRemediationConfigPath(instruction.config_file_path);
1333
+ const inst = resolvedPath === instruction.config_file_path
1334
+ ? instruction
1335
+ : { ...instruction, config_file_path: resolvedPath };
1336
+ const fail = (reason, extra) => {
1337
+ const spec = remediationFixSpec(inst);
1338
+ logRemediationApplyFailure('enforceRemediation', {
1339
+ uuid: inst.uuid,
1340
+ config_file_path: inst.config_file_path,
1341
+ file_type: inst.file_type ?? '',
1342
+ finding_formatted_id: spec?.finding_formatted_id ?? '',
1343
+ reason,
1344
+ ...extra,
1345
+ });
1346
+ hookRunLog(`remediation_enforce: failed uuid=${inst.uuid} reason=${reason}`);
1347
+ return { ok: false, failureReason: reason };
1348
+ };
1349
+ try {
1350
+ const fixSpec = remediationFixSpec(inst);
1351
+ const checks = fixSpec?.checks ?? [];
1352
+ if (checks.length === 0) {
1353
+ return fail('no checks in fix spec (empty or missing compliance checks)');
1354
+ }
1355
+ const sqliteOps = checks.filter((c) => {
1356
+ const raw = c;
1357
+ return raw.sqlite_op !== undefined;
1358
+ });
1359
+ if (sqliteOps.length > 0) {
1360
+ complianceRunnerDiag(`remediation_enforce: sqlite path uuid=${inst.uuid} checks_with_sqlite=${sqliteOps.length}`);
1361
+ const ops = sqliteOps.map((c) => c.sqlite_op);
1362
+ const firstOp = ops[0];
1363
+ // apply_to_all_workspaces: no specific vscdb was collected — discover and patch every
1364
+ // workspaceStorage/<hash>/state.vscdb on disk (fallback for mcp_config-linked findings).
1365
+ if (firstOp?.apply_to_all_workspaces) {
1366
+ const segments = firstOp.workspace_storage_path_segments ?? [
1367
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
1368
+ ];
1369
+ const wsPath = join(homedir(), ...segments);
1370
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
1371
+ hookRunLog(`remediation_enforce: apply_to_all_workspaces uuid=${inst.uuid} wsPath=${wsPath} found=${vscdbPaths.length}`);
1372
+ if (vscdbPaths.length === 0) {
1373
+ return fail(`no workspace vscdbs found at ${wsPath} (open Cursor in at least one project first)`);
1374
+ }
1375
+ let anyOk = false;
1376
+ for (const vscdbPath of vscdbPaths) {
1377
+ // Queue a post-apply upload for each workspace vscdb so that after the deferred
1378
+ // SQLite write is applied at restart time, the updated cursor/disabledMcpServers
1379
+ // value is immediately sent to the server. This closes the race condition where
1380
+ // the user re-enables the server between the enforcement write and the next
1381
+ // scheduled log-config run: the upload after the deferred apply gives the server
1382
+ // the authoritative disabled state without waiting for a full log-config cycle.
1383
+ const postApplyUpload = {
1384
+ file_path: `${vscdbPath}#${firstOp.target_key}`,
1385
+ file_type: 'cursor_workspace_vscdb',
1386
+ };
1387
+ const q = queueDeferredSqliteOpsMerged(vscdbPath, ops, postApplyUpload);
1388
+ if (q.ok) {
1389
+ anyOk = true;
1390
+ hookRunLog(`remediation_enforce: queued apply_to_all_workspaces op for ${vscdbPath}`);
1391
+ }
1392
+ else {
1393
+ hookRunLog(`remediation_enforce: skip ${vscdbPath}: ${q.reason}`);
1394
+ }
1395
+ }
1396
+ if (!anyOk) {
1397
+ return fail('all workspace vscdb deferred writes failed (see sqlite_update lines above)');
1398
+ }
1399
+ return { ok: true, deferredSqlite: true };
1400
+ }
1401
+ const restartRequired = !!fixSpec?.restart_required;
1402
+ if (restartRequired) {
1403
+ const ft = inst.file_type?.trim();
1404
+ const postApplyUpload = ft && inst.config_file_path.includes('#')
1405
+ ? { file_path: inst.config_file_path, file_type: ft }
1406
+ : undefined;
1407
+ const q = queueDeferredSqliteOpsMerged(inst.config_file_path, ops, postApplyUpload);
1408
+ if (!q.ok) {
1409
+ const dbOnly = inst.config_file_path.split('#')[0];
1410
+ return fail(`deferred state.vscdb queue failed — ${q.reason} (see sqlite_update lines above in hook_log for SELECT/exec details)`, {
1411
+ config_file_path: inst.config_file_path,
1412
+ resolved_db_path: dbOnly,
1413
+ sqlite3_binary: resolveSqlite3Binary() ?? 'unresolved',
1414
+ });
1415
+ }
1416
+ return { ok: true, deferredSqlite: true };
1417
+ }
1418
+ let allSuccess = true;
1419
+ for (const check of sqliteOps) {
1420
+ const raw = check;
1421
+ const sqliteOp = raw.sqlite_op;
1422
+ const rowOk = applyOrQueueSqliteJsonUpdate(inst.config_file_path, sqliteOp, false);
1423
+ if (!rowOk)
1424
+ allSuccess = false;
1425
+ }
1426
+ if (!allSuccess) {
1427
+ return fail('immediate sqlite apply failed for one or more checks');
1428
+ }
1429
+ return { ok: true, deferredSqlite: false };
1430
+ }
1431
+ if (fixSpec?.file_format !== 'json' && fixSpec?.file_format !== 'toml') {
1432
+ return fail(`unsupported file format: ${String(fixSpec?.file_format ?? 'undefined')}`);
1433
+ }
1434
+ const worktreeRoot = parseWorktreeRootFromPath(inst.config_file_path);
1435
+ if (worktreeRoot && !existsSync(worktreeRoot)) {
1436
+ void reportAbsentWorktrees([
1437
+ {
1438
+ worktree_root: worktreeRoot,
1439
+ reason: 'path_missing_on_remediate',
1440
+ source_path: inst.config_file_path,
1441
+ },
1442
+ ]);
1443
+ return fail('worktree_absent');
1444
+ }
1445
+ const dir = dirname(inst.config_file_path);
1446
+ if (worktreeRoot) {
1447
+ if (!existsSync(dir)) {
1448
+ return fail('worktree_path_missing');
1449
+ }
1450
+ }
1451
+ else if (!existsSync(dir)) {
1452
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1453
+ }
1454
+ const isToml = fixSpec?.file_format === 'toml';
1455
+ let configObj = {};
1456
+ if (existsSync(inst.config_file_path)) {
1457
+ const rawText = readFileSync(inst.config_file_path, 'utf8');
1458
+ if (isToml) {
1459
+ const parsed = parseTomlText(rawText);
1460
+ if (parsed !== null) {
1461
+ configObj = parsed;
1462
+ }
1463
+ else {
1464
+ hookRunLog(`remediation_enforce: could not parse existing TOML file, starting fresh uuid=${inst.uuid}`);
1465
+ }
1466
+ }
1467
+ else {
1468
+ // OpenCode configs are JSONC; parse strict JSON first, then JSONC fallback so a
1469
+ // comment-bearing opencode.json(c) keeps its existing settings instead of being reset to
1470
+ // {} on a parse failure. NOTE: the write-back below is JSON.stringify, so comments and
1471
+ // trailing commas in the original file are not preserved (same as every other agent's
1472
+ // JSON config) — only the key/value settings are retained and patched.
1473
+ const parsed = parseJsonWithJsoncFallback(rawText);
1474
+ if (parsed !== null) {
1475
+ configObj = parsed;
1476
+ }
1477
+ else {
1478
+ hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
1479
+ }
1480
+ }
1481
+ }
1482
+ for (const check of checks) {
1483
+ applyCheck(configObj, check);
1484
+ }
1485
+ syncFlatGlobalCursorIgnoreListKey(configObj);
1486
+ const content = isToml ? stringifyToml(configObj) : JSON.stringify(configObj, null, 2);
1487
+ const tmp = `${inst.config_file_path}.tmp`;
1488
+ writeFileSync(tmp, content, 'utf8');
1489
+ renameSync(tmp, inst.config_file_path);
1490
+ return { ok: true };
1491
+ }
1492
+ catch (err) {
1493
+ const msg = err instanceof Error ? err.message : String(err);
1494
+ const stack = err instanceof Error ? err.stack : undefined;
1495
+ logRemediationApplyFailure('enforceRemediation', {
1496
+ uuid: inst.uuid,
1497
+ config_file_path: inst.config_file_path,
1498
+ file_type: inst.file_type ?? '',
1499
+ finding_formatted_id: remediationFixSpec(inst)?.finding_formatted_id ?? '',
1500
+ reason: 'exception during enforce',
1501
+ error: msg,
1502
+ stack: stack ?? '',
1503
+ });
1504
+ hookRunLog(`remediation_enforce_error: uuid=${inst.uuid} err=${msg}`);
1505
+ return { ok: false, failureReason: `exception: ${msg}` };
1506
+ }
1507
+ }
1508
+ /**
1509
+ * Fire-and-forget: notify the server that this machine applied an autofix for the given
1510
+ * remediation UUID. Creates one EnforcementLog row on the server for the audit trail.
1511
+ * Never throws — any failure is logged and silently swallowed so it cannot block the
1512
+ * autofix flow.
1513
+ */
1514
+ export function reportAutofixApplied(remediationUuid, result, details) {
1515
+ const authKey = readStoredAuthKey();
1516
+ if (!authKey) {
1517
+ hookRunLog(`autofix_report: no auth key available, skipping report for uuid=${remediationUuid}`);
1518
+ return Promise.resolve();
1519
+ }
1520
+ const hardwareUuid = tryResolveHardwareUuid();
1521
+ if (!hardwareUuid) {
1522
+ hookRunLog(`autofix_report: hardware UUID unavailable, skipping report for uuid=${remediationUuid}`);
1523
+ return Promise.resolve();
1524
+ }
1525
+ const endpointBase = loadEndpointBase();
1526
+ const url = buildApiUrl(endpointBase, '/endpoint_security/api/autofix-applied/');
1527
+ const payload = {
1528
+ hardware_uuid: hardwareUuid,
1529
+ remediation_uuid: remediationUuid,
1530
+ result,
1531
+ };
1532
+ if (details?.failure_reason)
1533
+ payload.failure_reason = details.failure_reason.slice(0, 2000);
1534
+ if (details?.consecutive_failures != null)
1535
+ payload.consecutive_failures = details.consecutive_failures;
1536
+ const bodyPayload = { ...payload, signature: '' };
1537
+ if (details?.config_snapshot_after && typeof details.config_snapshot_after === 'object') {
1538
+ bodyPayload.config_snapshot_after = details.config_snapshot_after;
1539
+ }
1540
+ const signature = createSignature(payload, authKey.key);
1541
+ const body = JSON.stringify({ ...bodyPayload, signature });
1542
+ return executeBody(url, 'POST', body, 8000)
1543
+ .then(({ statusCode }) => {
1544
+ if (statusCode === 200) {
1545
+ hookRunLog(`autofix_report: ok result=${result} uuid=${remediationUuid}`);
1546
+ }
1547
+ else {
1548
+ hookRunLog(`autofix_report: server returned ${statusCode} for uuid=${remediationUuid}`);
1549
+ }
1550
+ })
1551
+ .catch((err) => {
1552
+ hookRunLog(`autofix_report: request failed for uuid=${remediationUuid}: ${err instanceof Error ? err.message : String(err)}`);
1553
+ });
1554
+ }
1555
+ // ---------------------------------------------------------------------------
1556
+ // Main entry point
1557
+ // ---------------------------------------------------------------------------
1558
+ export async function syncRemediations(endpointBase, machineUuid) {
1559
+ let remediations = readInstructions().remediations;
1560
+ const activeUuids = remediations.map((r) => r.uuid);
1561
+ complianceRunnerDiag(`remediation_sync: begin endpoint=${endpointBase} machine_uuid=${machineUuid} local_rows=${remediations.length}`);
1562
+ try {
1563
+ if (!existsSync(getFileCollectionVscdbContractPath())) {
1564
+ const pr = await getFileCollectionPatterns(endpointBase);
1565
+ if (pr)
1566
+ persistVscdbComposerContractFromPatternsResponse(pr);
1567
+ }
1568
+ }
1569
+ catch {
1570
+ /* contract also written on full log-config run */
1571
+ }
1572
+ let syncResult;
1573
+ try {
1574
+ syncResult = await fetchSync(endpointBase, machineUuid, activeUuids);
1575
+ }
1576
+ catch (err) {
1577
+ const msg = `remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`;
1578
+ hookRunLog(msg);
1579
+ complianceRunnerDiag(msg);
1580
+ return;
1581
+ }
1582
+ if (!syncResult) {
1583
+ hookRunLog(`remediation_sync: no response from server, skipping`);
1584
+ complianceRunnerDiag('remediation_sync: no response from server (non-200 or empty body), skipping');
1585
+ return;
1586
+ }
1587
+ applyClearApplyTrackingUuids(syncResult.clear_apply_tracking_uuids);
1588
+ if (syncResult.status === 'unchanged') {
1589
+ hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
1590
+ complianceRunnerDiag(`remediation_sync: server status=unchanged local_uuids=${activeUuids.length} — no new rows to fetch; if a deploy should exist, verify Finding.machine matches this hardware_uuid`);
1591
+ const want = new Set(activeUuids);
1592
+ const have = new Map(remediations.map((r) => [r.uuid, r]));
1593
+ const needBackfill = activeUuids.some((u) => !have.has(u));
1594
+ const haveIncomplete = remediations.some((r) => want.has(r.uuid) && needsManifestRefetch(r));
1595
+ const haveOrphan = remediations.some((r) => !want.has(r.uuid));
1596
+ const path = getRemediationInstructionsPath();
1597
+ if (activeUuids.length > 0) {
1598
+ if (needBackfill || haveIncomplete || haveOrphan || !existsSync(path)) {
1599
+ try {
1600
+ await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
1601
+ }
1602
+ catch (err) {
1603
+ hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
1604
+ }
1605
+ }
1606
+ }
1607
+ else if (remediations.length > 0) {
1608
+ try {
1609
+ await ensureInstructionsForUuids(endpointBase, machineUuid, new Set(), null);
1610
+ }
1611
+ catch (err) {
1612
+ hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
1613
+ }
1614
+ }
1615
+ return;
1616
+ }
1617
+ const { added, removed } = syncResult;
1618
+ hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1619
+ complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
1620
+ const removedSet = new Set(removed);
1621
+ const retiredInstructions = remediations.filter((r) => removedSet.has(r.uuid));
1622
+ let removedCount = 0;
1623
+ remediations = remediations.filter((r) => {
1624
+ if (removedSet.has(r.uuid)) {
1625
+ removedCount++;
1626
+ hookRunLog(`remediation_remove: uuid=${r.uuid} path=${r.config_file_path}`);
1627
+ return false;
1628
+ }
1629
+ return true;
1630
+ });
1631
+ let manifest = null;
1632
+ if (added.length > 0) {
1633
+ try {
1634
+ manifest = await fetchManifest(endpointBase, machineUuid, added);
1635
+ }
1636
+ catch (err) {
1637
+ const msg = `remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`;
1638
+ hookRunLog(msg);
1639
+ complianceRunnerDiag(msg);
1640
+ if (removedCount > 0) {
1641
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1642
+ writeInstructions({ remediations });
1643
+ }
1644
+ return;
1645
+ }
1646
+ if (!manifest) {
1647
+ hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
1648
+ complianceRunnerDiag('remediation_sync: manifest fetch failed (null), skipping — remediation_instructions.json not updated');
1649
+ if (removedCount > 0) {
1650
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1651
+ writeInstructions({ remediations });
1652
+ }
1653
+ return;
1654
+ }
1655
+ }
1656
+ const existingUuids = new Set(remediations.map((r) => r.uuid));
1657
+ const addedSet = new Set(added);
1658
+ const overlay = [];
1659
+ for (const instruction of manifest ?? []) {
1660
+ if (!addedSet.has(instruction.uuid))
1661
+ continue;
1662
+ if (existingUuids.has(instruction.uuid)) {
1663
+ // Enforced remediations are always in `added`; overwrite local copy with fresh server data.
1664
+ const idx = remediations.findIndex((r) => r.uuid === instruction.uuid);
1665
+ if (idx >= 0)
1666
+ remediations[idx] = instruction;
1667
+ }
1668
+ else {
1669
+ remediations.push(instruction);
1670
+ existingUuids.add(instruction.uuid);
1671
+ }
1672
+ overlay.push(instruction);
1673
+ hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
1674
+ }
1675
+ transferQuarantineForRetiredUuids(removed, added, retiredInstructions, overlay);
1676
+ const addedCount = overlay.length;
1677
+ if (added.length > 0 && addedCount === 0) {
1678
+ const msg = `remediation_sync: manifest had no rows for added uuids=${added.join(',')} — check server manifest/machine linkage`;
1679
+ hookRunLog(msg);
1680
+ complianceRunnerDiag(`${msg} — remediation_instructions.json not written`);
1681
+ }
1682
+ if (removedCount > 0 || addedCount > 0) {
1683
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1684
+ writeInstructions({ remediations });
1685
+ complianceRunnerDiag(`remediation_sync: wrote ${getRemediationInstructionsPath()} rows=${remediations.length} (added_from_manifest=${addedCount} removed=${removedCount})`);
1686
+ const finalWant = new Set(remediations.map((r) => r.uuid));
1687
+ try {
1688
+ await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
1689
+ }
1690
+ catch (err) {
1691
+ hookRunLog(`remediation_instructions_persist_error: ${err instanceof Error ? err.message : String(err)}`);
1692
+ }
1693
+ // Post-sync heartbeat: report the updated UUID set so the server has a before/after pair.
1694
+ const finalUuids = remediations.map((r) => r.uuid);
1695
+ try {
1696
+ await fetchSync(endpointBase, machineUuid, finalUuids);
1697
+ hookRunLog(`remediation_sync: post-sync heartbeat reported uuids=${finalUuids.length}`);
1698
+ }
1699
+ catch {
1700
+ // Non-critical — skip silently.
1701
+ }
1702
+ }
1703
+ hookRunLog(`remediation_sync: saved=${addedCount} removed=${removedCount}`);
1704
+ complianceRunnerDiag(`remediation_sync: done saved=${addedCount} removed=${removedCount}`);
1705
+ }