@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,396 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Synchronous pre-prompt gate: local compliance only (stdout = single JSON line for IDE hooks).
4
+ * stderr must stay clean; logs go via hookRunLog (compliance session file when --compliance-log is set).
5
+ *
6
+ * When autofix returns restart_commands, this process does not spawn them — the shell hook pipes
7
+ * the same JSON line to execute_trusted_restarts (TS allowlist + spawn).
8
+ */
9
+ import { applyAutofixViolations, confirmAppliedAutofixVerified, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, reportCompliantRemediationVerifiedStatus, reportPostRestartVerificationOutcomes, runLocalRemediationComplianceCheck, uploadSatisfiedManifestConfigs, } from './log_config_files/runtime/compliance_check.js';
10
+ import { isRemediationQuarantined } from './log_config_files/runtime/remediation_apply_tracking.js';
11
+ import { existsSync, readFileSync, statSync } from 'node:fs';
12
+ import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
13
+ import { hookLogSessionBanner, hookRunLog, logRemediationApplyFailure } from './log_config_files/runtime/hook_logger.js';
14
+ import { getStoryId, bufferFunctionalRun } from './log_config_files/runtime/client_event_reporter.js';
15
+ import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForGate, parseComplianceLogArg, } from './log_config_files/runtime/compliance_session_log.js';
16
+ import { isThisCliModule } from './cli_invocation_match.js';
17
+ import { ensureAuthentication } from './log_config_files/auth/auth_flow.js';
18
+ import { sendConfigFile } from './log_config_files/sender/batch_sender.js';
19
+ import { parseJsonWithJsoncFallback } from './log_config_files/readers/file_readers.js';
20
+ import { PORTABLE_CURSOR_USER_SETTINGS, resolveRemediationConfigPath, resolveRemediationUploadFileType, } from './log_config_files/runtime/remediation_config_path.js';
21
+ import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid.js';
22
+ import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
23
+ import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
24
+ import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
25
+ const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
26
+ /** After verified (or failed verify), push enforcement + session log immediately — do not wait for background runner. */
27
+ async function flushRemediationOutcomeToServer(complianceLogPath) {
28
+ if (!complianceLogPath)
29
+ return;
30
+ try {
31
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath);
32
+ }
33
+ catch (err) {
34
+ hookRunLog(`compliance_prompt_gate: session finalize/upload failed: ${err instanceof Error ? err.message : String(err)}`);
35
+ }
36
+ }
37
+ function parseIde() {
38
+ const eq = process.argv.find((a) => a.startsWith('--ide='));
39
+ if (eq) {
40
+ const v = eq.slice('--ide='.length).toLowerCase();
41
+ if (v === 'claude')
42
+ return 'claude';
43
+ if (v === 'copilot')
44
+ return 'copilot';
45
+ if (v === 'opencode')
46
+ return 'opencode';
47
+ return 'cursor';
48
+ }
49
+ if (process.argv.includes('--claude'))
50
+ return 'claude';
51
+ return 'cursor';
52
+ }
53
+ function defaultAgentFromIde(ide) {
54
+ if (ide === 'copilot')
55
+ return 'copilot';
56
+ if (ide === 'opencode')
57
+ return 'opencode';
58
+ return ide === 'claude' ? 'claude' : 'cursor';
59
+ }
60
+ function parseAgent(ide) {
61
+ const eq = process.argv.find((a) => a.startsWith('--agent='));
62
+ if (eq) {
63
+ const normalized = normalizeAgentToken(eq.slice('--agent='.length));
64
+ if (normalized)
65
+ return normalized;
66
+ }
67
+ return defaultAgentFromIde(ide);
68
+ }
69
+ function printAllow(ide) {
70
+ if (ide === 'claude' || ide === 'copilot' || ide === 'opencode')
71
+ console.log('{}');
72
+ else
73
+ console.log(JSON.stringify({ continue: true }));
74
+ }
75
+ function printAllowWithAdvisory(ide, advisoryMessage) {
76
+ if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
77
+ console.log(JSON.stringify({ __optimus_advisory: true, advisory_message: advisoryMessage }));
78
+ }
79
+ else {
80
+ console.log(JSON.stringify({
81
+ continue: true,
82
+ __optimus_advisory: true,
83
+ advisory_message: advisoryMessage,
84
+ }));
85
+ }
86
+ }
87
+ function blockPayload(ide, violationMessage) {
88
+ const prefix = 'Prompt blocked by Optimus: ';
89
+ const text = prefix + violationMessage;
90
+ if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
91
+ return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
92
+ }
93
+ return JSON.stringify({ continue: false, user_message: text });
94
+ }
95
+ function getManifestStalenessMs() {
96
+ try {
97
+ const path = getRemediationInstructionsPath();
98
+ if (!existsSync(path))
99
+ return null;
100
+ const st = statSync(path);
101
+ return Date.now() - st.mtimeMs;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function isRunAsCliModule() {
108
+ return isThisCliModule(process.argv[1], import.meta.url);
109
+ }
110
+ /** Short line for success dialog: finding title/sentence from manifest, not per-check remediation technical text. */
111
+ function autofixDialogLine(v) {
112
+ const title = v.finding_title?.trim();
113
+ if (title)
114
+ return `• [${v.finding_formatted_id}] ${title}`;
115
+ const fd = v.finding_description?.trim();
116
+ if (fd)
117
+ return `• [${v.finding_formatted_id}] ${fd}`;
118
+ const d = v.description.trim();
119
+ const short = d.length > 160 ? `${d.slice(0, 157)}…` : d;
120
+ return `• [${v.finding_formatted_id}] ${short}`;
121
+ }
122
+ /** Ruleset name(s) for preventive enforcement dialog copy. */
123
+ function uniqueRulesetLabelsFromViolations(appliedViolations) {
124
+ const rulesetNames = [
125
+ ...new Set(appliedViolations
126
+ .map((v) => v.ruleset_name?.trim() || v.finding_title?.trim() || '')
127
+ .filter(Boolean)),
128
+ ];
129
+ return rulesetNames.length > 0 ? rulesetNames.join('\n\n') : 'Agent security policy';
130
+ }
131
+ function formatPreventiveAutofixDialog(appliedViolations, applyLine) {
132
+ const changePreview = formatRemediationChangePreviewForApplied(appliedViolations);
133
+ const changePreviewBlock = changePreview ? `${changePreview}\n\n` : '';
134
+ return ('Optimus Labs enforced a preventive agent security policy as determined by your security team:\n\n' +
135
+ `${uniqueRulesetLabelsFromViolations(appliedViolations)}\n\n` +
136
+ changePreviewBlock +
137
+ `${applyLine}\n\n` +
138
+ 'Click OK to continue');
139
+ }
140
+ /** Claude dialog after enforced/preventive remediation is applied locally. */
141
+ export function formatClaudeAutofixDialog(appliedViolations) {
142
+ return formatPreventiveAutofixDialog(appliedViolations, 'Claude will now apply this policy to your environment.');
143
+ }
144
+ /** Copilot dialog after enforced/preventive remediation is applied locally (no restart). */
145
+ export function formatCopilotAutofixDialog(appliedViolations) {
146
+ return formatPreventiveAutofixDialog(appliedViolations, 'Copilot will now apply this policy to your environment.');
147
+ }
148
+ /** OpenCode dialog after enforced/preventive remediation is applied locally (terminal, no restart). */
149
+ export function formatOpenCodeAutofixDialog(appliedViolations) {
150
+ return formatPreventiveAutofixDialog(appliedViolations, 'OpenCode will now apply this policy to your environment.');
151
+ }
152
+ /** Cursor restart after local autofix (all JSON settings + vscdb paths use restart_commands). */
153
+ export function formatCursorRestartAutofixDialog(appliedViolations) {
154
+ return formatPreventiveAutofixDialog(appliedViolations, 'Cursor will restart so the running app reloads policy from disk. Your context will be retained.');
155
+ }
156
+ /**
157
+ * Upload a secondary compliance file to the backend so the server can resolve the finding.
158
+ * Fire-and-forget: upload runs in background; any failure is logged but does not block the gate.
159
+ */
160
+ async function _uploadSecondaryFile(entry) {
161
+ const { uuid, config_file_path, file_type } = entry;
162
+ const uploadFileType = resolveRemediationUploadFileType(config_file_path, file_type ?? undefined);
163
+ if (!uploadFileType) {
164
+ hookRunLog(`secondary_upload: skipping uuid=${uuid} — no file_type on secondary group`);
165
+ return;
166
+ }
167
+ const diskPath = resolveRemediationConfigPath(config_file_path);
168
+ let rawContent;
169
+ try {
170
+ const parsed = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
171
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
172
+ hookRunLog(`secondary_upload: invalid JSON uuid=${uuid} path=${diskPath}`);
173
+ return;
174
+ }
175
+ rawContent = parsed;
176
+ }
177
+ catch {
178
+ hookRunLog(`secondary_upload: could not read file uuid=${uuid} path=${diskPath}`);
179
+ return;
180
+ }
181
+ const hw = tryResolveHardwareUuid();
182
+ if (!hw) {
183
+ hookRunLog(`secondary_upload: hardware UUID unavailable, skipping uuid=${uuid}`);
184
+ return;
185
+ }
186
+ const portablePath = config_file_path.includes('Cursor/User/settings.json')
187
+ ? PORTABLE_CURSOR_USER_SETTINGS
188
+ : config_file_path.trim() || diskPath;
189
+ try {
190
+ const authKey = await ensureAuthentication(hw);
191
+ const sent = await sendConfigFile({ file_type: uploadFileType, file_path: portablePath, raw_content: rawContent }, hw, authKey);
192
+ hookRunLog(`secondary_upload: uuid=${uuid} path=${portablePath} sent=${sent}`);
193
+ }
194
+ catch (err) {
195
+ hookRunLog(`secondary_upload: upload failed uuid=${uuid} path=${portablePath} err=${err instanceof Error ? err.message : String(err)}`);
196
+ }
197
+ }
198
+ /**
199
+ * Entry when the default npm bin (`log-llm-config` / cli.js) dispatches
200
+ * `compliance_prompt_gate` — npx does not execute compliance_prompt_gate.js as argv[1], so
201
+ * {@link isRunAsCliModule} is false and the gate must be invoked explicitly from cli.ts.
202
+ */
203
+ export async function runCompliancePromptGateCli() {
204
+ getStoryId();
205
+ try {
206
+ await runCompliancePromptGate();
207
+ // Gate blocks prompt submit — buffer telemetry (no inline POST); the background runner flushes it.
208
+ bufferFunctionalRun({ unit: 'compliance', command: 'compliance_prompt_gate', outcome: 'ok' });
209
+ }
210
+ catch (err) {
211
+ bufferFunctionalRun({
212
+ unit: 'compliance',
213
+ command: 'compliance_prompt_gate',
214
+ outcome: 'failed',
215
+ message: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
216
+ detail: err instanceof Error && err.stack ? err.stack : String(err),
217
+ });
218
+ printAllow(parseIde());
219
+ }
220
+ }
221
+ /** Exported for tests; runs when `dist/compliance_prompt_gate.js` is the process entry (argv[1]). */
222
+ export async function runCompliancePromptGate() {
223
+ const ide = parseIde();
224
+ const agent = parseAgent(ide);
225
+ let complianceLogPath = parseComplianceLogArg(process.argv);
226
+ if (complianceLogPath) {
227
+ complianceLogPath = initComplianceSessionForGate(complianceLogPath);
228
+ }
229
+ else {
230
+ hookLogSessionBanner('compliance_prompt_gate (before submit)');
231
+ }
232
+ let status = runLocalRemediationComplianceCheck(agent);
233
+ const postRestartVerify = reportPostRestartVerificationOutcomes(status.violations);
234
+ if (postRestartVerify.outcomes.length > 0) {
235
+ await Promise.allSettled(postRestartVerify.reportPromises);
236
+ await flushRemediationOutcomeToServer(complianceLogPath);
237
+ hookRunLog(`compliance_prompt_gate: post_restart_verification count=${postRestartVerify.outcomes.length} quarantined=${postRestartVerify.outcomes.filter((o) => o.status === 'quarantined').length}`);
238
+ status = runLocalRemediationComplianceCheck(agent);
239
+ }
240
+ const hw = tryResolveHardwareUuid();
241
+ if (hw) {
242
+ try {
243
+ await Promise.race([
244
+ syncRemediations(loadEndpointBase(), hw),
245
+ new Promise((resolve) => setTimeout(resolve, 3000)),
246
+ ]);
247
+ }
248
+ catch {
249
+ // Network or auth failure — fall back to local file state.
250
+ }
251
+ }
252
+ status = runLocalRemediationComplianceCheck(agent);
253
+ // Secondary-satisfied: primary checks failed but settings.json (or equiv) has the fix.
254
+ // Upload those files fire-and-forget so the backend can resolve the finding immediately.
255
+ for (const e of status.secondarySatisfied ?? []) {
256
+ _uploadSecondaryFile(e).catch(() => undefined);
257
+ }
258
+ if (status.status === 'fail' && status.violations.length > 0) {
259
+ const staleMs = getManifestStalenessMs();
260
+ if (staleMs !== null && staleMs > MANIFEST_STALE_MS) {
261
+ const staleDays = Math.floor(staleMs / (24 * 60 * 60 * 1000));
262
+ const advisory = `Remediation enforcement suspended: local remediation manifest is stale (${staleDays} days old). ` +
263
+ 'Please reconnect to sync latest policy state.';
264
+ logRemediationApplyFailure('prompt_gate_stale_manifest_advisory', {
265
+ reason: advisory,
266
+ stale_days: staleDays,
267
+ violation_count: status.violations.length,
268
+ });
269
+ printAllowWithAdvisory(ide, advisory);
270
+ return;
271
+ }
272
+ const actionableViolations = status.violations.filter((v) => !isRemediationQuarantined(v.uuid));
273
+ if (actionableViolations.length === 0 && status.violations.length > 0) {
274
+ hookRunLog(`compliance_prompt_gate: all ${status.violations.length} violation(s) quarantined — allowing prompt silently (backend notified on quarantine)`);
275
+ logRemediationApplyFailure('prompt_gate_quarantined_silent_allow', {
276
+ reason: 'all violations quarantined; no dialog — enforcement paused on this machine',
277
+ quarantined_count: status.violations.length,
278
+ });
279
+ printAllow(ide);
280
+ return;
281
+ }
282
+ const { fixed, appliedViolations = [], restartCommands, failedViolations, reportPromises, deferredSqlitePending, } = applyAutofixViolations(actionableViolations, agent);
283
+ hookRunLog(`compliance_prompt_gate: autofix result ide=${ide} fixed=${fixed} applied=${appliedViolations.length} failed=${failedViolations.length} restart_commands=${restartCommands.length} deferred_sqlite_pending=${deferredSqlitePending}`);
284
+ if (fixed > 0) {
285
+ // Wait for all server reports before exiting so the POST lands.
286
+ await Promise.allSettled(reportPromises);
287
+ // Deferred SQLite can leave recheck failing until restart; that must not hide a separate
288
+ // failed autofix (e.g. JSON remediation failed while vscdb was only queued).
289
+ if (failedViolations.length > 0) {
290
+ const ids = failedViolations.map((v) => `[${v.finding_formatted_id}]`).join(', ');
291
+ const msg = `Auto-fix failed for ${ids} — please fix manually or contact your security team.\n\n${failedViolations[0]?.message ?? ''}`;
292
+ logRemediationApplyFailure('prompt_gate_block_autofix_failed', {
293
+ reason: 'autofix had fixed>0 path but failedViolations non-empty after apply',
294
+ ide,
295
+ failed_formatted_ids: failedViolations.map((v) => v.finding_formatted_id).join(', '),
296
+ detail: failedViolations[0]?.message ?? '',
297
+ });
298
+ console.log(blockPayload(ide, msg));
299
+ return;
300
+ }
301
+ const recheck = runLocalRemediationComplianceCheck(agent);
302
+ const recheckOk = recheck.status === 'ok' || recheck.violations.length === 0;
303
+ // Cursor: tolerate a failing recheck only when SQLite updates are deferred (apply after restart).
304
+ // Claude / Copilot: JSON remediations are written immediately; merge/verify timing can still leave
305
+ // the in-process recheck red for the same UUID — allow in that case for immediate JSON agents.
306
+ const appliedUuids = new Set(appliedViolations.map((v) => v.uuid));
307
+ const claudeRecheckStaleAfterImmediateApply = (ide === 'claude' || ide === 'copilot' || ide === 'opencode') &&
308
+ !recheckOk &&
309
+ recheck.violations.length > 0 &&
310
+ recheck.violations.every((v) => appliedUuids.has(v.uuid));
311
+ if (claudeRecheckStaleAfterImmediateApply) {
312
+ hookRunLog(`compliance_prompt_gate: ${ide} — autofix wrote JSON; recheck still flags same UUID(s) — proceeding (immediate apply)`);
313
+ }
314
+ if (deferredSqlitePending || recheckOk || claudeRecheckStaleAfterImmediateApply) {
315
+ // Immediate-JSON agents (Claude / Claude Desktop, Copilot, OpenCode) write the config file to
316
+ // disk synchronously; a restart_command, if present, only reloads the already-compliant file
317
+ // into the running app — it is not the mechanism that makes the fix take effect. So report
318
+ // "verified" now instead of leaving the finding "pending" in the UI until the next prompt.
319
+ // Cursor JSON: disk recheck OK → verified before restart. Cursor deferred vscdb → verify after restart.
320
+ const immediateJsonAgent = ide === 'claude' || ide === 'copilot' || ide === 'opencode';
321
+ const diskProvenCompliant = recheckOk || claudeRecheckStaleAfterImmediateApply;
322
+ const cursorRestartVerifyPending = cursorAutofixDefersInlineVerify(ide, restartCommands.length, deferredSqlitePending === true, diskProvenCompliant);
323
+ const immediateVerified = !deferredSqlitePending &&
324
+ !cursorRestartVerifyPending &&
325
+ diskProvenCompliant &&
326
+ (restartCommands.length === 0 || immediateJsonAgent || ide === 'cursor');
327
+ if (immediateVerified) {
328
+ confirmAppliedAutofixVerified(appliedViolations, reportPromises);
329
+ await Promise.allSettled(reportPromises);
330
+ await flushRemediationOutcomeToServer(complianceLogPath);
331
+ }
332
+ const changePreview = formatRemediationChangePreviewForApplied(appliedViolations);
333
+ const changePreviewSuffix = changePreview ? `\n\n${changePreview}` : '';
334
+ const autofixMessage = ide === 'cursor' && restartCommands.length > 0
335
+ ? formatCursorRestartAutofixDialog(appliedViolations)
336
+ : ide === 'claude'
337
+ ? formatClaudeAutofixDialog(appliedViolations)
338
+ : ide === 'copilot'
339
+ ? formatCopilotAutofixDialog(appliedViolations)
340
+ : ide === 'opencode'
341
+ ? formatOpenCodeAutofixDialog(appliedViolations)
342
+ : `Optimus Labs auto-fixed ${fixed} ${fixed === 1 ? 'ruleset violation' : 'ruleset violations'}:\n\n${appliedViolations
343
+ .map((v) => autofixDialogLine(v))
344
+ .join('\n')}${changePreviewSuffix}`;
345
+ const payload = { __optimus_autofix: true, autofix_message: autofixMessage };
346
+ if (restartCommands.length > 0)
347
+ payload.restart_commands = restartCommands;
348
+ console.log(JSON.stringify(payload));
349
+ // Restarts: always executed by optimus-compliance-check.sh via execute_trusted_restarts (TS allowlist + spawn).
350
+ return;
351
+ }
352
+ const msg = recheck.violations[0]?.message ?? 'A security ruleset violation has been detected.';
353
+ logRemediationApplyFailure('prompt_gate_block_recheck_failed', {
354
+ reason: 'after autofix, local compliance recheck still reports violations',
355
+ ide,
356
+ deferred_sqlite_pending: deferredSqlitePending,
357
+ first_uuid: recheck.violations[0]?.uuid ?? '',
358
+ message: msg,
359
+ });
360
+ console.log(blockPayload(ide, msg));
361
+ return;
362
+ }
363
+ if (failedViolations.length > 0) {
364
+ const ids = failedViolations.map((v) => `[${v.finding_formatted_id}]`).join(', ');
365
+ const msg = `Auto-fix failed for ${ids} — please fix manually or contact your security team.\n\n${failedViolations[0]?.message ?? ''}`;
366
+ logRemediationApplyFailure('prompt_gate_block_autofix_failed', {
367
+ reason: 'autofix failed (fixed=0 branch or partial failure)',
368
+ ide,
369
+ failed_formatted_ids: failedViolations.map((v) => v.finding_formatted_id).join(', '),
370
+ detail: failedViolations[0]?.message ?? '',
371
+ });
372
+ console.log(blockPayload(ide, msg));
373
+ return;
374
+ }
375
+ const msg = status.violations[0]?.message ?? 'A security ruleset violation has been detected.';
376
+ logRemediationApplyFailure('prompt_gate_block_violations_no_autofix_applied', {
377
+ reason: 'violations on submit but autofix did not succeed — see autofix_skipped_not_allowed, enforceRemediation, or compliance_check entries',
378
+ ide,
379
+ first_uuid: status.violations[0]?.uuid ?? '',
380
+ finding_formatted_id: status.violations[0]?.finding_formatted_id ?? '',
381
+ });
382
+ console.log(blockPayload(ide, msg));
383
+ return;
384
+ }
385
+ // No violations: clean up satisfied one-time remediations so they don't linger locally forever.
386
+ const pruned = pruneSatisfiedOneTimeRemediations(agent);
387
+ if (pruned.removed > 0) {
388
+ await Promise.allSettled(pruned.reportPromises);
389
+ }
390
+ await Promise.allSettled(uploadSatisfiedManifestConfigs(agent));
391
+ await Promise.allSettled(reportCompliantRemediationVerifiedStatus(agent));
392
+ printAllow(ide);
393
+ }
394
+ if (isRunAsCliModule()) {
395
+ runCompliancePromptGateCli().finally(() => process.exit(0));
396
+ }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI for macOS non-restart autofix dialogs and dialog preferences in ~/.optimuslabs/management.
4
+ * Invoked from optimus-compliance-check.sh hooks.
5
+ */
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { isNonRestartAutofixDialogSuppressed, setSuppressNonRestartAutofixDialogs, } from './log_config_files/runtime/dialog_preferences.js';
9
+ export const STOP_NOTIFICATIONS_BUTTON = 'Mute Alerts';
10
+ export const OK_BUTTON = 'OK';
11
+ function cliParts() {
12
+ const a = process.argv;
13
+ if (a[2] === 'dialog_prefs')
14
+ return a.slice(2);
15
+ // Invoked as npm bin `dialog_prefs-staging` / `dialog_prefs` (argv[2] is the subcommand).
16
+ if (a[2] === 'is_suppressed' || a[2] === 'set_suppress' || a[2] === 'show') {
17
+ return ['dialog_prefs', a[2], ...a.slice(3)];
18
+ }
19
+ return a.slice(2);
20
+ }
21
+ function parseArgValue(parts, flag) {
22
+ const eq = parts.find((p) => p.startsWith(`${flag}=`));
23
+ if (eq)
24
+ return eq.slice(flag.length + 1);
25
+ const idx = parts.indexOf(flag);
26
+ if (idx >= 0 && parts[idx + 1])
27
+ return parts[idx + 1];
28
+ return undefined;
29
+ }
30
+ function escapeAppleScriptString(text) {
31
+ return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
32
+ }
33
+ function icnsIconClause() {
34
+ const p = (process.env.OPTIMUS_DIALOG_ICNS ?? '').trim();
35
+ if (!p || !existsSync(p))
36
+ return 'with icon caution';
37
+ try {
38
+ const buf = readFileSync(p);
39
+ if (buf.length < 8 || buf.subarray(0, 4).toString('ascii') !== 'icns') {
40
+ return 'with icon caution';
41
+ }
42
+ }
43
+ catch {
44
+ return 'with icon caution';
45
+ }
46
+ return `with icon POSIX file "${escapeAppleScriptString(p)}"`;
47
+ }
48
+ export function buildNonRestartAutofixDialogScript(message, target) {
49
+ const msg = escapeAppleScriptString(message);
50
+ const icon = icnsIconClause();
51
+ const mute = escapeAppleScriptString(STOP_NOTIFICATIONS_BUTTON);
52
+ const body = `display dialog "${msg}" with title "Optimus Labs" buttons {"${mute}", "${OK_BUTTON}"} default button "${OK_BUTTON}" ${icon}`;
53
+ if (target === 'cursor') {
54
+ return `tell application "Cursor" to ${body}`;
55
+ }
56
+ if (target === 'claude-app') {
57
+ return `tell application "Claude" to ${body}`;
58
+ }
59
+ return body;
60
+ }
61
+ export function showNonRestartAutofixDialog(message, target) {
62
+ const script = buildNonRestartAutofixDialogScript(message, target);
63
+ let out = '';
64
+ try {
65
+ out = execFileSync('osascript', ['-'], { input: script, encoding: 'utf8' });
66
+ }
67
+ catch (err) {
68
+ const e = err;
69
+ out = typeof e.stdout === 'string' ? e.stdout : '';
70
+ // User dismissed or osascript failed — do not block callers; preference only set on explicit Stop.
71
+ if (!out.includes(STOP_NOTIFICATIONS_BUTTON)) {
72
+ throw err;
73
+ }
74
+ }
75
+ if (out.includes(STOP_NOTIFICATIONS_BUTTON)) {
76
+ setSuppressNonRestartAutofixDialogs(true);
77
+ return 'stop_notifications';
78
+ }
79
+ return 'ok';
80
+ }
81
+ function readMessageFile(path) {
82
+ if (!path || !existsSync(path)) {
83
+ throw new Error(`message file not found: ${path}`);
84
+ }
85
+ return readFileSync(path, 'utf8');
86
+ }
87
+ export async function runDialogPrefsCli() {
88
+ const parts = cliParts();
89
+ const sub = parts[1];
90
+ if (sub === 'is_suppressed') {
91
+ console.log(isNonRestartAutofixDialogSuppressed() ? 'true' : 'false');
92
+ return;
93
+ }
94
+ if (sub === 'set_suppress') {
95
+ setSuppressNonRestartAutofixDialogs(true);
96
+ return;
97
+ }
98
+ if (sub === 'show') {
99
+ const messageFile = parseArgValue(parts, '--message-file');
100
+ const targetRaw = parseArgValue(parts, '--target') ?? 'terminal';
101
+ if (targetRaw !== 'cursor' && targetRaw !== 'claude-app' && targetRaw !== 'terminal') {
102
+ throw new Error(`invalid --target: ${targetRaw}`);
103
+ }
104
+ const message = readMessageFile(messageFile ?? '');
105
+ showNonRestartAutofixDialog(message, targetRaw);
106
+ return;
107
+ }
108
+ throw new Error(`unknown dialog_prefs subcommand: ${sub ?? '(none)'}`);
109
+ }
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ /** macOS ships gzip at a fixed path; call it directly so a modified PATH cannot redirect us. */
3
+ const GZIP_BIN = '/usr/bin/gzip';
4
+ const GZIP_TIMEOUT_MS = 10_000;
5
+ const GZIP_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
6
+ /**
7
+ * Compress a request body with the system gzip. Output is gzip-framed, so it pairs with
8
+ * `Content-Encoding: gzip`. Throws if gzip is missing, times out, or exits non-zero.
9
+ */
10
+ export function gzipPayload(payload) {
11
+ const child = spawnSync(GZIP_BIN, ['-c'], {
12
+ input: Buffer.from(payload, 'utf8'),
13
+ timeout: GZIP_TIMEOUT_MS,
14
+ maxBuffer: GZIP_MAX_OUTPUT_BYTES,
15
+ });
16
+ if (child.error)
17
+ throw child.error;
18
+ if (child.status !== 0) {
19
+ const stderr = (child.stderr ?? '').toString().trim();
20
+ throw new Error(stderr || `${GZIP_BIN} exited ${child.status ?? 'unknown'}`);
21
+ }
22
+ if (!child.stdout || child.stdout.length === 0)
23
+ throw new Error(`${GZIP_BIN} produced no output`);
24
+ return child.stdout;
25
+ }
@@ -0,0 +1,90 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { URL } from 'node:url';
4
+ /** Normalize localhost/::1 to 127.0.0.1 to avoid IPv6 resolution issues. */
5
+ function resolveHostname(raw) {
6
+ return raw === 'localhost' || raw === '::1' ? '127.0.0.1' : raw;
7
+ }
8
+ function buildGetOptions(url, timeoutMs) {
9
+ const isHttps = url.protocol === 'https:';
10
+ return {
11
+ hostname: resolveHostname(url.hostname),
12
+ port: url.port || (isHttps ? 443 : 80),
13
+ path: url.pathname + url.search,
14
+ method: 'GET',
15
+ timeout: timeoutMs,
16
+ };
17
+ }
18
+ function buildBodyOptions(url, payload, method, timeoutMs) {
19
+ const isHttps = url.protocol === 'https:';
20
+ return {
21
+ hostname: resolveHostname(url.hostname),
22
+ port: url.port || (isHttps ? 443 : 80),
23
+ path: `${url.pathname}${url.search}`,
24
+ method,
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'Content-Length': Buffer.byteLength(payload).toString(),
28
+ // A Buffer body is always gzip output from compress_payload.gzipPayload.
29
+ ...(Buffer.isBuffer(payload) ? { 'Content-Encoding': 'gzip' } : {}),
30
+ },
31
+ timeout: timeoutMs,
32
+ };
33
+ }
34
+ /** Execute a GET request. Returns statusCode=0 and empty body on network/timeout failure. */
35
+ export function executeGet(urlStr, timeoutMs) {
36
+ const url = new URL(urlStr);
37
+ const options = buildGetOptions(url, timeoutMs);
38
+ const transport = url.protocol === 'https:' ? https.request : http.request;
39
+ return new Promise((resolve) => {
40
+ const req = transport(options, (res) => {
41
+ let body = '';
42
+ res.setEncoding('utf8');
43
+ res.on('data', (chunk) => { body += chunk; });
44
+ res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, body }));
45
+ });
46
+ req.on('error', () => resolve({ statusCode: 0, body: '' }));
47
+ req.on('timeout', () => { req.destroy(); resolve({ statusCode: 0, body: '' }); });
48
+ req.end();
49
+ });
50
+ }
51
+ /** Execute a POST/PATCH request. A Buffer payload is sent as `Content-Encoding: gzip`. Rejects on network error or timeout. */
52
+ export function executeBody(urlStr, method, payload, timeoutMs) {
53
+ const url = new URL(urlStr);
54
+ const options = buildBodyOptions(url, payload, method, timeoutMs);
55
+ const transport = url.protocol === 'https:' ? https.request : http.request;
56
+ return new Promise((resolve, reject) => {
57
+ let done = false;
58
+ const timer = setTimeout(() => {
59
+ if (!done) {
60
+ done = true;
61
+ req.destroy();
62
+ reject(new Error(`Request timeout after ${timeoutMs}ms`));
63
+ }
64
+ }, timeoutMs);
65
+ const req = transport(options, (res) => {
66
+ let body = '';
67
+ res.setEncoding('utf8');
68
+ res.on('data', (chunk) => { body += chunk; });
69
+ res.on('end', () => {
70
+ done = true;
71
+ clearTimeout(timer);
72
+ resolve({ statusCode: res.statusCode ?? 0, statusMessage: res.statusMessage ?? '', headers: res.headers, body });
73
+ });
74
+ });
75
+ req.on('error', (err) => {
76
+ done = true;
77
+ clearTimeout(timer);
78
+ console.error('Request error:', err.message, err.code);
79
+ reject(err);
80
+ });
81
+ req.on('timeout', () => {
82
+ done = true;
83
+ clearTimeout(timer);
84
+ req.destroy();
85
+ reject(new Error(`Request timeout after ${timeoutMs}ms`));
86
+ });
87
+ req.write(payload);
88
+ req.end();
89
+ });
90
+ }
@@ -0,0 +1,3 @@
1
+ export { FILE_PATH_REGISTRY_FILE_PATTERNS_PATH, FILE_PATH_REGISTRY_SENSITIVE_PATHS_PATH, } from './types.js';
2
+ export { buildApiUrl, getFileCollectionPatterns, getSensitivePathsAuditCandidates, } from './registry_api.js';
3
+ export { postStartupPayload, patchPayload, classifyEndpointResponse } from './startup_api.js';
@@ -0,0 +1,41 @@
1
+ import { URL } from 'node:url';
2
+ import { executeGet } from './http_transport.js';
3
+ import { FILE_PATH_REGISTRY_FILE_PATTERNS_PATH, FILE_PATH_REGISTRY_SENSITIVE_PATHS_PATH, } from './types.js';
4
+ /** Join API base (may include a path prefix, e.g. https://host/optimus/) with an absolute API path. */
5
+ export function buildApiUrl(base, path) {
6
+ const normalizedBase = `${base.replace(/\/+$/, '')}/`;
7
+ const relativePath = path.replace(/^\/+/, '');
8
+ return new URL(relativePath, normalizedBase).href;
9
+ }
10
+ /**
11
+ * GET file collection patterns from the backend (what to look for).
12
+ * No auth required. Returns the complete list of path + type + file_type.
13
+ */
14
+ export const getFileCollectionPatterns = async (apiBaseUrl, timeoutMs = 5000) => {
15
+ const { statusCode, body } = await executeGet(buildApiUrl(apiBaseUrl, FILE_PATH_REGISTRY_FILE_PATTERNS_PATH), timeoutMs);
16
+ if (statusCode !== 200 || !body)
17
+ return null;
18
+ try {
19
+ const parsed = JSON.parse(body);
20
+ return Array.isArray(parsed.patterns) && typeof parsed.count === 'number' ? parsed : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ };
26
+ /**
27
+ * GET sensitive path audit candidates from the backend (concrete paths to check for existence).
28
+ * No auth required. Returns path templates (~ = home). Client checks existence only, never sends contents.
29
+ */
30
+ export const getSensitivePathsAuditCandidates = async (apiBaseUrl, timeoutMs = 5000) => {
31
+ const { statusCode, body } = await executeGet(buildApiUrl(apiBaseUrl, FILE_PATH_REGISTRY_SENSITIVE_PATHS_PATH), timeoutMs);
32
+ if (statusCode !== 200 || !body)
33
+ return null;
34
+ try {
35
+ const parsed = JSON.parse(body);
36
+ return Array.isArray(parsed.paths) ? parsed : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ };