@optimuslabs/harness-map-staging 1.5.36 → 1.5.39

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.
@@ -22,9 +22,12 @@ import { parseJsonWithJsoncFallback } from './log_config_files/readers/file_read
22
22
  import { PORTABLE_CURSOR_USER_SETTINGS, resolveRemediationConfigPath, resolveRemediationUploadFileType, } from './log_config_files/runtime/remediation_config_path.js';
23
23
  import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid.js';
24
24
  import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
25
+ import { blockedCommandMessage, blockedSkillOfCommand, claudeProcess, recordSessionProcess, stoppedSessionMessage, openSkillBlockWindow, stoppingSkills, } from './log_config_files/runtime/skill_block_sessions.js';
25
26
  import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
27
+ import { agentAccessDecision, approvalId } from './agent_access/decision.js';
28
+ import { launchAgentAccessWindow } from './agent_access/window_runner.js';
26
29
  import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
27
- import { agentApprovalViolation, approvedTenantViolation, codexTenantViolation, cursorTeamViolation, ORGANIZATION_LEAD, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, unapprovedEmailDomainViolation, } from './approved_tenant_gate.js';
30
+ import { agentApprovalViolation, approvedTenantBlock, codexTenantBlock, cursorTeamBlock, emailDomainBlockWindow, ORGANIZATION_LEAD, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, tenantBlockText, tenantBlockWindow, unapprovedEmailDomainBlock, unapprovedEmailDomainViolation, } from './approved_tenant_gate.js';
28
31
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
29
32
  /** After verified (or failed verify), push enforcement + session log immediately — do not wait for background runner. */
