log-llm-config 1.2.8 → 1.3.2

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.
@@ -1,16 +1,37 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
3
4
  import { executeGet, executeBody } from '../../endpoint_client/http_transport.js';
4
- import { hookRunLog } from './hook_logger.js';
5
- import { getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
5
+ import { complianceRunnerDiag, hookRunLog } from './hook_logger.js';
6
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readFileCollectionVscdbContract, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
6
7
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
7
8
  import { createSignature } from '../sender/signing.js';
8
9
  import { loadEndpointBase } from '../sender/endpoint_config.js';
9
- import { resolveHardwareUuid } from './hardware_uuid.js';
10
+ import { tryResolveHardwareUuid } from './hardware_uuid.js';
11
+ import { persistVscdbComposerContractFromPatternsResponse, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
12
+ import { sendConfigFile } from '../sender/batch_sender.js';
13
+ import { getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
14
+ function reactiveStorageItemKeyFromContract() {
15
+ const k = readFileCollectionVscdbContract()?.reactive_storage_item_key;
16
+ return typeof k === 'string' && k.trim() !== '' ? k.trim() : undefined;
17
+ }
18
+ function composerShadowKeySetFromContract() {
19
+ return new Set(readFileCollectionVscdbContract()?.composer_shadow_keys ?? []);
20
+ }
10
21
  /** Resolve fix payload from API or legacy `compliance` key in local JSON. */
11
22
  export function remediationFixSpec(inst) {
12
23
  return inst.fix ?? inst.compliance ?? null;
13
24
  }
25
+ /** True when this row should be replaced from GET manifest (missing or empty fix checks). */
26
+ function needsManifestRefetch(inst) {
27
+ if (!inst)
28
+ return true;
29
+ const spec = remediationFixSpec(inst);
30
+ if (!spec)
31
+ return true;
32
+ const checks = spec.checks;
33
+ return !Array.isArray(checks) || checks.length === 0;
34
+ }
14
35
  // ---------------------------------------------------------------------------
15
36
  // Persistence (single file: remediation_instructions.json)
16
37
  // ---------------------------------------------------------------------------
@@ -34,7 +55,8 @@ async function ensureInstructionsForUuids(endpointBase, machineUuid, want, overl
34
55
  if (want.has(inst.uuid))
35
56
  byUuid.set(inst.uuid, inst);
36
57
  }
37
- const missing = [...want].filter((u) => !byUuid.has(u));
58
+ // Refetch rows that are missing entirely or have no usable checks (stale/incomplete local cache).
59
+ const missing = [...want].filter((u) => needsManifestRefetch(byUuid.get(u)));
38
60
  if (missing.length > 0) {
39
61
  try {
40
62
  const fetched = await fetchManifest(endpointBase, machineUuid, missing);
@@ -85,24 +107,36 @@ export async function fetchSync(endpointBase, machineUuid, activeUuids, timeoutM
85
107
  const uuidsParam = activeUuids.join(',');
86
108
  const url = `${resolveOrigin(endpointBase)}/api/findings/remediations/sync/?machine_uuid=${encodeURIComponent(machineUuid)}&active_uuids=${encodeURIComponent(uuidsParam)}`;
87
109
  const { statusCode, body } = await executeGet(url, timeoutMs);
88
- if (statusCode !== 200 || !body)
110
+ if (statusCode !== 200 || !body) {
111
+ const line = `remediation_sync_get: url=${url} status=${statusCode} bytes=${body?.length ?? 0}`;
112
+ hookRunLog(line);
113
+ complianceRunnerDiag(line);
89
114
  return null;
115
+ }
90
116
  try {
91
117
  return JSON.parse(body);
92
118
  }
93
119
  catch {
120
+ hookRunLog('remediation_sync_get: invalid JSON body');
121
+ complianceRunnerDiag('remediation_sync_get: invalid JSON body');
94
122
  return null;
95
123
  }
96
124
  }
97
125
  async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000) {
98
126
  const url = `${resolveOrigin(endpointBase)}/api/findings/remediations/manifest/?machine_uuid=${encodeURIComponent(machineUuid)}&uuids=${encodeURIComponent(uuids.join(','))}`;
99
127
  const { statusCode, body } = await executeGet(url, timeoutMs);
100
- if (statusCode !== 200 || !body)
128
+ if (statusCode !== 200 || !body) {
129
+ const line = `remediation_manifest_get: url=${url} status=${statusCode} bytes=${body?.length ?? 0}`;
130
+ hookRunLog(line);
131
+ complianceRunnerDiag(line);
101
132
  return null;
133
+ }
102
134
  try {
103
135
  const parsed = JSON.parse(body);
104
- if (!Array.isArray(parsed.remediations))
136
+ if (!Array.isArray(parsed.remediations)) {
137
+ complianceRunnerDiag('remediation_manifest_get: JSON remediations is not an array');
105
138
  return null;
139
+ }
106
140
  return parsed.remediations.map((raw) => {
107
141
  const { compliance, ...rest } = raw;
108
142
  const fix = rest.fix ?? compliance ?? null;
@@ -110,6 +144,7 @@ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000)
110
144
  });
111
145
  }
112
146
  catch {
147
+ complianceRunnerDiag('remediation_manifest_get: invalid JSON body');
113
148
  return null;
114
149
  }
115
150
  }
@@ -139,6 +174,19 @@ function getByPath(obj, path) {
139
174
  }
140
175
  return current;
141
176
  }
177
+ /** Recursively set a value at a dot-notation path in a JSON object. */
178
+ function setByPathNested(obj, path, value) {
179
+ const parts = path.split('.');
180
+ let current = obj;
181
+ for (let i = 0; i < parts.length - 1; i++) {
182
+ const part = parts[i];
183
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
184
+ current[part] = {};
185
+ }
186
+ current = current[part];
187
+ }
188
+ current[parts[parts.length - 1]] = value;
189
+ }
142
190
  function isPlainObject(v) {
143
191
  return v != null && typeof v === 'object' && !Array.isArray(v);
144
192
  }
@@ -247,17 +295,628 @@ function applyCheck(configJson, check) {
247
295
  }
248
296
  setByPath(configJson, check.setting_path, value);
249
297
  }
