@optimuslabs/harness-map-staging 1.5.25 → 1.5.27

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.
@@ -9,7 +9,9 @@
9
9
  import { applyAutofixViolations, confirmAppliedAutofixVerified, normalizeAgentToken, pruneSatisfiedOneTimeRemediations, reportCompliantRemediationVerifiedStatus, reportPostRestartVerificationOutcomes, runLocalRemediationComplianceCheck, uploadSatisfiedManifestConfigs, } from './log_config_files/runtime/compliance_check.js';
10
10
  import { isRemediationQuarantined } from './log_config_files/runtime/remediation_apply_tracking.js';
11
11
  import { existsSync, readFileSync, statSync } from 'node:fs';
12
- import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
12
+ import { setTimeout as sleep } from 'node:timers/promises';
13
+ import { managementEnvPath, readEnvironment } from './tofu_environment.js';
14
+ import { getRemediationInstructionsPath, readRemediationInstructionsFile } from './log_config_files/runtime/management_storage.js';
13
15
  import { hookLogSessionBanner, hookRunLog, logRemediationApplyFailure } from './log_config_files/runtime/hook_logger.js';
14
16
  import { getStoryId, bufferFunctionalRun } from './log_config_files/runtime/client_event_reporter.js';
15
17
  import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForGate, parseComplianceLogArg, } from './log_config_files/runtime/compliance_session_log.js';
@@ -22,6 +24,7 @@ import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid
22
24
  import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
23
25
  import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
24
26
  import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
27
+ import { agentApprovalViolation, approvedTenantViolation, codexTenantViolation, codexTenants, emailAllowlistViolation, cursorTeamViolation, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, } from './approved_tenant_gate.js';
25
28
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
26
29
  /** After verified (or failed verify), push enforcement + session log immediately — do not wait for background runner. */
27
30
  async function flushRemediationOutcomeToServer(complianceLogPath) {
@@ -92,8 +95,20 @@ function blockPayload(ide, violationMessage) {
92
95
  }
93
96
  return JSON.stringify({ continue: false, user_message: text });
94
97
  }
