log-llm-config-staging 1.3.44

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 (51) hide show
  1. package/README.md +46 -0
  2. package/dist/apply_deferred_vscdb.js +8 -0
  3. package/dist/bootstrap_constants.js +5 -0
  4. package/dist/cli/bash_script_generator.js +95 -0
  5. package/dist/cli.js +103 -0
  6. package/dist/cli_invocation_match.js +28 -0
  7. package/dist/compliance_check_runner.js +17 -0
  8. package/dist/compliance_prompt_gate.js +197 -0
  9. package/dist/endpoint_client/http_transport.js +88 -0
  10. package/dist/endpoint_client/index.js +3 -0
  11. package/dist/endpoint_client/registry_api.js +41 -0
  12. package/dist/endpoint_client/startup_api.js +43 -0
  13. package/dist/endpoint_client/types.js +4 -0
  14. package/dist/execute_trusted_restarts.js +54 -0
  15. package/dist/log_config_files/auth/auth_flow.js +22 -0
  16. package/dist/log_config_files/auth/auth_key_store.js +14 -0
  17. package/dist/log_config_files/collection/config_collector.js +160 -0
  18. package/dist/log_config_files/collection/directory_collector.js +96 -0
  19. package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
  20. package/dist/log_config_files/collection/file_type_rules.js +47 -0
  21. package/dist/log_config_files/collection/mcp_tool_collector.js +37 -0
  22. package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
  23. package/dist/log_config_files/collection/plugin_collector.js +89 -0
  24. package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
  25. package/dist/log_config_files/index.js +19 -0
  26. package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
  27. package/dist/log_config_files/paths/pattern_resolver.js +227 -0
  28. package/dist/log_config_files/readers/file_readers.js +69 -0
  29. package/dist/log_config_files/readers/vscdb_config_builder.js +146 -0
  30. package/dist/log_config_files/readers/vscdb_reader.js +247 -0
  31. package/dist/log_config_files/runtime/compliance_check.js +518 -0
  32. package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
  33. package/dist/log_config_files/runtime/hook_logger.js +197 -0
  34. package/dist/log_config_files/runtime/main_runner.js +192 -0
  35. package/dist/log_config_files/runtime/management_storage.js +82 -0
  36. package/dist/log_config_files/runtime/remediation_config_path.js +90 -0
  37. package/dist/log_config_files/runtime/remediation_sync.js +1290 -0
  38. package/dist/log_config_files/runtime/sqlite_binary.js +92 -0
  39. package/dist/log_config_files/runtime/trusted_restarts.js +52 -0
  40. package/dist/log_config_files/sender/batch_sender.js +220 -0
  41. package/dist/log_config_files/sender/endpoint_config.js +24 -0
  42. package/dist/log_config_files/sender/signing.js +1 -0
  43. package/dist/log_sensitive_paths_audit.js +97 -0
  44. package/dist/log_uuid/auth_key_store.js +71 -0
  45. package/dist/log_uuid/hardware_uuid.js +35 -0
  46. package/dist/log_uuid/index.js +11 -0
  47. package/dist/log_uuid/log_uuid_helper.js +30 -0
  48. package/dist/log_uuid/startup_sender.js +74 -0
  49. package/dist/log_uuid/user_profile.js +178 -0
  50. package/dist/types/config_file_types.js +1 -0
  51. package/package.json +62 -0