298
+ function mergePostApplyUploadIntoPayload(payload, hint) {
299
+ if (!hint)
300
+ return;
301
+ const ft = hint.file_type?.trim();
302
+ if (!ft || !hint.file_path.includes('#'))
303
+ return;
304
+ if (!payload.post_apply_uploads)
305
+ payload.post_apply_uploads = [];
306
+ if (payload.post_apply_uploads.some((u) => u.file_path === hint.file_path))
307
+ return;
308
+ payload.post_apply_uploads.push({ file_path: hint.file_path, file_type: ft });
309
+ }
310
+ function assertSqlite3Available() {
311
+ try {
312
+ execFileSync('which', ['sqlite3'], { stdio: 'ignore' });
313
+ return true;
314
+ }
315
+ catch {
316
+ const line = 'sqlite_update: sqlite3 command not found';
317
+ hookRunLog(line);
318
+ complianceRunnerDiag(line);
319
+ return false;
320
+ }
321
+ }
322
+ /**
323
+ * Unquoted SQLite identifiers must be [A-Za-z_][A-Za-z0-9_]* so values read from
324
+ * deferred_vscdb_apply.json cannot inject SQL via bogus table/column names.
325
+ */
326
+ function isSafeSqliteIdentifier(ident) {
327
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(ident);
328
+ }
329
+ function assertSafeSqliteIdentifiersForItemTable(table, keyColumn, valueColumn) {
330
+ if (isSafeSqliteIdentifier(table) && isSafeSqliteIdentifier(keyColumn) && isSafeSqliteIdentifier(valueColumn)) {
331
+ return true;
332
+ }
333
+ hookRunLog(`sqlite_update: rejected unsafe SQL identifier(s) table=${table} key_column=${keyColumn} value_column=${valueColumn}`);
334
+ complianceRunnerDiag('sqlite_update: rejected unsafe SQL identifier(s)');
335
+ return false;
336
+ }
337
+ /**
338
+ * Autofix restart_command allowlist: manifest strings are attacker-controlled if JSON is tampered.
339
+ * Deferred vscdb path always uses buildDeferredCursorRestartCommand(); this guards non-deferred restarts.
340
+ */
341
+ export function isTrustedRestartCommandForAutofix(cmd) {
342
+ const t = cmd.trim();
343
+ if (!t)
344
+ return false;
345
+ const deferredPrefix = 'REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT CURSOR_PROJECT="$REPO_ROOT" && ';
346
+ const legacyCursorPrefix = 'CURSOR_PROJECT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export CURSOR_PROJECT && ';
347
+ const legacyCursorSnippet = 'nohup bash -c \'sleep 2 && open -a Cursor "$CURSOR_PROJECT"\'';
348
+ const deferred = t.startsWith(deferredPrefix) &&
349
+ (t.includes('apply_deferred_vscdb') || t.includes('apply-deferred-vscdb')) &&
350
+ t.includes('killall -9 Cursor') &&
351
+ t.includes('open -a Cursor');
352
+ const legacyCursor = t.startsWith(legacyCursorPrefix) &&
353
+ t.includes(legacyCursorSnippet) &&
354
+ t.includes('killall -9 Cursor');
355
+ const claude = t.startsWith("nohup bash -c 'sleep 2 && open -a Claude'") && t.includes("pkill -x 'Claude'");
356
+ return deferred || legacyCursor || claude;
357
+ }
358
+ /** Legacy Cursor: dedicated ItemTable row `composerState`. Current Cursor: nested under reactive `applicationUser` blob. */
359
+ function cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp) {
360
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, 'composerState').trim();
361
+ if (!raw || raw === '{}')
362
+ return false;
363
+ try {
364
+ const o = JSON.parse(raw);
365
+ return typeof o === 'object' && o !== null && !Array.isArray(o) && Object.keys(o).length > 0;
366
+ }
367
+ catch {
368
+ return false;
369
+ }
370
+ }
371
+ function resolveCursorComposerSqliteOp(dbPath, sqliteOp) {
372
+ if (sqliteOp.target_key !== 'composerState')
373
+ return sqliteOp;
374
+ const reactiveKey = reactiveStorageItemKeyFromContract();
375
+ /** Merge into applicationUser (or equivalent) JSON: inner `composerState` or nested path under it. */
376
+ const resolveToReactive = () => {
377
+ if (!reactiveKey)
378
+ return sqliteOp;
379
+ const jp = (sqliteOp.json_path ?? '').trim();
380
+ if (!jp) {
381
+ return {
382
+ ...sqliteOp,
383
+ target_key: reactiveKey,
384
+ json_path: 'composerState',
385
+ };
386
+ }
387
+ const nestedPath = jp.startsWith('composerState.') ? jp : `composerState.${jp}`;
388
+ return {
389
+ ...sqliteOp,
390
+ target_key: reactiveKey,
391
+ json_path: nestedPath,
392
+ };
393
+ };
394
+ // Prefer the reactive storage blob when it exists and is non-empty. Cursor reads web/composer
395
+ // toggles from there; a legacy ItemTable `composerState` row may still hold stale JSON — writing
396
+ // only that row leaves the UI unchanged (user sees "remediation did nothing" after restart).
397
+ if (reactiveKey) {
398
+ const raw = sqliteSelectValueCell(dbPath, sqliteOp.table, sqliteOp.key_column, sqliteOp.value_column, reactiveKey).trim();
399
+ const reactiveHasBlob = raw !== '' && raw !== '{}';
400
+ if (reactiveHasBlob) {
401
+ return resolveToReactive();
402
+ }
403
+ }
404
+ if (cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp))
405
+ return sqliteOp;
406
+ if (reactiveKey) {
407
+ return resolveToReactive();
408
+ }
409
+ hookRunLog('vscdb_contract: missing file_collection_vscdb_contract.json (reactive_storage_item_key); run log-config or remediation sync against API');
410
+ return sqliteOp;
411
+ }
412
+ /** Apply sqlite merge: dot-path, or array match where `json_path` is `…container.arrayKey` (e.g. `modes4` or `composerState.modes4`). */
413
+ /** ItemTable keys that store a JSON primitive; map to one field for merge + serialize. */
414
+ const CURSOR_SCALAR_ITEMTABLE_FIELDS = {
415
+ 'cursor/thirdPartyExtensibilityEnabled': 'thirdPartyExtensibilityEnabled',
416
+ 'cursorai/donotchange/privacyMode': 'privacyMode',
417
+ 'cursor/autoOpenLocalhostUrls': 'autoOpenLocalhostUrls',
418
+ };
419
+ function coerceScalarForItemTableField(parsed) {
420
+ if (typeof parsed === 'boolean')
421
+ return parsed;
422
+ if (typeof parsed === 'string') {
423
+ const lower = parsed.trim().toLowerCase();
424
+ if (lower === 'true' || lower === '1' || lower === 'yes')
425
+ return true;
426
+ return false;
427
+ }
428
+ if (typeof parsed === 'number' && !Number.isNaN(parsed))
429
+ return parsed !== 0;
430
+ return undefined;
431
+ }
432
+ /**
433
+ * ItemTable values are sometimes bare JSON booleans for toggle keys; sqlite merge expects an object root.
434
+ * Maps known scalar roots to the policy-shaped object before applying updates.
435
+ */
436
+ function coerceItemTableValueToObjectRoot(targetKey, parsed) {
437
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
438
+ return parsed;
439
+ }
440
+ const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
441
+ if (field) {
442
+ const b = coerceScalarForItemTableField(parsed);
443
+ if (b !== undefined)
444
+ return { [field]: b };
445
+ }
446
+ return {};
447
+ }
448
+ /**
449
+ * Cursor stores some ItemTable toggles as bare JSON booleans (`true`/`false` text). Writing a wrapper
450
+ * object can be ignored or overwritten on launch; match the native primitive shape when disabling.
451
+ */
452
+ function serializeItemTableValueForWrite(targetKey, root) {
453
+ const field = CURSOR_SCALAR_ITEMTABLE_FIELDS[targetKey];
454
+ if (field) {
455
+ const keys = Object.keys(root);
456
+ if (keys.length === 1 && keys[0] === field) {
457
+ const v = root[field];
458
+ if (typeof v === 'boolean')
459
+ return v ? 'true' : 'false';
460
+ if (typeof v === 'number' && !Number.isNaN(v))
461
+ return v !== 0 ? 'true' : 'false';
462
+ }
463
+ }
464
+ return JSON.stringify(root);
465
+ }
466
+ function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
467
+ const where = sqliteOp.array_item_where;
468
+ const jp = sqliteOp.json_path ?? '';
469
+ if (where && typeof where === 'object' && Object.keys(where).length > 0 && jp) {
470
+ const parts = jp.split('.').filter(Boolean);
471
+ if (parts.length < 1) {
472
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
473
+ return;
474
+ }
475
+ const arrayKey = parts[parts.length - 1];
476
+ const parentParts = parts.slice(0, -1);
477
+ let node = currentJson;
478
+ for (const p of parentParts) {
479
+ if (node == null || typeof node !== 'object')
480
+ return;
481
+ node = node[p];
482
+ }
483
+ if (node == null || typeof node !== 'object' || Array.isArray(node))
484
+ return;
485
+ const container = node;
486
+ const arr = container[arrayKey];
487
+ if (!Array.isArray(arr))
488
+ return;
489
+ const idx = arr.findIndex((item) => {
490
+ if (item === null || typeof item !== 'object')
491
+ return false;
492
+ const o = item;
493
+ return Object.entries(where).every(([k, v]) => o[k] === v);
494
+ });
495
+ if (idx < 0)
496
+ return;
497
+ Object.assign(arr[idx], sqliteOp.updates);
498
+ return;
499
+ }
500
+ mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
501
+ mirrorComposerShadowKeysToReactiveRoot(currentJson, sqliteOp);
502
+ }
503
+ /**
504
+ * Cursor remediations historically used json_path `composerState.` (trailing dot). That yields an empty
505
+ * segment and mergeJsonAtSqlitePath wrote under composerState[''] instead of composerState — the real
506
+ * isWebFetchToolEnabled stayed true. Strip that spurious object if present.
507
+ */
508
+ function repairComposerStateEmptySegmentBug(root) {
509
+ const cs = root.composerState;
510
+ if (!cs || typeof cs !== 'object' || Array.isArray(cs))
511
+ return;
512
+ const o = cs;
513
+ if (!('' in o))
514
+ return;
515
+ delete o[''];
516
+ }
517
+ /**
518
+ * Cursor sometimes reads web-tool toggles from the reactive blob root; policy/reads merge those into
519
+ * `composerState` for scan. After patching `composerState` directly (resolved json_path exactly
520
+ * `composerState`), copy whitelisted keys to the root using cached
521
+ * `composer_shadow_keys` from the file-patterns API.
522
+ * Skips array-target ops (`modes4`, etc.).
523
+ */
524
+ function mirrorComposerShadowKeysToReactiveRoot(root, resolvedOp) {
525
+ const rk = reactiveStorageItemKeyFromContract();
526
+ if (!rk || resolvedOp.target_key !== rk)
527
+ return;
528
+ const where = resolvedOp.array_item_where;
529
+ if (where && typeof where === 'object' && Object.keys(where).length > 0)
530
+ return;
531
+ const jp = (resolvedOp.json_path ?? '').split('.').filter(Boolean);
532
+ if (jp.length !== 1 || jp[0] !== 'composerState')
533
+ return;
534
+ const updates = resolvedOp.updates;
535
+ if (!updates || typeof updates !== 'object')
536
+ return;
537
+ const shadow = composerShadowKeySetFromContract();
538
+ for (const k of Object.keys(updates)) {
539
+ if (shadow.has(k))
540
+ root[k] = updates[k];
541
+ }
542
+ }
543
+ /** Merge `updates` into JSON at dot-path `json_path` (same rules as server-generated ops). */
544
+ function mergeJsonAtSqlitePath(currentJson, json_path, updates) {
545
+ if (json_path?.trim()) {
546
+ const pathParts = json_path.split('.').filter(Boolean);
547
+ if (pathParts.length === 0) {
548
+ Object.assign(currentJson, updates);
549
+ return;
550
+ }
551
+ let current = currentJson;
552
+ for (let i = 0; i < pathParts.length - 1; i++) {
553
+ const part = pathParts[i];
554
+ const idx = parseInt(part, 10);
555
+ if (!isNaN(idx) && Array.isArray(current)) {
556
+ if (!current[idx])
557
+ current[idx] = {};
558
+ current = current[idx];
559
+ }
560
+ else {
561
+ if (!current[part])
562
+ current[part] = {};
563
+ current = current[part];
564
+ }
565
+ }
566
+ const lastPart = pathParts[pathParts.length - 1];
567
+ const lastIdx = parseInt(lastPart, 10);
568
+ if (!isNaN(lastIdx) && Array.isArray(current)) {
569
+ if (!current[lastIdx])
570
+ current[lastIdx] = {};
571
+ const target = current[lastIdx];
572
+ Object.assign(target, updates);
573
+ }
574
+ else {
575
+ if (!current[lastPart])
576
+ current[lastPart] = {};
577
+ const target = current[lastPart];
578
+ Object.assign(target, updates);
579
+ }
580
+ }
581
+ else {
582
+ Object.assign(currentJson, updates);
583
+ }
584
+ }
585
+ function sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key) {
586
+ if (!assertSafeSqliteIdentifiersForItemTable(table, key_column, value_column)) {
587
+ return '';
588
+ }
589
+ const safeName = target_key.replace(/'/g, "''");
590
+ const script = `.timeout 60000\nSELECT ${value_column} FROM ${table} WHERE ${key_column}='${safeName}';\n`;
591
+ return execFileSync('sqlite3', ['-noheader', dbPath], {
592
+ input: script,
593
+ encoding: 'utf8',
594
+ stdio: ['pipe', 'pipe', 'pipe'],
595
+ }).trim();
596
+ }
597
+ function sqliteExecWithTimeout(dbPath, sqlBody) {
598
+ const script = `.timeout 60000\n${sqlBody}\n`;
599
+ execFileSync('sqlite3', [dbPath], {
600
+ input: script,
601
+ encoding: 'utf8',
602
+ stdio: ['pipe', 'pipe', 'pipe'],
603
+ });
604
+ }
605
+ /** Runs a single UPDATE (or other SQL) and returns sqlite `changes()` for the last statement. */
606
+ function sqliteRunUpdateReturningChanges(dbPath, updateSql) {
607
+ const script = `.timeout 60000\n${updateSql}\nSELECT changes();\n`;
608
+ const out = execFileSync('sqlite3', [dbPath], {
609
+ input: script,
610
+ encoding: 'utf8',
611
+ stdio: ['pipe', 'pipe', 'pipe'],
612
+ }).trim();
613
+ const lines = out.split(/\r?\n/).filter((l) => l.length > 0);
614
+ const last = lines[lines.length - 1] ?? '0';
615
+ return parseInt(last, 10) || 0;
616
+ }
617
+ function queueDeferredVscdbItem(item, postApplyUpload) {
618
+ const path = getDeferredVscdbApplyPath();
619
+ let payload = { version: 1, items: [] };
620
+ if (existsSync(path)) {
621
+ try {
622
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
623
+ if (parsed?.items && Array.isArray(parsed.items)) {
624
+ payload = parsed;
625
+ if (!Array.isArray(payload.post_apply_uploads))
626
+ payload.post_apply_uploads = [];
627
+ }
628
+ }
629
+ catch {
630
+ /* replace corrupt file */
631
+ }
632
+ }
633
+ payload.items.push(item);
634
+ mergePostApplyUploadIntoPayload(payload, postApplyUpload);
635
+ atomicWriteJson(path, payload);
636
+ }
637
+ /**
638
+ * After Cursor exits: apply queued ItemTable JSON updates, optional log-config-file uploads (re-read sqlite),
639
+ * then delete the queue file.
640
+ * No-op (returns true) if there is nothing pending so `open -a Cursor` still runs.
641
+ */
642
+ export async function applyDeferredVscdbFromDisk() {
643
+ const path = getDeferredVscdbApplyPath();
644
+ if (!existsSync(path))
645
+ return true;
646
+ if (!assertSqlite3Available())
647
+ return false;
648
+ let payload;
649
+ try {
650
+ payload = JSON.parse(readFileSync(path, 'utf8'));
651
+ }
652
+ catch {
653
+ hookRunLog('deferred_vscdb: could not parse queue file');
654
+ return false;
655
+ }
656
+ const postApplyUploads = [...(payload.post_apply_uploads ?? [])];
657
+ if (!payload.items?.length) {
658
+ try {
659
+ unlinkSync(path);
660
+ }
661
+ catch {
662
+ /* ignore */
663
+ }
664
+ return true;
665
+ }
666
+ try {
667
+ for (const it of payload.items) {
668
+ if (!existsSync(it.dbPath)) {
669
+ hookRunLog(`deferred_vscdb: database missing ${it.dbPath}`);
670
+ return false;
671
+ }
672
+ if (!assertSafeSqliteIdentifiersForItemTable(it.table, it.key_column, it.value_column)) {
673
+ return false;
674
+ }
675
+ const safeJson = it.new_value_json.replace(/'/g, "''");
676
+ const safeName = it.target_key.replace(/'/g, "''");
677
+ const sql = `UPDATE ${it.table} SET ${it.value_column}='${safeJson}' WHERE ${it.key_column}='${safeName}';`;
678
+ const changed = sqliteRunUpdateReturningChanges(it.dbPath, sql);
679
+ if (changed < 1) {
680
+ hookRunLog(`deferred_vscdb: UPDATE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
681
+ return false;
682
+ }
683
+ }
684
+ hookRunLog(`deferred_vscdb: applied ${payload.items.length} queued update(s)`);
685
+ const authKey = readStoredAuthKey();
686
+ for (const u of postApplyUploads) {
687
+ if (!authKey) {
688
+ hookRunLog(`deferred_vscdb: skip post-apply upload (no auth) path=${u.file_path}`);
689
+ continue;
690
+ }
691
+ const hi = u.file_path.indexOf('#');
692
+ if (hi < 0)
693
+ continue;
694
+ const dbPath = u.file_path.slice(0, hi);
695
+ const itemKey = u.file_path.slice(hi + 1).trim();
696
+ if (!itemKey)
697
+ continue;
698
+ const rawContent = readVscdbItemTableJson(dbPath, itemKey);
699
+ if (rawContent === null) {
700
+ hookRunLog(`deferred_vscdb: post-apply read failed path=${u.file_path}`);
701
+ continue;
702
+ }
703
+ const hw = tryResolveHardwareUuid();
704
+ if (!hw) {
705
+ hookRunLog(`deferred_vscdb: skip post-apply upload (hardware UUID unavailable) path=${u.file_path}`);
706
+ continue;
707
+ }
708
+ const sent = await sendConfigFile({ file_type: u.file_type, file_path: u.file_path, raw_content: rawContent }, hw, authKey);
709
+ hookRunLog(`deferred_vscdb: post-apply upload path=${u.file_path} ok=${sent}`);
710
+ }
711
+ unlinkSync(path);
712
+ return true;
713
+ }
714
+ catch (e) {
715
+ hookRunLog(`deferred_vscdb: apply failed: ${e instanceof Error ? e.message : String(e)}`);
716
+ return false;
717
+ }
718
+ }
719
+ /**
720
+ * macOS Cursor: SIGKILL, apply queued vscdb writes, reopen project.
721
+ *
722
+ * When `restart_required` implies deferred state.vscdb, `applyAutofixViolations` replaces any
723
+ * `restart_command` from remediation specs with this string (see compliance_check.ts). Spec JSON
724
+ * still documents a simpler Cursor reopen for non-code readers; that template is not executed on the deferred path.
725
+ */
726
+ export function buildDeferredCursorRestartCommand() {
727
+ // Prefer monorepo path when hooks run from optimus-secure-fdn; otherwise `npx -p log-llm-config apply-deferred-vscdb`
728
+ // (package bin) so published installs work without a local npx_packages copy.
729
+ return ('REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && export REPO_ROOT CURSOR_PROJECT="$REPO_ROOT" && ' +
730
+ "nohup bash -c 'sleep 2; if [ -f \"$REPO_ROOT/npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\" ]; then node \"$REPO_ROOT/npx_packages/log-llm-config/dist/apply_deferred_vscdb.js\"; else npx --yes -p log-llm-config apply-deferred-vscdb; fi || true; open -a Cursor \"$CURSOR_PROJECT\"' >/dev/null 2>&1 & killall -9 Cursor");
731
+ }
732
+ function sqliteRowGroupKey(dbPath, op) {
733
+ return `${dbPath}|${op.table}|${op.key_column}|${op.value_column}|${op.target_key}`;
734
+ }
735
+ /**
736
+ * Deferred sqlite path: one read per logical row, apply every `sqlite_op` merge in memory, then one queue
737
+ * entry per row. Without this, two ops on the same `composerState` row each read the stale DB and queue
738
+ * two full JSON blobs — the second UPDATE overwrites the first (e.g. only one of autoRun/fullAutoRun sticks).
739
+ */
740
+ function queueDeferredSqliteOpsMerged(configPath, sqliteOps, postApplyUpload) {
741
+ const dbPath = configPath.split('#')[0];
742
+ if (!existsSync(dbPath)) {
743
+ const line = `sqlite_update: database not found at ${dbPath}`;
744
+ hookRunLog(line);
745
+ complianceRunnerDiag(line);
746
+ return false;
747
+ }
748
+ if (!assertSqlite3Available())
749
+ return false;
750
+ const resolvedOps = sqliteOps.map((op) => resolveCursorComposerSqliteOp(dbPath, op));
751
+ const groups = new Map();
752
+ for (const op of resolvedOps) {
753
+ const k = sqliteRowGroupKey(dbPath, op);
754
+ const arr = groups.get(k);
755
+ if (arr)
756
+ arr.push(op);
757
+ else
758
+ groups.set(k, [op]);
759
+ }
760
+ try {
761
+ for (const ops of groups.values()) {
762
+ const first = ops[0];
763
+ complianceRunnerDiag(`sqlite_update: deferred merge db=${dbPath} target_key=${first.target_key} operations=${ops.length}`);
764
+ let currentJson = {};
765
+ try {
766
+ const result = sqliteSelectValueCell(dbPath, first.table, first.key_column, first.value_column, first.target_key);
767
+ if (result) {
768
+ const parsed = JSON.parse(result);
769
+ currentJson = coerceItemTableValueToObjectRoot(first.target_key, parsed);
770
+ }
771
+ }
772
+ catch (e) {
773
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
774
+ hookRunLog(line);
775
+ complianceRunnerDiag(line);
776
+ return false;
777
+ }
778
+ for (const op of ops) {
779
+ mergeSqliteOpIntoJson(currentJson, op);
780
+ }
781
+ repairComposerStateEmptySegmentBug(currentJson);
782
+ const updatedJson = serializeItemTableValueForWrite(first.target_key, currentJson);
783
+ queueDeferredVscdbItem({
784
+ dbPath,
785
+ table: first.table,
786
+ key_column: first.key_column,
787
+ value_column: first.value_column,
788
+ target_key: first.target_key,
789
+ new_value_json: updatedJson,
790
+ }, postApplyUpload);
791
+ const okLine = `sqlite_update: queued deferred write (merged ${ops.length} op(s)) for ${first.target_key} in ${dbPath}`;
792
+ hookRunLog(okLine);
793
+ complianceRunnerDiag(okLine);
794
+ }
795
+ return true;
796
+ }
797
+ catch (err) {
798
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
799
+ hookRunLog(line);
800
+ complianceRunnerDiag(line);
801
+ return false;
802
+ }
803
+ }
804
+ /**
805
+ * Read + merge JSON for a sqlite op; when `deferred`, queue UPDATE for after IDE exit (state.vscdb lock).
806
+ */
807
+ function applyOrQueueSqliteJsonUpdate(configPath, sqliteOp, deferred) {
808
+ try {
809
+ const dbPath = configPath.split('#')[0];
810
+ complianceRunnerDiag(`sqlite_update: attempt db=${dbPath} target_key=${sqliteOp.target_key} json_path=${sqliteOp.json_path} deferred=${deferred}`);
811
+ if (!existsSync(dbPath)) {
812
+ const line = `sqlite_update: database not found at ${dbPath}`;
813
+ hookRunLog(line);
814
+ complianceRunnerDiag(line);
815
+ return false;
816
+ }
817
+ if (!assertSqlite3Available())
818
+ return false;
819
+ sqliteOp = resolveCursorComposerSqliteOp(dbPath, sqliteOp);
820
+ const { table, key_column, value_column, target_key } = sqliteOp;
821
+ if (!assertSafeSqliteIdentifiersForItemTable(table, key_column, value_column)) {
822
+ return false;
823
+ }
824
+ const safeName = target_key.replace(/'/g, "''");
825
+ let currentJson = {};
826
+ try {
827
+ const result = sqliteSelectValueCell(dbPath, table, key_column, value_column, target_key);
828
+ if (result) {
829
+ const parsed = JSON.parse(result);
830
+ currentJson = coerceItemTableValueToObjectRoot(target_key, parsed);
831
+ }
832
+ }
833
+ catch (e) {
834
+ const line = `sqlite_update: error querying database: ${e instanceof Error ? e.message : String(e)}`;
835
+ hookRunLog(line);
836
+ complianceRunnerDiag(line);
837
+ return false;
838
+ }
839
+ mergeSqliteOpIntoJson(currentJson, sqliteOp);
840
+ repairComposerStateEmptySegmentBug(currentJson);
841
+ const updatedJson = serializeItemTableValueForWrite(target_key, currentJson);
842
+ if (deferred) {
843
+ queueDeferredVscdbItem({
844
+ dbPath,
845
+ table,
846
+ key_column,
847
+ value_column,
848
+ target_key,
849
+ new_value_json: updatedJson,
850
+ });
851
+ const okLine = `sqlite_update: queued deferred write for ${target_key} in ${dbPath}`;
852
+ hookRunLog(okLine);
853
+ complianceRunnerDiag(okLine);
854
+ return true;
855
+ }
856
+ const safeJson = updatedJson.replace(/'/g, "''");
857
+ const updateSql = `UPDATE ${table} SET ${value_column}='${safeJson}' WHERE ${key_column}='${safeName}';`;
858
+ try {
859
+ sqliteExecWithTimeout(dbPath, updateSql);
860
+ const okLine = `sqlite_update: successfully updated ${target_key} in ${dbPath}`;
861
+ hookRunLog(okLine);
862
+ complianceRunnerDiag(okLine);
863
+ return true;
864
+ }
865
+ catch (e) {
866
+ const line = `sqlite_update: error updating database: ${e instanceof Error ? e.message : String(e)}`;
867
+ hookRunLog(line);
868
+ complianceRunnerDiag(line);
869
+ return false;
870
+ }
871
+ }
872
+ catch (err) {
873
+ const line = `sqlite_update: unexpected error: ${err instanceof Error ? err.message : String(err)}`;
874
+ hookRunLog(line);
875
+ complianceRunnerDiag(line);
876
+ return false;
877
+ }
878
+ }
250
879
  export function enforceRemediation(instruction) {
251
880
  try {
252
- const dir = dirname(instruction.config_file_path);
253
- if (!existsSync(dir))
254
- mkdirSync(dir, { recursive: true, mode: 0o700 });
255
881
  const fixSpec = remediationFixSpec(instruction);
256
- const checks = fixSpec?.file_format === 'json' ? (fixSpec.checks ?? []) : [];
882
+ const checks = fixSpec?.checks ?? [];
257
883
  if (checks.length === 0) {
258
884
  hookRunLog(`remediation_enforce: no checks to apply uuid=${instruction.uuid}`);
259
- return false;
885
+ return { ok: false };
886
+ }
887
+ const sqliteOps = checks.filter((c) => {
888
+ const raw = c;
889
+ return raw.sqlite_op !== undefined;
890
+ });
891
+ if (sqliteOps.length > 0) {
892
+ complianceRunnerDiag(`remediation_enforce: sqlite path uuid=${instruction.uuid} checks_with_sqlite=${sqliteOps.length}`);
893
+ const restartRequired = !!fixSpec?.restart_required;
894
+ if (restartRequired) {
895
+ const ops = sqliteOps.map((c) => c.sqlite_op);
896
+ const ft = instruction.file_type?.trim();
897
+ const postApplyUpload = ft && instruction.config_file_path.includes('#')
898
+ ? { file_path: instruction.config_file_path, file_type: ft }
899
+ : undefined;
900
+ const ok = queueDeferredSqliteOpsMerged(instruction.config_file_path, ops, postApplyUpload);
901
+ return { ok, deferredSqlite: ok };
902
+ }
903
+ let allSuccess = true;
904
+ for (const check of sqliteOps) {
905
+ const raw = check;
906
+ const sqliteOp = raw.sqlite_op;
907
+ const rowOk = applyOrQueueSqliteJsonUpdate(instruction.config_file_path, sqliteOp, false);
908
+ if (!rowOk)
909
+ allSuccess = false;
910
+ }
911
+ return { ok: allSuccess, deferredSqlite: false };
912
+ }
913
+ if (fixSpec?.file_format !== 'json') {
914
+ hookRunLog(`remediation_enforce: unsupported file format ${fixSpec?.file_format} uuid=${instruction.uuid}`);
915
+ return { ok: false };
260
916
  }
917
+ const dir = dirname(instruction.config_file_path);
918
+ if (!existsSync(dir))
919
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
261
920
  let configJson = {};
262
921
  if (existsSync(instruction.config_file_path)) {
263
922
  try {
@@ -274,11 +933,11 @@ export function enforceRemediation(instruction) {
274
933
  const tmp = `${instruction.config_file_path}.tmp`;
275
934
  writeFileSync(tmp, content, 'utf8');
276
935
  renameSync(tmp, instruction.config_file_path);
277
- return true;
936
+ return { ok: true };
278
937
  }
279
938
  catch (err) {
280
939
  hookRunLog(`remediation_enforce_error: uuid=${instruction.uuid} err=${err instanceof Error ? err.message : String(err)}`);
281
- return false;
940
+ return { ok: false };
282
941
  }
283
942
  }
284
943
  // ---------------------------------------------------------------------------
@@ -296,7 +955,11 @@ export function reportAutofixApplied(remediationUuid, result) {
296
955
  hookRunLog(`autofix_report: no auth key available, skipping report for uuid=${remediationUuid}`);
297
956
  return Promise.resolve();
298
957
  }
299
- const hardwareUuid = resolveHardwareUuid();
958
+ const hardwareUuid = tryResolveHardwareUuid();
959
+ if (!hardwareUuid) {
960
+ hookRunLog(`autofix_report: hardware UUID unavailable, skipping report for uuid=${remediationUuid}`);
961
+ return Promise.resolve();
962
+ }
300
963
  const endpointBase = loadEndpointBase();
301
964
  const url = `${resolveOrigin(endpointBase)}/endpoint_security/api/autofix-applied/`;
302
965
  const payload = { hardware_uuid: hardwareUuid, remediation_uuid: remediationUuid, result };
@@ -318,27 +981,43 @@ export function reportAutofixApplied(remediationUuid, result) {
318
981
  export async function syncRemediations(endpointBase, machineUuid) {
319
982
  let remediations = readInstructions().remediations;
320
983
  const activeUuids = remediations.map((r) => r.uuid);
984
+ complianceRunnerDiag(`remediation_sync: begin endpoint=${endpointBase} machine_uuid=${machineUuid} local_rows=${remediations.length}`);
985
+ try {
986
+ if (!existsSync(getFileCollectionVscdbContractPath())) {
987
+ const pr = await getFileCollectionPatterns(endpointBase);
988
+ if (pr)
989
+ persistVscdbComposerContractFromPatternsResponse(pr);
990
+ }
991
+ }
992
+ catch {
993
+ /* contract also written on full log-config run */
994
+ }
321
995
  let syncResult;
322
996
  try {
323
997
  syncResult = await fetchSync(endpointBase, machineUuid, activeUuids);
324
998
  }
325
999
  catch (err) {
326
- hookRunLog(`remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`);
1000
+ const msg = `remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`;
1001
+ hookRunLog(msg);
1002
+ complianceRunnerDiag(msg);
327
1003
  return;
328
1004
  }
329
1005
  if (!syncResult) {
330
1006
  hookRunLog(`remediation_sync: no response from server, skipping`);
1007
+ complianceRunnerDiag('remediation_sync: no response from server (non-200 or empty body), skipping');
331
1008
  return;
332
1009
  }
333
1010
  if (syncResult.status === 'unchanged') {
334
1011
  hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
1012
+ 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`);
335
1013
  const want = new Set(activeUuids);
336
1014
  const have = new Map(remediations.map((r) => [r.uuid, r]));
337
1015
  const needBackfill = activeUuids.some((u) => !have.has(u));
1016
+ const haveIncomplete = remediations.some((r) => want.has(r.uuid) && needsManifestRefetch(r));
338
1017
  const haveOrphan = remediations.some((r) => !want.has(r.uuid));
339
1018
  const path = getRemediationInstructionsPath();
340
1019
  if (activeUuids.length > 0) {
341
- if (needBackfill || haveOrphan || !existsSync(path)) {
1020
+ if (needBackfill || haveIncomplete || haveOrphan || !existsSync(path)) {
342
1021
  try {
343
1022
  await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
344
1023
  }
@@ -359,6 +1038,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
359
1038
  }
360
1039
  const { added, removed } = syncResult;
361
1040
  hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1041
+ complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
362
1042
  const removedSet = new Set(removed);
363
1043
  let removedCount = 0;
364
1044
  remediations = remediations.filter((r) => {
@@ -375,7 +1055,9 @@ export async function syncRemediations(endpointBase, machineUuid) {
375
1055
  manifest = await fetchManifest(endpointBase, machineUuid, added);
376
1056
  }
377
1057
  catch (err) {
378
- hookRunLog(`remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`);
1058
+ const msg = `remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`;
1059
+ hookRunLog(msg);
1060
+ complianceRunnerDiag(msg);
379
1061
  if (removedCount > 0) {
380
1062
  remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
381
1063
  writeInstructions({ remediations });
@@ -384,6 +1066,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
384
1066
  }
385
1067
  if (!manifest) {
386
1068
  hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
1069
+ complianceRunnerDiag('remediation_sync: manifest fetch failed (null), skipping — remediation_instructions.json not updated');
387
1070
  if (removedCount > 0) {
388
1071
  remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
389
1072
  writeInstructions({ remediations });
@@ -411,9 +1094,15 @@ export async function syncRemediations(endpointBase, machineUuid) {
411
1094
  hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
412
1095
  }
413
1096
  const addedCount = overlay.length;
1097
+ if (added.length > 0 && addedCount === 0) {
1098
+ const msg = `remediation_sync: manifest had no rows for added uuids=${added.join(',')} — check server manifest/machine linkage`;
1099
+ hookRunLog(msg);
1100
+ complianceRunnerDiag(`${msg} — remediation_instructions.json not written`);
1101
+ }
414
1102
  if (removedCount > 0 || addedCount > 0) {
415
1103
  remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
416
1104
  writeInstructions({ remediations });
1105
+ complianceRunnerDiag(`remediation_sync: wrote ${getRemediationInstructionsPath()} rows=${remediations.length} (added_from_manifest=${addedCount} removed=${removedCount})`);
417
1106
  const finalWant = new Set(remediations.map((r) => r.uuid));
418
1107
  try {
419
1108
  await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
@@ -432,4 +1121,5 @@ export async function syncRemediations(endpointBase, machineUuid) {
432
1121
  }
433
1122
  }
434
1123
  hookRunLog(`remediation_sync: saved=${addedCount} removed=${removedCount}`);
1124
+ complianceRunnerDiag(`remediation_sync: done saved=${addedCount} removed=${removedCount}`);
435
1125
  }