@optimuslabs/harness-map-staging 1.5.25 → 1.5.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approved_tenant_gate.js +302 -0
- package/dist/compliance_prompt_gate.js +315 -156
- package/dist/log_config_files/collection/claude_desktop_orgs_collector.js +173 -0
- package/dist/log_config_files/collection/codex_token_usage_collector.js +1 -1
- package/dist/log_config_files/collection/config_collector.js +1 -0
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +127 -91
- package/dist/log_config_files/runtime/main_runner.js +67 -147
- package/dist/log_config_files/runtime/management_storage.js +46 -2
- package/dist/log_config_files/runtime/remediation_sync.js +156 -109
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
249
|
+
catch {
|
|
250
|
+
return false;
|
|
231
251
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
}
|