@optimuslabs/harness-map-staging 1.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +46 -0
  2. package/dist/apply_deferred_vscdb.js +38 -0
  3. package/dist/bootstrap_constants.js +15 -0
  4. package/dist/cli/bash_script_generator.js +95 -0
  5. package/dist/cli.js +144 -0
  6. package/dist/cli_invocation_match.js +30 -0
  7. package/dist/compliance_check_runner.js +57 -0
  8. package/dist/compliance_prompt_gate.js +396 -0
  9. package/dist/dialog_prefs_cli.js +109 -0
  10. package/dist/endpoint_client/compress_payload.js +25 -0
  11. package/dist/endpoint_client/http_transport.js +90 -0
  12. package/dist/endpoint_client/index.js +3 -0
  13. package/dist/endpoint_client/registry_api.js +41 -0
  14. package/dist/endpoint_client/startup_api.js +44 -0
  15. package/dist/endpoint_client/types.js +4 -0
  16. package/dist/execute_trusted_restarts.js +60 -0
  17. package/dist/log_config_files/auth/auth_flow.js +22 -0
  18. package/dist/log_config_files/auth/auth_key_store.js +14 -0
  19. package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +248 -0
  20. package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
  21. package/dist/log_config_files/collection/claude_token_usage_collector.js +203 -0
  22. package/dist/log_config_files/collection/codex_token_usage_collector.js +342 -0
  23. package/dist/log_config_files/collection/config_collector.js +245 -0
  24. package/dist/log_config_files/collection/copilot_token_usage_collector.js +167 -0
  25. package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
  26. package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
  27. package/dist/log_config_files/collection/cursor_project_mcp_collector.js +230 -0
  28. package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
  29. package/dist/log_config_files/collection/cursor_token_usage_collector.js +192 -0
  30. package/dist/log_config_files/collection/directory_collector.js +126 -0
  31. package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
  32. package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -0
  33. package/dist/log_config_files/collection/file_type_rules.js +47 -0
  34. package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
  35. package/dist/log_config_files/collection/hermes_token_usage_collector.js +241 -0
  36. package/dist/log_config_files/collection/mcp_tool_collector.js +106 -0
  37. package/dist/log_config_files/collection/metadata_merge.js +43 -0
  38. package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
  39. package/dist/log_config_files/collection/openclaw_token_usage_collector.js +223 -0
  40. package/dist/log_config_files/collection/opencode_token_usage_collector.js +177 -0
  41. package/dist/log_config_files/collection/pi_token_usage_collector.js +239 -0
  42. package/dist/log_config_files/collection/plugin_collector.js +223 -0
  43. package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
  44. package/dist/log_config_files/collection/skills_cli_collector.js +162 -0
  45. package/dist/log_config_files/index.js +19 -0
  46. package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
  47. package/dist/log_config_files/paths/pattern_resolver.js +326 -0
  48. package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
  49. package/dist/log_config_files/readers/cursor_shadow_merge_ruleset.js +46 -0
  50. package/dist/log_config_files/readers/file_readers.js +196 -0
  51. package/dist/log_config_files/readers/vscdb_config_builder.js +148 -0
  52. package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
  53. package/dist/log_config_files/readers/vscdb_reader.js +337 -0
  54. package/dist/log_config_files/runtime/client_event_reporter.js +347 -0
  55. package/dist/log_config_files/runtime/compliance_check.js +1013 -0
  56. package/dist/log_config_files/runtime/compliance_session_log.js +378 -0
  57. package/dist/log_config_files/runtime/dialog_preferences.js +31 -0
  58. package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
  59. package/dist/log_config_files/runtime/hook_logger.js +267 -0
  60. package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
  61. package/dist/log_config_files/runtime/log_metadata.js +122 -0
  62. package/dist/log_config_files/runtime/main_runner.js +376 -0
  63. package/dist/log_config_files/runtime/management_storage.js +144 -0
  64. package/dist/log_config_files/runtime/ops_target_path.js +18 -0
  65. package/dist/log_config_files/runtime/remediation_apply_tracking.js +152 -0
  66. package/dist/log_config_files/runtime/remediation_config_path.js +140 -0
  67. package/dist/log_config_files/runtime/remediation_sync.js +1705 -0
  68. package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
  69. package/dist/log_config_files/runtime/sqlite_binary.js +124 -0
  70. package/dist/log_config_files/runtime/trusted_restarts.js +113 -0
  71. package/dist/log_config_files/runtime/workspace_repo.js +170 -0
  72. package/dist/log_config_files/runtime/worktree_absent.js +50 -0
  73. package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
  74. package/dist/log_config_files/sender/batch_sender.js +283 -0
  75. package/dist/log_config_files/sender/endpoint_config.js +24 -0
  76. package/dist/log_config_files/sender/signing.js +1 -0
  77. package/dist/log_sensitive_paths_audit.js +89 -0
  78. package/dist/log_uuid/auth_key_store.js +37 -0
  79. package/dist/log_uuid/hardware_uuid.js +35 -0
  80. package/dist/log_uuid/index.js +11 -0
  81. package/dist/log_uuid/log_uuid_helper.js +30 -0
  82. package/dist/log_uuid/startup_sender.js +131 -0
  83. package/dist/log_uuid/user_profile.js +192 -0
  84. package/dist/remediation_change_preview.js +182 -0
  85. package/dist/tofu.js +22 -0
  86. package/dist/tofu_environment.js +36 -0
  87. package/dist/types/config_file_types.js +1 -0
  88. package/package.json +67 -0
