log-llm-config-staging 1.3.82 → 1.3.83

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.
@@ -6,7 +6,8 @@
6
6
  * When autofix returns restart_commands, this process does not spawn them — the shell hook pipes
7
7
  * the same JSON line to execute_trusted_restarts (TS allowlist + spawn).
8
8
  */
9
- import { applyAutofixViolations, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, runLocalRemediationComplianceCheck, } from './log_config_files/runtime/compliance_check.js';
9
+ import { applyAutofixViolations, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, reportPostRestartVerificationOutcomes, runLocalRemediationComplianceCheck, } from './log_config_files/runtime/compliance_check.js';
10
+ import { isRemediationQuarantined } from './log_config_files/runtime/remediation_apply_tracking.js';
10
11
  import { existsSync, readFileSync, statSync } from 'node:fs';
11
12
  import { getRemediationInstructionsPath, readRemediationInstructionsFile } from './log_config_files/runtime/management_storage.js';
12
13
  import { hookLogSessionBanner, hookRunLog, logRemediationApplyFailure } from './log_config_files/runtime/hook_logger.js';
@@ -179,6 +180,12 @@ export async function runCompliancePromptGate() {
179
180
  }
180
181
  }
181
182
  const status = runLocalRemediationComplianceCheck(agent);
183
+ // Always process pending post-restart rows (even when compliance is ok — apply may have stuck).
184
+ const postRestartVerify = reportPostRestartVerificationOutcomes(status.violations);
185
+ if (postRestartVerify.outcomes.length > 0) {
186
+ await Promise.allSettled(postRestartVerify.reportPromises);
187
+ hookRunLog(`compliance_prompt_gate: post_restart_verification count=${postRestartVerify.outcomes.length} quarantined=${postRestartVerify.outcomes.filter((o) => o.status === 'quarantined').length}`);
188
+ }
182
189
  // Secondary-satisfied: primary checks failed but settings.json (or equiv) has the fix.
183
190
  // Upload those files fire-and-forget so the backend can resolve the finding immediately.
