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.
- package/dist/apply_deferred_vscdb.js +8 -0
- package/dist/cli/bash_script_generator.js +23 -11
- package/dist/compliance_check_runner.js +31 -0
- package/dist/compliance_prompt_gate.js +64 -8
- package/dist/log_config_files/readers/vscdb_config_builder.js +6 -2
- package/dist/log_config_files/readers/vscdb_reader.js +102 -1
- package/dist/log_config_files/runtime/compliance_check.js +147 -59
- package/dist/log_config_files/runtime/hardware_uuid.js +9 -0
- package/dist/log_config_files/runtime/hook_logger.js +38 -5
- package/dist/log_config_files/runtime/main_runner.js +2 -0
- package/dist/log_config_files/runtime/management_storage.js +31 -0
- package/dist/log_config_files/runtime/remediation_sync.js +709 -19
- package/package.json +7 -5
|
@@ -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 {
|
|
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
|
-
|
|
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?.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|