@@ -0,0 +1,1013 @@
1
+ /**
2
+ * Compliance check: types and check runner entry point.
3
+ *
4
+ * Compliance is in-memory only (no compliance.json): runLocalRemediationComplianceCheck returns
5
+ * ComplianceStatus; the prompt gate and tests use that return value. Autofix may write config files
6
+ * and remediation_instructions.json via applyAutofixViolations / enforceRemediation.
7
+ *
8
+ * Prompt gate: compliance_prompt_gate runs runLocalRemediationComplianceCheck then applyAutofixViolations.
9
+ * Background: compliance_check_runner runs syncRemediations (network) then the same local check.
10
+ * Apply (autofix) is intentionally left to the gate on the next prompt — the background pass only
11
+ * downloads the latest manifest so the gate has fresh data to act on.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { parseJsonWithJsoncFallback, parseTomlText } from '../readers/file_readers.js';
17
+ import { mergeComposerShadowKeysFromReactiveBlob, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
18
+ import { readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
19
+ import { resolveRemediationConfigPath, resolveRemediationUploadFileType } from './remediation_config_path.js';
20
+ import { resolveOpsTargetPath } from './ops_target_path.js';
21
+ import { isRemediationQuarantined, markRemediationApplyPendingVerification, markRemediationApplyVerified, processPendingPostRestartVerifications, readRemediationApplyTrackingFile, writeRemediationApplyTrackingFile, } from './remediation_apply_tracking.js';
22
+ import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
23
+ import { isEnvBackedSecretValue, scanJsonForHardcodedSecrets, } from './secret_regex_scan.js';
24
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
25
+ import { resolveHardwareUuid, tryResolveHardwareUuid } from './hardware_uuid.js';
26
+ import { buildDeferredCursorRestartCommand, discoverAllWorkspaceVscdbs, enforceRemediation, fetchSync, globalCursorIgnoreListFromSettings, GLOBAL_CURSOR_IGNORE_SETTING_PATH, isTrustedRestartCommandForAutofix, remediationFixSpec, reportAutofixApplied, syncRemediations, } from './remediation_sync.js';
27
+ import { sendConfigFile } from '../sender/batch_sender.js';
28
+ import { ensureAuthentication } from '../auth/auth_flow.js';
29
+ /** Normalize manifest/env/CLI agent tokens to a known Agent, or '' if unrecognized. */
30
+ export function normalizeAgentToken(raw) {
31
+ if (typeof raw !== 'string')
32
+ return '';
33
+ const s = raw.trim().toLowerCase();
34
+ if (!s)
35
+ return '';
36
+ if (s === 'claude-desktop')
37
+ return 'claude_desktop';
38
+ if (s === 'claude_desktop')
39
+ return 'claude_desktop';
40
+ if (s === 'claude')
41
+ return 'claude';
42
+ if (s === 'cursor')
43
+ return 'cursor';
44
+ if (s === 'copilot')
45
+ return 'copilot';
46
+ if (s === 'opencode')
47
+ return 'opencode';
48
+ if (s === 'codex')
49
+ return 'codex';
50
+ return '';
51
+ }
52
+ function currentAgentFromEnv() {
53
+ // Allow explicit override for debugging / special launchers.
54
+ const override = normalizeAgentToken(process.env.OPTIMUS_AGENT);
55
+ if (override)
56
+ return override;
57
+ // Backwards-compatible: hook wrappers set OPTIMUS_HOOK_TYPE to cursor|claude|copilot|opencode.
58
+ const hookType = normalizeAgentToken(process.env.OPTIMUS_HOOK_TYPE);
59
+ if (hookType === 'cursor')
60
+ return 'cursor';
61
+ if (hookType === 'copilot')
62
+ return 'copilot';
63
+ if (hookType === 'opencode')
64
+ return 'opencode';
65
+ if (hookType === 'codex')
66
+ return 'codex';
67
+ return 'claude';
68
+ }
69
+ function targetsCurrentAgent(entry, agent) {
70
+ // Prefer top-level manifest field; fall back to embedded fix payload for older local files.
71
+ const embedded = (entry.fix && typeof entry.fix === 'object'
72
+ ? entry.fix.target_agent
73
+ : null) ??
74
+ (entry.compliance && typeof entry.compliance === 'object'
75
+ ? entry.compliance.target_agent
76
+ : null);
77
+ const t = entry.target_agent ?? embedded;
78
+ // Backwards compat: missing/empty target_agent means "applies to all agents".
79
+ if (t === null || t === undefined)
80
+ return true;
81
+ if (typeof t !== 'string') {
82
+ complianceRunnerDiag(`Ignoring remediation with non-string target_agent: ${String(t)}`);
83
+ return false;
84
+ }
85
+ if (!t.trim())
86
+ return true;
87
+ const normalized = normalizeAgentToken(t);
88
+ if (!normalized) {
89
+ complianceRunnerDiag(`Ignoring remediation with unknown target_agent: ${t}`);
90
+ return false;
91
+ }
92
+ return normalized === agent;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Helpers
96
+ // ---------------------------------------------------------------------------
97
+ /**
98
+ * ItemTable keys use slashes (e.g. cursorai/donotchange/privacyMode) and do not contain dots.
99
+ * Compliance setting_path is `${itemKey}.${nested.path}` — take the segment before the first `.`.
100
+ */
101
+ export function itemTableKeyFromSettingPath(settingPath) {
102
+ const i = settingPath.indexOf('.');
103
+ return i === -1 ? settingPath : settingPath.slice(0, i);
104
+ }
105
+ /**
106
+ * Compliance reads the live config at `config_file_path` (#itemKey), but sqlite apply may target
107
+ * a different ItemTable row. Verify against the manifest path's item key, not the apply target.
108
+ */
109
+ export function canonicalComplianceSettingPath(configFilePath, check) {
110
+ const settingPath = check.setting_path;
111
+ const applyKey = check.sqlite_op?.target_key?.trim();
112
+ if (!applyKey || applyKey === settingPath)
113
+ return settingPath;
114
+ const hashIdx = configFilePath.indexOf('#');
115
+ if (hashIdx < 0)
116
+ return settingPath;
117
+ const verifyKey = configFilePath.slice(hashIdx + 1).trim();
118
+ if (!verifyKey || applyKey === verifyKey)
119
+ return settingPath;
120
+ const prefix = `${applyKey}.`;
121
+ if (settingPath === applyKey)
122
+ return verifyKey;
123
+ if (settingPath.startsWith(prefix))
124
+ return `${verifyKey}${settingPath.slice(applyKey.length)}`;
125
+ return settingPath;
126
+ }
127
+ /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
128
+ export function getByPath(obj, path) {
129
+ const parts = path.split('.');
130
+ let current = obj;
131
+ for (const part of parts) {
132
+ if (current == null || typeof current !== 'object')
133
+ return undefined;
134
+ // Cursor composerState.modes4: non-numeric segment indexes by mode id (agent row is not always [0]).
135
+ if (Array.isArray(current) && !/^\d+$/.test(part)) {
136
+ const idx = current.findIndex((item) => item !== null &&
137
+ typeof item === 'object' &&
138
+ item.id === part);
139
+ current = idx >= 0 ? current[idx] : undefined;
140
+ continue;
141
+ }
142
+ current = current[part];
143
+ }
144
+ return current;
145
+ }
146
+ /** Key-order-independent deep-equal comparison (handles primitives, arrays, plain objects). */
147
+ function deepEqual(a, b) {
148
+ if (a === b)
149
+ return true;
150
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
151
+ return false;
152
+ if (Array.isArray(a) || Array.isArray(b)) {
153
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
154
+ return false;
155
+ for (let i = 0; i < a.length; i++) {
156
+ if (!deepEqual(a[i], b[i]))
157
+ return false;
158
+ }
159
+ return true;
160
+ }
161
+ const ao = a;
162
+ const bo = b;
163
+ const aKeys = Object.keys(ao);
164
+ const bKeys = Object.keys(bo);
165
+ if (aKeys.length !== bKeys.length)
166
+ return false;
167
+ for (const k of aKeys) {
168
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
169
+ return false;
170
+ if (!deepEqual(ao[k], bo[k]))
171
+ return false;
172
+ }
173
+ return true;
174
+ }
175
+ function isStringArray(v) {
176
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
177
+ }
178
+ function escapeRegExp(s) {
179
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
+ }
181
+ /** Match Cursor yolo allowlist entries (same boundary idea as server filter_yolo_allowlist_entries). */
182
+ function allowlistEntryMatchesRemoveToken(entry, token) {
183
+ const valStr = String(entry).toLowerCase();
184
+ const t = token.toLowerCase().trim();
185
+ if (!t)
186
+ return false;
187
+ const first = valStr.trim().split(/\s+/)[0] ?? '';
188
+ if (first === t || valStr === t)
189
+ return true;
190
+ const pattern = new RegExp(`(^|[^a-z0-9_|])${escapeRegExp(t)}([^a-z0-9_|]|$)`);
191
+ return pattern.test(valStr);
192
+ }
193
+ /**
194
+ * MCP secret remediations deploy ops.set to ${env:KEY}. Local compliance passes when the
195
+ * user picks any env reference, not only the exact string from the manifest.
196
+ */
197
+ function remediationSetOpSatisfied(current, expected) {
198
+ if (deepEqual(current, expected))
199
+ return true;
200
+ if (typeof expected === 'string' &&
201
+ isEnvBackedSecretValue(expected) &&
202
+ isEnvBackedSecretValue(current)) {
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+ function violationFromSecretScanFinding(entry, compliance, finding) {
208
+ return {
209
+ uuid: entry.uuid,
210
+ finding_formatted_id: compliance.finding_formatted_id,
211
+ setting_path: finding.path,
212
+ description: compliance.description,
213
+ finding_title: entry.finding_title,
214
+ finding_description: entry.finding_description,
215
+ ruleset_name: entry.ruleset_name,
216
+ severity: compliance.severity,
217
+ autofix_allowed: compliance.autofix_allowed,
218
+ config_file_path: entry.config_file_path,
219
+ expected_value: { op: 'secret_scan', path: finding.path, secret_type: finding.secretType },
220
+ message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Remove the hardcoded ${finding.secretType} from ${finding.path} in ${entry.config_file_path}`,
221
+ };
222
+ }
223
+ function currentArrayAtComplianceTarget(configJson, targetPath) {
224
+ if (configJson &&
225
+ typeof configJson === 'object' &&
226
+ !Array.isArray(configJson) &&
227
+ targetPath === GLOBAL_CURSOR_IGNORE_SETTING_PATH) {
228
+ return globalCursorIgnoreListFromSettings(configJson);
229
+ }
230
+ const cur = getByPath(configJson, targetPath);
231
+ return Array.isArray(cur) ? cur : [];
232
+ }
233
+ function verifyOpsApplied(configJson, settingPath, ops) {
234
+ const parts = settingPath.split('.');
235
+ const leafKey = parts[parts.length - 1] ?? '';
236
+ const parentPath = parts.slice(0, -1).join('.');
237
+ const set = ops.set ?? {};
238
+ const add = ops.add ?? {};
239
+ const remove = ops.remove ?? {};
240
+ const keys = new Set([...Object.keys(set), ...Object.keys(add), ...Object.keys(remove)]);
241
+ for (const k of keys) {
242
+ const targetPath = resolveOpsTargetPath(settingPath, k);
243
+ if (Object.prototype.hasOwnProperty.call(set, k)) {
244
+ const expected = set[k];
245
+ const currentForSet = targetPath === GLOBAL_CURSOR_IGNORE_SETTING_PATH
246
+ ? globalCursorIgnoreListFromSettings(configJson)
247
+ : getByPath(configJson, targetPath);
248
+ if (!remediationSetOpSatisfied(currentForSet, expected)) {
249
+ return { ok: false, expected: { op: 'set', path: targetPath, value: expected } };
250
+ }
251
+ continue;
252
+ }
253
+ const curArr = currentArrayAtComplianceTarget(configJson, targetPath);
254
+ const toAdd = add[k] ?? [];
255
+ const toRemove = remove[k] ?? [];
256
+ const isYoloAllowlist = leafKey === 'yoloCommandAllowlist' || targetPath.endsWith('.yoloCommandAllowlist');
257
+ for (const item of toRemove) {
258
+ if (isYoloAllowlist) {
259
+ if (curArr.some((entry) => allowlistEntryMatchesRemoveToken(entry, String(item)))) {
260
+ return { ok: false, expected: { op: 'remove', path: targetPath, value: item } };
261
+ }
262
+ continue;
263
+ }
264
+ if (curArr.some((curItem) => deepEqual(curItem, item))) {
265
+ return { ok: false, expected: { op: 'remove', path: targetPath, value: item } };
266
+ }
267
+ }
268
+ for (const item of toAdd) {
269
+ if (!curArr.some((curItem) => deepEqual(curItem, item))) {
270
+ return { ok: false, expected: { op: 'add', path: targetPath, value: item } };
271
+ }
272
+ }
273
+ }
274
+ return { ok: true, expected: null };
275
+ }
276
+ function shouldMergeComposerShadowKeys(itemKeyFromPath, checkSettingPaths) {
277
+ if (itemKeyFromPath === 'composerState')
278
+ return true;
279
+ return checkSettingPaths.some((p) => p === 'composerState' || p.startsWith('composerState.'));
280
+ }
281
+ /** @deprecated Import from vscdb_reader — re-export for existing tests. */
282
+ export { mergeComposerShadowKeysFromReactiveBlob } from '../readers/vscdb_reader.js';
283
+ /** Plain JSON/TOML file or virtual `…/state.vscdb#itemKey` path for ItemTable-backed settings. */
284
+ function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
285
+ const resolvedPath = resolveRemediationConfigPath(configFilePath);
286
+ const hashIdx = resolvedPath.indexOf('#');
287
+ if (hashIdx >= 0) {
288
+ const dbPath = resolvedPath.slice(0, hashIdx);
289
+ const itemKeyFromPath = resolvedPath.slice(hashIdx + 1).trim();
290
+ if (!itemKeyFromPath)
291
+ return { ok: false, reason: 'empty_vscdb_key' };
292
+ if (!existsSync(dbPath))
293
+ return { ok: false, reason: 'db_not_found' };
294
+ const keys = new Set([itemKeyFromPath, ...checkSettingPaths.map(itemTableKeyFromSettingPath)].filter(Boolean));
295
+ const merged = {};
296
+ for (const k of keys) {
297
+ const wrapped = readVscdbItemTableJson(dbPath, k);
298
+ if (wrapped === null)
299
+ return { ok: false, reason: 'vscdb_read_failed' };
300
+ Object.assign(merged, wrapped);
301
+ }
302
+ if (shouldMergeComposerShadowKeys(itemKeyFromPath, checkSettingPaths)) {
303
+ mergeComposerShadowKeysFromReactiveBlob(dbPath, merged);
304
+ }
305
+ return { ok: true, json: merged };
306
+ }
307
+ if (!existsSync(resolvedPath))
308
+ return { ok: false, reason: 'file_not_found' };
309
+ const rawText = readFileSync(resolvedPath, 'utf8');
310
+ if (resolvedPath.toLowerCase().endsWith('.toml')) {
311
+ const parsedToml = parseTomlText(rawText);
312
+ if (parsedToml === null)
313
+ return { ok: false, reason: 'parse_error' };
314
+ return { ok: true, json: parsedToml };
315
+ }
316
+ // OpenCode configs are JSONC (comments / trailing commas); parse strict JSON first, then
317
+ // fall back to JSONC sanitization so comment-bearing files are not skipped as parse_error.
318
+ const parsed = parseJsonWithJsoncFallback(rawText);
319
+ if (parsed === null)
320
+ return { ok: false, reason: 'parse_error' };
321
+ return { ok: true, json: parsed };
322
+ }
323
+ function isSupportedTextConfigFormat(fileFormat) {
324
+ return fileFormat === 'json' || fileFormat === 'toml';
325
+ }
326
+ /**
327
+ * Evaluate all checks in a secondary group against the group's config file.
328
+ * Returns true only if every check passes (ops-based). Non-ops checks are treated as failing.
329
+ */
330
+ function evaluateSecondaryGroup(group) {
331
+ const loaded = loadRemediationConfigJson(group.config_file_path, group.checks.map((c) => c.setting_path));
332
+ if (!loaded.ok) {
333
+ hookRunLog(`evaluateSecondaryGroup: could not load secondary file path=${group.config_file_path} reason=${loaded.reason}`);
334
+ return false;
335
+ }
336
+ for (const check of group.checks) {
337
+ if (!check.ops)
338
+ return false;
339
+ const { ok } = verifyOpsApplied(loaded.json, check.setting_path, check.ops);
340
+ if (!ok)
341
+ return false;
342
+ }
343
+ return true;
344
+ }
345
+ // ---------------------------------------------------------------------------
346
+ // Check runner — Section 6: real per-check evaluation
347
+ // ---------------------------------------------------------------------------
348
+ function violationFromCheck(entry, compliance, check, expected) {
349
+ return {
350
+ uuid: entry.uuid,
351
+ finding_formatted_id: compliance.finding_formatted_id,
352
+ setting_path: check.setting_path,
353
+ description: check.description,
354
+ finding_title: entry.finding_title,
355
+ finding_description: entry.finding_description,
356
+ ruleset_name: entry.ruleset_name,
357
+ severity: compliance.severity,
358
+ autofix_allowed: compliance.autofix_allowed,
359
+ config_file_path: entry.config_file_path,
360
+ expected_value: expected,
361
+ 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}`,
362
+ };
363
+ }
364
+ /** Evaluate one manifest row against on-disk config (used by gate + post-restart verify). */
365
+ export function evaluateManifestEntryCompliance(entry) {
366
+ const compliance = entry.fix ?? entry.compliance;
367
+ if (!compliance || !isSupportedTextConfigFormat(compliance.file_format))
368
+ return { violations: [] };
369
+ const checks = compliance.checks ?? [];
370
+ if (checks.length === 0)
371
+ return { violations: [] };
372
+ const loaded = loadRemediationConfigJson(entry.config_file_path, checks.map((c) => c.setting_path));
373
+ if (!loaded.ok)
374
+ return { violations: [] };
375
+ const configJson = loaded.json;
376
+ if (compliance.requires_secret_scan === true) {
377
+ const secretFindings = scanJsonForHardcodedSecrets(configJson);
378
+ return secretFindings.length === 0
379
+ ? { violations: [] }
380
+ : {
381
+ violations: secretFindings.map((f) => violationFromSecretScanFinding(entry, compliance, f)),
382
+ };
383
+ }
384
+ const entryViolations = [];
385
+ for (const check of checks) {
386
+ const effectivePath = canonicalComplianceSettingPath(entry.config_file_path, check);
387
+ if (check.sqlite_op?.apply_to_all_workspaces && check.ops) {
388
+ const segments = check.sqlite_op.workspace_storage_path_segments ?? [
389
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
390
+ ];
391
+ const wsPath = join(homedir(), ...segments);
392
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
393
+ if (vscdbPaths.length === 0)
394
+ continue;
395
+ let violated = false;
396
+ let expectedForViolation = null;
397
+ for (const dbPath of vscdbPaths) {
398
+ const wrapped = readVscdbItemTableJson(dbPath, check.sqlite_op.target_key ?? '');
399
+ if (wrapped === null) {
400
+ violated = true;
401
+ expectedForViolation = { op: 'read_failed', db: dbPath };
402
+ break;
403
+ }
404
+ const { ok, expected } = verifyOpsApplied(wrapped, check.setting_path, check.ops);
405
+ if (!ok) {
406
+ violated = true;
407
+ expectedForViolation = expected;
408
+ break;
409
+ }
410
+ }
411
+ if (violated) {
412
+ entryViolations.push(violationFromCheck(entry, compliance, check, expectedForViolation));
413
+ }
414
+ continue;
415
+ }
416
+ if (check.ops) {
417
+ const { ok, expected } = verifyOpsApplied(configJson, effectivePath, check.ops);
418
+ if (!ok) {
419
+ entryViolations.push(violationFromCheck(entry, compliance, { ...check, setting_path: effectivePath }, expected));
420
+ }
421
+ continue;
422
+ }
423
+ const currentValue = getByPath(configJson, effectivePath);
424
+ const leafKey = effectivePath.split('.').pop();
425
+ const expectedValue = check.after?.[leafKey];
426
+ if (expectedValue === undefined)
427
+ continue;
428
+ if (!deepEqual(currentValue, expectedValue)) {
429
+ entryViolations.push(violationFromCheck(entry, compliance, { ...check, setting_path: effectivePath }, expectedValue));
430
+ }
431
+ }
432
+ if (entryViolations.length === 0)
433
+ return { violations: [] };
434
+ const secondaryGroups = compliance.secondary_checks ?? [];
435
+ const passingGroup = secondaryGroups.length > 0 ? secondaryGroups.find((g) => evaluateSecondaryGroup(g)) : undefined;
436
+ if (passingGroup) {
437
+ return {
438
+ violations: [],
439
+ secondarySatisfied: {
440
+ uuid: entry.uuid,
441
+ config_file_path: passingGroup.config_file_path,
442
+ file_type: passingGroup.file_type,
443
+ },
444
+ };
445
+ }
446
+ return { violations: entryViolations };
447
+ }
448
+ export function collectManifestEntryViolations(entry) {
449
+ return evaluateManifestEntryCompliance(entry).violations;
450
+ }
451
+ /**
452
+ * Evaluate current on-disk configs against remediation_instructions.json only (no server).
453
+ * Returns status for prompt gating / callers; does not persist compliance.json.
454
+ */
455
+ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
456
+ try {
457
+ const { remediations: rawEntries } = readRemediationInstructionsFile();
458
+ const entries = rawEntries.filter((e) => targetsCurrentAgent(e, agent));
459
+ if (entries.length === 0) {
460
+ hookRunLog('compliance_check: no remediation instructions present');
461
+ return { status: 'ok', checked_at: new Date().toISOString(), manifest_uuids: [], violations: [] };
462
+ }
463
+ const uuids = entries.map((e) => e.uuid);
464
+ const violations = [];
465
+ const secondarySatisfied = [];
466
+ let skippedNoCompliance = 0;
467
+ let skippedNonJson = 0;
468
+ let skippedNoChecks = 0;
469
+ let skippedUnreadable = 0;
470
+ for (const entry of entries) {
471
+ const compliance = entry.fix ?? entry.compliance;
472
+ if (!compliance) {
473
+ skippedNoCompliance++;
474
+ continue;
475
+ }
476
+ if (!isSupportedTextConfigFormat(compliance.file_format)) {
477
+ skippedNonJson++;
478
+ hookRunLog(`compliance_check: skipping unsupported file_format=${compliance.file_format} uuid=${entry.uuid}`);
479
+ continue;
480
+ }
481
+ if ((compliance.checks ?? []).length === 0) {
482
+ skippedNoChecks++;
483
+ continue;
484
+ }
485
+ const loaded = loadRemediationConfigJson(entry.config_file_path, (compliance.checks ?? []).map((c) => c.setting_path));
486
+ if (!loaded.ok) {
487
+ skippedUnreadable++;
488
+ const msg = loaded.reason === 'file_not_found'
489
+ ? `compliance_check: config file not found, skipping uuid=${entry.uuid}`
490
+ : loaded.reason === 'db_not_found'
491
+ ? `compliance_check: vscdb file not found, skipping uuid=${entry.uuid}`
492
+ : loaded.reason === 'vscdb_read_failed'
493
+ ? `compliance_check: could not read vscdb (sqlite3 missing or invalid JSON?), skipping uuid=${entry.uuid}`
494
+ : loaded.reason === 'empty_vscdb_key'
495
+ ? `compliance_check: invalid vscdb path (empty # key), skipping uuid=${entry.uuid}`
496
+ : `compliance_check: could not parse config file, skipping uuid=${entry.uuid}`;
497
+ hookRunLog(msg);
498
+ logRemediationApplyFailure('compliance_check_config_unreadable', {
499
+ uuid: entry.uuid,
500
+ config_file_path: entry.config_file_path,
501
+ finding_formatted_id: compliance.finding_formatted_id,
502
+ load_reason: loaded.reason,
503
+ reason: 'cannot read config for compliance verify — remediation may not run until path/db is valid',
504
+ });
505
+ continue;
506
+ }
507
+ const { violations: entryViolations, secondarySatisfied: entrySecondary } = evaluateManifestEntryCompliance(entry);
508
+ for (const v of entryViolations) {
509
+ hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${v.setting_path} expected=${JSON.stringify(v.expected_value)}`);
510
+ }
511
+ if (entrySecondary) {
512
+ hookRunLog(`compliance_check: secondary check satisfied uuid=${entry.uuid} — skipping primary violation(s)`);
513
+ secondarySatisfied.push(entrySecondary);
514
+ }
515
+ else if (entryViolations.length > 0) {
516
+ violations.push(...entryViolations);
517
+ }
518
+ }
519
+ const status = {
520
+ status: violations.length > 0 ? 'fail' : 'ok',
521
+ checked_at: new Date().toISOString(),
522
+ manifest_uuids: uuids,
523
+ violations,
524
+ secondarySatisfied: secondarySatisfied.length > 0 ? secondarySatisfied : undefined,
525
+ };
526
+ const skipParts = [];
527
+ if (skippedNoCompliance)
528
+ skipParts.push(`no_fix=${skippedNoCompliance}`);
529
+ if (skippedNonJson)
530
+ skipParts.push(`non_json=${skippedNonJson}`);
531
+ if (skippedNoChecks)
532
+ skipParts.push(`no_checks=${skippedNoChecks}`);
533
+ if (skippedUnreadable)
534
+ skipParts.push(`unreadable_config=${skippedUnreadable}`);
535
+ const skipSummary = skipParts.length > 0 ? ` skipped{${skipParts.join(' ')}}` : '';
536
+ hookRunLog(`compliance_check: done — status=${status.status} violations=${violations.length} manifest_entries=${uuids.length}${skipSummary}`);
537
+ if (skippedUnreadable > 0) {
538
+ complianceRunnerDiag(`compliance_check: ${skippedUnreadable} manifest row(s) skipped — vscdb/config unreadable (often DB locked while Cursor runs, or sqlite3/PATH). Autofix cannot evaluate until read succeeds.`);
539
+ }
540
+ return status;
541
+ }
542
+ catch (err) {
543
+ const emsg = err instanceof Error ? err.message : String(err);
544
+ const stack = err instanceof Error ? err.stack : undefined;
545
+ hookRunLog(`compliance_check: unexpected error: ${emsg}`);
546
+ logRemediationApplyFailure('compliance_check_exception', {
547
+ reason: 'runLocalRemediationComplianceCheck threw',
548
+ error: emsg,
549
+ stack: stack ?? '',
550
+ });
551
+ const fallback = {
552
+ status: 'ok',
553
+ checked_at: new Date().toISOString(),
554
+ manifest_uuids: [],
555
+ violations: [],
556
+ };
557
+ return fallback;
558
+ }
559
+ }
560
+ /**
561
+ * After restart, verify pending remediations and report outcomes to the server.
562
+ */
563
+ export function reportPostRestartVerificationOutcomes(violations) {
564
+ const { remediations } = readRemediationInstructionsFile();
565
+ const entriesByUuid = new Map(remediations.map((entry) => [entry.uuid, entry]));
566
+ const outcomes = processPendingPostRestartVerifications((uuid) => {
567
+ const entry = entriesByUuid.get(uuid);
568
+ if (entry && collectManifestEntryViolations(entry).length > 0)
569
+ return true;
570
+ return violations.some((v) => v.uuid === uuid);
571
+ });
572
+ const reportPromises = outcomes.map((o) => {
573
+ if (o.status === 'quarantined' || o.status === 'verification_failed') {
574
+ logRemediationApplyFailure('post_restart_verification_failed', {
575
+ uuid: o.uuid,
576
+ reason: o.reason ?? 'Setting unchanged after apply',
577
+ status: o.status,
578
+ consecutive_failures: o.consecutive_failures,
579
+ });
580
+ }
581
+ return reportAutofixApplied(o.uuid, o.status, {
582
+ failure_reason: o.reason,
583
+ consecutive_failures: o.consecutive_failures,
584
+ });
585
+ });
586
+ return { outcomes, reportPromises };
587
+ }
588
+ /**
589
+ * After deferred vscdb apply (`apply_deferred_vscdb.ts`): confirm pending remediations and POST
590
+ * outcomes before the user's next prompt.
591
+ */
592
+ export async function runPostApplyVerification(agent = 'cursor') {
593
+ const status = runLocalRemediationComplianceCheck(agent);
594
+ const { outcomes, reportPromises } = reportPostRestartVerificationOutcomes(status.violations);
595
+ if (outcomes.length > 0) {
596
+ await Promise.allSettled(reportPromises);
597
+ }
598
+ return outcomes;
599
+ }
600
+ /**
601
+ * Immediate autofix succeeded (inline recheck OK or Claude stale-recheck tolerance).
602
+ * Clear pending verification locally and report verified so the next prompt does not POST
603
+ * verification_failed while the user already saw a successful fix.
604
+ */
605
+ export function confirmAppliedAutofixVerified(appliedViolations, reportPromises) {
606
+ for (const v of appliedViolations) {
607
+ markRemediationApplyVerified(v.uuid);
608
+ reportPromises.push(reportAutofixApplied(v.uuid, 'verified'));
609
+ }
610
+ }
611
+ export function applyAutofixViolations(violations, agent = 'cursor') {
612
+ for (const v of violations) {
613
+ if (!v.autofix_allowed) {
614
+ logRemediationApplyFailure('autofix_skipped_not_allowed', {
615
+ uuid: v.uuid,
616
+ finding_formatted_id: v.finding_formatted_id,
617
+ config_file_path: v.config_file_path,
618
+ setting_path: v.setting_path,
619
+ reason: 'autofix_allowed is false — policy/manual fix required',
620
+ });
621
+ }
622
+ }
623
+ const autofixable = violations.filter((v) => v.autofix_allowed);
624
+ if (autofixable.length === 0)
625
+ return {
626
+ fixed: 0,
627
+ appliedViolations: [],
628
+ restartCommands: [],
629
+ failedViolations: [],
630
+ reportPromises: [],
631
+ };
632
+ const { remediations } = readRemediationInstructionsFile();
633
+ const scopedRemediations = remediations.filter((e) => targetsCurrentAgent(e, agent));
634
+ const byUuid = new Map(scopedRemediations.map((r) => [r.uuid, r]));
635
+ let fixed = 0;
636
+ const appliedViolations = [];
637
+ const seen = new Set();
638
+ const restartCommands = [];
639
+ const failedViolations = [];
640
+ const reportPromises = [];
641
+ const oneTimeAppliedUuids = new Set();
642
+ let deferredSqlitePending = false;
643
+ for (const violation of autofixable) {
644
+ if (seen.has(violation.uuid))
645
+ continue;
646
+ if (isRemediationQuarantined(violation.uuid)) {
647
+ hookRunLog(`autofix: skipped quarantined uuid=${violation.uuid}`);
648
+ logRemediationApplyFailure('autofix_skipped_quarantined', {
649
+ uuid: violation.uuid,
650
+ finding_formatted_id: violation.finding_formatted_id,
651
+ config_file_path: violation.config_file_path,
652
+ setting_path: violation.setting_path,
653
+ reason: 'Auto-fix paused after repeated post-restart verification failures — agent config may have changed',
654
+ });
655
+ failedViolations.push(violation);
656
+ continue;
657
+ }
658
+ const instruction = byUuid.get(violation.uuid);
659
+ if (!instruction) {
660
+ hookRunLog(`autofix: no instruction found for uuid=${violation.uuid}, skipping`);
661
+ logRemediationApplyFailure('autofix_no_local_instruction', {
662
+ uuid: violation.uuid,
663
+ finding_formatted_id: violation.finding_formatted_id,
664
+ config_file_path: violation.config_file_path,
665
+ setting_path: violation.setting_path,
666
+ reason: 'violation UUID not present in remediation_instructions.json remediations[]',
667
+ });
668
+ failedViolations.push(violation);
669
+ continue;
670
+ }
671
+ const inst = instruction;
672
+ const configPathForDisk = resolveRemediationConfigPath(inst.config_file_path);
673
+ complianceRunnerDiag(`autofix: calling enforceRemediation uuid=${inst.uuid} path=${configPathForDisk}`);
674
+ const er = enforceRemediation(inst);
675
+ if (!er.ok) {
676
+ failedViolations.push(violation);
677
+ continue;
678
+ }
679
+ if (er.deferredSqlite)
680
+ deferredSqlitePending = true;
681
+ seen.add(violation.uuid);
682
+ fixed++;
683
+ appliedViolations.push(violation);
684
+ hookRunLog(`autofix: applied uuid=${inst.uuid} path=${configPathForDisk}`);
685
+ reportPromises.push(reportAutofixApplied(inst.uuid, 'success'));
686
+ // Every successful autofix (Cursor + Claude, restart or immediate JSON) awaits verification on
687
+ // the next compliance check so we can quarantine stuck applies and stop restart/retry loops.
688
+ markRemediationApplyPendingVerification(inst.uuid);
689
+ const spec = remediationFixSpec(inst);
690
+ if (er.deferredSqlite && configPathForDisk.includes('#')) {
691
+ hookRunLog(`autofix: skip immediate vscdb upload (deferred until after restart) uuid=${inst.uuid}`);
692
+ }
693
+ else {
694
+ let updatedContent;
695
+ if (configPathForDisk.includes('#')) {
696
+ const hi = configPathForDisk.indexOf('#');
697
+ const dbPath = configPathForDisk.slice(0, hi);
698
+ const itemKey = configPathForDisk.slice(hi + 1).trim();
699
+ updatedContent = itemKey ? (readVscdbItemTableJson(dbPath, itemKey) ?? undefined) : undefined;
700
+ }
701
+ else {
702
+ updatedContent =
703
+ parseJsonWithJsoncFallback(readFileSync(configPathForDisk, 'utf8')) ?? undefined;
704
+ }
705
+ if (updatedContent !== undefined) {
706
+ const fileType = resolveRemediationUploadFileType(inst.config_file_path, inst.file_type ?? undefined);
707
+ if (fileType) {
708
+ const hw = tryResolveHardwareUuid();
709
+ if (hw) {
710
+ // Do not rely on the bulk uploader (start-every-prompt throttles at 1800s).
711
+ // Always attempt to upload the single remediated file immediately.
712
+ reportPromises.push(ensureAuthentication(hw)
713
+ .then((authKey) => sendConfigFile({ file_type: fileType, file_path: configPathForDisk, raw_content: updatedContent }, hw, authKey))
714
+ .then((sentOk) => {
715
+ hookRunLog(`autofix: uploaded remediated file uuid=${inst.uuid} path=${configPathForDisk} ok=${sentOk}`);
716
+ })
717
+ .catch((err) => {
718
+ hookRunLog(`autofix: upload failed uuid=${inst.uuid} path=${configPathForDisk} err=${err instanceof Error ? err.message : String(err)}`);
719
+ }));
720
+ }
721
+ else {
722
+ hookRunLog(`autofix: skip upload uuid=${inst.uuid} (hardware UUID unavailable)`);
723
+ }
724
+ }
725
+ else {
726
+ hookRunLog(`autofix: skip upload uuid=${inst.uuid} — remediation_instructions.json missing file_type (re-sync manifest)`);
727
+ logRemediationApplyFailure('autofix_post_apply_upload_skipped', {
728
+ uuid: inst.uuid,
729
+ config_file_path: inst.config_file_path,
730
+ reason: 'file_type missing on instruction — server sync/manifest will not see applied file',
731
+ });
732
+ }
733
+ }
734
+ }
735
+ if (spec?.restart_required && spec.restart_command) {
736
+ if (!er.deferredSqlite) {
737
+ if (isTrustedRestartCommandForAutofix(spec.restart_command)) {
738
+ hookRunLog(`autofix: restart required uuid=${inst.uuid} command=${spec.restart_command}`);
739
+ restartCommands.push(spec.restart_command);
740
+ }
741
+ else {
742
+ hookRunLog(`autofix: restart command rejected (not an allowlisted template) uuid=${inst.uuid}`);
743
+ }
744
+ }
745
+ }
746
+ if (!inst.is_enforced) {
747
+ oneTimeAppliedUuids.add(inst.uuid);
748
+ }
749
+ }
750
+ if (deferredSqlitePending) {
751
+ restartCommands.length = 0;
752
+ restartCommands.push(buildDeferredCursorRestartCommand());
753
+ hookRunLog('autofix: deferred vscdb — restart command runs apply_deferred_vscdb.js then open -a Cursor');
754
+ }
755
+ if (oneTimeAppliedUuids.size > 0) {
756
+ // Only prune one-time remediations for this agent; keep other-agent remediations intact.
757
+ const remaining = remediations.filter((r) => {
758
+ if (!targetsCurrentAgent(r, agent))
759
+ return true;
760
+ return !oneTimeAppliedUuids.has(r.uuid);
761
+ });
762
+ writeRemediationInstructionsFile({ remediations: remaining });
763
+ hookRunLog(`autofix: removed ${oneTimeAppliedUuids.size} one-time remediation(s) from local store`);
764
+ // Send a post-autofix heartbeat so the server sees the updated (reduced) UUID set immediately,
765
+ // without waiting for the background runner (which may be locked out).
766
+ // Heartbeat should reflect the full local file state (including other-agent remediations that remain).
767
+ const remainingUuids = remaining.map((r) => r.uuid);
768
+ const hwHeartbeat = tryResolveHardwareUuid();
769
+ if (hwHeartbeat) {
770
+ reportPromises.push(fetchSync(loadEndpointBase(), hwHeartbeat, remainingUuids)
771
+ .then(() => hookRunLog(`autofix: post-autofix heartbeat sent uuids=${remainingUuids.length}`))
772
+ .catch(() => undefined));
773
+ }
774
+ else {
775
+ hookRunLog('autofix: skip post-autofix heartbeat (hardware UUID unavailable)');
776
+ }
777
+ }
778
+ if (fixed > 0) {
779
+ hookRunLog(`autofix: total_applied=${fixed}`);
780
+ }
781
+ return { fixed, appliedViolations, restartCommands, failedViolations, reportPromises, deferredSqlitePending };
782
+ }
783
+ /**
784
+ * Remove satisfied one-time remediations from local remediation_instructions.json.
785
+ *
786
+ * This handles the case where a user (or another tool) manually applied the change, so the
787
+ * instruction no longer needs to persist locally. Enforced remediations are never pruned.
788
+ *
789
+ * Returns number removed and any async report promises (heartbeat).
790
+ */
791
+ export function pruneSatisfiedOneTimeRemediations(agent = 'cursor') {
792
+ const { remediations } = readRemediationInstructionsFile();
793
+ if (!Array.isArray(remediations) || remediations.length === 0)
794
+ return { removed: 0, reportPromises: [] };
795
+ const remaining = [];
796
+ let removed = 0;
797
+ for (const raw of remediations) {
798
+ const inst = raw;
799
+ if (!targetsCurrentAgent(inst, agent)) {
800
+ remaining.push(raw);
801
+ continue;
802
+ }
803
+ if (inst.is_enforced) {
804
+ remaining.push(raw);
805
+ continue;
806
+ }
807
+ const spec = remediationFixSpec(inst);
808
+ const checks = isSupportedTextConfigFormat(spec?.file_format) ? (spec?.checks ?? []) : [];
809
+ if (checks.length === 0) {
810
+ remaining.push(raw);
811
+ continue;
812
+ }
813
+ const prLoaded = loadRemediationConfigJson(inst.config_file_path, checks.map((c) => c.setting_path));
814
+ if (!prLoaded.ok) {
815
+ remaining.push(raw);
816
+ continue;
817
+ }
818
+ const configJson = prLoaded.json;
819
+ if (spec?.requires_secret_scan === true) {
820
+ if (scanJsonForHardcodedSecrets(configJson).length === 0) {
821
+ removed++;
822
+ hookRunLog(`remediation_prune: satisfied secret-scan uuid=${inst.uuid} path=${inst.config_file_path}`);
823
+ }
824
+ else {
825
+ remaining.push(raw);
826
+ }
827
+ continue;
828
+ }
829
+ // Only prune when every check is ops-based and currently satisfied.
830
+ let okAll = true;
831
+ for (const check of checks) {
832
+ if (!check.ops) {
833
+ okAll = false;
834
+ break;
835
+ }
836
+ // apply_to_all_workspaces: verify against workspace vscdbs, not the loaded config file.
837
+ if (check.sqlite_op?.apply_to_all_workspaces) {
838
+ const segments = check.sqlite_op.workspace_storage_path_segments ?? [
839
+ 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage',
840
+ ];
841
+ const wsPath = join(homedir(), ...segments);
842
+ const vscdbPaths = discoverAllWorkspaceVscdbs(wsPath);
843
+ if (vscdbPaths.length === 0) {
844
+ okAll = false;
845
+ break;
846
+ }
847
+ for (const dbPath of vscdbPaths) {
848
+ const wrapped = readVscdbItemTableJson(dbPath, check.sqlite_op.target_key ?? '');
849
+ if (wrapped === null || !verifyOpsApplied(wrapped, check.setting_path, check.ops).ok) {
850
+ okAll = false;
851
+ break;
852
+ }
853
+ }
854
+ if (!okAll)
855
+ break;
856
+ continue;
857
+ }
858
+ const res = verifyOpsApplied(configJson, check.setting_path, check.ops);
859
+ if (!res.ok) {
860
+ okAll = false;
861
+ break;
862
+ }
863
+ }
864
+ if (!okAll) {
865
+ const secondaryGroups = spec?.secondary_checks ?? [];
866
+ if (secondaryGroups.length > 0 && secondaryGroups.some((g) => evaluateSecondaryGroup(g))) {
867
+ okAll = true;
868
+ hookRunLog(`remediation_prune: secondary check satisfied uuid=${inst.uuid}`);
869
+ }
870
+ }
871
+ if (okAll) {
872
+ removed++;
873
+ hookRunLog(`remediation_prune: satisfied one-time uuid=${inst.uuid} path=${inst.config_file_path}`);
874
+ }
875
+ else {
876
+ remaining.push(raw);
877
+ }
878
+ }
879
+ if (removed === 0)
880
+ return { removed: 0, reportPromises: [] };
881
+ writeRemediationInstructionsFile({ remediations: remaining });
882
+ hookRunLog(`remediation_prune: removed=${removed} remaining=${remaining.length}`);
883
+ const remainingUuids = remaining.map((r) => r.uuid);
884
+ const hw = tryResolveHardwareUuid();
885
+ const reportPromises = [];
886
+ if (hw) {
887
+ reportPromises.push(fetchSync(loadEndpointBase(), hw, remainingUuids)
888
+ .then(() => hookRunLog(`remediation_prune: post-prune heartbeat sent uuids=${remainingUuids.length}`))
889
+ .catch(() => undefined));
890
+ }
891
+ else {
892
+ hookRunLog('remediation_prune: skip post-prune heartbeat (hardware UUID unavailable)');
893
+ }
894
+ return { removed, reportPromises };
895
+ }
896
+ /** Throttle satisfied-manifest uploads (disk already matches ops; autofix did not run). */
897
+ const SATISFIED_MANIFEST_UPLOAD_COOLDOWN_MS = 5 * 60 * 1000;
898
+ /**
899
+ * When local compliance already passes, autofix is skipped — so settings never reach the server.
900
+ * Upload satisfied JSON remediations (throttled) so scan can resolve findings.
901
+ */
902
+ export function uploadSatisfiedManifestConfigs(agent = 'cursor') {
903
+ const { remediations } = readRemediationInstructionsFile();
904
+ const entries = remediations.filter((e) => targetsCurrentAgent(e, agent));
905
+ const promises = [];
906
+ for (const entry of entries) {
907
+ const { violations } = evaluateManifestEntryCompliance(entry);
908
+ if (violations.length > 0)
909
+ continue;
910
+ const inst = entry;
911
+ const uploadFileType = resolveRemediationUploadFileType(entry.config_file_path, inst.file_type ?? undefined);
912
+ if (!uploadFileType)
913
+ continue;
914
+ const diskPath = resolveRemediationConfigPath(entry.config_file_path);
915
+ if (diskPath.includes('#'))
916
+ continue;
917
+ const tracking = readRemediationApplyTrackingFile();
918
+ const prev = tracking.entries[entry.uuid];
919
+ const lastUpload = prev?.last_satisfied_upload_at;
920
+ if (lastUpload) {
921
+ const elapsed = Date.now() - Date.parse(lastUpload);
922
+ if (!Number.isNaN(elapsed) && elapsed < SATISFIED_MANIFEST_UPLOAD_COOLDOWN_MS) {
923
+ continue;
924
+ }
925
+ }
926
+ const rawContent = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
927
+ if (rawContent === null) {
928
+ hookRunLog(`satisfied_upload: could not read path=${diskPath} uuid=${entry.uuid}`);
929
+ continue;
930
+ }
931
+ const hw = tryResolveHardwareUuid();
932
+ if (!hw) {
933
+ hookRunLog(`satisfied_upload: skip uuid=${entry.uuid} (hardware UUID unavailable)`);
934
+ continue;
935
+ }
936
+ const uploadPath = entry.config_file_path.trim() || diskPath;
937
+ const shouldReportVerified = !prev?.pending_post_restart_verify && !prev?.server_verified_at;
938
+ promises.push(ensureAuthentication(hw)
939
+ .then((authKey) => sendConfigFile({ file_type: uploadFileType, file_path: uploadPath, raw_content: rawContent }, hw, authKey))
940
+ .then(async (sentOk) => {
941
+ hookRunLog(`satisfied_upload: uuid=${entry.uuid} path=${uploadPath} ok=${sentOk}`);
942
+ if (!sentOk)
943
+ return;
944
+ const file = readRemediationApplyTrackingFile();
945
+ const ent = file.entries[entry.uuid] ?? { consecutive_verify_failures: 0, quarantined: false };
946
+ ent.last_satisfied_upload_at = new Date().toISOString();
947
+ file.entries[entry.uuid] = ent;
948
+ writeRemediationApplyTrackingFile(file);
949
+ if (shouldReportVerified) {
950
+ await reportAutofixApplied(entry.uuid, 'verified', {
951
+ config_snapshot_after: rawContent,
952
+ });
953
+ markRemediationApplyVerified(entry.uuid);
954
+ hookRunLog(`satisfied_upload: reported verified uuid=${entry.uuid}`);
955
+ }
956
+ })
957
+ .catch((err) => {
958
+ hookRunLog(`satisfied_upload: failed uuid=${entry.uuid} err=${err instanceof Error ? err.message : String(err)}`);
959
+ }));
960
+ }
961
+ return promises;
962
+ }
963
+ /**
964
+ * When disk already matches remediation ops but autofix never ran, POST verified so the UI
965
+ * leaves Pending (EnforcementLog) even if satisfied_upload is throttled.
966
+ */
967
+ export function reportCompliantRemediationVerifiedStatus(agent = 'cursor') {
968
+ const { remediations } = readRemediationInstructionsFile();
969
+ const entries = remediations.filter((e) => targetsCurrentAgent(e, agent));
970
+ const promises = [];
971
+ for (const entry of entries) {
972
+ const { violations } = evaluateManifestEntryCompliance(entry);
973
+ if (violations.length > 0)
974
+ continue;
975
+ const tracking = readRemediationApplyTrackingFile();
976
+ const prev = tracking.entries[entry.uuid];
977
+ if (prev?.pending_post_restart_verify || prev?.server_verified_at)
978
+ continue;
979
+ const diskPath = resolveRemediationConfigPath(entry.config_file_path);
980
+ if (diskPath.includes('#'))
981
+ continue;
982
+ const rawContent = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
983
+ if (rawContent === null)
984
+ continue;
985
+ promises.push(reportAutofixApplied(entry.uuid, 'verified', { config_snapshot_after: rawContent }).then(() => {
986
+ markRemediationApplyVerified(entry.uuid);
987
+ hookRunLog(`compliance_check: reported verified (already compliant) uuid=${entry.uuid}`);
988
+ }));
989
+ }
990
+ return promises;
991
+ }
992
+ /**
993
+ * Background refresh: server sync for latest instructions, then the same local evaluation as the hook.
994
+ * Apply (autofix) is intentionally deferred to the gate on the next prompt — this pass only downloads
995
+ * a fresh manifest so the gate has up-to-date data when it runs.
996
+ */
997
+ export async function runComplianceCheck(agentOverride) {
998
+ const agent = agentOverride ?? currentAgentFromEnv();
999
+ try {
1000
+ await syncRemediations(loadEndpointBase(), resolveHardwareUuid());
1001
+ }
1002
+ catch (err) {
1003
+ hookRunLog(`compliance_check: remediation_sync unexpected error: ${err instanceof Error ? err.message : String(err)}`);
1004
+ }
1005
+ const status = runLocalRemediationComplianceCheck(agent);
1006
+ if (status.status === 'ok' || status.violations.length === 0) {
1007
+ const pruned = pruneSatisfiedOneTimeRemediations(agent);
1008
+ if (pruned.removed > 0) {
1009
+ await Promise.allSettled(pruned.reportPromises);
1010
+ }
1011
+ await Promise.allSettled(uploadSatisfiedManifestConfigs(agent));
1012
+ }
1013
+ }