98
+ /**
99
+ * Codex's block: `reason` only. Codex shows `reason` as the block and `systemMessage` as a separate
100
+ * warning, so the Claude-shaped payload (which carries both) prints the same sentence twice.
101
+ */
102
+ function codexBlockPayload(violationMessage) {
103
+ return JSON.stringify({ decision: 'block', reason: 'Prompt blocked by Optimus: ' + violationMessage });
104
+ }
95
105
  function getManifestStalenessMs() {
96
106
  try {
107
+ // When the remediations were written, not the file's mtime: approval-list writes rewrite the
108
+ // file on every sync without touching the remediations.
109
+ const writtenAt = Date.parse(readRemediationInstructionsFile().remediations_written_at ?? '');
110
+ if (Number.isFinite(writtenAt))
111
+ return Date.now() - writtenAt;
97
112
  const path = getRemediationInstructionsPath();
98
113
  if (!existsSync(path))
99
114
  return null;
@@ -218,171 +233,289 @@ export async function runCompliancePromptGateCli() {
218
233
  printAllow(parseIde());
219
234
  }
220
235
  }
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);
236
+ /**
237
+ * Whether tenant and email-domain blocking is switched off on this machine: a developer's own
238
+ * escape hatch, `OPTIMUS_TENANT_GATE=off` in ~/.optimuslabs/management/optimus_dev.env. Honoured only
239
+ * when that file also says `environment=development`, so it can never turn enforcement off on a
240
+ * customer's machine.
241
+ */
242
+ export function tenantGateSwitchedOff() {
243
+ const envPath = managementEnvPath();
244
+ if (!envPath || readEnvironment() !== 'development')
245
+ return false;
246
+ try {
247
+ return /^OPTIMUS_TENANT_GATE\s*=\s*off\s*$/im.test(readFileSync(envPath, 'utf8'));
228
248
  }
229
- else {
230
- hookLogSessionBanner('compliance_prompt_gate (before submit)');
249
+ catch {
250
+ return false;
231
251
  }
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);
252
+ }
253
+ /** The email the agent is signed in with, '' when it has none we can read, null for an agent not checked. */
254
+ function loginEmail(agent) {
255
+ if (agent === 'claude')
256
+ return readClaudeSignedInTenant()?.email ?? '';
257
+ // Claude Desktop's Code tab runs Claude Code under Desktop's own login, checked as its own agent.
258
+ if (agent === 'claude_desktop')
259
+ return readClaudeDesktopSignedInTenant()?.email ?? '';
260
+ if (agent === 'codex')
261
+ return readCodexSignedIn()?.email ?? '';
262
+ if (agent === 'cursor')
263
+ return readCursorSignedInTeams()?.email ?? '';
264
+ return null;
265
+ }
266
+ /** Why this agent's signed-in tenant is not approved, or null when it is (or cannot be read). */
267
+ function tenantViolation(agent) {
268
+ const approved = readRemediationInstructionsFile().approved_tenants;
269
+ if (agent === 'claude')
270
+ return approvedTenantViolation('claude', approved, readClaudeSignedInTenant());
271
+ if (agent === 'claude_desktop') {
272
+ return approvedTenantViolation('claude_desktop', approved, readClaudeDesktopSignedInTenant());
273
+ }
274
+ // A personal account (Cursor on no team, Codex on a personal plan) has no tenant to approve: only
275
+ // the email allowlist decides it.
276
+ if (agent === 'codex') {
277
+ const signedIn = readCodexSignedIn();
278
+ const tenants = signedIn ? codexTenants(signedIn) : null;
279
+ return tenants?.some((t) => !t.startsWith('@')) ? codexTenantViolation(approved, signedIn) : null;
280
+ }
281
+ if (agent === 'cursor') {
282
+ const signedIn = readCursorSignedInTeams();
283
+ return signedIn?.team_ids.length ? cursorTeamViolation(approved, signedIn) : null;
239
284
  }
285
+ return null;
286
+ }
287
+ /** The block for a login: which check refused it and why, or null when every check passes. */
288
+ function loginBlock(agent, email) {
289
+ const synced = readRemediationInstructionsFile();
290
+ const agentMsg = agentApprovalViolation(agent, synced.approved_agents);
291
+ if (agentMsg)
292
+ return { event: 'prompt_gate_block_unapproved_agent', reason: agentMsg };
293
+ if (email === null)
294
+ return null;
295
+ const emailMsg = emailAllowlistViolation(email, synced.allowed_email_domains);
296
+ if (emailMsg)
297
+ return { event: 'prompt_gate_block_email_not_allowlisted', reason: emailMsg };
298
+ const tenantMsg = tenantViolation(agent);
299
+ return tenantMsg ? { event: 'prompt_gate_block_unapproved_tenant', reason: tenantMsg } : null;
300
+ }
301
+ /**
302
+ * Block the prompt unless the agent is approved, the login's email is on the organization's email
303
+ * allowlist and its tenant is approved (all as remediation sync stored them), printing the block
304
+ * payload; true when it did. A login whose email or tenant cannot be read passes that check, and a
305
+ * machine that has synced nothing yet is not blocked.
306
+ */
307
+ function blockedByLogin(agent, ide) {
308
+ if (tenantGateSwitchedOff())
309
+ return false;
310
+ const email = loginEmail(agent);
311
+ const block = loginBlock(agent, email);
312
+ if (!block)
313
+ return false;
314
+ logRemediationApplyFailure(block.event, { reason: block.reason, ide, agent, email: email ?? '' });
315
+ console.log(agent === 'codex' ? codexBlockPayload(block.reason) : blockPayload(ide, block.reason));
316
+ return true;
317
+ }
318
+ /** Pull the organization's latest remediations and approved tenants, waiting at most 3 seconds. */
319
+ async function syncApprovals() {
240
320
  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
- }
321
+ if (!hw)
322
+ return;
323
+ try {
324
+ await Promise.race([syncRemediations(loadEndpointBase(), hw), sleep(3000)]);
325
+ }
326
+ catch {
327
+ // Network or auth failure — fall back to local file state.
251
328
  }
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.
329
+ }
330
+ /** Agents whose remediations are JSON files written to disk at once (no deferred SQLite apply). */
331
+ function isImmediateJsonIde(ide) {
332
+ return ide === 'claude' || ide === 'copilot' || ide === 'opencode';
333
+ }
334
+ /** The compliance session log this run writes to, or null when it only logs to the hook log. */
335
+ function openComplianceLog() {
336
+ const complianceLogPath = parseComplianceLogArg(process.argv);
337
+ if (complianceLogPath)
338
+ return initComplianceSessionForGate(complianceLogPath);
339
+ hookLogSessionBanner('compliance_prompt_gate (before submit)');
340
+ return complianceLogPath;
341
+ }
342
+ /** Report remediations verified (or not) after the restart an earlier autofix asked for. */
343
+ async function reportPostRestartVerification(agent, complianceLogPath) {
344
+ const status = runLocalRemediationComplianceCheck(agent);
345
+ const postRestartVerify = reportPostRestartVerificationOutcomes(status.violations);
346
+ if (postRestartVerify.outcomes.length === 0)
347
+ return;
348
+ await Promise.allSettled(postRestartVerify.reportPromises);
349
+ await flushRemediationOutcomeToServer(complianceLogPath);
350
+ hookRunLog(`compliance_prompt_gate: post_restart_verification count=${postRestartVerify.outcomes.length} quarantined=${postRestartVerify.outcomes.filter((o) => o.status === 'quarantined').length}`);
351
+ }
352
+ /**
353
+ * Secondary-satisfied: primary checks failed but settings.json (or equiv) has the fix.
354
+ * Upload those files fire-and-forget so the backend can resolve the finding immediately.
355
+ */
356
+ function uploadSecondarySatisfied(status) {
255
357
  for (const e of status.secondarySatisfied ?? []) {
256
358
  _uploadSecondaryFile(e).catch(() => undefined);
257
359
  }
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));
360
+ }
361
+ /** Allow with an advisory when the local manifest is too old to enforce; true when it did. */
362
+ function allowedOnStaleManifest(status, ide) {
363
+ const staleMs = getManifestStalenessMs();
364
+ if (staleMs === null || staleMs <= MANIFEST_STALE_MS)
365
+ return false;
366
+ const staleDays = Math.floor(staleMs / (24 * 60 * 60 * 1000));
367
+ const advisory = `Remediation enforcement suspended: local remediation manifest is stale (${staleDays} days old). ` +
368
+ 'Please reconnect to sync latest policy state.';
369
+ logRemediationApplyFailure('prompt_gate_stale_manifest_advisory', {
370
+ reason: advisory,
371
+ stale_days: staleDays,
372
+ violation_count: status.violations.length,
373
+ });
374
+ printAllowWithAdvisory(ide, advisory);
375
+ return true;
376
+ }
377
+ function allowAllQuarantined(status, ide) {
378
+ hookRunLog(`compliance_prompt_gate: all ${status.violations.length} violation(s) quarantined — allowing prompt silently (backend notified on quarantine)`);
379
+ logRemediationApplyFailure('prompt_gate_quarantined_silent_allow', {
380
+ reason: 'all violations quarantined; no dialog — enforcement paused on this machine',
381
+ quarantined_count: status.violations.length,
382
+ });
383
+ printAllow(ide);
384
+ }
385
+ function blockAutofixFailed(ide, failedViolations, reason) {
386
+ const ids = failedViolations.map((v) => `[${v.finding_formatted_id}]`).join(', ');
387
+ const msg = `Auto-fix failed for ${ids} — please fix manually or contact your security team.\n\n${failedViolations[0]?.message ?? ''}`;
388
+ logRemediationApplyFailure('prompt_gate_block_autofix_failed', {
389
+ reason,
390
+ ide,
391
+ failed_formatted_ids: failedViolations.map((v) => v.finding_formatted_id).join(', '),
392
+ detail: failedViolations[0]?.message ?? '',
393
+ });
394
+ console.log(blockPayload(ide, msg));
395
+ }
396
+ /**
397
+ * Claude / Copilot: JSON remediations are written immediately; merge/verify timing can still leave
398
+ * the in-process recheck red for the same UUID — allow in that case for immediate JSON agents.
399
+ */
400
+ function recheckStaleAfterImmediateApply(ide, recheck, recheckOk, appliedViolations) {
401
+ const appliedUuids = new Set(appliedViolations.map((v) => v.uuid));
402
+ return (isImmediateJsonIde(ide) &&
403
+ !recheckOk &&
404
+ recheck.violations.length > 0 &&
405
+ recheck.violations.every((v) => appliedUuids.has(v.uuid)));
406
+ }
407
+ /**
408
+ * Immediate-JSON agents (Claude / Claude Desktop, Copilot, OpenCode) write the config file to
409
+ * disk synchronously; a restart_command, if present, only reloads the already-compliant file
410
+ * into the running app — it is not the mechanism that makes the fix take effect. So report
411
+ * "verified" now instead of leaving the finding "pending" in the UI until the next prompt.
412
+ * Cursor JSON: disk recheck OK → verified before restart. Cursor deferred vscdb → verify after restart.
413
+ */
414
+ function autofixVerifiedNow(ide, result, diskProvenCompliant) {
415
+ const restartCount = result.restartCommands.length;
416
+ const cursorRestartVerifyPending = cursorAutofixDefersInlineVerify(ide, restartCount, result.deferredSqlitePending === true, diskProvenCompliant);
417
+ return (!result.deferredSqlitePending &&
418
+ !cursorRestartVerifyPending &&
419
+ diskProvenCompliant &&
420
+ (restartCount === 0 || isImmediateJsonIde(ide) || ide === 'cursor'));
421
+ }
422
+ function autofixMessage(ide, result) {
423
+ const { fixed, appliedViolations } = result;
424
+ if (ide === 'cursor' && result.restartCommands.length > 0)
425
+ return formatCursorRestartAutofixDialog(appliedViolations);
426
+ if (ide === 'claude')
427
+ return formatClaudeAutofixDialog(appliedViolations);
428
+ if (ide === 'copilot')
429
+ return formatCopilotAutofixDialog(appliedViolations);
430
+ if (ide === 'opencode')
431
+ return formatOpenCodeAutofixDialog(appliedViolations);
432
+ const changePreview = formatRemediationChangePreviewForApplied(appliedViolations);
433
+ const changePreviewSuffix = changePreview ? `\n\n${changePreview}` : '';
434
+ return `Optimus Labs auto-fixed ${fixed} ${fixed === 1 ? 'ruleset violation' : 'ruleset violations'}:\n\n${appliedViolations
435
+ .map((v) => autofixDialogLine(v))
436
+ .join('\n')}${changePreviewSuffix}`;
437
+ }
438
+ /** Report the autofix verified when it already is, and print the autofix dialog (and any restarts). */
439
+ async function printAutofixApplied(ide, result, diskProvenCompliant, complianceLogPath) {
440
+ if (autofixVerifiedNow(ide, result, diskProvenCompliant)) {
441
+ confirmAppliedAutofixVerified(result.appliedViolations, result.reportPromises);
442
+ await Promise.allSettled(result.reportPromises);
443
+ await flushRemediationOutcomeToServer(complianceLogPath);
444
+ }
445
+ const payload = { __optimus_autofix: true, autofix_message: autofixMessage(ide, result) };
446
+ if (result.restartCommands.length > 0)
447
+ payload.restart_commands = result.restartCommands;
448
+ console.log(JSON.stringify(payload));
449
+ // Restarts: always executed by optimus-compliance-check.sh via execute_trusted_restarts (TS allowlist + spawn).
450
+ }
451
+ function blockRecheckFailed(ide, recheck, deferredSqlitePending) {
452
+ const msg = recheck.violations[0]?.message ?? 'A security ruleset violation has been detected.';
453
+ logRemediationApplyFailure('prompt_gate_block_recheck_failed', {
454
+ reason: 'after autofix, local compliance recheck still reports violations',
455
+ ide,
456
+ deferred_sqlite_pending: deferredSqlitePending,
457
+ first_uuid: recheck.violations[0]?.uuid ?? '',
458
+ message: msg,
459
+ });
460
+ console.log(blockPayload(ide, msg));
461
+ }
462
+ /** After an autofix fixed something: block on a partial failure, else recheck and allow or block. */
463
+ async function finishAppliedAutofix(agent, ide, result, complianceLogPath) {
464
+ // Wait for all server reports before exiting so the POST lands.
465
+ await Promise.allSettled(result.reportPromises);
466
+ // Deferred SQLite can leave recheck failing until restart; that must not hide a separate
467
+ // failed autofix (e.g. JSON remediation failed while vscdb was only queued).
468
+ if (result.failedViolations.length > 0) {
469
+ blockAutofixFailed(ide, result.failedViolations, 'autofix had fixed>0 path but failedViolations non-empty after apply');
470
+ return;
471
+ }
472
+ const recheck = runLocalRemediationComplianceCheck(agent);
473
+ const recheckOk = recheck.status === 'ok' || recheck.violations.length === 0;
474
+ // Cursor: tolerate a failing recheck only when SQLite updates are deferred (apply after restart).
475
+ const staleAfterImmediateApply = recheckStaleAfterImmediateApply(ide, recheck, recheckOk, result.appliedViolations);
476
+ if (staleAfterImmediateApply) {
477
+ hookRunLog(`compliance_prompt_gate: ${ide} — autofix wrote JSON; recheck still flags same UUID(s) — proceeding (immediate apply)`);
478
+ }
479
+ if (result.deferredSqlitePending || recheckOk || staleAfterImmediateApply) {
480
+ await printAutofixApplied(ide, result, recheckOk || staleAfterImmediateApply, complianceLogPath);
481
+ return;
482
+ }
483
+ blockRecheckFailed(ide, recheck, result.deferredSqlitePending);
484
+ }
485
+ function blockUnfixedViolations(status, ide) {
486
+ const msg = status.violations[0]?.message ?? 'A security ruleset violation has been detected.';
487
+ logRemediationApplyFailure('prompt_gate_block_violations_no_autofix_applied', {
488
+ reason: 'violations on submit but autofix did not succeed — see autofix_skipped_not_allowed, enforceRemediation, or compliance_check entries',
489
+ ide,
490
+ first_uuid: status.violations[0]?.uuid ?? '',
491
+ finding_formatted_id: status.violations[0]?.finding_formatted_id ?? '',
492
+ });
493
+ console.log(blockPayload(ide, msg));
494
+ }
495
+ /** Local violations found: allow (stale manifest, all quarantined), autofix, or block. */
496
+ async function enforceViolations(agent, ide, status, complianceLogPath) {
497
+ if (allowedOnStaleManifest(status, ide))
498
+ return;
499
+ const actionableViolations = status.violations.filter((v) => !isRemediationQuarantined(v.uuid));
500
+ if (actionableViolations.length === 0) {
501
+ allowAllQuarantined(status, ide);
383
502
  return;
384
503
  }
385
- // No violations: clean up satisfied one-time remediations so they don't linger locally forever.
504
+ const applied = applyAutofixViolations(actionableViolations, agent);
505
+ const result = { ...applied, appliedViolations: applied.appliedViolations ?? [] };
506
+ hookRunLog(`compliance_prompt_gate: autofix result ide=${ide} fixed=${result.fixed} applied=${result.appliedViolations.length} failed=${result.failedViolations.length} restart_commands=${result.restartCommands.length} deferred_sqlite_pending=${result.deferredSqlitePending}`);
507
+ if (result.fixed > 0) {
508
+ await finishAppliedAutofix(agent, ide, result, complianceLogPath);
509
+ return;
510
+ }
511
+ if (result.failedViolations.length > 0) {
512
+ blockAutofixFailed(ide, result.failedViolations, 'autofix failed (fixed=0 branch or partial failure)');
513
+ return;
514
+ }
515
+ blockUnfixedViolations(status, ide);
516
+ }
517
+ /** No violations: clean up satisfied one-time remediations so they don't linger locally forever. */
518
+ async function finishCompliant(agent, ide) {
386
519
  const pruned = pruneSatisfiedOneTimeRemediations(agent);
387
520
  if (pruned.removed > 0) {
388
521
  await Promise.allSettled(pruned.reportPromises);
@@ -391,6 +524,32 @@ export async function runCompliancePromptGate() {
391
524
  await Promise.allSettled(reportCompliantRemediationVerifiedStatus(agent));
392
525
  printAllow(ide);
393
526
  }
527
+ /** Exported for tests; runs when `dist/compliance_prompt_gate.js` is the process entry (argv[1]). */
528
+ export async function runCompliancePromptGate() {
529
+ const ide = parseIde();
530
+ const agent = parseAgent(ide);
531
+ const complianceLogPath = openComplianceLog();
532
+ // `--tenant-only`: an agent whose hook enforces approved tenants and nothing else yet (Codex). Its
533
+ // remediations are not enforced from here, so turning this hook on blocks nothing but tenants.
534
+ if (process.argv.includes('--tenant-only')) {
535
+ await syncApprovals();
536
+ if (!blockedByLogin(agent, ide))
537
+ printAllow(ide);
538
+ return;
539
+ }
540
+ await reportPostRestartVerification(agent, complianceLogPath);
541
+ await syncApprovals();
542
+ const status = runLocalRemediationComplianceCheck(agent);
543
+ uploadSecondarySatisfied(status);
544
+ // Approved tenants (OPT-687): checked against the list the sync above stored locally.
545
+ if (blockedByLogin(agent, ide))
546
+ return;
547
+ if (status.status === 'fail' && status.violations.length > 0) {
548
+ await enforceViolations(agent, ide, status, complianceLogPath);
549
+ return;
550
+ }
551
+ await finishCompliant(agent, ide);
552
+ }
394
553
  if (isRunAsCliModule()) {
395
554
  runCompliancePromptGateCli().finally(() => process.exit(0));
396
555
  }