@optimuslabs/harness-map-staging 1.5.35 → 1.5.38

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.
@@ -87,7 +87,7 @@ export function readClaudeSignedInTenant(env = process.env) {
87
87
  /**
88
88
  * Block messages, one shape for every agent (Optimus Labs' prompt-block wording):
89
89
  * "This device only allows <agent> <under/on/in> the <approved> <kind>. You're signed in to
90
- * <what>. <how to switch>." With nothing approved yet: "This device doesn't allow <agent> signed in
90
+ * <what>. <how to switch>." With nothing approved yet: "This device doesn't allow <agent> to be signed in
91
91
  * to <what>." and the same last sentence.
92
92
  */
93
93
  const TENANT_WORDS = {
@@ -96,6 +96,8 @@ const TENANT_WORDS = {
96
96
  cursor: { name: 'Cursor', kind: 'team', prep: 'on' },
97
97
  codex: { name: 'Codex', kind: 'workspace', prep: 'in' },
98
98
  };
99
+ /** How a block that names the organization's own rule begins; such a block needs no title above it. */
100
+ export const ORGANIZATION_LEAD = 'Your organization';
99
101
  /** A tenant's name as the admin approved it, or its id when it has none. */
100
102
  function nameOf(t) {
101
103
  return (t.tenant_name || '').trim() || t.tenant_id.trim();
@@ -108,35 +110,129 @@ function emailDomain(email) {
108
110
  function capitalised(word) {
109
111
  return word ? word[0].toUpperCase() + word.slice(1) : word;
110
112
  }
111
- /** What to pick to get back in: the one approved tenant, one of several, or the company's own. */
112
- function choice(approved, kind) {
113
+ /**
114
+ * What to pick to get back in: the one approved tenant, one of several, or the company's own,
115
+ * named after `company` when it is known ("your Optimus Labs organization").
116
+ */
117
+ function choice(approved, kind, company) {
113
118
  if (approved.length === 1)
114
119
  return approved[0];
115
- return approved.length > 1 ? 'one of them' : `your company's ${kind}`;
120
+ return approved.length > 1 ? 'one of them' : `your ${company || "company's"} ${kind}`;
116
121
  }
117
- function switchStep(agent, approved) {
122
+ function switchStep(agent, approved, company) {
118
123
  const kind = TENANT_WORDS[agent].kind;
119
124
  if (agent === 'claude_desktop')
120
- return `Switch Claude Desktop to ${choice(approved, kind)}.`;
121
- if (agent === 'codex')
122
- return `Run codex logout, then codex login and choose ${choice(approved, kind)}.`;
125
+ return `Switch Claude Desktop to ${choice(approved, kind, company)}.`;
126
+ if (agent === 'codex') {
127
+ return `Run codex logout, then codex login and choose ${choice(approved, kind, company)}.`;
128
+ }
123
129
  if (agent === 'cursor') {
124
- if (approved.length === 1)
125
- return `Sign out of Cursor and sign in with your ${approved[0]} team account.`;
130
+ // Reached only without one account to name (see cursorTeamViolation).
126
131
  return approved.length > 1
127
132
  ? 'Sign out of Cursor and sign in with an account on one of those teams.'
128
133
  : "Sign out of Cursor and sign in with your company's team account.";
129
134
  }
130
- return `Run /login and choose ${choice(approved, kind)}.`;
135
+ return `Run /login and choose ${choice(approved, kind, company)}.`;
131
136
  }
132
- /** The block message for `agent` signed in to `signedIn` (already worded) with these approved. */
133
- export function tenantBlockMessage(agent, approved, signedIn) {
137
+ /**
138
+ * The block message for `agent` signed in to `signedIn` (already worded) with these approved.
139
+ * `company` names the company when the agent has nothing approved of its own; '' says "company's".
140
+ */
141
+ export function tenantBlockMessage(agent, approved, signedIn, company = '') {
134
142
  const w = TENANT_WORDS[agent];
135
143
  const lead = approved.length
136
144
  ? `This device only allows ${w.name} ${w.prep} the ${approved.join(' or ')} ${w.kind}. ` +
137
145
  `You're signed in to ${signedIn}.`
138
- : `This device doesn't allow ${w.name} signed in to ${signedIn}.`;
139
- return `${lead} ${switchStep(agent, approved)}`;
146
+ : `This device doesn't allow ${w.name} to be signed in to ${signedIn}.`;
147
+ return `${lead} ${switchStep(agent, approved, company)}`;
148
+ }
149
+ /**
150
+ * The company's name for "your <company> team account": the one name its approved tenants go by,
151
+ * across every agent (a Cursor login on no approved team is pointed at the company's Claude
152
+ * organization's name). '' when there is none, or several. Personal accounts (`@<domain>`) and a
153
+ * person's own org ("ada@gmail.com's Organization") are not the company's.
154
+ */
155
+ export function companyName(approved) {
156
+ const names = new Set(Object.values(approved ?? {})
157
+ .flatMap((policy) => tenantIds(policy?.tenants))
158
+ .filter((t) => !t.tenant_id.trim().startsWith('@') && !/'s organization$/i.test(nameOf(t)))
159
+ .map(nameOf));
160
+ return names.size === 1 ? [...names][0] : '';
161
+ }
162
+ /** The one account to name: the approved one, else the company's; '' for several or none known. */
163
+ function accountOf(block) {
164
+ if (block.approved.length === 1)
165
+ return block.approved[0];
166
+ return block.approved.length ? '' : block.company;
167
+ }
168
+ /** The one-line block message, as the agent shows it. */
169
+ export function tenantBlockText(block) {
170
+ const account = accountOf(block);
171
+ if (block.agent !== 'cursor' || !account) {
172
+ return tenantBlockMessage(block.agent, block.approved, block.signedIn, block.company);
173
+ }
174
+ const domains = block.domains.length ? ` (${block.domains.join(', ')})` : '';
175
+ return (`${ORGANIZATION_LEAD} does not allow Cursor to be signed in to ${block.signedIn}. ` +
176
+ `The approved organization's team account for Cursor is ${account}${domains}. ` +
177
+ 'Please sign out and sign in with the approved account to use Cursor.');
178
+ }
179
+ /** What an approved account is called, per agent: "team account" for Cursor's teams. */
180
+ const ACCOUNT_NOUN = {
181
+ claude: 'organization',
182
+ claude_desktop: 'organization',
183
+ cursor: 'team account',
184
+ codex: 'workspace',
185
+ };
186
+ /** A login block's window has nothing to open, so its one button only acknowledges: OK. */
187
+ function loginWindow(title, reason, path) {
188
+ return { brand: 'Optimus Labs', scope: 'Org-wide policy', title, reason, best_match: path, cancel: 'OK' };
189
+ }
190
+ /** When personal accounts on an email domain are what is approved: "your optimuslabs.io account". */
191
+ function personalPathCard(name, domains) {
192
+ return {
193
+ label: 'Approved path',
194
+ title: domains.length === 1 ? `Sign in with your ${domains[0]} account` : 'Sign in with an account on one of these domains',
195
+ subtitle: domains.length === 1 ? '' : domains.join(', '),
196
+ body: `Sign out of ${name}, then sign in with that account.`,
197
+ };
198
+ }
199
+ /** When nothing is approved to switch to: say so, and point at an admin rather than an account. */
200
+ function noPathCard(name, kind) {
201
+ return {
202
+ label: 'What to do',
203
+ title: 'Ask an admin which account to use',
204
+ subtitle: '',
205
+ body: `Your organisation hasn't approved a ${name} ${kind} yet. Sign out of ${name}, then sign in with your work account once an admin has approved it.`,
206
+ };
207
+ }
208
+ /** The window for a login whose tenant is refused: what it is signed in to, and what to use instead. */
209
+ export function tenantBlockWindow(block) {
210
+ const words = TENANT_WORDS[block.agent];
211
+ const account = accountOf(block);
212
+ const title = `${words.name} is signed in to an account your organisation doesn't allow`;
213
+ const reason = {
214
+ title: block.personal ? "It's a personal account." : `It isn't an approved ${words.kind}.`,
215
+ body: 'Agents signed in to accounts outside your organisation can send its code and data there.',
216
+ };
217
+ // An approved path is something approved for this agent itself: a team, or personal accounts on
218
+ // an email domain. The company's name alone (from another agent's tenant, such as a Claude
219
+ // organization) is not one, so it gets What to do.
220
+ if (!block.approved.length && block.personalDomains.length) {
221
+ return loginWindow(title, reason, personalPathCard(words.name, block.personalDomains));
222
+ }
223
+ if (!block.approved.length)
224
+ return loginWindow(title, reason, noPathCard(words.name, words.kind));
225
+ const how = block.agent === 'cursor'
226
+ ? 'Sign out of Cursor, then sign in with that account.'
227
+ : switchStep(block.agent, block.approved, block.company);
228
+ return loginWindow(title, reason, {
229
+ label: 'Approved path',
230
+ title: account
231
+ ? `Sign in with your ${account} ${ACCOUNT_NOUN[block.agent]}`
232
+ : `Sign in with one of: ${block.approved.join(', ')}`,
233
+ subtitle: block.domains.join(', '),
234
+ body: how,
235
+ });
140
236
  }
141
237
  const CLAUDE_PERSONAL_PLANS = new Set(['free', 'pro', 'max']);
142
238
  /** "a personal Max account (gmail.com)" or "the Beta Corp organization". */
@@ -159,23 +255,31 @@ function tenantIds(tenants) {
159
255
  /**
160
256
  * The block message when `signedIn` may not be used for `agent`, else null.
161
257
  *
162
- * Mirrors the backend's `tenant_approval`: a tenant the organization marked unapproved is always
163
- * blocked; a shadow tenant (never decided) only once the agent has at least one approved tenant.
164
- * Ids compare exactly, as the backend does -- vendor ids are not ours to case-fold.
258
+ * Only a tenant the organization blocked (marked unapproved) is refused. One never decided is
259
+ * awaiting review and gets through, as an approved one does. Ids compare exactly, as the backend
260
+ * does -- vendor ids are not ours to case-fold.
165
261
  */
166
- export function approvedTenantViolation(agent, approved, signedIn) {
262
+ export function approvedTenantBlock(agent, approved, signedIn) {
167
263
  if (!signedIn)
168
264
  return null;
169
265
  const policy = approved?.[agent];
170
- const approvedTenants = tenantIds(policy?.tenants);
171
- const unapprovedTenants = tenantIds(policy?.unapproved_tenants);
172
266
  const signedInId = signedIn.tenant_id.trim();
173
- const isIn = (list) => list.some((t) => t.tenant_id.trim() === signedInId);
174
- const blocked = isIn(unapprovedTenants) ||
175
- ((policy?.enforced || approvedTenants.length > 0) && !isIn(approvedTenants));
176
- if (!blocked)
267
+ if (!tenantIds(policy?.unapproved_tenants).some((t) => t.tenant_id.trim() === signedInId))
177
268
  return null;
178
- return tenantBlockMessage(agent, approvedTenants.map(nameOf), claudeSignedInText(signedIn));
269
+ const signedInText = claudeSignedInText(signedIn);
270
+ return {
271
+ agent,
272
+ signedIn: signedInText,
273
+ personal: signedInText.startsWith('a personal'),
274
+ approved: tenantIds(policy?.tenants).map(nameOf),
275
+ company: companyName(approved),
276
+ domains: [],
277
+ personalDomains: [],
278
+ };
279
+ }
280
+ export function approvedTenantViolation(agent, approved, signedIn) {
281
+ const block = approvedTenantBlock(agent, approved, signedIn);
282
+ return block ? tenantBlockText(block) : null;
179
283
  }
180
284
  /**
181
285
  * The tenants a Cursor login is in: its teams, or, on no team, `@` and its email domain. Mirrors the
@@ -203,42 +307,37 @@ export function readCursorSignedInTeams(home = homedir()) {
203
307
  /**
204
308
  * Why a login in these tenants may not be used, or null when it may. Shared by every agent whose
205
309
  * login is a list of tenants (Cursor's teams) or one tenant that may be a personal account's
206
- * `@<domain>` (Codex), and mirrors the backend's `team_tenant_approval`: any approved tenant admits
207
- * the login; failing that, one marked unapproved is always blocked, and any other -- an unknown
208
- * tenant, or a personal account -- only once at least one tenant is approved for this agent.
310
+ * `@<domain>` (Codex). Blocked only when every tenant the login is in was blocked (marked
311
+ * unapproved): Cursor admits a login when any of its teams is allowed, and a tenant awaiting review
312
+ * is allowed, as an approved one is.
209
313
  */
210
314
  function listedTenantViolation(policy, tenants) {
211
- const approvedList = tenantIds(policy?.tenants);
212
- if (tenants.some((id) => isListed(approvedList, id)))
315
+ const unapprovedList = tenantIds(policy?.unapproved_tenants);
316
+ if (!tenants.length || !tenants.every((id) => isListed(unapprovedList, id)))
213
317
  return null;
214
- const enforced = approvedList.length > 0 || Boolean(policy?.enforced);
215
- if (!isRefused(tenants, enforced, tenantIds(policy?.unapproved_tenants)))
216
- return null;
217
- return { approved: approvedList.map(nameOf) };
318
+ // Approved personal-account keys (`@acme.io`) are not teams to sign in to: never named as one,
319
+ // but kept as the email domains whose personal accounts are approved.
320
+ const approved = tenantIds(policy?.tenants);
321
+ const isPersonal = (t) => t.tenant_id.trim().startsWith('@');
322
+ return {
323
+ approved: approved.filter((t) => !isPersonal(t)).map(nameOf),
324
+ personalDomains: approved.filter(isPersonal).map((t) => t.tenant_id.trim().slice(1)),
325
+ };
218
326
  }
219
327
  function isListed(list, id) {
220
328
  return list.some((t) => t.tenant_id.trim() === id);
221
329
  }
222
- /** Whether a login in these tenants is refused: one marked unapproved always; any other only once
223
- * the agent is enforced (an older backend that sends no flag, with nothing approved, lets it pass). */
224
- function isRefused(tenants, enforced, unapprovedList) {
225
- if (tenants.some((id) => isListed(unapprovedList, id)))
226
- return true;
227
- return enforced;
228
- }
229
330
  /** A tenant's name from the organization's lists, or '' when it has none there. */
230
331
  function knownName(policy, id) {
231
332
  const all = [...tenantIds(policy?.tenants), ...tenantIds(policy?.unapproved_tenants)];
232
333
  const hit = all.find((t) => t.tenant_id.trim() === id);
233
334
  return (hit?.tenant_name || '').trim();
234
335
  }
235
- /** "a personal account on no team (gmail.com)", "the Beta team" or "team 9876". */
336
+ /** "a personal account", "the Beta team" or "team 9876". */
236
337
  function cursorSignedInText(signedIn, policy) {
237
338
  const tenants = cursorTenants(signedIn);
238
- if (!tenants.length || tenants[0].startsWith('@')) {
239
- const domain = emailDomain(signedIn.email) || (tenants[0] ?? '').slice(1);
240
- return `a personal account on no team${domain ? ` (${domain})` : ''}`;
241
- }
339
+ if (!tenants.length || tenants[0].startsWith('@'))
340
+ return 'a personal account';
242
341
  return tenants
243
342
  .map((id) => (knownName(policy, id) ? `the ${knownName(policy, id)} team` : `team ${id}`))
244
343
  .join(' and ');
@@ -255,13 +354,27 @@ function codexSignedInText(signedIn, tenants, policy) {
255
354
  return name ? `the ${name} workspace` : `workspace ${tenant}`;
256
355
  }
257
356
  /** The block message when Cursor's login may not be used, else null. */
258
- export function cursorTeamViolation(approved, signedIn) {
357
+ export function cursorTeamBlock(approved, signedIn, teamDomains = []) {
259
358
  if (!signedIn)
260
359
  return null;
261
360
  const v = listedTenantViolation(approved?.cursor, cursorTenants(signedIn));
262
361
  if (!v)
263
362
  return null;
264
- return tenantBlockMessage('cursor', v.approved, cursorSignedInText(signedIn, approved?.cursor));
363
+ const signedInText = cursorSignedInText(signedIn, approved?.cursor);
364
+ // The one account to point at is the approved team, else the company's name (accountOf).
365
+ return {
366
+ agent: 'cursor',
367
+ signedIn: signedInText,
368
+ personal: signedInText === 'a personal account',
369
+ approved: v.approved,
370
+ company: companyName(approved),
371
+ domains: teamDomains,
372
+ personalDomains: v.personalDomains,
373
+ };
374
+ }
375
+ export function cursorTeamViolation(approved, signedIn, teamDomains = []) {
376
+ const block = cursorTeamBlock(approved, signedIn, teamDomains);
377
+ return block ? tenantBlockText(block) : null;
265
378
  }
266
379
  /**
267
380
  * Codex plans that are personal, mirroring the backend's `CodexPlans.PERSONAL`. On one of these
@@ -300,41 +413,27 @@ function codexEmailDomainTenant(signedIn) {
300
413
  return at >= 0 && at < email.length - 1 ? [`@${email.slice(at + 1).toLowerCase()}`] : [];
301
414
  }
302
415
  /** The block message when Codex's login may not be used, else null. */
303
- export function codexTenantViolation(approved, signedIn) {
416
+ export function codexTenantBlock(approved, signedIn) {
304
417
  const tenants = signedIn ? codexTenants(signedIn) : null;
305
418
  if (!tenants)
306
419
  return null;
307
420
  const v = listedTenantViolation(approved?.codex, tenants);
308
421
  if (!v)
309
422
  return null;
310
- return tenantBlockMessage('codex', v.approved, codexSignedInText(signedIn, tenants, approved?.codex));
311
- }
312
- /**
313
- * Whether a personal account's `@<domain>` tenant is on a team domain: an allowlisted domain that
314
- * someone on an approved team or enterprise tenant signs in with (any vendor's). A subdomain counts.
315
- */
316
- export function onTeamDomain(tenant, teamDomains) {
317
- if (!tenant.startsWith('@') || !teamDomains?.length)
318
- return false;
319
- const domain = tenant.slice(1);
320
- return teamDomains.some((d) => domain === d || domain.endsWith(`.${d}`));
321
- }
322
- /**
323
- * The block message for Cursor's login, else null. A login on a team is checked against the
324
- * approved teams. A personal account is only checked once its email is on a team domain; then it
325
- * is blocked unless an admin approved that personal tenant (`@<domain>`).
326
- */
327
- export function cursorLoginViolation(approved, signedIn, teamDomains) {
328
- if (!signedIn)
329
- return null;
330
- const checked = signedIn.team_ids.length > 0 || cursorTenants(signedIn).some((t) => onTeamDomain(t, teamDomains));
331
- return checked ? cursorTeamViolation(approved, signedIn) : null;
423
+ const signedInText = codexSignedInText(signedIn, tenants, approved?.codex);
424
+ return {
425
+ agent: 'codex',
426
+ signedIn: signedInText,
427
+ personal: signedInText.startsWith('a personal'),
428
+ approved: v.approved,
429
+ company: companyName(approved),
430
+ domains: [],
431
+ personalDomains: v.personalDomains,
432
+ };
332
433
  }
333
- /** The block message for Codex's login, else null; a personal plan is checked as Cursor's is. */
334
- export function codexLoginViolation(approved, signedIn, teamDomains) {
335
- const tenants = signedIn ? codexTenants(signedIn) : null;
336
- const checked = tenants?.some((t) => !t.startsWith('@') || onTeamDomain(t, teamDomains));
337
- return checked ? codexTenantViolation(approved, signedIn) : null;
434
+ export function codexTenantViolation(approved, signedIn) {
435
+ const block = codexTenantBlock(approved, signedIn);
436
+ return block ? tenantBlockText(block) : null;
338
437
  }
339
438
  /**
340
439
  * The org Claude Desktop is signed into now, for its Code tab's prompts, or null when unknown.
@@ -360,44 +459,36 @@ export function readClaudeDesktopSignedInTenant(home = homedir(), env = process.
360
459
  plan: same ? fromClaudeCode.plan : '',
361
460
  };
362
461
  }
363
- /**
364
- * The block message when a login's email is on a domain the organization unapproved, else null.
365
- *
366
- * Unapproving a domain covers every agent and every address on it, and its subdomains
367
- * (`mail.acme.com` falls under `acme.com`), the way an approved domain covers them.
368
- */
369
- export function unapprovedEmailDomainViolation(email, unapproved) {
462
+ export function unapprovedEmailDomainBlock(email, unapproved) {
370
463
  const address = (email ?? '').trim().toLowerCase();
371
464
  const at = address.lastIndexOf('@');
372
465
  if (at < 0 || !unapproved?.length)
373
466
  return null;
374
467
  const domain = address.slice(at + 1);
375
468
  const hit = unapproved.find((d) => domain === d || domain.endsWith(`.${d}`));
376
- if (!hit)
377
- return null;
378
- return (`This device doesn't allow accounts on ${hit}. You're signed in as ${address}. ` +
379
- 'Sign in with your work account.');
469
+ return hit ? { domain: hit, address } : null;
380
470
  }
381
- /**
382
- * The block message when a login's email is not on the organization's email allowlist, else null.
383
- *
384
- * The allowlist is the organization's approved email domains; a domain covers its subdomains
385
- * (`mail.acme.com` under `acme.com`). Null, so the login is allowed, when no allowlist has been
386
- * synced to this machine yet or the login has no email we can read.
387
- */
388
- export function emailAllowlistViolation(email, allowed) {
389
- if (!Array.isArray(allowed))
390
- return null;
391
- const address = (email ?? '').trim().toLowerCase();
392
- const at = address.lastIndexOf('@');
393
- if (at < 0 || at === address.length - 1)
394
- return null;
395
- const domain = address.slice(at + 1);
396
- if (allowed.some((d) => domain === d || domain.endsWith(`.${d}`)))
471
+ export function unapprovedEmailDomainViolation(email, unapproved) {
472
+ const block = unapprovedEmailDomainBlock(email, unapproved);
473
+ if (!block)
397
474
  return null;
398
- return (`This device only allows accounts on your organization's email domains. You're signed in as ${address}. ` +
475
+ return (`This device doesn't allow accounts on ${block.domain}. You're signed in as ${block.address}. ` +
399
476
  'Sign in with your work account.');
400
477
  }
478
+ /** The window for a login on an unapproved email domain. `agent` is one the gate reads logins for. */
479
+ export function emailDomainBlockWindow(agent, block, teamDomains = []) {
480
+ const name = TENANT_WORDS[agent]?.name ?? agent;
481
+ return loginWindow(`${name} is signed in with an email your organisation doesn't allow`, {
482
+ title: `Accounts on ${block.domain} aren't allowed.`,
483
+ body: `You're signed in as ${block.address}. Your organisation doesn't allow this email domain in any agent.`,
484
+ }, {
485
+ // With a team domain to name there is an approved path; without one, only advice.
486
+ label: teamDomains.length ? 'Approved path' : 'What to do',
487
+ title: 'Sign in with your work account',
488
+ subtitle: teamDomains.join(', '),
489
+ body: `Sign out of ${name}, then sign in with your work email.`,
490
+ });
491
+ }
401
492
  /** The id an agent is approved under on the Agents page: Desktop's Code tab runs Claude Code. */
402
493
  const APPROVAL_ID = { claude_desktop: 'claude' };
403
494
  const AGENT_NAME = {
@@ -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, skillBlockWindow, 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, codexLoginViolation, emailAllowlistViolation, cursorLoginViolation, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, } 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) {
@@ -89,19 +92,27 @@ function printAllowWithAdvisory(ide, advisoryMessage) {
89
92
  }
90
93
  /** Every block reads as an Optimus Labs notice: a title line, then the message. */
91
94
  const BLOCK_TITLE = 'Optimus Labs\n\n';
92
- function blockPayload(ide, violationMessage) {
93
- const text = BLOCK_TITLE + violationMessage;
95
+ /** The block as shown: titled, unless it already says it is the organization's rule. */
96
+ function titled(violationMessage) {
97
+ return violationMessage.startsWith(ORGANIZATION_LEAD) ? violationMessage : BLOCK_TITLE + violationMessage;
98
+ }
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) {
104
+ const text = titled(violationMessage);
94
105
  if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
95
106
  return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
96
107
  }
97
- return JSON.stringify({ continue: false, user_message: text });
108
+ return JSON.stringify({ continue: false, user_message: text, ...(windowShown ? { __optimus_window: true } : {}) });
98
109
  }
99
110
  /**
100
111
  * Codex's block: `reason` only. Codex shows `reason` as the block and `systemMessage` as a separate
101
112
  * warning, so the Claude-shaped payload (which carries both) prints the same sentence twice.
102
113
  */
103
114
  function codexBlockPayload(violationMessage) {
104
- return JSON.stringify({ decision: 'block', reason: BLOCK_TITLE + violationMessage });
115
+ return JSON.stringify({ decision: 'block', reason: titled(violationMessage) });
105
116
  }
106
117
  function getManifestStalenessMs() {
107
118
  try {
@@ -264,51 +275,65 @@ function loginEmail(agent) {
264
275
  return readCursorSignedInTeams()?.email ?? '';
265
276
  return null;
266
277
  }
267
- /** Why this agent's signed-in tenant is not approved, or null when it is (or cannot be read). */
268
- function tenantViolation(agent) {
278
+ /** Why this agent's signed-in tenant is blocked, or null when it is not (or cannot be read). */
279
+ function tenantBlock(agent) {
269
280
  const { approved_tenants: approved, team_email_domains: teamDomains } = readRemediationInstructionsFile();
270
281
  if (agent === 'claude')
271
- return approvedTenantViolation('claude', approved, readClaudeSignedInTenant());
282
+ return approvedTenantBlock('claude', approved, readClaudeSignedInTenant());
272
283
  if (agent === 'claude_desktop') {
273
- return approvedTenantViolation('claude_desktop', approved, readClaudeDesktopSignedInTenant());
284
+ return approvedTenantBlock('claude_desktop', approved, readClaudeDesktopSignedInTenant());
274
285
  }
275
- // A personal account (Cursor on no team, Codex on a personal plan) is left to the email allowlist
276
- // until its domain is a team domain; then it needs its personal tenant approved.
277
286
  if (agent === 'codex')
278
- return codexLoginViolation(approved, readCodexSignedIn(), teamDomains);
287
+ return codexTenantBlock(approved, readCodexSignedIn());
279
288
  if (agent === 'cursor')
280
- return cursorLoginViolation(approved, readCursorSignedInTeams(), teamDomains);
289
+ return cursorTeamBlock(approved, readCursorSignedInTeams(), teamDomains);
281
290
  return null;
282
291
  }
283
- /** The block for a login: which check refused it and why, or null when every check passes. */
284
- function loginBlock(agent, email) {
292
+ /**
293
+ * The block for a login: which check refused it, why, and the blocked-agent window's words for a
294
+ * tenant or email-domain block (an agent's own block window comes synced); null when all pass.
295
+ */
296
+ function loginBlock(agent, email, agentAllowed) {
285
297
  const synced = readRemediationInstructionsFile();
286
- const agentMsg = agentApprovalViolation(agent, synced.approved_agents);
298
+ const agentMsg = agentAllowed ? null : agentApprovalViolation(agent, synced.approved_agents);
287
299
  if (agentMsg)
288
300
  return { event: 'prompt_gate_block_unapproved_agent', reason: agentMsg };
289
301
  if (email === null)
290
302
  return null;
291
- const emailMsg = emailAllowlistViolation(email, synced.allowed_email_domains);
292
- if (emailMsg)
293
- return { event: 'prompt_gate_block_email_not_allowlisted', reason: emailMsg };
294
- const tenantMsg = tenantViolation(agent);
295
- return tenantMsg ? { event: 'prompt_gate_block_unapproved_tenant', reason: tenantMsg } : null;
303
+ const emailBlock = unapprovedEmailDomainBlock(email, synced.unapproved_email_domains);
304
+ if (emailBlock) {
305
+ return {
306
+ event: 'prompt_gate_block_unapproved_email_domain',
307
+ reason: unapprovedEmailDomainViolation(email, synced.unapproved_email_domains) ?? '',
308
+ window: emailDomainBlockWindow(agent, emailBlock, synced.team_email_domains),
309
+ };
310
+ }
311
+ const tenant = tenantBlock(agent);
312
+ if (!tenant)
313
+ return null;
314
+ return { event: 'prompt_gate_block_unapproved_tenant', reason: tenantBlockText(tenant), window: tenantBlockWindow(tenant) };
296
315
  }
297
316
  /**
298
- * Block the prompt unless the agent is approved, the login's email is on the organization's email
299
- * allowlist and its tenant is approved (all as remediation sync stored them), printing the block
300
- * payload; true when it did. A login whose email or tenant cannot be read passes that check, and a
317
+ * Block the prompt when the agent is not approved, or the login's email domain or its tenant was
318
+ * blocked (all as remediation sync stored them), printing the block payload; true when it did. A
319
+ * domain or tenant awaiting review gets through. A login whose email or tenant cannot be read passes that check, and a
301
320
  * machine that has synced nothing yet is not blocked.
302
321
  */
303
322
  function blockedByLogin(agent, ide) {
304
323
  if (tenantGateSwitchedOff())
305
324
  return false;
306
325
  const email = loginEmail(agent);
307
- const block = loginBlock(agent, email);
326
+ const synced = readRemediationInstructionsFile();
327
+ const access = agentAccessDecision(agent, synced.approved_agents);
328
+ const block = loginBlock(agent, email, access.allowed);
308
329
  if (!block)
309
330
  return false;
310
331
  logRemediationApplyFailure(block.event, { reason: block.reason, ide, agent, email: email ?? '' });
311
- console.log(agent === 'codex' ? codexBlockPayload(block.reason) : blockPayload(ide, block.reason));
332
+ // The blocked-agent window opens beside every block, in the background, so the hook answers at
333
+ // once: an agent's own from the synced approvals, a login's from the words built here. Where it
334
+ // could not start, Cursor's hook keeps its alert.
335
+ const windowShown = launchAgentAccessWindow(approvalId(agent), block.window);
336
+ console.log(agent === 'codex' ? codexBlockPayload(block.reason) : blockPayload(ide, block.reason, windowShown));
312
337
  return true;
313
338
  }
314
339
  /** Pull the organization's latest remediations and approved tenants, waiting at most 3 seconds. */
@@ -323,6 +348,42 @@ async function syncApprovals() {
323
348
  // Network or auth failure — fall back to local file state.
324
349
  }
325
350
  }
351
+ /** Open the skill block window beside the block, in the background, when the sync sent its words. */
352
+ function openSkillBlockWindow(skill) {
353
+ const words = skillBlockWindow(skill);
354
+ if (words)
355
+ launchAgentAccessWindow('claude', words);
356
+ }
357
+ /**
358
+ * Skill block killswitch, Claude Code only for now: refuse the prompt of a session that loaded a
359
+ * skill which is now blocked, or whose `claude` process did in an earlier session (a `/clear` keeps
360
+ * the skill), docs/skill-block/DESIGN.md. True when it refused.
361
+ */
362
+ function blockedBySkillBlock(agent, ide, sessionId) {
363
+ if (ide !== 'claude')
364
+ return false;
365
+ const proc = claudeProcess();
366
+ try {
367
+ recordSessionProcess(sessionId, proc);
368
+ }
369
+ catch (err) {
370
+ hookRunLog(`skill_block_process_table_error: ${err instanceof Error ? err.message : String(err)}`);
371
+ }
372
+ const skills = stoppingSkills(sessionId, proc);
373
+ if (skills.length > 0) {
374
+ logRemediationApplyFailure('skill_block_session_stopped', { ide, agent, skills: skills.join(',') });
375
+ openSkillBlockWindow(skills[0]);
376
+ console.log(blockPayload(ide, stoppedSessionMessage(skills)));
377
+ return true;
378
+ }
379
+ const typed = blockedSkillOfCommand(process.env.OPTIMUS_PROMPT_FIRST_LINE || '', () => proc?.startedAt ?? null);
380
+ if (!typed)
381
+ return false;
382
+ logRemediationApplyFailure('skill_block_command_refused', { ide, agent, skills: typed });
383
+ openSkillBlockWindow(typed);
384
+ console.log(blockPayload(ide, blockedCommandMessage(typed)));
385
+ return true;
386
+ }
326
387
  /** Agents whose remediations are JSON files written to disk at once (no deferred SQLite apply). */
327
388
  function isImmediateJsonIde(ide) {
328
389
  return ide === 'claude' || ide === 'copilot' || ide === 'opencode';
@@ -535,6 +596,8 @@ export async function runCompliancePromptGate() {
535
596
  }
536
597
  await reportPostRestartVerification(agent, complianceLogPath);
537
598
  await syncApprovals();
599
+ if (blockedBySkillBlock(agent, ide, (process.env.OPTIMUS_SESSION_ID || '').trim()))
600
+ return;
538
601
  const status = runLocalRemediationComplianceCheck(agent);
539
602
  uploadSecondarySatisfied(status);
540
603
  // 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) {