@@ -0,0 +1,1290 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
+ import { delimiter, dirname } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { executeGet, executeBody } from '../../endpoint_client/http_transport.js';
5
+ import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
6
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readFileCollectionVscdbContract, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
7
+ import { readStoredAuthKey } from '../auth/auth_key_store.js';
8
+ import { createSignature } from '../sender/signing.js';
9
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
10
+ import { tryResolveHardwareUuid } from './hardware_uuid.js';
11
+ import { CURSOR_SCALAR_ITEMTABLE_FIELDS, persistVscdbComposerContractFromPatternsResponse, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
12
+ import { sendConfigFile } from '../sender/batch_sender.js';
13
+ import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
14
+ import { resolveRemediationConfigPath } from './remediation_config_path.js';
15
+ import { resolveSqlite3Binary } from './sqlite_binary.js';
16
+ /** Best-effort detail from execFileSync failures (stderr, exit code, errno). */
17
+ function formatNodeChildException(err) {
18
+ if (!(err instanceof Error)) {
19
+ const s = String(err);
20
+ return { short: s, long: s };
21
+ }
22
+ const e = err;
23
+ const bits = [e.message];
24
+ if (e.code)
25
+ bits.push(`errno_code=${e.code}`);
26
+ if (typeof e.status === 'number')
27
+ bits.push(`exit_status=${e.status}`);
28
+ let stderr = '';
29
+ if (e.stderr != null) {
30
+ stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf8');
31
+ const t = stderr.trim();
32
+ if (t)
33
+ bits.push(`stderr=${t.slice(0, 1200)}`);
34
+ }
35
+ const long = bits.join(' | ');
36
+ return { short: bits.slice(0, 2).join(' | '), long };
37
+ }
38
+ function reactiveStorageItemKeyFromContract() {
39
+ const k = readFileCollectionVscdbContract()?.reactive_storage_item_key;
40
+ return typeof k === 'string' && k.trim() !== '' ? k.trim() : undefined;
41
+ }
42
+ function composerShadowKeySetFromContract() {
43
+ return new Set(readFileCollectionVscdbContract()?.composer_shadow_keys ?? []);
44
+ }
45
+ /** Resolve fix payload from API or legacy `compliance` key in local JSON. */
46
+ export function remediationFixSpec(inst) {
47
+ return inst.fix ?? inst.compliance ?? null;
48
+ }
49
+ /** True when this row should be replaced from GET manifest (missing or empty fix checks). */
50
+ function needsManifestRefetch(inst) {
51
+ if (!inst)
52
+ return true;
53
+ const spec = remediationFixSpec(inst);
54
+ if (!spec)
55
+ return true;
56
+ const checks = spec.checks;
57
+ return !Array.isArray(checks) || checks.length === 0;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Persistence (single file: remediation_instructions.json)
61
+ // ---------------------------------------------------------------------------
62
+ function readInstructions() {
63
+ const { remediations } = readRemediationInstructionsFile();
64
+ return { remediations: remediations };
65
+ }
66
+ function writeInstructions(file) {
67
+ writeRemediationInstructionsFile(file);
68
+ }
69
+ /**
70
+ * Merge persisted rows with API overlay; GET manifest for enforced UUIDs still missing rows.
71
+ */
72
+ async function ensureInstructionsForUuids(endpointBase, machineUuid, want, overlayFromApi) {
73
+ const byUuid = new Map();
74
+ for (const inst of readInstructions().remediations) {
75
+ if (want.has(inst.uuid))
76
+ byUuid.set(inst.uuid, inst);
77
+ }
78
+ for (const inst of overlayFromApi ?? []) {
79
+ if (want.has(inst.uuid))
80
+ byUuid.set(inst.uuid, inst);
81
+ }
82
+ // Refetch rows that are missing entirely or have no usable checks (stale/incomplete local cache).
83
+ const missing = [...want].filter((u) => needsManifestRefetch(byUuid.get(u)));
84
+ if (missing.length > 0) {
85
+ try {
86
+ const fetched = await fetchManifest(endpointBase, machineUuid, missing);
87
+ if (fetched) {
88
+ for (const inst of fetched) {
89
+ if (want.has(inst.uuid))
90
+ byUuid.set(inst.uuid, inst);
91
+ }
92
+ }
93
+ }
94
+ catch (err) {
95
+ hookRunLog(`remediation_manifest_backfill_error: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+ const stillMissing = [...want].filter((u) => !byUuid.has(u));
98
+ if (stillMissing.length > 0) {
99
+ hookRunLog(`remediation_manifest_backfill_incomplete: uuids=${stillMissing.join(',')}`);
100
+ }
101
+ }
102
+ const remediations = [...byUuid.values()].sort((a, b) => a.uuid.localeCompare(b.uuid));
103
+ const payload = { remediations };
104
+ const nextJson = JSON.stringify(payload, null, 2);
105
+ const path = getRemediationInstructionsPath();
106
+ let prev = '';
107
+ if (existsSync(path)) {
108
+ try {
109
+ prev = readFileSync(path, 'utf8');
110
+ }
111
+ catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+ if (prev !== nextJson) {
116
+ writeInstructions(payload);
117
+ }
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // API helpers
121
+ // ---------------------------------------------------------------------------
122
+ export async function fetchSync(endpointBase, machineUuid, activeUuids, timeoutMs = 8000) {
123
+ const uuidsParam = activeUuids.join(',');
124
+ const url = `${buildApiUrl(endpointBase, '/api/findings/remediations/sync/')}?machine_uuid=${encodeURIComponent(machineUuid)}&active_uuids=${encodeURIComponent(uuidsParam)}`;
125
+ const { statusCode, body } = await executeGet(url, timeoutMs);
126
+ if (statusCode !== 200 || !body) {
127
+ const line = `remediation_sync_get: url=${url} status=${statusCode} bytes=${body?.length ?? 0}`;
128
+ hookRunLog(line);
129
+ complianceRunnerDiag(line);
130
+ return null;
131
+ }
132
+ try {
133
+ return JSON.parse(body);
134
+ }
135
+ catch {
136
+ hookRunLog('remediation_sync_get: invalid JSON body');
137
+ complianceRunnerDiag('remediation_sync_get: invalid JSON body');
138
+ return null;
139
+ }
140
+ }
141
+ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000) {
142
+ const url = `${buildApiUrl(endpointBase, '/api/findings/remediations/manifest/')}?machine_uuid=${encodeURIComponent(machineUuid)}&uuids=${encodeURIComponent(uuids.join(','))}`;
143
+ const { statusCode, body } = await executeGet(url, timeoutMs);
144
+ if (statusCode !== 200 || !body) {
145
+ const line = `remediation_manifest_get: url=${url} status=${statusCode} bytes=${body?.length ?? 0}`;
146
+ hookRunLog(line);
147
+ complianceRunnerDiag(line);
148
+ return null;
149
+ }
150
+ try {
151
+ const parsed = JSON.parse(body);
152
+ if (!Array.isArray(parsed.remediations)) {
153
+ complianceRunnerDiag('remediation_manifest_get: JSON remediations is not an array');
154
+ return null;
155
+ }
156
+ return parsed.remediations.map((raw) => {
157
+ const { compliance, ...rest } = raw;
158
+ const fix = rest.fix ?? compliance ?? null;
159
+ return { ...rest, fix };
160
+ });
161
+ }
162
+ catch {
163
+ complianceRunnerDiag('remediation_manifest_get: invalid JSON body');
164
+ return null;
165
+ }
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Enforce / remove
169
+ // ---------------------------------------------------------------------------
170
+ function setByPath(obj, path, value) {
171
+ const parts = path.split('.');
172
+ let current = obj;
173
+ for (let i = 0; i < parts.length - 1; i++) {
174
+ const part = parts[i];
175
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
176
+ current[part] = {};
177
+ }
178
+ current = current[part];
179
+ }
180
+ current[parts[parts.length - 1]] = value;
181
+ }
182
+ /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
183
+ function getByPath(obj, path) {
184
+ const parts = path.split('.');
185
+ let current = obj;
186
+ for (const part of parts) {
187
+ if (current == null || typeof current !== 'object')
188
+ return undefined;
189
+ current = current[part];
190
+ }
191
+ return current;
192
+ }
193
+ /** Recursively set a value at a dot-notation path in a JSON object. */
194
+ function setByPathNested(obj, path, value) {
195
+ const parts = path.split('.');
196
+ let current = obj;
197
+ for (let i = 0; i < parts.length - 1; i++) {
198
+ const part = parts[i];
199
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
200
+ current[part] = {};
201
+ }
202
+ current = current[part];
203
+ }
204
+ current[parts[parts.length - 1]] = value;
205
+ }
206
+ function isPlainObject(v) {
207
+ return v != null && typeof v === 'object' && !Array.isArray(v);
208
+ }
209
+ function isStringArray(v) {
210
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
211
+ }
212
+ function applyStringArrayDelta(current, before, after) {
213
+ const cur = isStringArray(current) ? [...current] : [];
214
+ const b = isStringArray(before) ? before : [];
215
+ const a = isStringArray(after) ? after : [];
216
+ const bSet = new Set(b);
217
+ const aSet = new Set(a);
218
+ const removed = b.filter((x) => !aSet.has(x));
219
+ const added = a.filter((x) => !bSet.has(x));
220
+ // Remove any items that were removed by the remediation (preserve other local additions).
221
+ const removedSet = new Set(removed);
222
+ const next = cur.filter((x) => !removedSet.has(x));
223
+ // Append items that were added by the remediation (avoid duplicates).
224
+ const nextSet = new Set(next);
225
+ for (const x of added) {
226
+ if (!nextSet.has(x)) {
227
+ next.push(x);
228
+ nextSet.add(x);
229
+ }
230
+ }
231
+ return next;
232
+ }
233
+ function applyCheck(configJson, check) {
234
+ const parts = check.setting_path.split('.');
235
+ const leafKey = parts[parts.length - 1] ?? '';
236
+ const parentPath = parts.slice(0, -1).join('.');
237
+ // Prefer explicit ops when provided by server (safest: avoids clobbering local edits).
238
+ if (check.ops && typeof check.ops === 'object') {
239
+ const { set, add, remove } = check.ops;
240
+ const keys = new Set([
241
+ ...Object.keys(set ?? {}),
242
+ ...Object.keys(add ?? {}),
243
+ ...Object.keys(remove ?? {}),
244
+ ]);
245
+ for (const k of keys) {
246
+ const targetPath = k === leafKey || keys.size === 1 && (k === leafKey || k === '')
247
+ ? check.setting_path
248
+ : parentPath
249
+ ? `${parentPath}.${k}`
250
+ : k;
251
+ if (set && Object.prototype.hasOwnProperty.call(set, k)) {
252
+ setByPath(configJson, targetPath, set[k]);
253
+ continue;
254
+ }
255
+ const curVal = getByPath(configJson, targetPath);
256
+ const cur = isStringArray(curVal) ? [...curVal] : [];
257
+ const toRemove = (remove && remove[k]) ?? [];
258
+ 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);
262
+ for (const x of toAdd) {
263
+ if (!nextSet.has(x)) {
264
+ next.push(x);
265
+ nextSet.add(x);
266
+ }
267
+ }
268
+ setByPath(configJson, targetPath, next);
269
+ }
270
+ return;
271
+ }
272
+ // Normal v2 shape: after[leafKey] is the intended leaf value.
273
+ // Some remediations may include multi-key objects (e.g. {allow, deny}) while the path points at one leaf.
274
+ // In that case, treat the payload as a patch for the parent object and apply per-key deltas.
275
+ if (isPlainObject(check.before) && isPlainObject(check.after)) {
276
+ const afterKeys = Object.keys(check.after);
277
+ const beforeKeys = Object.keys(check.before);
278
+ const looksMultiKey = afterKeys.length > 1 || (afterKeys.length > 0 && !Object.prototype.hasOwnProperty.call(check.after, leafKey));
279
+ if (looksMultiKey) {
280
+ for (const k of afterKeys) {
281
+ const targetPath = parentPath ? `${parentPath}.${k}` : k;
282
+ const curVal = getByPath(configJson, targetPath);
283
+ const bVal = check.before[k];
284
+ const aVal = check.after[k];
285
+ if (isStringArray(bVal) && isStringArray(aVal)) {
286
+ setByPath(configJson, targetPath, applyStringArrayDelta(curVal, bVal, aVal));
287
+ }
288
+ else {
289
+ setByPath(configJson, targetPath, aVal);
290
+ }
291
+ }
292
+ return;
293
+ }
294
+ }
295
+ let value = undefined;
296
+ if (isPlainObject(check.after) && Object.prototype.hasOwnProperty.call(check.after, leafKey)) {
297
+ value = check.after[leafKey];
298
+ }
299
+ else {
300
+ value = check.after;
301
+ }
302
+ // If the leaf is a string[] and we have before/after snapshots, apply delta not replacement.
303
+ if (isPlainObject(check.before) && isPlainObject(check.after)) {
304
+ const b = check.before[leafKey];
305
+ const a = check.after[leafKey];
306
+ if (isStringArray(b) && isStringArray(a)) {
307
+ const cur = getByPath(configJson, check.setting_path);
308
+ setByPath(configJson, check.setting_path, applyStringArrayDelta(cur, b, a));
309
+ return;
310
+ }
311
+ }
312
+ setByPath(configJson, check.setting_path, value);
313
+ }
314
+ function mergePostApplyUploadIntoPayload(payload, hint) {
315
+ if (!hint)
316
+ return;
317
+ const ft = hint.file_type?.trim();
318
+ if (!ft || !hint.file_path.includes('#'))
319
+ return;
320
+ if (!payload.post_apply_uploads)
321
+ payload.post_apply_uploads = [];
322
+ if (payload.post_apply_uploads.some((u) => u.file_path === hint.file_path))
323
+ return;
324
+ payload.post_apply_uploads.push({ file_path: hint.file_path, file_type: ft });
325
+ }
326
+ function assertSqlite3Available() {
327
+ const bin = resolveSqlite3Binary();
328
+ if (bin) {
329
+ complianceRunnerDiag(`sqlite_update: using sqlite3 binary ${bin}`);
330
+ return true;
331
+ }
332
+ const pathEnv = process.env.PATH ?? '';
333
+ const n = pathEnv ? pathEnv.split(delimiter).filter(Boolean).length : 0;
334
+ const line = 'sqlite_update: sqlite3 command not found';
335
+ hookRunLog(line);
336
+ hookRunLog(`sqlite_update: no sqlite3 resolved after checking env OPTIMUS_SQLITE3/SQLITE3_PATH, common paths, and PATH (${n} entries)`);
337
+ hookRunLog('sqlite_update: hint set OPTIMUS_SQLITE3 to the full path (e.g. /usr/bin/sqlite3 on macOS) if sqlite3 is outside PATH');
338
+ complianceRunnerDiag(`${line} PATH_entry_count=${n}`);
339
+ return false;
340
+ }
341
+ /**
342
+ * Unquoted SQLite identifiers must be [A-Za-z_][A-Za-z0-9_]* so values read from
343
+ * deferred_vscdb_apply.json cannot inject SQL via bogus table/column names.
344
+ */
345
+ function isSafeSqliteIdentifier(ident) {
346
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(ident);
347
+ }
348
+ function assertSafeSqliteIdentifiersForItemTable(table, keyColumn, valueColumn) {
349
+ if (isSafeSqliteIdentifier(table) && isSafeSqliteIdentifier(keyColumn) && isSafeSqliteIdentifier(valueColumn)) {
350
+ return true;
351
+ }
352
+ hookRunLog(`sqlite_update: rejected unsafe SQL identifier(s) table=${table} key_column=${keyColumn} value_column=${valueColumn}`);
353
+ complianceRunnerDiag('sqlite_update: rejected unsafe SQL identifier(s)');
354
+ return false;
355
+ }
356
+ /**
357
+ * Canonical Cursor restart_command strings — single source for buildDeferredCursorRestartCommand + allowlist.
358
+ *
359
+ * - **SQLite / state.vscdb (deferred):** autofix queued ItemTable writes; restart runs `apply_deferred_vscdb` then reopens Cursor.
360
+ * - **JSON settings files:** autofix wrote normal config JSON; restart is kill + reopen only (no deferred apply).
361
+ *
362
+ * Exact-match only: `spawn('sh', ['-c', cmd])` runs the whole string; substring checks would allow
363
+ * appending `; arbitrary shell` after a trusted prefix.
364
+ */
365
+ const TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND = 'REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT && ' +
366
+ 'CURSOR_PROJECT="${CURSOR_PROJECT_DIR:-$REPO_ROOT}" && export CURSOR_PROJECT && ' +
367
+ 'OPTIMUS_DEFERRED_LOG="${HOME}/opt-ai-sec/management/deferred_vscdb_restart.log" && mkdir -p "$(dirname "$OPTIMUS_DEFERRED_LOG")" && export OPTIMUS_DEFERRED_LOG && ' +
368
+ "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; if [ -f \"\$REPO_ROOT/dev_npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\" ]; then echo deferred_restart:apply_via_monorepo_node; node \"\$REPO_ROOT/dev_npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\"; APPLY_EC=\$?; else echo deferred_restart:apply_via_npx; cd \"\$REPO_ROOT\" && npx --yes log-llm-config@latest apply-deferred-vscdb; APPLY_EC=\$?; 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; 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";
369
+ const TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND = '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';
370
+ const TRUSTED_CLAUDE_RESTART_COMMAND = "nohup bash -c 'sleep 2 && open -a Claude' >/dev/null 2>&1 & pkill -x 'Claude'";
371
+ /**
372
+ * Autofix restart_command allowlist: manifest strings are attacker-controlled if JSON is tampered.
373
+ * SQLite-deferred Cursor path always uses {@link buildDeferredCursorRestartCommand}; manifests may still
374
+ * embed the JSON-settings-only Cursor template when `restart_required` applies to file-based autofix.
375
+ */
376
+ export function isTrustedRestartCommandForAutofix(cmd) {
377
+ const t = cmd.trim();
378
+ if (!t)
379
+ return false;
380
+ return (t === TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND ||
381
+ t === TRUSTED_CURSOR_JSON_SETTINGS_RESTART_COMMAND ||
382
+ t === TRUSTED_CLAUDE_RESTART_COMMAND);
383
+ }
384
+ /** Legacy Cursor: dedicated ItemTable row `composerState`. Current Cursor: nested under reactive `applicationUser` blob. */
385
+ function cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp) {
386
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, 'composerState').trim();
387
+ if (!raw || raw === '{}')
388
+ return false;
389
+ try {
390
+ const o = JSON.parse(raw);
391
+ return typeof o === 'object' && o !== null && !Array.isArray(o) && Object.keys(o).length > 0;
392
+ }
393
+ catch {
394
+ return false;
395
+ }
396
+ }
397
+ function resolveCursorComposerSqliteOp(dbPath, sqliteOp) {
398
+ if (sqliteOp.target_key !== 'composerState')
399
+ return sqliteOp;
400
+ const reactiveKey = reactiveStorageItemKeyFromContract();
401
+ /** Merge into applicationUser (or equivalent) JSON: inner `composerState` or nested path under it. */
402
+ const resolveToReactive = () => {
403
+ if (!reactiveKey)
404
+ return sqliteOp;
405
+ const jp = (sqliteOp.json_path ?? '').trim();
406
+ if (!jp) {
407
+ return {
408
+ ...sqliteOp,
409
+ target_key: reactiveKey,
410
+ json_path: 'composerState',
411
+ };
412
+ }
413
+ const nestedPath = jp.startsWith('composerState.') ? jp : `composerState.${jp}`;
414
+ return {
415
+ ...sqliteOp,
416
+ target_key: reactiveKey,
417
+ json_path: nestedPath,
418
+ };
419
+ };
420
+ // Prefer the reactive storage blob when it exists and is non-empty. Cursor reads web/composer
421
+ // toggles from there; a legacy ItemTable `composerState` row may still hold stale JSON — writing
422
+ // only that row leaves the UI unchanged (user sees "remediation did nothing" after restart).
423
+ if (reactiveKey) {
424
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, reactiveKey).trim();
425
+ const reactiveHasBlob = raw !== '' && raw !== '{}';
426
+ if (reactiveHasBlob) {
427
+ return resolveToReactive();
428
+ }
429
+ }
430
+ if (cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp))
431
+ return sqliteOp;
432
+ if (reactiveKey) {
433
+ return resolveToReactive();
434
+ }
435
+ hookRunLog('vscdb_contract: missing file_collection_vscdb_contract.json (reactive_storage_item_key); run log-config or remediation sync against API');
436
+ return sqliteOp;
437
+ }
438
+ /** Apply sqlite merge: dot-path, or array match where `json_path` is `…container.arrayKey` (e.g. `modes4` or `composerState.modes4`). */
439
+ function coerceScalarForItemTableField(parsed) {
440
+ if (typeof parsed === 'boolean')
441
+ return parsed;
442
+ if (typeof parsed === 'string') {
443
+ const lower = parsed.trim().toLowerCase();
444
+ if (lower === 'true' || lower === '1' || lower === 'yes')
445
+ return true;
446
+ return false;
447
+ }
448
+ if (typeof parsed === 'number' && !Number.isNaN(parsed))
449
+ return parsed !== 0;
450
+ return undefined;
451
+ }
452
+ /**
453
+ * ItemTable values are sometimes bare JSON booleans for toggle keys; sqlite merge expects an object root.
454
+ * Maps known scalar roots to the policy-shaped object before applying updates.
455
+ */
456
+ function coerceItemTableValueToObjectRoot(targetKey, parsed) {
457
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
458
+ return parsed;
459
+ }
460
+ const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
461
+ if (field) {
462
+ const b = coerceScalarForItemTableField(parsed);
463
+ if (b !== undefined)
464
+ return { [field]: b };
465
+ }
466
+ return {};
467
+ }
468
+ /**
469
+ * Cursor stores some ItemTable toggles as bare JSON booleans (`true`/`false` text). Writing a wrapper
470
+ * object can be ignored or overwritten on launch; match the native primitive shape when disabling.
471
+ */
472
+ function serializeItemTableValueForWrite(targetKey, root) {
473
+ const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
474
+ if (field) {
475
+ const keys = Object.keys(root);
476
+ if (keys.length === 1 && keys[0] === field) {
477
+ const v = root[field];
478
+ if (typeof v === 'boolean')
479
+ return v ? 'true' : 'false';
480
+ if (typeof v === 'number' && !Number.isNaN(v))
481
+ return v !== 0 ? 'true' : 'false';
482
+ }
483
+ }
484
+ return JSON.stringify(root);
485
+ }
486
+ function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
487
+ const where = sqliteOp.array_item_where;
488
+ const jp = sqliteOp.json_path ?? '';
489
+ if (where && typeof where === 'object' && Object.keys(where).length > 0 && jp) {
490
+ const parts = jp.split('.').filter(Boolean);
491
+ if (parts.length < 1) {
492
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
493
+ return;
494
+ }
495
+ const arrayKey = parts[parts.length - 1];
496
+ const parentParts = parts.slice(0, -1);
497
+ let node = currentJson;
498
+ for (const p of parentParts) {
499
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
500
+ return;
501
+ const rec = node;
502
+ const child = rec[p];
503
+ if (child == null) {
504
+ rec[p] = {};
505
+ node = rec[p];
506
+ }
507
+ else if (typeof child === 'object' && !Array.isArray(child)) {
508
+ node = child;
509
+ }
510
+ else {
511
+ return;
512
+ }
513
+ }
514
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
515
+ return;
516
+ const container = node;
517
+ const existing = container[arrayKey];
518
+ let arr;
519
+ if (Array.isArray(existing)) {
520
+ arr = existing;
521
+ }
522
+ else if (existing == null) {
523
+ arr = [];
524
+ container[arrayKey] = arr;
525
+ }
526
+ else {
527
+ return;
528
+ }
529
+ const idx = arr.findIndex((item) => {
530
+ if (item === null || typeof item !== 'object')
531
+ return false;
532
+ const o = item;
533
+ return Object.entries(where).every(([k, v]) => o[k] === v);
534
+ });
535
+ if (idx >= 0) {
536
+ Object.assign(arr[idx], sqliteOp.updates);
537
+ return;
538
+ }
539
+ const newItem = { ...where };
540
+ Object.assign(newItem, sqliteOp.updates);
541
+ arr.push(newItem);
542
+ return;
543
+ }
544
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
545
+ mirrorComposerShadowKeysToReactiveRoot(currentJson, sqliteOp);
546
+ }
547
+ /**
548
+ * Cursor remediations historically used json_path `composerState.` (trailing dot). That yields an empty
549
+ * segment and mergeJsonAtSqlitePath wrote under composerState[''] instead of composerState — the real
550
+ * isWebFetchToolEnabled stayed true. Strip that spurious object if present.
551
+ */
552
+ function repairComposerStateEmptySegmentBug(root) {
553
+ const cs = root.composerState;
554
+ if (!cs || typeof cs !== 'object' || Array.isArray(cs))
555
+ return;
556
+ const o = cs;
557
+ if (!('' in o))
558
+ return;
559
+ delete o[''];
560
+ }
561
+ /**
562
+ * Cursor sometimes reads web-tool toggles from the reactive blob root; policy/reads merge those into
563
+ * `composerState` for scan. After patching `composerState` directly (resolved json_path exactly
564
+ * `composerState`), copy whitelisted keys to the root using cached
565
+ * `composer_shadow_keys` from the file-patterns API.
566
+ * Skips array-target ops (`modes4`, etc.).
567
+ */
568
+ function mirrorComposerShadowKeysToReactiveRoot(root, resolvedOp) {
569
+ const rk = reactiveStorageItemKeyFromContract();
570
+ if (!rk || resolvedOp.target_key !== rk)
571
+ return;
572
+ const where = resolvedOp.array_item_where;
573
+ if (where && typeof where === 'object' && Object.keys(where).length > 0)
574
+ return;
575
+ const jp = (resolvedOp.json_path ?? '').split('.').filter(Boolean);
576
+ if (jp.length !== 1 || jp[0] !== 'composerState')
577
+ return;
578
+ const updates = resolvedOp.updates;
579
+ if (!updates || typeof updates !== 'object')
580
+ return;
581
+ const shadow = composerShadowKeySetFromContract();
582
+ for (const k of Object.keys(updates)) {
583
+ if (shadow.has(k))
584
+ root[k] = updates[k];
585
+ }
586
+ }
587
+ /** Merge `updates` into JSON at dot-path `json_path` (same rules as server-generated ops). */
588
+ function mergeJsonAtSqlitePath(currentJson, json_path, updates) {
589
+ if (json_path?.trim()) {
590
+ const pathParts = json_path.split('.').filter(Boolean);
591
+ if (pathParts.length === 0) {
592
+ Object.assign(currentJson, updates);
593
+ return;
594
+ }
595
+ let current = currentJson;
596
+ for (let i = 0; i < pathParts.length - 1; i++) {
597
+ const part = pathParts[i];
598
+ const idx = parseInt(part, 10);
599
+ if (!isNaN(idx) && Array.isArray(current)) {
600
+ if (!current[idx])
601
+ current[idx] = {};
602
+ current = current[idx];
603
+ }
604
+ else {
605
+ if (!current[part])
606
+ current[part] = {};
607
+ current = current[part];
608
+ }
609
+ }
610
+ const lastPart = pathParts[pathParts.length - 1];
611
+ const lastIdx = parseInt(lastPart, 10);
612
+ if (!isNaN(lastIdx) && Array.isArray(current)) {
613
+ if (!current[lastIdx])
614
+ current[lastIdx] = {};
615
+ const target = current[lastIdx];
616
+ Object.assign(target, updates);
617
+ }
618
+ else {
619
+ if (!current[lastPart])
620
+ current[lastPart] = {};
621
+ const target = current[lastPart];
622
+ Object.assign(target, updates);
623
+ }
624
+ }
625
+ else {
626
+ Object.assign(currentJson, updates);
627
+ }
628
+ }
629
+ function sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key) {
630
+ if (!assertSafeSqliteIdentifiersForItemTable(table, key_column, value_column)) {
631
+ return '';
632
+ }
633
+ const sqlite3 = resolveSqlite3Binary();
634
+ if (!sqlite3) {
635
+ const line = 'sqlite_update: sqliteSelectValueCell called with no resolved sqlite3 binary (logic error: assertSqlite3Available should run first)';
636
+ hookRunLog(line);
637
+ complianceRunnerDiag(line);
638
+ return '';
639
+ }
640
+ const safeName = target_key.replace(/'/g, "''");
641
+ const script = `.timeout 60000\nSELECT ${value_column} FROM ${table} WHERE ${key_column}='${safeName}';\n`;
642
+ try {
643
+ return execFileSync(sqlite3, ['-noheader', dbPath], {
644
+ input: script,
645
+ encoding: 'utf8',
646
+ stdio: ['pipe', 'pipe', 'pipe'],
647
+ }).trim();
648
+ }
649
+ catch (err) {
650
+ const { short, long } = formatNodeChildException(err);
651
+ hookRunLog(`sqlite_update: SELECT failed binary=${sqlite3} db=${dbPath} table=${table} target_key=${target_key} ${long}`);
652
+ complianceRunnerDiag(`sqlite_update: SELECT failed target_key=${target_key} ${long.slice(0, 1800)}`);
653
+ throw new Error(`sqlite3 SELECT failed: ${short}`);
654
+ }
655
+ }
656
+ function sqliteExecWithTimeout(dbPath, sqlBody) {
657
+ const sqlite3 = resolveSqlite3Binary();
658
+ if (!sqlite3)
659
+ throw new Error('sqlite3 not found');
660
+ const script = `.timeout 60000\n${sqlBody}\n`;
661
+ try {
662
+ execFileSync(sqlite3, [dbPath], {
663
+ input: script,
664
+ encoding: 'utf8',
665
+ stdio: ['pipe', 'pipe', 'pipe'],
666
+ });
667
+ }
668
+ catch (err) {
669
+ const { long } = formatNodeChildException(err);
670
+ hookRunLog(`sqlite_update: UPDATE/exec failed binary=${sqlite3} db=${dbPath} ${long}`);
671
+ complianceRunnerDiag(`sqlite_update: UPDATE/exec failed ${long.slice(0, 1800)}`);
672
+ throw err;
673
+ }
674
+ }
675
+ /** Runs a single UPDATE (or other SQL) and returns sqlite `changes()` for the last statement. */
676
+ function sqliteRunUpdateReturningChanges(dbPath, updateSql) {
677
+ const sqlite3 = resolveSqlite3Binary();
678
+ if (!sqlite3)
679
+ throw new Error('sqlite3 not found');
680
+ const script = `.timeout 60000\n${updateSql}\nSELECT changes();\n`;
681
+ let out;
682
+ try {
683
+ out = execFileSync(sqlite3, [dbPath], {
684
+ input: script,
685
+ encoding: 'utf8',
686
+ stdio: ['pipe', 'pipe', 'pipe'],
687
+ }).trim();
688
+ }
689
+ catch (err) {
690
+ const { long } = formatNodeChildException(err);
691
+ hookRunLog(`sqlite_update: batch UPDATE/changes failed binary=${sqlite3} db=${dbPath} ${long}`);
692
+ complianceRunnerDiag(`sqlite_update: batch UPDATE failed ${long.slice(0, 1800)}`);
693
+ throw err;
694
+ }
695
+ const lines = out.split(/\r?\n/).filter((l) => l.length > 0);
696
+ const last = lines[lines.length - 1] ?? '0';
697
+ return parseInt(last, 10) || 0;
698
+ }
699
+ function queueDeferredVscdbItem(item, postApplyUpload) {
700
+ const path = getDeferredVscdbApplyPath();
701
+ let payload = { version: 1, items: [] };
702
+ if (existsSync(path)) {
703
+ try {
704
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
705
+ if (parsed?.items && Array.isArray(parsed.items)) {
706
+ payload = parsed;
707
+ if (!Array.isArray(payload.post_apply_uploads))
708
+ payload.post_apply_uploads = [];
709
+ }
710
+ }
711
+ catch {
712
+ /* replace corrupt file */
713
+ }
714
+ }
715
+ payload.items.push(item);
716
+ mergePostApplyUploadIntoPayload(payload, postApplyUpload);
717
+ atomicWriteJson(path, payload);
718
+ }
719
+ /**
720
+ * After Cursor exits: apply queued ItemTable JSON updates, optional log-config-file uploads (re-read sqlite),
721
+ * then delete the queue file.
722
+ * No-op (returns true) if there is nothing pending so `open -a Cursor` still runs.
723
+ */
724
+ export async function applyDeferredVscdbFromDisk() {
725
+ const path = getDeferredVscdbApplyPath();
726
+ hookRunLog(`deferred_vscdb: applyDeferredVscdbFromDisk queue_path=${path} exists=${existsSync(path)}`);
727
+ complianceRunnerDiag(`deferred_vscdb: applyDeferredVscdbFromDisk start exists=${existsSync(path)}`);
728
+ if (!existsSync(path))
729
+ return true;
730
+ if (!assertSqlite3Available())
731
+ return false;
732
+ let payload;
733
+ try {
734
+ payload = JSON.parse(readFileSync(path, 'utf8'));
735
+ }
736
+ catch {
737
+ hookRunLog('deferred_vscdb: could not parse queue file');
738
+ return false;
739
+ }
740
+ const postApplyUploads = [...(payload.post_apply_uploads ?? [])];
741
+ if (!payload.items?.length) {
742
+ try {
743
+ unlinkSync(path);
744
+ }
745
+ catch {
746
+ /* ignore */
747
+ }
748
+ return true;
749
+ }
750
+ try {
751
+ for (const it of payload.items) {
752
+ if (!existsSync(it.dbPath)) {
753
+ hookRunLog(`deferred_vscdb: database missing ${it.dbPath}`);
754
+ return false;
755
+ }
756
+ if (!assertSafeSqliteIdentifiersForItemTable(it.table, it.key_column, it.value_column)) {
757
+ return false;
758
+ }
759
+ const safeJson = it.new_value_json.replace(/'/g, "''");
760
+ const safeName = it.target_key.replace(/'/g, "''");
761
+ const sql = `UPDATE ${it.table} SET ${it.value_column}='${safeJson}' WHERE ${it.key_column}='${safeName}';`;
762
+ const changed = sqliteRunUpdateReturningChanges(it.dbPath, sql);
763
+ if (changed < 1) {
764
+ hookRunLog(`deferred_vscdb: UPDATE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
765
+ return false;
766
+ }
767
+ }
768
+ hookRunLog(`deferred_vscdb: applied ${payload.items.length} queued update(s)`);
769
+ const authKey = readStoredAuthKey();
770
+ for (const u of postApplyUploads) {
771
+ if (!authKey) {
772
+ hookRunLog(`deferred_vscdb: skip post-apply upload (no auth) path=${u.file_path}`);
773
+ continue;
774
+ }
775
+ const hi = u.file_path.indexOf('#');
776
+ if (hi < 0)
777
+ continue;
778
+ const dbPath = u.file_path.slice(0, hi);
779
+ const itemKey = u.file_path.slice(hi + 1).trim();
780
+ if (!itemKey)
781
+ continue;
782
+ const rawContent = readVscdbItemTableJson(dbPath, itemKey);
783
+ if (rawContent === null) {
784
+ hookRunLog(`deferred_vscdb: post-apply read failed path=${u.file_path}`);
785
+ continue;
786
+ }
787
+ const hw = tryResolveHardwareUuid();
788
+ if (!hw) {
789
+ hookRunLog(`deferred_vscdb: skip post-apply upload (hardware UUID unavailable) path=${u.file_path}`);
790
+ continue;
791
+ }
792
+ const sent = await sendConfigFile({ file_type: u.file_type, file_path: u.file_path, raw_content: rawContent }, hw, authKey);
793
+ hookRunLog(`deferred_vscdb: post-apply upload path=${u.file_path} ok=${sent}`);
794
+ }
795
+ unlinkSync(path);
796
+ return true;
797
+ }
798
+ catch (e) {
799
+ hookRunLog(`deferred_vscdb: apply failed: ${e instanceof Error ? e.message : String(e)}`);
800
+ return false;
801
+ }
802
+ }
803
+ /**
804
+ * macOS Cursor: after deferred **SQLite / state.vscdb** autofix — apply queued writes, SIGKILL, reopen project.
805
+ *
806
+ * When autofix used the deferred vscdb path, `applyAutofixViolations` replaces any manifest `restart_command`
807
+ * with this string (see compliance_check.ts). For **JSON settings-file** remediations only, the trusted template
808
+ * is the JSON-settings-only Cursor template (kill + reopen, no `apply_deferred_vscdb`).
809
+ */
810
+ export function buildDeferredCursorRestartCommand() {
811
+ // Prefer monorepo path when hooks run from optimus-secure-fdn; otherwise `npx --yes log-llm-config@latest apply-deferred-vscdb`
812
+ // (package bin) so published installs work without a local dev_npx_packages copy.
813
+ return TRUSTED_CURSOR_SQLITE_DEFERRED_RESTART_COMMAND;
814
+ }
815
+ function sqliteRowGroupKey(dbPath, op) {
816
+ return `${dbPath}|${op.table}|${op.key_column}|${op.value_column}|${op.target_key}`;
817
+ }
818
+ /**
819
+ * Deferred sqlite path: one read per logical row, apply every `sqlite_op` merge in memory, then one queue
820
+ * entry per row. Without this, two ops on the same `composerState` row each read the stale DB and queue
821
+ * two full JSON blobs — the second UPDATE overwrites the first (e.g. only one of autoRun/fullAutoRun sticks).
822
+ */
823
+ /** ItemTable keys must not contain SQL-quote garbage (bad vscdb #fragment or corrupted manifest). */
824
+ function assertSafeDeferredItemTableKey(targetKey) {
825
+ if (!targetKey || targetKey.length > 512)
826
+ return false;
827
+ if (/['"\\]/.test(targetKey))
828
+ return false;
829
+ return true;
830
+ }
831
+ function queueDeferredSqliteOpsMerged(configPath, sqliteOps, postApplyUpload) {
832
+ const dbPath = configPath.split('#')[0];
833
+ hookRunLog(`sqlite_update: deferred_queue begin configPath=${configPath} dbPath=${dbPath} dbExists=${existsSync(dbPath)} sqliteOps=${sqliteOps.length}`);
834
+ complianceRunnerDiag(`sqlite_update: deferred_queue begin dbExists=${existsSync(dbPath)} ops=${sqliteOps.length}`);
835
+ if (!existsSync(dbPath)) {
836
+ const line = `sqlite_update: database not found at ${dbPath}`;
837
+ hookRunLog(line);
838
+ hookRunLog(`sqlite_update: parent dir exists=${existsSync(dirname(dbPath))} (if false, wrong Cursor profile path or never launched Cursor)`);
839
+ complianceRunnerDiag(line);
840
+ return {
841
+ ok: false,
842
+ reason: `state.vscdb not on disk at ${dbPath} (Cursor may not have created globalStorage yet — open Cursor once).`,
843
+ };
844
+ }
845
+ if (!assertSqlite3Available()) {
846
+ return {
847
+ ok: false,
848
+ 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.',
849
+ };
850
+ }
851
+ const resolvedOps = sqliteOps.map((op) => resolveCursorComposerSqliteOp(dbPath, op));
852
+ const groups = new Map();
853
+ for (const op of resolvedOps) {
854
+ const k = sqliteRowGroupKey(dbPath, op);
855
+ const arr = groups.get(k);
856
+ if (arr)
857
+ arr.push(op);
858
+ else
859
+ groups.set(k, [op]);
860
+ }
861
+ try {
862
+ for (const ops of groups.values()) {
863
+ const first = ops[0];
864
+ if (!assertSafeDeferredItemTableKey(first.target_key)) {
865
+ const line = `sqlite_update: rejected unsafe or empty target_key for deferred queue (refusing to write state.vscdb)`;
866
+ hookRunLog(line);
867
+ complianceRunnerDiag(`${line} target_key_preview=${first.target_key.slice(0, 80)}`);
868
+ return { ok: false, reason: 'unsafe or empty ItemTable target_key (see hook_log).' };
869
+ }
870
+ complianceRunnerDiag(`sqlite_update: deferred merge db=${dbPath} target_key=${first.target_key} operations=${ops.length}`);
871
+ let currentJson = {};
872
+ let rawSelectLength = 0;
873
+ try {
874
+ const result = sqliteSelectValueCell(dbPath, first.table, first.key_column, first.value_column, first.target_key);
875
+ rawSelectLength = result.length;
876
+ if (result) {
877
+ const parsed = JSON.parse(result);
878
+ currentJson = coerceItemTableValueToObjectRoot(first.target_key, parsed);
879
+ }
880
+ }
881
+ catch (e) {
882
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
883
+ hookRunLog(line);
884
+ complianceRunnerDiag(line);
885
+ return {
886
+ ok: false,
887
+ reason: `sqlite read/parse failed: ${e instanceof Error ? e.message : String(e)}`,
888
+ };
889
+ }
890
+ for (const op of ops) {
891
+ mergeSqliteOpIntoJson(currentJson, op);
892
+ }
893
+ repairComposerStateEmptySegmentBug(currentJson);
894
+ const updatedJson = serializeItemTableValueForWrite(first.target_key, currentJson);
895
+ if (updatedJson === '{}' && Object.keys(currentJson).length === 0) {
896
+ const opSummary = ops
897
+ .map((o) => `path=${o.json_path ?? ''} updates=${JSON.stringify(o.updates ?? {})}`)
898
+ .join(' || ')
899
+ .slice(0, 900);
900
+ 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}`;
901
+ hookRunLog(line);
902
+ complianceRunnerDiag(line.slice(0, 2000));
903
+ return {
904
+ ok: false,
905
+ 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.`,
906
+ };
907
+ }
908
+ queueDeferredVscdbItem({
909
+ dbPath,
910
+ table: first.table,
911
+ key_column: first.key_column,
912
+ value_column: first.value_column,
913
+ target_key: first.target_key,
914
+ new_value_json: updatedJson,
915
+ }, postApplyUpload);
916
+ const okLine = `sqlite_update: queued deferred write (merged ${ops.length} op(s)) for ${first.target_key} in ${dbPath}`;
917
+ hookRunLog(okLine);
918
+ complianceRunnerDiag(okLine);
919
+ }
920
+ return { ok: true };
921
+ }
922
+ catch (err) {
923
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
924
+ hookRunLog(line);
925
+ complianceRunnerDiag(line);
926
+ return {
927
+ ok: false,
928
+ reason: `unexpected: ${err instanceof Error ? err.message : String(err)}`,
929
+ };
930
+ }
931
+ }
932
+ /**
933
+ * Read + merge JSON for a sqlite op; when `deferred`, queue UPDATE for after IDE exit (state.vscdb lock).
934
+ */
935
+ function applyOrQueueSqliteJsonUpdate(configPath, sqliteOp, deferred) {
936
+ try {
937
+ const dbPath = configPath.split('#')[0];
938
+ complianceRunnerDiag(`sqlite_update: attempt db=${dbPath} target_key=${sqliteOp.target_key} json_path=${sqliteOp.json_path} deferred=${deferred}`);
939
+ if (!existsSync(dbPath)) {
940
+ const line = `sqlite_update: database not found at ${dbPath}`;
941
+ hookRunLog(line);
942
+ complianceRunnerDiag(line);
943
+ return false;
944
+ }
945
+ if (!assertSqlite3Available())
946
+ return false;
947
+ sqliteOp = resolveCursorComposerSqliteOp(dbPath, sqliteOp);
948
+ const { table, key_column, value_column, target_key } = sqliteOp;
949
+ if (!assertSafeSqliteIdentifiersForItemTable(table, key_column, value_column)) {
950
+ return false;
951
+ }
952
+ const safeName = target_key.replace(/'/g, "''");
953
+ let currentJson = {};
954
+ try {
955
+ const result = sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key);
956
+ if (result) {
957
+ const parsed = JSON.parse(result);
958
+ currentJson = coerceItemTableValueToObjectRoot(target_key, parsed);
959
+ }
960
+ }
961
+ catch (e) {
962
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
963
+ hookRunLog(line);
964
+ complianceRunnerDiag(line);
965
+ return false;
966
+ }
967
+ mergeSqliteOpIntoJson(currentJson, sqliteOp);
968
+ repairComposerStateEmptySegmentBug(currentJson);
969
+ const updatedJson = serializeItemTableValueForWrite(target_key, currentJson);
970
+ if (deferred) {
971
+ queueDeferredVscdbItem({
972
+ dbPath,
973
+ table,
974
+ key_column,
975
+ value_column,
976
+ target_key,
977
+ new_value_json: updatedJson,
978
+ });
979
+ const okLine = `sqlite_update: queued deferred write for ${target_key} in ${dbPath}`;
980
+ hookRunLog(okLine);
981
+ complianceRunnerDiag(okLine);
982
+ return true;
983
+ }
984
+ const safeJson = updatedJson.replace(/'/g, "''");
985
+ const updateSql = `UPDATE ${table} SET ${value_column}='${safeJson}' WHERE ${key_column}='${safeName}';`;
986
+ try {
987
+ sqliteExecWithTimeout(dbPath, updateSql);
988
+ const okLine = `sqlite_update: successfully updated ${target_key} in ${dbPath}`;
989
+ hookRunLog(okLine);
990
+ complianceRunnerDiag(okLine);
991
+ return true;
992
+ }
993
+ catch (e) {
994
+ const line = `sqlite_update: error updating database: ${e instanceof Error ? e.message : String(e)}`;
995
+ hookRunLog(line);
996
+ complianceRunnerDiag(line);
997
+ return false;
998
+ }
999
+ }
1000
+ catch (err) {
1001
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
1002
+ hookRunLog(line);
1003
+ complianceRunnerDiag(line);
1004
+ return false;
1005
+ }
1006
+ }
1007
+ export function enforceRemediation(instruction) {
1008
+ const resolvedPath = resolveRemediationConfigPath(instruction.config_file_path);
1009
+ const inst = resolvedPath === instruction.config_file_path
1010
+ ? instruction
1011
+ : { ...instruction, config_file_path: resolvedPath };
1012
+ const fail = (reason, extra) => {
1013
+ const spec = remediationFixSpec(inst);
1014
+ logRemediationApplyFailure('enforceRemediation', {
1015
+ uuid: inst.uuid,
1016
+ config_file_path: inst.config_file_path,
1017
+ file_type: inst.file_type ?? '',
1018
+ finding_formatted_id: spec?.finding_formatted_id ?? '',
1019
+ reason,
1020
+ ...extra,
1021
+ });
1022
+ hookRunLog(`remediation_enforce: failed uuid=${inst.uuid} reason=${reason}`);
1023
+ return { ok: false, failureReason: reason };
1024
+ };
1025
+ try {
1026
+ const fixSpec = remediationFixSpec(inst);
1027
+ const checks = fixSpec?.checks ?? [];
1028
+ if (checks.length === 0) {
1029
+ return fail('no checks in fix spec (empty or missing compliance checks)');
1030
+ }
1031
+ const sqliteOps = checks.filter((c) => {
1032
+ const raw = c;
1033
+ return raw.sqlite_op !== undefined;
1034
+ });
1035
+ if (sqliteOps.length > 0) {
1036
+ complianceRunnerDiag(`remediation_enforce: sqlite path uuid=${inst.uuid} checks_with_sqlite=${sqliteOps.length}`);
1037
+ const restartRequired = !!fixSpec?.restart_required;
1038
+ if (restartRequired) {
1039
+ const ops = sqliteOps.map((c) => c.sqlite_op);
1040
+ const ft = inst.file_type?.trim();
1041
+ const postApplyUpload = ft && inst.config_file_path.includes('#')
1042
+ ? { file_path: inst.config_file_path, file_type: ft }
1043
+ : undefined;
1044
+ const q = queueDeferredSqliteOpsMerged(inst.config_file_path, ops, postApplyUpload);
1045
+ if (!q.ok) {
1046
+ const dbOnly = inst.config_file_path.split('#')[0];
1047
+ return fail(`deferred state.vscdb queue failed — ${q.reason} (see sqlite_update lines above in hook_log for SELECT/exec details)`, {
1048
+ config_file_path: inst.config_file_path,
1049
+ resolved_db_path: dbOnly,
1050
+ sqlite3_binary: resolveSqlite3Binary() ?? 'unresolved',
1051
+ });
1052
+ }
1053
+ return { ok: true, deferredSqlite: true };
1054
+ }
1055
+ let allSuccess = true;
1056
+ for (const check of sqliteOps) {
1057
+ const raw = check;
1058
+ const sqliteOp = raw.sqlite_op;
1059
+ const rowOk = applyOrQueueSqliteJsonUpdate(inst.config_file_path, sqliteOp, false);
1060
+ if (!rowOk)
1061
+ allSuccess = false;
1062
+ }
1063
+ if (!allSuccess) {
1064
+ return fail('immediate sqlite apply failed for one or more checks');
1065
+ }
1066
+ return { ok: true, deferredSqlite: false };
1067
+ }
1068
+ if (fixSpec?.file_format !== 'json') {
1069
+ return fail(`unsupported file format: ${String(fixSpec?.file_format ?? 'undefined')}`);
1070
+ }
1071
+ const dir = dirname(inst.config_file_path);
1072
+ if (!existsSync(dir))
1073
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1074
+ let configJson = {};
1075
+ if (existsSync(inst.config_file_path)) {
1076
+ try {
1077
+ configJson = JSON.parse(readFileSync(inst.config_file_path, 'utf8'));
1078
+ }
1079
+ catch {
1080
+ hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
1081
+ }
1082
+ }
1083
+ for (const check of checks) {
1084
+ applyCheck(configJson, check);
1085
+ }
1086
+ const content = JSON.stringify(configJson, null, 2);
1087
+ const tmp = `${inst.config_file_path}.tmp`;
1088
+ writeFileSync(tmp, content, 'utf8');
1089
+ renameSync(tmp, inst.config_file_path);
1090
+ return { ok: true };
1091
+ }
1092
+ catch (err) {
1093
+ const msg = err instanceof Error ? err.message : String(err);
1094
+ const stack = err instanceof Error ? err.stack : undefined;
1095
+ logRemediationApplyFailure('enforceRemediation', {
1096
+ uuid: inst.uuid,
1097
+ config_file_path: inst.config_file_path,
1098
+ file_type: inst.file_type ?? '',
1099
+ finding_formatted_id: remediationFixSpec(inst)?.finding_formatted_id ?? '',
1100
+ reason: 'exception during enforce',
1101
+ error: msg,
1102
+ stack: stack ?? '',
1103
+ });
1104
+ hookRunLog(`remediation_enforce_error: uuid=${inst.uuid} err=${msg}`);
1105
+ return { ok: false, failureReason: `exception: ${msg}` };
1106
+ }
1107
+ }
1108
+ // ---------------------------------------------------------------------------
1109
+ // Autofix reporting
1110
+ // ---------------------------------------------------------------------------
1111
+ /**
1112
+ * Fire-and-forget: notify the server that this machine applied an autofix for the given
1113
+ * remediation UUID. Creates one EnforcementLog row on the server for the audit trail.
1114
+ * Never throws — any failure is logged and silently swallowed so it cannot block the
1115
+ * autofix flow.
1116
+ */
1117
+ export function reportAutofixApplied(remediationUuid, result) {
1118
+ const authKey = readStoredAuthKey();
1119
+ if (!authKey) {
1120
+ hookRunLog(`autofix_report: no auth key available, skipping report for uuid=${remediationUuid}`);
1121
+ return Promise.resolve();
1122
+ }
1123
+ const hardwareUuid = tryResolveHardwareUuid();
1124
+ if (!hardwareUuid) {
1125
+ hookRunLog(`autofix_report: hardware UUID unavailable, skipping report for uuid=${remediationUuid}`);
1126
+ return Promise.resolve();
1127
+ }
1128
+ const endpointBase = loadEndpointBase();
1129
+ const url = buildApiUrl(endpointBase, '/endpoint_security/api/autofix-applied/');
1130
+ const payload = { hardware_uuid: hardwareUuid, remediation_uuid: remediationUuid, result };
1131
+ const signature = createSignature(payload, authKey.key);
1132
+ const body = JSON.stringify({ ...payload, signature });
1133
+ return executeBody(url, 'POST', body, 8000)
1134
+ .then(({ statusCode }) => {
1135
+ if (statusCode !== 200) {
1136
+ hookRunLog(`autofix_report: server returned ${statusCode} for uuid=${remediationUuid}`);
1137
+ }
1138
+ })
1139
+ .catch((err) => {
1140
+ hookRunLog(`autofix_report: request failed for uuid=${remediationUuid}: ${err instanceof Error ? err.message : String(err)}`);
1141
+ });
1142
+ }
1143
+ // ---------------------------------------------------------------------------
1144
+ // Main entry point
1145
+ // ---------------------------------------------------------------------------
1146
+ export async function syncRemediations(endpointBase, machineUuid) {
1147
+ let remediations = readInstructions().remediations;
1148
+ const activeUuids = remediations.map((r) => r.uuid);
1149
+ complianceRunnerDiag(`remediation_sync: begin endpoint=${endpointBase} machine_uuid=${machineUuid} local_rows=${remediations.length}`);
1150
+ try {
1151
+ if (!existsSync(getFileCollectionVscdbContractPath())) {
1152
+ const pr = await getFileCollectionPatterns(endpointBase);
1153
+ if (pr)
1154
+ persistVscdbComposerContractFromPatternsResponse(pr);
1155
+ }
1156
+ }
1157
+ catch {
1158
+ /* contract also written on full log-config run */
1159
+ }
1160
+ let syncResult;
1161
+ try {
1162
+ syncResult = await fetchSync(endpointBase, machineUuid, activeUuids);
1163
+ }
1164
+ catch (err) {
1165
+ const msg = `remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`;
1166
+ hookRunLog(msg);
1167
+ complianceRunnerDiag(msg);
1168
+ return;
1169
+ }
1170
+ if (!syncResult) {
1171
+ hookRunLog(`remediation_sync: no response from server, skipping`);
1172
+ complianceRunnerDiag('remediation_sync: no response from server (non-200 or empty body), skipping');
1173
+ return;
1174
+ }
1175
+ if (syncResult.status === 'unchanged') {
1176
+ hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
1177
+ 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`);
1178
+ const want = new Set(activeUuids);
1179
+ const have = new Map(remediations.map((r) => [r.uuid, r]));
1180
+ const needBackfill = activeUuids.some((u) => !have.has(u));
1181
+ const haveIncomplete = remediations.some((r) => want.has(r.uuid) && needsManifestRefetch(r));
1182
+ const haveOrphan = remediations.some((r) => !want.has(r.uuid));
1183
+ const path = getRemediationInstructionsPath();
1184
+ if (activeUuids.length > 0) {
1185
+ if (needBackfill || haveIncomplete || haveOrphan || !existsSync(path)) {
1186
+ try {
1187
+ await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
1188
+ }
1189
+ catch (err) {
1190
+ hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
1191
+ }
1192
+ }
1193
+ }
1194
+ else if (remediations.length > 0) {
1195
+ try {
1196
+ await ensureInstructionsForUuids(endpointBase, machineUuid, new Set(), null);
1197
+ }
1198
+ catch (err) {
1199
+ hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
1200
+ }
1201
+ }
1202
+ return;
1203
+ }
1204
+ const { added, removed } = syncResult;
1205
+ hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1206
+ complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
1207
+ const removedSet = new Set(removed);
1208
+ let removedCount = 0;
1209
+ remediations = remediations.filter((r) => {
1210
+ if (removedSet.has(r.uuid)) {
1211
+ removedCount++;
1212
+ hookRunLog(`remediation_remove: uuid=${r.uuid} path=${r.config_file_path}`);
1213
+ return false;
1214
+ }
1215
+ return true;
1216
+ });
1217
+ let manifest = null;
1218
+ if (added.length > 0) {
1219
+ try {
1220
+ manifest = await fetchManifest(endpointBase, machineUuid, added);
1221
+ }
1222
+ catch (err) {
1223
+ const msg = `remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`;
1224
+ hookRunLog(msg);
1225
+ complianceRunnerDiag(msg);
1226
+ if (removedCount > 0) {
1227
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1228
+ writeInstructions({ remediations });
1229
+ }
1230
+ return;
1231
+ }
1232
+ if (!manifest) {
1233
+ hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
1234
+ complianceRunnerDiag('remediation_sync: manifest fetch failed (null), skipping — remediation_instructions.json not updated');
1235
+ if (removedCount > 0) {
1236
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1237
+ writeInstructions({ remediations });
1238
+ }
1239
+ return;
1240
+ }
1241
+ }
1242
+ const existingUuids = new Set(remediations.map((r) => r.uuid));
1243
+ const addedSet = new Set(added);
1244
+ const overlay = [];
1245
+ for (const instruction of manifest ?? []) {
1246
+ if (!addedSet.has(instruction.uuid))
1247
+ continue;
1248
+ if (existingUuids.has(instruction.uuid)) {
1249
+ // Enforced remediations are always in `added`; overwrite local copy with fresh server data.
1250
+ const idx = remediations.findIndex((r) => r.uuid === instruction.uuid);
1251
+ if (idx >= 0)
1252
+ remediations[idx] = instruction;
1253
+ }
1254
+ else {
1255
+ remediations.push(instruction);
1256
+ existingUuids.add(instruction.uuid);
1257
+ }
1258
+ overlay.push(instruction);
1259
+ hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
1260
+ }
1261
+ const addedCount = overlay.length;
1262
+ if (added.length > 0 && addedCount === 0) {
1263
+ const msg = `remediation_sync: manifest had no rows for added uuids=${added.join(',')} — check server manifest/machine linkage`;
1264
+ hookRunLog(msg);
1265
+ complianceRunnerDiag(`${msg} — remediation_instructions.json not written`);
1266
+ }
1267
+ if (removedCount > 0 || addedCount > 0) {
1268
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
1269
+ writeInstructions({ remediations });
1270
+ complianceRunnerDiag(`remediation_sync: wrote ${getRemediationInstructionsPath()} rows=${remediations.length} (added_from_manifest=${addedCount} removed=${removedCount})`);
1271
+ const finalWant = new Set(remediations.map((r) => r.uuid));
1272
+ try {
1273
+ await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
1274
+ }
1275
+ catch (err) {
1276
+ hookRunLog(`remediation_instructions_persist_error: ${err instanceof Error ? err.message : String(err)}`);
1277
+ }
1278
+ // Post-sync heartbeat: report the updated UUID set so the server has a before/after pair.
1279
+ const finalUuids = remediations.map((r) => r.uuid);
1280
+ try {
1281
+ await fetchSync(endpointBase, machineUuid, finalUuids);
1282
+ hookRunLog(`remediation_sync: post-sync heartbeat reported uuids=${finalUuids.length}`);
1283
+ }
1284
+ catch {
1285
+ // Non-critical — skip silently.
1286
+ }
1287
+ }
1288
+ hookRunLog(`remediation_sync: saved=${addedCount} removed=${removedCount}`);
1289
+ complianceRunnerDiag(`remediation_sync: done saved=${addedCount} removed=${removedCount}`);
1290
+ }