30
33
  async function flushRemediationOutcomeToServer(complianceLogPath) {
@@ -93,12 +96,18 @@ const BLOCK_TITLE = 'Optimus Labs\n\n';
93
96
  function titled(violationMessage) {
94
97
  return violationMessage.startsWith(ORGANIZATION_LEAD) ? violationMessage : BLOCK_TITLE + violationMessage;
95
98
  }
96
- function blockPayload(ide, violationMessage) {
99
+ /**
100
+ * `windowShown` tells Cursor's hook the blocked-agent window already says why, so it skips its own
101
+ * alert of the same message (`__optimus_window`); Cursor itself ignores the extra field.
102
+ */
103
+ function blockPayload(ide, violationMessage, windowShown = false) {
97
104
  const text = titled(violationMessage);
98
105
  if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
99
- return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
106
+ // Claude's hook skips its own alert too, and takes the field out before Claude Code reads it.
107
+ const flag = windowShown && ide === 'claude' ? { __optimus_window: true } : {};
108
+ return JSON.stringify({ decision: 'block', reason: text, systemMessage: text, ...flag });
100
109
  }
101
- return JSON.stringify({ continue: false, user_message: text });
110
+ return JSON.stringify({ continue: false, user_message: text, ...(windowShown ? { __optimus_window: true } : {}) });
102
111
  }
103
112
  /**
104
113
  * Codex's block: `reason` only. Codex shows `reason` as the block and `systemMessage` as a separate
@@ -269,32 +278,42 @@ function loginEmail(agent) {
269
278
  return null;
270
279
  }
271
280
  /** Why this agent's signed-in tenant is blocked, or null when it is not (or cannot be read). */
272
- function tenantViolation(agent) {
281
+ function tenantBlock(agent) {
273
282
  const { approved_tenants: approved, team_email_domains: teamDomains } = readRemediationInstructionsFile();
274
283
  if (agent === 'claude')
275
- return approvedTenantViolation('claude', approved, readClaudeSignedInTenant());
284
+ return approvedTenantBlock('claude', approved, readClaudeSignedInTenant());
276
285
  if (agent === 'claude_desktop') {
277
- return approvedTenantViolation('claude_desktop', approved, readClaudeDesktopSignedInTenant());
286
+ return approvedTenantBlock('claude_desktop', approved, readClaudeDesktopSignedInTenant());
278
287
  }
279
288
  if (agent === 'codex')
280
- return codexTenantViolation(approved, readCodexSignedIn());
289
+ return codexTenantBlock(approved, readCodexSignedIn());
281
290
  if (agent === 'cursor')
282
- return cursorTeamViolation(approved, readCursorSignedInTeams(), teamDomains);
291
+ return cursorTeamBlock(approved, readCursorSignedInTeams(), teamDomains);
283
292
  return null;
284
293
  }
285
- /** The block for a login: which check refused it and why, or null when every check passes. */
286
- function loginBlock(agent, email) {
294
+ /**
295
+ * The block for a login: which check refused it, why, and the blocked-agent window's words for a
296
+ * tenant or email-domain block (an agent's own block window comes synced); null when all pass.
297
+ */
298
+ function loginBlock(agent, email, agentAllowed) {
287
299
  const synced = readRemediationInstructionsFile();
288
- const agentMsg = agentApprovalViolation(agent, synced.approved_agents);
300
+ const agentMsg = agentAllowed ? null : agentApprovalViolation(agent, synced.approved_agents);
289
301
  if (agentMsg)
290
302
  return { event: 'prompt_gate_block_unapproved_agent', reason: agentMsg };
291
303
  if (email === null)
292
304
  return null;
293
- const emailMsg = unapprovedEmailDomainViolation(email, synced.unapproved_email_domains);
294
- if (emailMsg)
295
- return { event: 'prompt_gate_block_unapproved_email_domain', reason: emailMsg };
296
- const tenantMsg = tenantViolation(agent);
297
- return tenantMsg ? { event: 'prompt_gate_block_unapproved_tenant', reason: tenantMsg } : null;
305
+ const emailBlock = unapprovedEmailDomainBlock(email, synced.unapproved_email_domains);
306
+ if (emailBlock) {
307
+ return {
308
+ event: 'prompt_gate_block_unapproved_email_domain',
309
+ reason: unapprovedEmailDomainViolation(email, synced.unapproved_email_domains) ?? '',
310
+ window: emailDomainBlockWindow(agent, emailBlock, synced.team_email_domains),
311
+ };
312
+ }
313
+ const tenant = tenantBlock(agent);
314
+ if (!tenant)
315
+ return null;
316
+ return { event: 'prompt_gate_block_unapproved_tenant', reason: tenantBlockText(tenant), window: tenantBlockWindow(tenant) };
298
317
  }
299
318
  /**
300
319
  * Block the prompt when the agent is not approved, or the login's email domain or its tenant was
@@ -306,11 +325,17 @@ function blockedByLogin(agent, ide) {
306
325
  if (tenantGateSwitchedOff())
307
326
  return false;
308
327
  const email = loginEmail(agent);
309
- const block = loginBlock(agent, email);
328
+ const synced = readRemediationInstructionsFile();
329
+ const access = agentAccessDecision(agent, synced.approved_agents);
330
+ const block = loginBlock(agent, email, access.allowed);
310
331
  if (!block)
311
332
  return false;
312
333
  logRemediationApplyFailure(block.event, { reason: block.reason, ide, agent, email: email ?? '' });
313
- console.log(agent === 'codex' ? codexBlockPayload(block.reason) : blockPayload(ide, block.reason));
334
+ // The blocked-agent window opens beside every block, in the background, so the hook answers at
335
+ // once: an agent's own from the synced approvals, a login's from the words built here. Where it
336
+ // could not start, Cursor's hook keeps its alert.
337
+ const windowShown = launchAgentAccessWindow(approvalId(agent), block.window);
338
+ console.log(agent === 'codex' ? codexBlockPayload(block.reason) : blockPayload(ide, block.reason, windowShown));
314
339
  return true;
315
340
  }
316
341
  /** Pull the organization's latest remediations and approved tenants, waiting at most 3 seconds. */
@@ -325,6 +350,36 @@ async function syncApprovals() {
325
350
  // Network or auth failure — fall back to local file state.
326
351
  }
327
352
  }
353
+ /**
354
+ * Skill block killswitch, Claude Code only for now: refuse the prompt of a session that loaded a
355
+ * skill which is now blocked, or whose `claude` process did in an earlier session (a `/clear` keeps
356
+ * the skill), docs/skill-block/DESIGN.md. True when it refused.
357
+ */
358
+ function blockedBySkillBlock(agent, ide, sessionId) {
359
+ if (ide !== 'claude')
360
+ return false;
361
+ const proc = claudeProcess();
362
+ try {
363
+ recordSessionProcess(sessionId, proc);
364
+ }
365
+ catch (err) {
366
+ hookRunLog(`skill_block_process_table_error: ${err instanceof Error ? err.message : String(err)}`);
367
+ }
368
+ const skills = stoppingSkills(sessionId, proc);
369
+ if (skills.length > 0) {
370
+ logRemediationApplyFailure('skill_block_session_stopped', { ide, agent, skills: skills.join(',') });
371
+ const windowShown = openSkillBlockWindow(skills[0]);
372
+ console.log(blockPayload(ide, stoppedSessionMessage(skills), windowShown));
373
+ return true;
374
+ }
375
+ const typed = blockedSkillOfCommand(process.env.OPTIMUS_PROMPT_FIRST_LINE || '', () => proc?.startedAt ?? null);
376
+ if (!typed)
377
+ return false;
378
+ logRemediationApplyFailure('skill_block_command_refused', { ide, agent, skills: typed });
379
+ const windowShown = openSkillBlockWindow(typed);
380
+ console.log(blockPayload(ide, blockedCommandMessage(typed), windowShown));
381
+ return true;
382
+ }
328
383
  /** Agents whose remediations are JSON files written to disk at once (no deferred SQLite apply). */
329
384
  function isImmediateJsonIde(ide) {
330
385
  return ide === 'claude' || ide === 'copilot' || ide === 'opencode';
@@ -537,6 +592,8 @@ export async function runCompliancePromptGate() {
537
592
  }
538
593
  await reportPostRestartVerification(agent, complianceLogPath);
539
594
  await syncApprovals();
595
+ if (blockedBySkillBlock(agent, ide, (process.env.OPTIMUS_SESSION_ID || '').trim()))
596
+ return;
540
597
  const status = runLocalRemediationComplianceCheck(agent);
541
598
  uploadSecondarySatisfied(status);
542
599
  // Approved tenants (OPT-687): checked against the list the sync above stored locally.
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants, lstatSync, readlinkSync, realpathSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { readMarkdownFile } from '../readers/file_readers.js';
4
+ import { quarantineReceiptFor } from '../runtime/skill_block.js';
4
5
  function isMissingPathError(err) {
5
6
  const code = err?.code;
6
7
  return code === 'ENOENT' || code === 'ENOTDIR';
@@ -173,6 +174,14 @@ export function collectSkillMdRecord(skillDir, fileType, source, skillFilename =
173
174
  ...extraRaw,
174
175
  };
175
176
  const symlink_info = skillLinkPayload(info);
177
+ const quarantine = quarantineReceiptFor(skillDir);
178
+ if (quarantine) {
179
+ // Quarantined for a skill block: moved into the vault, unchanged since (docs/skill-block). The
180
+ // receipt says when, and which files with which hashes; never their contents.
181
+ raw.locked_by_optimus = true;
182
+ raw.quarantine = { quarantined_at: quarantine.quarantined_at, files: quarantine.files };
183
+ return { file_type: fileType, file_path: mdPath, raw_content: raw, symlink_info };
184
+ }
176
185
  if (info.status === 'valid') {
177
186
  const content = readMarkdownFile(mdPath);
178
187
  if (content !== null) {
@@ -77,6 +77,18 @@ export function getRemediationStateDir() {
77
77
  export function getRemediationInstructionsPath() {
78
78
  return join(getRemediationStateDir(), REMEDIATION_INSTRUCTIONS_BASENAME);
79
79
  }
80
+ /** The mode each locked skill folder had before it was locked, so unblocking can restore it. */
81
+ export function getSkillBlockStatePath() {
82
+ return join(getRemediationStateDir(), 'skill_block_state.json');
83
+ }
84
+ /** The vault of quarantined skill folders and their receipts (docs/skill-block, "Quarantine"). */
85
+ export function getQuarantineDir() {
86
+ return join(getManagementDir(), 'quarantine');
87
+ }
88
+ /** The session ids each `claude` process has run, so a `/clear` cannot escape a skill block stop. */
89
+ export function getSkillBlockProcessesPath() {
90
+ return join(getRemediationStateDir(), 'skill_block_processes.json');
91
+ }
80
92
  export function getDeferredVscdbApplyPath() {
81
93
  return join(getVscdbStateDir(), DEFERRED_VSCDB_APPLY_BASENAME);
82
94
  }
@@ -139,12 +151,18 @@ function writeInstructionsFile(file, remediationsWrittenAt) {
139
151
  remediations_written_at: remediationsWrittenAt,
140
152
  });
141
153
  }
154
+ // The lists the sync replaces on every answer (the skill blocks with them); a writer that passes
155
+ // only `remediations` keeps them.
142
156
  const APPROVAL_LISTS = [
143
157
  'approved_tenants',
144
158
  'unapproved_email_domains',
145
159
  'allowed_email_domains',
146
160
  'approved_agents',
147
161
  'team_email_domains',
162
+ 'skill_blocks',
163
+ 'skill_block_sessions',
164
+ 'skill_block_windows',
165
+ 'agent_access_windows',
148
166
  ];
149
167
  /** Each approval list from `file` when it names one, else the one on disk; absent ones left out. */
150
168
  function keptApprovalLists(file, onDisk) {
@@ -172,6 +190,10 @@ function writeApprovalList(patch) {
172
190
  export function writeUnapprovedEmailDomains(unapproved_email_domains) {
173
191
  writeApprovalList({ unapproved_email_domains });
174
192
  }
193
+ /** Replace only the blocked-agent windows, keeping everything else on disk. */
194
+ export function writeAgentAccessWindows(agent_access_windows) {
195
+ writeApprovalList({ agent_access_windows });
196
+ }
175
197
  /** Replace only the approved agents, keeping everything else on disk. */
176
198
  export function writeApprovedAgents(approved_agents) {
177
199
  writeApprovalList({ approved_agents });
@@ -184,6 +206,18 @@ export function writeTeamEmailDomains(team_email_domains) {
184
206
  export function writeAllowedEmailDomains(allowed_email_domains) {
185
207
  writeApprovalList({ allowed_email_domains });
186
208
  }
209
+ /** Replace only the skill blocks, keeping everything else on disk. */
210
+ export function writeSkillBlocks(skill_blocks) {
211
+ writeApprovalList({ skill_blocks });
212
+ }
213
+ /** Replace only the sessions to stop for skill blocks, keeping everything else on disk. */
214
+ export function writeSkillBlockSessions(skill_block_sessions) {
215
+ writeApprovalList({ skill_block_sessions });
216
+ }
217
+ /** Replace only the skill block windows, keeping everything else on disk. */
218
+ export function writeSkillBlockWindows(skill_block_windows) {
219
+ writeApprovalList({ skill_block_windows });
220
+ }
187
221
  /** Replace only the approved-tenant list, keeping the remediations already on disk. */
188
222
  export function writeApprovedTenants(approved_tenants) {
189
223
  writeApprovalList({ approved_tenants });
@@ -4,7 +4,9 @@ import { homedir } from 'node:os';
4
4
  import { executeBody } from '../../endpoint_client/http_transport.js';
5
5
  import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
6
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
- import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeTeamEmailDomains, writeApprovedAgents, writeRemediationInstructionsFile, } from './management_storage.js';
7
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeTeamEmailDomains, writeApprovedAgents, writeAgentAccessWindows, writeRemediationInstructionsFile, writeSkillBlockSessions, writeSkillBlockWindows, writeSkillBlocks, } from './management_storage.js';
8
+ import { applySkillBlocks } from './skill_block.js';
9
+ import { openQuarantineWindows } from './skill_block_sessions.js';
8
10
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
9
11
  import { ensureAuthentication } from '../auth/auth_flow.js';
10
12
  import { createSignature } from '../sender/signing.js';
@@ -1656,6 +1658,69 @@ function storeApprovals(syncResult) {
1656
1658
  const teamDomains = syncResult.team_email_domains;
1657
1659
  if (Array.isArray(teamDomains))
1658
1660
  writeTeamEmailDomains(lowercaseStrings(teamDomains));
1661
+ storeAgentAccess(syncResult);
1662
+ }
1663
+ /** The blocked-agent window's words for each agent that is not approved. */
1664
+ function storeAgentAccess(syncResult) {
1665
+ const windows = syncResult.agent_access_windows;
1666
+ if (windows && typeof windows === 'object' && !Array.isArray(windows))
1667
+ writeAgentAccessWindows(windows);
1668
+ }
1669
+ function isSkillBlockEntry(value) {
1670
+ if (!value || typeof value !== 'object')
1671
+ return false;
1672
+ const entry = value;
1673
+ return typeof entry.skill_dir === 'string' && entry.skill_dir !== '' && typeof entry.resource_uuid === 'string';
1674
+ }
1675
+ /** Keep the server's sessions to stop (for the prompt gate); an older server sends none. */
1676
+ function storeSkillBlockSessions(sessions) {
1677
+ if (!Array.isArray(sessions))
1678
+ return;
1679
+ writeSkillBlockSessions(sessions
1680
+ .filter((s) => typeof s?.session_id === 'string' && s.session_id !== '')
1681
+ .map((s) => ({
1682
+ session_id: s.session_id,
1683
+ resource_uuid: typeof s.resource_uuid === 'string' ? s.resource_uuid : '',
1684
+ skill_name: typeof s.skill_name === 'string' ? s.skill_name : '',
1685
+ })));
1686
+ }
1687
+ /** Keep the server's skill block windows (words only, drawn as sent); an older server sends none. */
1688
+ function storeSkillBlockWindows(windows) {
1689
+ if (!windows || typeof windows !== 'object' || Array.isArray(windows))
1690
+ return;
1691
+ const kept = {};
1692
+ for (const [uuid, words] of Object.entries(windows)) {
1693
+ if (words && typeof words === 'object' && !Array.isArray(words))
1694
+ kept[uuid] = words;
1695
+ }
1696
+ writeSkillBlockWindows(kept);
1697
+ }
1698
+ /**
1699
+ * Store the server's skill blocks and make the folders on disk match them. An older server sends
1700
+ * no `skill_blocks`: the folders are then left as they are, never unlocked by mistake.
1701
+ */
1702
+ function enforceSkillBlocks(syncResult) {
1703
+ const blocks = syncResult.skill_blocks;
1704
+ if (!Array.isArray(blocks))
1705
+ return;
1706
+ const entries = blocks.filter(isSkillBlockEntry).map((entry) => ({
1707
+ resource_uuid: entry.resource_uuid,
1708
+ skill_name: typeof entry.skill_name === 'string' ? entry.skill_name : '',
1709
+ skill_dir: entry.skill_dir,
1710
+ }));
1711
+ writeSkillBlocks(entries);
1712
+ storeSkillBlockSessions(syncResult.skill_block_sessions);
1713
+ storeSkillBlockWindows(syncResult.skill_block_windows);
1714
+ try {
1715
+ const outcome = applySkillBlocks(entries);
1716
+ // Tell the user when a skill is quarantined, whether or not a session of theirs loaded it.
1717
+ openQuarantineWindows(outcome.quarantined, entries);
1718
+ }
1719
+ catch (err) {
1720
+ const msg = `skill_block_error: ${errorText(err)}`;
1721
+ hookRunLog(msg);
1722
+ complianceRunnerDiag(msg);
1723
+ }
1659
1724
  }
1660
1725
  async function reconcileInstructions(endpointBase, machineUuid, want) {
1661
1726
  try {
@@ -1792,6 +1857,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
1792
1857
  return;
1793
1858
  applyClearApplyTrackingUuids(syncResult.clear_apply_tracking_uuids);
1794
1859
  storeApprovals(syncResult);
1860
+ enforceSkillBlocks(syncResult);
1795
1861
  if (syncResult.status === 'unchanged') {
1796
1862
  await reconcileUnchanged(endpointBase, machineUuid, remediations, activeUuids);
1797
1863
  return;