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.
- package/dist/compliance_prompt_gate.js +19 -2
- package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
- package/dist/log_config_files/readers/cursor_shadow_merge_policy.js +46 -0
- package/dist/log_config_files/readers/vscdb_config_builder.js +3 -1
- package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
- package/dist/log_config_files/readers/vscdb_reader.js +77 -12
- package/dist/log_config_files/runtime/compliance_check.js +179 -159
- package/dist/log_config_files/runtime/remediation_apply_tracking.js +138 -0
- package/dist/log_config_files/runtime/remediation_sync.js +79 -22
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
331
|
-
const entryViolations
|
|
332
|
-
|
|
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 (
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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,
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
611
|
-
|
|
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
|
|
636
|
-
|
|
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 =
|
|
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 = {
|
|
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`;
|