184
191
  for (const e of status.secondarySatisfied ?? []) {
@@ -198,7 +205,17 @@ export async function runCompliancePromptGate() {
198
205
  printAllowWithAdvisory(ide, advisory);
199
206
  return;
200
207
  }
201
- const { fixed, appliedViolations = [], restartCommands, failedViolations, reportPromises, deferredSqlitePending, } = applyAutofixViolations(status.violations, agent);
208
+ const actionableViolations = status.violations.filter((v) => !isRemediationQuarantined(v.uuid));
209
+ if (actionableViolations.length === 0 && status.violations.length > 0) {
210
+ hookRunLog(`compliance_prompt_gate: all ${status.violations.length} violation(s) quarantined — allowing prompt silently (backend notified on quarantine)`);
211
+ logRemediationApplyFailure('prompt_gate_quarantined_silent_allow', {
212
+ reason: 'all violations quarantined; no dialog — enforcement paused on this machine',
213
+ quarantined_count: status.violations.length,
214
+ });
215
+ printAllow(ide);
216
+ return;
217
+ }
218
+ const { fixed, appliedViolations = [], restartCommands, failedViolations, reportPromises, deferredSqlitePending, } = applyAutofixViolations(actionableViolations, agent);
202
219
  hookRunLog(`compliance_prompt_gate: autofix result ide=${ide} fixed=${fixed} applied=${appliedViolations.length} failed=${failedViolations.length} restart_commands=${restartCommands.length} deferred_sqlite_pending=${deferredSqlitePending}`);
203
220
  if (fixed > 0) {
204
221
  // Wait for all server reports before exiting so the POST lands.
@@ -0,0 +1,67 @@
1
+ import { CURSOR_SHADOW_KEYS_NESTED_WINS, coalesceCursorEnabledShadowValue, stripShadowKeysFromReactiveBlobUpload, } from './cursor_shadow_merge_policy.js';
2
+ const CURSOR_SHADOW_KEYS_MERGE_ENABLED_OR = new Set([
3
+ 'isWebSearchToolEnabled',
4
+ 'isWebSearchToolEnabled2',
5
+ ]);
6
+ /**
7
+ * Collect include_keys from composer_with_include read steps (backend-driven list).
8
+ */
9
+ export function shadowKeysFromReadQueries(readQueries) {
10
+ const keys = new Set();
11
+ for (const step of readQueries ?? []) {
12
+ if (step.value_kind === 'composer_with_include' && step.include_keys?.length) {
13
+ for (const k of step.include_keys) {
14
+ keys.add(k);
15
+ }
16
+ }
17
+ }
18
+ return [...keys];
19
+ }
20
+ /**
21
+ * Resolve one shadow key from reactive blob root + nested composerState (matches scan_targets).
22
+ */
23
+ export function mergeCursorShadowKeyValue(key, root, nested) {
24
+ const rootVal = Object.prototype.hasOwnProperty.call(root, key) ? root[key] : undefined;
25
+ const nestedVal = nested && Object.prototype.hasOwnProperty.call(nested, key) ? nested[key] : undefined;
26
+ if (CURSOR_SHADOW_KEYS_NESTED_WINS.has(key)) {
27
+ if (nestedVal !== undefined)
28
+ return nestedVal;
29
+ if (rootVal !== undefined)
30
+ return rootVal;
31
+ return undefined;
32
+ }
33
+ if (CURSOR_SHADOW_KEYS_MERGE_ENABLED_OR.has(key)) {
34
+ return coalesceCursorEnabledShadowValue(rootVal, nestedVal);
35
+ }
36
+ if (rootVal !== undefined)
37
+ return rootVal;
38
+ return nestedVal;
39
+ }
40
+ /**
41
+ * Merge shadow keys from the reactive blob into nested composerState (canonical for policy).
42
+ * Matches policy_engine.scan_targets; strips duplicate top-level keys from the upload shape.
43
+ */
44
+ export function mergeShadowKeysIntoComposerState(stateData, shadowKeys) {
45
+ if (!shadowKeys.length)
46
+ return;
47
+ const hasNested = stateData.composerState &&
48
+ typeof stateData.composerState === 'object' &&
49
+ !Array.isArray(stateData.composerState);
50
+ const hasRootKey = shadowKeys.some((k) => Object.prototype.hasOwnProperty.call(stateData, k));
51
+ if (!hasNested && !hasRootKey)
52
+ return;
53
+ let cs = stateData.composerState;
54
+ if (!hasNested) {
55
+ cs = {};
56
+ stateData.composerState = cs;
57
+ }
58
+ const composerState = cs;
59
+ const root = stateData;
60
+ for (const key of shadowKeys) {
61
+ const merged = mergeCursorShadowKeyValue(key, root, composerState);
62
+ if (merged !== undefined) {
63
+ composerState[key] = merged;
64
+ }
65
+ }
66
+ stripShadowKeysFromReactiveBlobUpload(stateData);
67
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Keep in sync with file_path_registry/cursor_composer_shadow_keys.py
3
+ * (CURSOR_REACTIVE_BLOB_SHADOW_KEYS + merge sets).
4
+ */
5
+ /** Keys Cursor duplicates on the reactive blob root and inside composerState. */
6
+ export const CURSOR_COMPOSER_SHADOW_KEYS = [
7
+ 'lastBrowserConnectionMode',
8
+ 'playwrightProtection',
9
+ 'isWebSearchToolEnabled',
10
+ 'isWebSearchToolEnabled2',
11
+ 'isWebSearchToolEnabled3',
12
+ 'isWebFetchToolEnabled',
13
+ 'autoAcceptWebSearchTool',
14
+ ];
15
+ /** Cursor Settings UI writes these on nested composerState; blob root copies are often stale. */
16
+ export const CURSOR_SHADOW_KEYS_NESTED_WINS = new Set([
17
+ 'playwrightProtection',
18
+ 'isWebFetchToolEnabled',
19
+ 'isWebSearchToolEnabled3',
20
+ 'autoAcceptWebSearchTool',
21
+ ]);
22
+ export function cursorShadowValueIsEnabled(value) {
23
+ return (value === true ||
24
+ value === 1 ||
25
+ (typeof value === 'string' && ['true', '1', 'yes'].includes(value.toLowerCase())));
26
+ }
27
+ /** Prefer nested when enabled (web search / auto-accept); else root. */
28
+ export function coalesceCursorEnabledShadowValue(root, nested) {
29
+ if (cursorShadowValueIsEnabled(nested))
30
+ return nested;
31
+ if (cursorShadowValueIsEnabled(root))
32
+ return root;
33
+ if (root !== undefined && root !== null)
34
+ return root;
35
+ return nested;
36
+ }
37
+ /**
38
+ * After merge, policy and uploads use composerState.<key> only — drop stale blob-root copies.
39
+ */
40
+ export function stripShadowKeysFromReactiveBlobUpload(stateData) {
41
+ if (!('composerState' in stateData))
42
+ return;
43
+ for (const key of CURSOR_COMPOSER_SHADOW_KEYS) {
44
+ delete stateData[key];
45
+ }
46
+ }
@@ -65,7 +65,9 @@ function buildRawContent(state, stateKey, value, includeKeys, ts) {
65
65
  else {
66
66
  raw[stateKey] = value;
67
67
  }
68
- if (includeKeys?.length) {
68
+ // Shadow keys are merged into composerState during readVSCDBState; do not copy stale blob-root
69
+ // copies onto the upload payload (policy paths are composerState.<key> only).
70
+ if (includeKeys?.length && stateKey !== 'composerState') {
69
71
  for (const k of includeKeys) {
70
72
  if (k in state && state[k] !== undefined)
71
73
  raw[k] = state[k];
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { readFileCollectionVscdbContract } from '../runtime/management_storage.js';
4
+ import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
5
+ import { CURSOR_COMPOSER_SHADOW_KEYS } from './cursor_shadow_merge_policy.js';
6
+ /** Suffix shared by Cursor reactive-storage ItemTable keys (see file_path_registry cursor agent). */
7
+ export const REACTIVE_STORAGE_ITEM_KEY_SUFFIX = 'reactiveStorageServiceImpl.persistentStorage.applicationUser';
8
+ function querySqliteValue(dbPath, key) {
9
+ const bin = resolveSqlite3Binary();
10
+ if (!bin)
11
+ return '';
12
+ const safe = key.replace(/'/g, "''");
13
+ const script = `.timeout 60000\nSELECT value FROM ItemTable WHERE key='${safe}';\n`;
14
+ return execFileSync(bin, ['-noheader', dbPath], {
15
+ input: script,
16
+ encoding: 'utf8',
17
+ stdio: ['pipe', 'pipe', 'pipe'],
18
+ }).trim();
19
+ }
20
+ /** When the cached contract is empty/stale, discover the live reactive blob row in state.vscdb. */
21
+ export function discoverReactiveStorageItemKeyInVscdb(dbPath) {
22
+ if (!existsSync(dbPath) || !resolveSqlite3Binary())
23
+ return undefined;
24
+ try {
25
+ const suffix = REACTIVE_STORAGE_ITEM_KEY_SUFFIX.replace(/'/g, "''");
26
+ const key = execFileSync(resolveSqlite3Binary(), [
27
+ dbPath,
28
+ `SELECT key FROM ItemTable WHERE key LIKE '%${suffix}%' LIMIT 1;`,
29
+ ], { encoding: 'utf8' }).trim();
30
+ if (!key || /['"\\]/.test(key))
31
+ return undefined;
32
+ const raw = querySqliteValue(dbPath, key);
33
+ if (!raw || raw === '{}')
34
+ return undefined;
35
+ return key;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ export function reactiveStorageItemKeyFromContract() {
42
+ const k = readFileCollectionVscdbContract()?.reactive_storage_item_key;
43
+ return typeof k === 'string' && k.trim() !== '' ? k.trim() : undefined;
44
+ }
45
+ export function reactiveStorageItemKeyForVscdb(dbPath) {
46
+ const fromContract = reactiveStorageItemKeyFromContract();
47
+ if (fromContract)
48
+ return fromContract;
49
+ if (dbPath)
50
+ return discoverReactiveStorageItemKeyInVscdb(dbPath);
51
+ return undefined;
52
+ }
53
+ export function composerShadowKeysFromContract() {
54
+ const fromContract = readFileCollectionVscdbContract()?.composer_shadow_keys ?? [];
55
+ if (fromContract.length > 0)
56
+ return [...fromContract];
57
+ return [...CURSOR_COMPOSER_SHADOW_KEYS];
58
+ }
59
+ export function reactiveBlobHasUsableContent(dbPath, reactiveKey) {
60
+ const raw = querySqliteValue(dbPath, reactiveKey).trim();
61
+ return raw !== '' && raw !== '{}';
62
+ }
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
4
- import { readFileCollectionVscdbContract, sanitizeFileCollectionVscdbContract, sanitizeReactiveStorageItemKey, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
4
+ import { mergeCursorShadowKeyValue, mergeShadowKeysIntoComposerState, shadowKeysFromReadQueries, } from './composer_shadow_merge.js';
5
+ import { composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from './vscdb_reactive_storage.js';
6
+ import { readFileCollectionVscdbContract, sanitizeFileCollectionVscdbContract, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
5
7
  /**
6
8
  * ItemTable keys that store a bare JSON boolean; compliance paths use `${key}.${field}`.
7
9
  * Kept in sync with `coerceItemTableValueToObjectRoot` / `serializeItemTableValueForWrite` in remediation_sync.
@@ -63,8 +65,16 @@ export function normalizeVscdbComposerContractFromPatternsResponse(resp) {
63
65
  /** Persist backend-derived contract next to remediation_instructions.json. */
64
66
  export function persistVscdbComposerContractFromPatternsResponse(resp) {
65
67
  const norm = normalizeVscdbComposerContractFromPatternsResponse(resp);
66
- if (norm.reactive_storage_item_key || norm.composer_shadow_keys.length > 0) {
67
- writeFileCollectionVscdbContract(norm);
68
+ const existing = readFileCollectionVscdbContract();
69
+ const merged = {
70
+ version: 1,
71
+ reactive_storage_item_key: norm.reactive_storage_item_key ?? existing?.reactive_storage_item_key ?? undefined,
72
+ composer_shadow_keys: norm.composer_shadow_keys.length > 0
73
+ ? norm.composer_shadow_keys
74
+ : [...(existing?.composer_shadow_keys ?? [])],
75
+ };
76
+ if (merged.reactive_storage_item_key || merged.composer_shadow_keys.length > 0) {
77
+ writeFileCollectionVscdbContract(merged);
68
78
  }
69
79
  }
70
80
  /**
@@ -87,8 +97,7 @@ export function readVscdbItemTableJson(dbPath, itemKey) {
87
97
  }
88
98
  try {
89
99
  const raw = querySqlite(dbPath, itemKey);
90
- const contract = readFileCollectionVscdbContract();
91
- const reactiveKey = sanitizeReactiveStorageItemKey(contract?.reactive_storage_item_key) ?? '';
100
+ const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath) ?? '';
92
101
  if (itemKey === 'composerState' && (!raw || raw === '{}') && reactiveKey) {
93
102
  const reactive = querySqlite(dbPath, reactiveKey);
94
103
  if (reactive) {
@@ -137,6 +146,62 @@ export function readVscdbItemTableJson(dbPath, itemKey) {
137
146
  return null;
138
147
  }
139
148
  }
149
+ /**
150
+ * Cursor stores web-tool toggles on the reactive blob root and nested `composerState`.
151
+ * Merge root shadow keys into nested composerState (matches policy_engine.scan_targets).
152
+ */
153
+ export function mergeComposerShadowKeysFromReactiveBlob(dbPath, merged) {
154
+ const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath)?.trim();
155
+ const shadowKeys = composerShadowKeysFromContract();
156
+ if (!reactiveKey || shadowKeys.length === 0)
157
+ return;
158
+ const reactiveWrapped = readVscdbItemTableJson(dbPath, reactiveKey);
159
+ if (reactiveWrapped === null)
160
+ return;
161
+ const blob = reactiveWrapped[reactiveKey];
162
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob))
163
+ return;
164
+ const root = blob;
165
+ let cs = merged.composerState;
166
+ if (!cs || typeof cs !== 'object' || Array.isArray(cs)) {
167
+ const nested = root.composerState;
168
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
169
+ cs = { ...nested };
170
+ }
171
+ else {
172
+ cs = {};
173
+ }
174
+ merged.composerState = cs;
175
+ }
176
+ const composerState = cs;
177
+ const nestedRoot = root.composerState && typeof root.composerState === 'object' && !Array.isArray(root.composerState)
178
+ ? root.composerState
179
+ : undefined;
180
+ for (const key of shadowKeys) {
181
+ const value = mergeCursorShadowKeyValue(key, root, nestedRoot ?? composerState);
182
+ if (value !== undefined) {
183
+ composerState[key] = value;
184
+ }
185
+ }
186
+ }
187
+ /**
188
+ * After a deferred state.vscdb write, upload the live reactive blob (not stale legacy composerState row).
189
+ * Return the full blob so the server can apply per-key shadow merge (same as log-config ingest).
190
+ */
191
+ export function buildVscdbPostApplyRawContent(dbPath, itemKeyFromPath) {
192
+ if (itemKeyFromPath === 'composerState') {
193
+ const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath);
194
+ if (reactiveKey) {
195
+ const reactiveWrapped = readVscdbItemTableJson(dbPath, reactiveKey);
196
+ const blob = reactiveWrapped?.[reactiveKey];
197
+ if (blob && typeof blob === 'object' && !Array.isArray(blob)) {
198
+ return { ...blob };
199
+ }
200
+ }
201
+ return readVscdbItemTableJson(dbPath, itemKeyFromPath);
202
+ }
203
+ return readVscdbItemTableJson(dbPath, itemKeyFromPath);
204
+ }
140
205
  function setNested(obj, dotPath, value) {
141
206
  const parts = dotPath.split('.');
142
207
  const safe = (k) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
@@ -172,16 +237,15 @@ function runOneStep(dbPath, stateData, step) {
172
237
  stateData.composerState = composerState;
173
238
  }
174
239
  if (step.include_keys?.length) {
175
- const nested = composerState && typeof composerState === 'object'
240
+ const nested = composerState && typeof composerState === 'object' && !Array.isArray(composerState)
176
241
  ? composerState
177
242
  : undefined;
243
+ if (!nested)
244
+ return;
178
245
  for (const k of step.include_keys) {
179
- // Reactive blob root wins over nested composerState (matches policy_engine.scan_targets merge).
180
- if (k in obj && obj[k] !== undefined) {
181
- stateData[k] = obj[k];
182
- }
183
- else if (nested && k in nested && nested[k] !== undefined) {
184
- stateData[k] = nested[k];
246
+ const merged = mergeCursorShadowKeyValue(k, obj, nested);
247
+ if (merged !== undefined) {
248
+ nested[k] = merged;
185
249
  }
186
250
  }
187
251
  }
@@ -243,6 +307,7 @@ export function readVSCDBState(dbPath, readQueries, mergeFromComposerStateKeys)
243
307
  if (mergeFromComposerStateKeys?.length) {
244
308
  mergeFromComposerState(stateData, mergeFromComposerStateKeys);
245
309
  }
310
+ mergeShadowKeysIntoComposerState(stateData, shadowKeysFromReadQueries(readQueries));
246
311
  return Object.keys(stateData).length > 0 ? stateData : null;
247
312
  }
248
313
  catch (error) {
@@ -13,14 +13,15 @@
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { join } from 'node:path';
16
- import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
17
- import { readFileCollectionVscdbContract, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
16
+ import { mergeComposerShadowKeysFromReactiveBlob, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
17
+ import { readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
18
18
  import { resolveRemediationConfigPath } from './remediation_config_path.js';
19
+ import { isRemediationQuarantined, markRemediationApplyPendingVerification, processPendingPostRestartVerifications, } from './remediation_apply_tracking.js';
19
20
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
20
21
  import { loadEndpointBase } from '../sender/endpoint_config.js';
21
22
  import { resolveHardwareUuid, tryResolveHardwareUuid } from './hardware_uuid.js';
22
23
  import { buildDeferredCursorRestartCommand, discoverAllWorkspaceVscdbs, enforceRemediation, fetchSync, isTrustedRestartCommandForAutofix, remediationFixSpec, reportAutofixApplied, syncRemediations, } from './remediation_sync.js';
23
- import { sendConfigFile, resolveRepoFromPath } from '../sender/batch_sender.js';
24
+ import { sendConfigFile } from '../sender/batch_sender.js';
24
25
  import { ensureAuthentication } from '../auth/auth_flow.js';
25
26
  /** Normalize manifest/env/CLI agent tokens to a known Agent, or '' if unrecognized. */
26
27
  export function normalizeAgentToken(raw) {
@@ -84,6 +85,28 @@ export function itemTableKeyFromSettingPath(settingPath) {
84
85
  const i = settingPath.indexOf('.');
85
86
  return i === -1 ? settingPath : settingPath.slice(0, i);
86
87
  }
88
+ /**
89
+ * Compliance reads the live config at `config_file_path` (#itemKey), but sqlite apply may target
90
+ * a different ItemTable row. Verify against the manifest path's item key, not the apply target.
91
+ */
92
+ export function canonicalComplianceSettingPath(configFilePath, check) {
93
+ const settingPath = check.setting_path;
94
+ const applyKey = check.sqlite_op?.target_key?.trim();
95
+ if (!applyKey || applyKey === settingPath)
96
+ return settingPath;
97
+ const hashIdx = configFilePath.indexOf('#');
98
+ if (hashIdx < 0)
99
+ return settingPath;
100
+ const verifyKey = configFilePath.slice(hashIdx + 1).trim();
101
+ if (!verifyKey || applyKey === verifyKey)
102
+ return settingPath;
103
+ const prefix = `${applyKey}.`;
104
+ if (settingPath === applyKey)
105
+ return verifyKey;
106
+ if (settingPath.startsWith(prefix))
107
+ return `${verifyKey}${settingPath.slice(applyKey.length)}`;
108
+ return settingPath;
109
+ }
87
110
  /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
88
111
  export function getByPath(obj, path) {
89
112
  const parts = path.split('.');
@@ -179,42 +202,8 @@ function shouldMergeComposerShadowKeys(itemKeyFromPath, checkSettingPaths) {
179
202
  return true;
180
203
  return checkSettingPaths.some((p) => p === 'composerState' || p.startsWith('composerState.'));
181
204
  }
182
- /**
183
- * Cursor stores web-tool toggles on the reactive blob root and nested `composerState`.
184
- * Policy scan merges root shadow keys into composerState; compliance must match or autofix
185
- * never runs when nested values are stale (e.g. lastBrowserConnectionMode none vs root editor).
186
- */
187
- export function mergeComposerShadowKeysFromReactiveBlob(dbPath, merged) {
188
- const contract = readFileCollectionVscdbContract();
189
- const reactiveKey = contract?.reactive_storage_item_key?.trim();
190
- const shadowKeys = contract?.composer_shadow_keys ?? [];
191
- if (!reactiveKey || shadowKeys.length === 0)
192
- return;
193
- const reactiveWrapped = readVscdbItemTableJson(dbPath, reactiveKey);
194
- if (reactiveWrapped === null)
195
- return;
196
- const blob = reactiveWrapped[reactiveKey];
197
- if (!blob || typeof blob !== 'object' || Array.isArray(blob))
198
- return;
199
- const root = blob;
200
- let cs = merged.composerState;
201
- if (!cs || typeof cs !== 'object' || Array.isArray(cs)) {
202
- const nested = root.composerState;
203
- if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
204
- cs = { ...nested };
205
- }
206
- else {
207
- cs = {};
208
- }
209
- merged.composerState = cs;
210
- }
211
- const composerState = cs;
212
- for (const key of shadowKeys) {
213
- if (Object.prototype.hasOwnProperty.call(root, key) && root[key] !== undefined) {
214
- composerState[key] = root[key];
215
- }
216
- }
217
- }
205
+ /** @deprecated Import from vscdb_reader — re-export for existing tests. */
206
+ export { mergeComposerShadowKeysFromReactiveBlob } from '../readers/vscdb_reader.js';
218
207
  /** Plain JSON file or virtual `…/state.vscdb#itemKey` path for ItemTable-backed settings. */
219
208
  function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
220
209
  const resolvedPath = resolveRemediationConfigPath(configFilePath);
@@ -270,6 +259,101 @@ function evaluateSecondaryGroup(group) {
270
259
  // ---------------------------------------------------------------------------
271
260
  // Check runner — Section 6: real per-check evaluation
272
261
  // ---------------------------------------------------------------------------
262
+ function violationFromCheck(entry, compliance, check, expected) {
263
+ return {
264
+ uuid: entry.uuid,
265
+ finding_formatted_id: compliance.finding_formatted_id,
266
+ setting_path: check.setting_path,
267
+ description: check.description,
268
+ finding_title: entry.finding_title,
269
+ finding_description: entry.finding_description,
270
+ policy_name: entry.policy_name,
271
+ severity: compliance.severity,
272
+ autofix_allowed: compliance.autofix_allowed,
273
+ config_file_path: entry.config_file_path,
274
+ expected_value: expected,
275
+ message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Apply remediation ops for ${check.setting_path} in ${entry.config_file_path}`,
276
+ };
277
+ }
278
+ /** Evaluate one manifest row against on-disk config (used by gate + post-restart verify). */
279
+ export function evaluateManifestEntryCompliance(entry) {
280
+ const compliance = entry.fix ?? entry.compliance;
281
+ if (!compliance || compliance.file_format !== 'json')
282
+ return { violations: [] };
283
+ const checks = compliance.checks ?? [];
284
+ if (checks.length === 0)
285
+ return { violations: [] };
286
+ const loaded = loadRemediationConfigJson(entry.config_file_path, checks.map((c) => c.setting_path));
287
+ if (!loaded.ok)
288
+ return { violations: [] };
289
+ const configJson = loaded.json;
290
+ const entryViolations = [];
291
+ for (const check of checks) {
292
+ const effectivePath = canonicalComplianceSettingPath(entry.config_file_path, check);
293
+ if (check.sqlite_op?.apply_to_all_workspaces && check.ops) {
294
+ const segments = check.sqlite_op.workspace_storage_path_segments ?? [
295
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
296
+ ];
297
+ const wsPath = join(homedir(), ...segments);
298
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
299
+ if (vscdbPaths.length === 0)
300
+ continue;
301
+ let violated = false;
302
+ let expectedForViolation = null;
303
+ for (const dbPath of vscdbPaths) {
304
+ const wrapped = readVscdbItemTableJson(dbPath, check.sqlite_op.target_key ?? '');
305
+ if (wrapped === null) {
306
+ violated = true;
307
+ expectedForViolation = { op: 'read_failed', db: dbPath };
308
+ break;
309
+ }
310
+ const { ok, expected } = verifyOpsApplied(wrapped, check.setting_path, check.ops);
311
+ if (!ok) {
312
+ violated = true;
313
+ expectedForViolation = expected;
314
+ break;
315
+ }
316
+ }
317
+ if (violated) {
318
+ entryViolations.push(violationFromCheck(entry, compliance, check, expectedForViolation));
319
+ }
320
+ continue;
321
+ }
322
+ if (check.ops) {
323
+ const { ok, expected } = verifyOpsApplied(configJson, effectivePath, check.ops);
324
+ if (!ok) {
325
+ entryViolations.push(violationFromCheck(entry, compliance, { ...check, setting_path: effectivePath }, expected));
326
+ }
327
+ continue;
328
+ }
329
+ const currentValue = getByPath(configJson, effectivePath);
330
+ const leafKey = effectivePath.split('.').pop();
331
+ const expectedValue = check.after?.[leafKey];
332
+ if (expectedValue === undefined)
333
+ continue;
334
+ if (!deepEqual(currentValue, expectedValue)) {
335
+ entryViolations.push(violationFromCheck(entry, compliance, { ...check, setting_path: effectivePath }, expectedValue));
336
+ }
337
+ }
338
+ if (entryViolations.length === 0)
339
+ return { violations: [] };
340
+ const secondaryGroups = compliance.secondary_checks ?? [];
341
+ const passingGroup = secondaryGroups.length > 0 ? secondaryGroups.find((g) => evaluateSecondaryGroup(g)) : undefined;
342
+ if (passingGroup) {
343
+ return {
344
+ violations: [],
345
+ secondarySatisfied: {
346
+ uuid: entry.uuid,
347
+ config_file_path: passingGroup.config_file_path,
348
+ file_type: passingGroup.file_type,
349
+ },
350
+ };
351
+ }
352
+ return { violations: entryViolations };
353
+ }
354
+ export function collectManifestEntryViolations(entry) {
355
+ return evaluateManifestEntryCompliance(entry).violations;
356
+ }
273
357
  /**
274
358
  * Evaluate current on-disk configs against remediation_instructions.json only (no server).
275
359
  * Returns status for prompt gating / callers; does not persist compliance.json.
@@ -300,12 +384,11 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
300
384
  hookRunLog(`compliance_check: skipping non-json entry uuid=${entry.uuid}`);
301
385
  continue;
302
386
  }
303
- const checks = compliance.checks ?? [];
304
- if (checks.length === 0) {
387
+ if ((compliance.checks ?? []).length === 0) {
305
388
  skippedNoChecks++;
306
389
  continue;
307
390
  }
308
- const loaded = loadRemediationConfigJson(entry.config_file_path, checks.map((c) => c.setting_path));
391
+ const loaded = loadRemediationConfigJson(entry.config_file_path, (compliance.checks ?? []).map((c) => c.setting_path));
309
392
  if (!loaded.ok) {
310
393
  skippedUnreadable++;
311
394
  const msg = loaded.reason === 'file_not_found'
@@ -327,121 +410,16 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
327
410
  });
328
411
  continue;
329
412
  }
330
- const configJson = loaded.json;
331
- const entryViolations = [];
332
- for (const check of checks) {
333
- // When the check carries a sqlite_op with apply_to_all_workspaces, the authoritative data
334
- // lives in workspace state.vscdb files — not in config_file_path (which may be mcp.json in
335
- // the fallback case where no workspace vscdb has been collected yet). Verify directly against
336
- // every discovered workspace vscdb; fail as a violation if any is missing the required entry.
337
- if (check.sqlite_op?.apply_to_all_workspaces && check.ops) {
338
- const segments = check.sqlite_op.workspace_storage_path_segments ?? [
339
- 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
340
- ];
341
- const wsPath = join(homedir(), ...segments);
342
- const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
343
- if (vscdbPaths.length === 0) {
344
- hookRunLog(`compliance_check: apply_to_all_workspaces — no workspace vscdbs found at ${wsPath}, skipping uuid=${entry.uuid}`);
345
- }
346
- else {
347
- let violated = false;
348
- let expectedForViolation = null;
349
- for (const dbPath of vscdbPaths) {
350
- const wrapped = readVscdbItemTableJson(dbPath, check.sqlite_op.target_key ?? '');
351
- if (wrapped === null) {
352
- violated = true;
353
- expectedForViolation = { op: 'read_failed', db: dbPath };
354
- break;
355
- }
356
- const { ok, expected } = verifyOpsApplied(wrapped, check.setting_path, check.ops);
357
- if (!ok) {
358
- violated = true;
359
- expectedForViolation = expected;
360
- break;
361
- }
362
- }
363
- if (violated) {
364
- hookRunLog(`compliance_check: VIOLATION (vscdb) uuid=${entry.uuid} path=${check.setting_path} expected=${JSON.stringify(expectedForViolation)}`);
365
- entryViolations.push({
366
- uuid: entry.uuid,
367
- finding_formatted_id: compliance.finding_formatted_id,
368
- setting_path: check.setting_path,
369
- description: check.description,
370
- finding_title: entry.finding_title,
371
- finding_description: entry.finding_description,
372
- policy_name: entry.policy_name,
373
- severity: compliance.severity,
374
- autofix_allowed: compliance.autofix_allowed,
375
- config_file_path: entry.config_file_path,
376
- expected_value: expectedForViolation,
377
- message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Apply remediation ops for ${check.setting_path} in workspace state.vscdb files`,
378
- });
379
- }
380
- }
381
- continue;
382
- }
383
- // Prefer ops-based verification (matches delta apply semantics; doesn't require full after snapshot).
384
- if (check.ops) {
385
- const { ok, expected } = verifyOpsApplied(configJson, check.setting_path, check.ops);
386
- if (!ok) {
387
- hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} expected=${JSON.stringify(expected)}`);
388
- entryViolations.push({
389
- uuid: entry.uuid,
390
- finding_formatted_id: compliance.finding_formatted_id,
391
- setting_path: check.setting_path,
392
- description: check.description,
393
- finding_title: entry.finding_title,
394
- finding_description: entry.finding_description,
395
- policy_name: entry.policy_name,
396
- severity: compliance.severity,
397
- autofix_allowed: compliance.autofix_allowed,
398
- config_file_path: entry.config_file_path,
399
- expected_value: expected,
400
- message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Apply remediation ops for ${check.setting_path} in ${entry.config_file_path}`,
401
- });
402
- }
403
- continue;
404
- }
405
- // Backwards compat: old local files may still carry after snapshots.
406
- const currentValue = getByPath(configJson, check.setting_path);
407
- const leafKey = check.setting_path.split('.').pop();
408
- const expectedValue = check.after?.[leafKey];
409
- if (expectedValue === undefined)
410
- continue;
411
- if (!deepEqual(currentValue, expectedValue)) {
412
- hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} current=${JSON.stringify(currentValue)} expected=${JSON.stringify(expectedValue)}`);
413
- entryViolations.push({
414
- uuid: entry.uuid,
415
- finding_formatted_id: compliance.finding_formatted_id,
416
- setting_path: check.setting_path,
417
- description: check.description,
418
- finding_title: entry.finding_title,
419
- finding_description: entry.finding_description,
420
- policy_name: entry.policy_name,
421
- severity: compliance.severity,
422
- autofix_allowed: compliance.autofix_allowed,
423
- config_file_path: entry.config_file_path,
424
- expected_value: expectedValue,
425
- message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Set ${check.setting_path} to ${JSON.stringify(expectedValue)} in ${entry.config_file_path}`,
426
- });
427
- }
413
+ const { violations: entryViolations, secondarySatisfied: entrySecondary } = evaluateManifestEntryCompliance(entry);
414
+ for (const v of entryViolations) {
415
+ hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${v.setting_path} expected=${JSON.stringify(v.expected_value)}`);
428
416
  }
429
- if (entryViolations.length > 0) {
430
- const secondaryGroups = compliance.secondary_checks ?? [];
431
- const passingGroup = secondaryGroups.length > 0
432
- ? secondaryGroups.find((g) => evaluateSecondaryGroup(g))
433
- : undefined;
434
- if (passingGroup) {
435
- hookRunLog(`compliance_check: secondary check satisfied uuid=${entry.uuid} — skipping ${entryViolations.length} primary violation(s)`);
436
- secondarySatisfied.push({
437
- uuid: entry.uuid,
438
- config_file_path: passingGroup.config_file_path,
439
- file_type: passingGroup.file_type,
440
- });
441
- }
442
- else {
443
- violations.push(...entryViolations);
444
- }
417
+ if (entrySecondary) {
418
+ hookRunLog(`compliance_check: secondary check satisfied uuid=${entry.uuid} skipping primary violation(s)`);
419
+ secondarySatisfied.push(entrySecondary);
420
+ }
421
+ else if (entryViolations.length > 0) {
422
+ violations.push(...entryViolations);
445
423
  }
446
424
  }
447
425
  const status = {
@@ -485,6 +463,34 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
485
463
  return fallback;
486
464
  }
487
465
  }
466
+ /**
467
+ * After restart, verify pending remediations and report outcomes to the server.
468
+ */
469
+ export function reportPostRestartVerificationOutcomes(violations) {
470
+ const { remediations } = readRemediationInstructionsFile();
471
+ const entriesByUuid = new Map(remediations.map((entry) => [entry.uuid, entry]));
472
+ const outcomes = processPendingPostRestartVerifications((uuid) => {
473
+ const entry = entriesByUuid.get(uuid);
474
+ if (entry && collectManifestEntryViolations(entry).length > 0)
475
+ return true;
476
+ return violations.some((v) => v.uuid === uuid);
477
+ });
478
+ const reportPromises = outcomes.map((o) => {
479
+ if (o.status === 'quarantined' || o.status === 'verification_failed') {
480
+ logRemediationApplyFailure('post_restart_verification_failed', {
481
+ uuid: o.uuid,
482
+ reason: o.reason ?? 'Setting unchanged after apply',
483
+ status: o.status,
484
+ consecutive_failures: o.consecutive_failures,
485
+ });
486
+ }
487
+ return reportAutofixApplied(o.uuid, o.status, {
488
+ failure_reason: o.reason,
489
+ consecutive_failures: o.consecutive_failures,
490
+ });
491
+ });
492
+ return { outcomes, reportPromises };
493
+ }
488
494
  export function applyAutofixViolations(violations, agent = 'cursor') {
489
495
  for (const v of violations) {
490
496
  if (!v.autofix_allowed) {
@@ -520,6 +526,18 @@ export function applyAutofixViolations(violations, agent = 'cursor') {
520
526
  for (const violation of autofixable) {
521
527
  if (seen.has(violation.uuid))
522
528
  continue;
529
+ if (isRemediationQuarantined(violation.uuid)) {
530
+ hookRunLog(`autofix: skipped quarantined uuid=${violation.uuid}`);
531
+ logRemediationApplyFailure('autofix_skipped_quarantined', {
532
+ uuid: violation.uuid,
533
+ finding_formatted_id: violation.finding_formatted_id,
534
+ config_file_path: violation.config_file_path,
535
+ setting_path: violation.setting_path,
536
+ reason: 'Auto-fix paused after repeated post-restart verification failures — agent config may have changed',
537
+ });
538
+ failedViolations.push(violation);
539
+ continue;
540
+ }
523
541
  const instruction = byUuid.get(violation.uuid);
524
542
  if (!instruction) {
525
543
  hookRunLog(`autofix: no instruction found for uuid=${violation.uuid}, skipping`);
@@ -548,6 +566,10 @@ export function applyAutofixViolations(violations, agent = 'cursor') {
548
566
  appliedViolations.push(violation);
549
567
  hookRunLog(`autofix: applied uuid=${inst.uuid} path=${configPathForDisk}`);
550
568
  reportPromises.push(reportAutofixApplied(inst.uuid, 'success'));
569
+ // Every successful autofix (Cursor + Claude, restart or immediate JSON) awaits verification on
570
+ // the next compliance check so we can quarantine stuck applies and stop restart/retry loops.
571
+ markRemediationApplyPendingVerification(inst.uuid);
572
+ const spec = remediationFixSpec(inst);
551
573
  if (er.deferredSqlite && configPathForDisk.includes('#')) {
552
574
  hookRunLog(`autofix: skip immediate vscdb upload (deferred until after restart) uuid=${inst.uuid}`);
553
575
  }
@@ -572,11 +594,10 @@ export function applyAutofixViolations(violations, agent = 'cursor') {
572
594
  if (fileType) {
573
595
  const hw = tryResolveHardwareUuid();
574
596
  if (hw) {
575
- const repoIdentifier = resolveRepoFromPath(configPathForDisk);
576
597
  // Do not rely on the bulk uploader (start-every-prompt throttles at 1800s).
577
598
  // Always attempt to upload the single remediated file immediately.
578
599
  reportPromises.push(ensureAuthentication(hw)
579
- .then((authKey) => sendConfigFile({ file_type: fileType, file_path: configPathForDisk, raw_content: updatedContent }, hw, authKey, repoIdentifier))
600
+ .then((authKey) => sendConfigFile({ file_type: fileType, file_path: configPathForDisk, raw_content: updatedContent }, hw, authKey))
580
601
  .then((sentOk) => {
581
602
  hookRunLog(`autofix: uploaded remediated file uuid=${inst.uuid} path=${configPathForDisk} ok=${sentOk}`);
582
603
  })
@@ -598,7 +619,6 @@ export function applyAutofixViolations(violations, agent = 'cursor') {
598
619
  }
599
620
  }
600
621
  }
601
- const spec = remediationFixSpec(inst);
602
622
  if (spec?.restart_required && spec.restart_command) {
603
623
  if (!er.deferredSqlite) {
604
624
  if (isTrustedRestartCommandForAutofix(spec.restart_command)) {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Tracks remediation apply attempts and post-apply verification (next compliance check)
3
+ * so we do not restart-loop or retry forever when an agent config format changed and
4
+ * autofix can never stick. Applies to every remediation on Cursor and Claude.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { atomicWriteJson, getManagementDir } from './management_storage.js';
9
+ import { hookRunLog } from './hook_logger.js';
10
+ export const REMEDIATION_APPLY_TRACKING_BASENAME = 'remediation_apply_tracking.json';
11
+ /** Consecutive post-restart verification failures before we stop autofix for a UUID. */
12
+ export const MAX_CONSECUTIVE_VERIFY_FAILURES = 1;
13
+ export function getRemediationApplyTrackingPath() {
14
+ return join(getManagementDir(), REMEDIATION_APPLY_TRACKING_BASENAME);
15
+ }
16
+ function emptyTrackingFile() {
17
+ return { entries: {} };
18
+ }
19
+ export function readRemediationApplyTrackingFile() {
20
+ const path = getRemediationApplyTrackingPath();
21
+ if (!existsSync(path))
22
+ return emptyTrackingFile();
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
25
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object') {
26
+ return emptyTrackingFile();
27
+ }
28
+ return parsed;
29
+ }
30
+ catch {
31
+ return emptyTrackingFile();
32
+ }
33
+ }
34
+ export function writeRemediationApplyTrackingFile(file) {
35
+ atomicWriteJson(getRemediationApplyTrackingPath(), file);
36
+ }
37
+ function defaultEntry() {
38
+ return { consecutive_verify_failures: 0, quarantined: false };
39
+ }
40
+ export function getRemediationApplyTrackingEntry(uuid) {
41
+ const file = readRemediationApplyTrackingFile();
42
+ return file.entries[uuid] ?? defaultEntry();
43
+ }
44
+ export function isRemediationQuarantined(uuid) {
45
+ return getRemediationApplyTrackingEntry(uuid).quarantined === true;
46
+ }
47
+ /** Admin retry: reset quarantine counters so autofix can run again on next prompt. */
48
+ export function clearRemediationApplyQuarantine(uuid) {
49
+ const file = readRemediationApplyTrackingFile();
50
+ const prev = file.entries[uuid];
51
+ if (!prev)
52
+ return;
53
+ file.entries[uuid] = {
54
+ consecutive_verify_failures: 0,
55
+ quarantined: false,
56
+ pending_post_restart_verify: false,
57
+ };
58
+ writeRemediationApplyTrackingFile(file);
59
+ hookRunLog(`remediation_tracking: quarantine_cleared uuid=${uuid}`);
60
+ }
61
+ export function markRemediationApplyPendingVerification(uuid) {
62
+ const file = readRemediationApplyTrackingFile();
63
+ const prev = file.entries[uuid] ?? defaultEntry();
64
+ file.entries[uuid] = {
65
+ ...prev,
66
+ pending_post_restart_verify: true,
67
+ last_apply_at: new Date().toISOString(),
68
+ };
69
+ writeRemediationApplyTrackingFile(file);
70
+ hookRunLog(`remediation_tracking: pending_post_restart_verify uuid=${uuid}`);
71
+ }
72
+ export function markRemediationApplyVerified(uuid) {
73
+ const file = readRemediationApplyTrackingFile();
74
+ const prev = file.entries[uuid] ?? defaultEntry();
75
+ const next = {
76
+ ...prev,
77
+ pending_post_restart_verify: false,
78
+ consecutive_verify_failures: 0,
79
+ quarantined: false,
80
+ last_failure_reason: undefined,
81
+ quarantined_at: undefined,
82
+ };
83
+ file.entries[uuid] = next;
84
+ writeRemediationApplyTrackingFile(file);
85
+ hookRunLog(`remediation_tracking: verified uuid=${uuid}`);
86
+ return next;
87
+ }
88
+ export function recordRemediationVerificationFailure(uuid, reason) {
89
+ const file = readRemediationApplyTrackingFile();
90
+ const prev = file.entries[uuid] ?? defaultEntry();
91
+ const failures = (prev.consecutive_verify_failures ?? 0) + 1;
92
+ const quarantined = failures >= MAX_CONSECUTIVE_VERIFY_FAILURES;
93
+ const next = {
94
+ ...prev,
95
+ pending_post_restart_verify: false,
96
+ consecutive_verify_failures: failures,
97
+ quarantined,
98
+ last_failure_reason: reason,
99
+ ...(quarantined ? { quarantined_at: new Date().toISOString() } : {}),
100
+ };
101
+ file.entries[uuid] = next;
102
+ writeRemediationApplyTrackingFile(file);
103
+ hookRunLog(`remediation_tracking: verification_failed uuid=${uuid} failures=${failures} quarantined=${quarantined}`);
104
+ return { entry: next, newlyQuarantined: quarantined && !prev.quarantined };
105
+ }
106
+ /**
107
+ * For each UUID awaiting post-restart verification, decide pass/fail.
108
+ *
109
+ * Prefer a per-UUID probe (re-runs that remediation's checks on disk). A Set of UUIDs is
110
+ * supported for unit tests only — do not use a global violation list from compliance_check
111
+ * in production: shadow-key merge bugs once made that list empty while the setting was still
112
+ * wrong, which incorrectly marked remediations verified.
113
+ */
114
+ export function processPendingPostRestartVerifications(violationProbe) {
115
+ const isStillViolating = typeof violationProbe === 'function'
116
+ ? violationProbe
117
+ : (uuid) => violationProbe.has(uuid);
118
+ const file = readRemediationApplyTrackingFile();
119
+ const outcomes = [];
120
+ for (const [uuid, entry] of Object.entries(file.entries)) {
121
+ if (!entry.pending_post_restart_verify)
122
+ continue;
123
+ if (!isStillViolating(uuid)) {
124
+ markRemediationApplyVerified(uuid);
125
+ outcomes.push({ uuid, status: 'verified', consecutive_failures: 0 });
126
+ continue;
127
+ }
128
+ const reason = 'Setting unchanged after apply — the agent may have changed its config format and auto-fix cannot apply this policy.';
129
+ const { entry: updated, newlyQuarantined } = recordRemediationVerificationFailure(uuid, reason);
130
+ outcomes.push({
131
+ uuid,
132
+ status: newlyQuarantined || updated.quarantined ? 'quarantined' : 'verification_failed',
133
+ reason,
134
+ consecutive_failures: updated.consecutive_verify_failures,
135
+ });
136
+ }
137
+ return outcomes;
138
+ }
@@ -4,18 +4,21 @@ import { homedir } from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { executeGet, executeBody } from '../../endpoint_client/http_transport.js';
6
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
- import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readFileCollectionVscdbContract, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
7
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
8
8
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
9
9
  import { createSignature } from '../sender/signing.js';
10
10
  import { loadEndpointBase } from '../sender/endpoint_config.js';
11
11
  import { tryResolveHardwareUuid } from './hardware_uuid.js';
12
- import { CURSOR_SCALAR_ITEMTABLE_FIELDS, persistVscdbComposerContractFromPatternsResponse, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
12
+ import { clearRemediationApplyQuarantine, readRemediationApplyTrackingFile, writeRemediationApplyTrackingFile, } from './remediation_apply_tracking.js';
13
+ import { CURSOR_SCALAR_ITEMTABLE_FIELDS, buildVscdbPostApplyRawContent, persistVscdbComposerContractFromPatternsResponse, } from '../readers/vscdb_reader.js';
13
14
  import { sendConfigFile } from '../sender/batch_sender.js';
14
15
  import { parseWorktreeRootFromPath } from './worktree_scanner.js';
15
16
  import { reportAbsentWorktrees } from './worktree_absent.js';
16
17
  import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
17
18
  import { resolveRemediationConfigPath } from './remediation_config_path.js';
18
19
  import { resolveSqlite3Binary } from './sqlite_binary.js';
20
+ import { CURSOR_COMPOSER_SHADOW_KEYS } from '../readers/cursor_shadow_merge_policy.js';
21
+ import { REACTIVE_STORAGE_ITEM_KEY_SUFFIX, composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from '../readers/vscdb_reactive_storage.js';
19
22
  /** Best-effort detail from execFileSync failures (stderr, exit code, errno). */
20
23
  function formatNodeChildException(err) {
21
24
  if (!(err instanceof Error)) {
@@ -38,12 +41,8 @@ function formatNodeChildException(err) {
38
41
  const long = bits.join(' | ');
39
42
  return { short: bits.slice(0, 2).join(' | '), long };
40
43
  }
41
- function reactiveStorageItemKeyFromContract() {
42
- const k = readFileCollectionVscdbContract()?.reactive_storage_item_key;
43
- return typeof k === 'string' && k.trim() !== '' ? k.trim() : undefined;
44
- }
45
44
  function composerShadowKeySetFromContract() {
46
- return new Set(readFileCollectionVscdbContract()?.composer_shadow_keys ?? []);
45
+ return new Set(composerShadowKeysFromContract());
47
46
  }
48
47
  /** Resolve fix payload from API or legacy `compliance` key in local JSON. */
49
48
  export function remediationFixSpec(inst) {
@@ -119,9 +118,44 @@ async function ensureInstructionsForUuids(endpointBase, machineUuid, want, overl
119
118
  writeInstructions(payload);
120
119
  }
121
120
  }
122
- // ---------------------------------------------------------------------------
123
- // API helpers
124
- // ---------------------------------------------------------------------------
121
+ function applyClearApplyTrackingUuids(uuids) {
122
+ if (!uuids?.length)
123
+ return;
124
+ for (const uuid of uuids) {
125
+ clearRemediationApplyQuarantine(uuid);
126
+ hookRunLog(`remediation_sync: cleared apply quarantine uuid=${uuid}`);
127
+ }
128
+ }
129
+ /** When the server redeploys enforcement under a new UUID, keep local quarantine state. */
130
+ function transferQuarantineForRetiredUuids(removed, added, retiredInstructions, addedInstructions) {
131
+ if (removed.length === 0 || added.length === 0 || addedInstructions.length === 0)
132
+ return;
133
+ const file = readRemediationApplyTrackingFile();
134
+ let changed = false;
135
+ for (const removedUuid of removed) {
136
+ const prev = file.entries[removedUuid];
137
+ if (!prev?.quarantined)
138
+ continue;
139
+ const retired = retiredInstructions.find((r) => r.uuid === removedUuid);
140
+ const retiredPath = retired?.config_file_path ?? '';
141
+ for (const inst of addedInstructions) {
142
+ if (!added.includes(inst.uuid))
143
+ continue;
144
+ if (retiredPath && inst.config_file_path !== retiredPath)
145
+ continue;
146
+ file.entries[inst.uuid] = {
147
+ ...prev,
148
+ pending_post_restart_verify: false,
149
+ };
150
+ changed = true;
151
+ hookRunLog(`remediation_sync: transferred quarantine ${removedUuid} -> ${inst.uuid} path=${inst.config_file_path}`);
152
+ }
153
+ delete file.entries[removedUuid];
154
+ changed = true;
155
+ }
156
+ if (changed)
157
+ writeRemediationApplyTrackingFile(file);
158
+ }
125
159
  export async function fetchSync(endpointBase, machineUuid, activeUuids, timeoutMs = 8000) {
126
160
  const uuidsParam = activeUuids.join(',');
127
161
  const url = `${buildApiUrl(endpointBase, '/api/findings/remediations/sync/')}?machine_uuid=${encodeURIComponent(machineUuid)}&active_uuids=${encodeURIComponent(uuidsParam)}`;
@@ -436,13 +470,13 @@ function cursorVscdbHasUsableComposerStateRow(dbPath, sqliteOp) {
436
470
  function resolveCursorComposerSqliteOp(dbPath, sqliteOp) {
437
471
  if (sqliteOp.target_key !== 'composerState')
438
472
  return sqliteOp;
439
- const reactiveKey = reactiveStorageItemKeyFromContract();
473
+ const reactiveKey = reactiveStorageItemKeyForVscdb(dbPath);
440
474
  /** Merge into applicationUser (or equivalent) JSON: inner `composerState` or nested path under it. */
441
475
  const resolveToReactive = () => {
442
476
  if (!reactiveKey)
443
477
  return sqliteOp;
444
478
  const jp = (sqliteOp.json_path ?? '').trim();
445
- if (!jp) {
479
+ if (!jp || jp === 'composerState') {
446
480
  return {
447
481
  ...sqliteOp,
448
482
  target_key: reactiveKey,
@@ -607,8 +641,18 @@ function mergeSqliteOpIntoJson(currentJson, sqliteOp) {
607
641
  arr.push(newItem);
608
642
  return;
609
643
  }
610
- mergeJsonAtSqlitePath(currentJson, sqliteOp.json_path, sqliteOp.updates);
611
- mirrorComposerShadowKeysToReactiveRoot(currentJson, sqliteOp);
644
+ const updates = sqliteOp.updates ?? {};
645
+ let jsonPath = sqliteOp.json_path ?? '';
646
+ const shadowKeys = new Set(CURSOR_COMPOSER_SHADOW_KEYS);
647
+ if (!jsonPath.trim() && Object.keys(updates).some((k) => shadowKeys.has(k))) {
648
+ const nested = currentJson.composerState;
649
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
650
+ jsonPath = 'composerState';
651
+ }
652
+ }
653
+ mergeJsonAtSqlitePath(currentJson, jsonPath, updates);
654
+ const opForMirror = jsonPath === (sqliteOp.json_path ?? '') ? sqliteOp : { ...sqliteOp, json_path: jsonPath };
655
+ mirrorComposerShadowKeysToReactiveRoot(currentJson, opForMirror);
612
656
  }
613
657
  /**
614
658
  * Cursor remediations historically used json_path `composerState.` (trailing dot). That yields an empty
@@ -632,8 +676,13 @@ function repairComposerStateEmptySegmentBug(root) {
632
676
  * Skips array-target ops (`modes4`, etc.).
633
677
  */
634
678
  function mirrorComposerShadowKeysToReactiveRoot(root, resolvedOp) {
635
- const rk = reactiveStorageItemKeyFromContract();
636
- if (!rk || resolvedOp.target_key !== rk)
679
+ const targetKey = resolvedOp.target_key;
680
+ const contractKey = reactiveStorageItemKeyForVscdb();
681
+ const reactiveTarget = (contractKey && targetKey === contractKey) ||
682
+ (typeof targetKey === 'string' &&
683
+ targetKey.includes(REACTIVE_STORAGE_ITEM_KEY_SUFFIX) &&
684
+ !/['"\\]/.test(targetKey));
685
+ if (!reactiveTarget)
637
686
  return;
638
687
  const where = resolvedOp.array_item_where;
639
688
  if (where && typeof where === 'object' && Object.keys(where).length > 0)
@@ -845,7 +894,7 @@ export async function applyDeferredVscdbFromDisk() {
845
894
  const itemKey = u.file_path.slice(hi + 1).trim();
846
895
  if (!itemKey)
847
896
  continue;
848
- const rawContent = readVscdbItemTableJson(dbPath, itemKey);
897
+ const rawContent = buildVscdbPostApplyRawContent(dbPath, itemKey);
849
898
  if (rawContent === null) {
850
899
  hookRunLog(`deferred_vscdb: post-apply read failed path=${u.file_path}`);
851
900
  continue;
@@ -1245,16 +1294,13 @@ export function enforceRemediation(instruction) {
1245
1294
  return { ok: false, failureReason: `exception: ${msg}` };
1246
1295
  }
1247
1296
  }
1248
- // ---------------------------------------------------------------------------
1249
- // Autofix reporting
1250
- // ---------------------------------------------------------------------------
1251
1297
  /**
1252
1298
  * Fire-and-forget: notify the server that this machine applied an autofix for the given
1253
1299
  * remediation UUID. Creates one EnforcementLog row on the server for the audit trail.
1254
1300
  * Never throws — any failure is logged and silently swallowed so it cannot block the
1255
1301
  * autofix flow.
1256
1302
  */
1257
- export function reportAutofixApplied(remediationUuid, result) {
1303
+ export function reportAutofixApplied(remediationUuid, result, details) {
1258
1304
  const authKey = readStoredAuthKey();
1259
1305
  if (!authKey) {
1260
1306
  hookRunLog(`autofix_report: no auth key available, skipping report for uuid=${remediationUuid}`);
@@ -1267,7 +1313,15 @@ export function reportAutofixApplied(remediationUuid, result) {
1267
1313
  }
1268
1314
  const endpointBase = loadEndpointBase();
1269
1315
  const url = buildApiUrl(endpointBase, '/endpoint_security/api/autofix-applied/');
1270
- const payload = { hardware_uuid: hardwareUuid, remediation_uuid: remediationUuid, result };
1316
+ const payload = {
1317
+ hardware_uuid: hardwareUuid,
1318
+ remediation_uuid: remediationUuid,
1319
+ result,
1320
+ };
1321
+ if (details?.failure_reason)
1322
+ payload.failure_reason = details.failure_reason.slice(0, 2000);
1323
+ if (details?.consecutive_failures != null)
1324
+ payload.consecutive_failures = details.consecutive_failures;
1271
1325
  const signature = createSignature(payload, authKey.key);
1272
1326
  const body = JSON.stringify({ ...payload, signature });
1273
1327
  return executeBody(url, 'POST', body, 8000)
@@ -1312,6 +1366,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
1312
1366
  complianceRunnerDiag('remediation_sync: no response from server (non-200 or empty body), skipping');
1313
1367
  return;
1314
1368
  }
1369
+ applyClearApplyTrackingUuids(syncResult.clear_apply_tracking_uuids);
1315
1370
  if (syncResult.status === 'unchanged') {
1316
1371
  hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
1317
1372
  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`);
@@ -1345,6 +1400,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
1345
1400
  hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
1346
1401
  complianceRunnerDiag(`remediation_sync: server status=updated added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`);
1347
1402
  const removedSet = new Set(removed);
1403
+ const retiredInstructions = remediations.filter((r) => removedSet.has(r.uuid));
1348
1404
  let removedCount = 0;
1349
1405
  remediations = remediations.filter((r) => {
1350
1406
  if (removedSet.has(r.uuid)) {
@@ -1398,6 +1454,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
1398
1454
  overlay.push(instruction);
1399
1455
  hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
1400
1456
  }
1457
+ transferQuarantineForRetiredUuids(removed, added, retiredInstructions, overlay);
1401
1458
  const addedCount = overlay.length;
1402
1459
  if (added.length > 0 && addedCount === 0) {
1403
1460
  const msg = `remediation_sync: manifest had no rows for added uuids=${added.join(',')} — check server manifest/machine linkage`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config-staging",
3
- "version": "1.3.82",
3
+ "version": "1.3.83",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {