@optimuslabs/harness-map-staging 1.5.36 → 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.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether an agent may be used, and whether to open the blocked-agent window beside the block
3
+ * (docs/agent-exception-requests/DESIGN.md). Pure: it reads only what it is given, so the prompt
4
+ * gate and the tests can call it alike.
5
+ *
6
+ * An agent the organization has not approved (Unapproved, or Shadow: never reviewed) is blocked, and
7
+ * the window opens beside every such block.
8
+ */
9
+ /** The id an agent is approved under: Claude Desktop's Code tab runs Claude Code. */
10
+ const APPROVAL_ID = { claude_desktop: 'claude' };
11
+ export function approvalId(agent) {
12
+ return APPROVAL_ID[agent] ?? agent;
13
+ }
14
+ export function agentAccessDecision(agent, approvedAgents) {
15
+ // Before the first sync nothing is enforced, as for the other approval lists.
16
+ if (!Array.isArray(approvedAgents))
17
+ return { allowed: true, window: false };
18
+ const allowed = approvedAgents.includes(approvalId(agent));
19
+ return { allowed, window: !allowed };
20
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Shows the blocked-agent window (docs/agent-exception-requests/DESIGN.md).
3
+ *
4
+ * Started detached by the prompt gate (`launchAgentAccessWindow`), so the hook returns its block at
5
+ * once. Run as `node window_runner.js <agent> [<words.json>]`: an agent's own block reads the window's
6
+ * words from the synced approvals; a tenant or email-domain block passes them, built by the gate, in
7
+ * a file of its own temporary folder. It draws the window with `osascript` (macOS only),
8
+ * then acts on what the user chose: Open launches the approved app, Cancel only closes it. The prompt
9
+ * was already blocked. Nothing here talks to the network; a failure is logged and never thrown.
10
+ */
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { basename, dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { hookRunLog } from '../log_config_files/runtime/hook_logger.js';
17
+ import { readRemediationInstructionsFile } from '../log_config_files/runtime/management_storage.js';
18
+ import { approvalId } from './decision.js';
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const WINDOW_SCRIPT = join(HERE, '..', 'agent_block_window', 'agent_block_window.jxa');
21
+ /** The agents the gate checks, by approval id: nothing else is ever passed to a process. */
22
+ const AGENTS = ['claude', 'cursor', 'codex', 'copilot', 'opencode'];
23
+ /** A macOS bundle identifier, and nothing that could be read as an option or a path. */
24
+ const BUNDLE_ID = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/;
25
+ /** Where the gate leaves a login block's words for the runner: a folder of its own under tmpdir. */
26
+ const WORDS_PREFIX = 'optimus-window-words-';
27
+ /** Write a login block's words where only the runner reads them; returns the file's path. */
28
+ function writeWords(words) {
29
+ const dir = mkdtempSync(join(tmpdir(), WORDS_PREFIX));
30
+ const path = join(dir, 'words.json');
31
+ writeFileSync(path, JSON.stringify(words), { mode: 0o600 });
32
+ return path;
33
+ }
34
+ /**
35
+ * The words in a file the gate wrote with `writeWords`, and nothing else: the path must be a
36
+ * `words.json` directly inside one of its folders under tmpdir. The folder is removed once read.
37
+ */
38
+ export function readWords(path) {
39
+ try {
40
+ const real = realpathSync(path);
41
+ const folder = dirname(real);
42
+ if (basename(real) !== 'words.json' || dirname(folder) !== realpathSync(tmpdir()))
43
+ return null;
44
+ if (!basename(folder).startsWith(WORDS_PREFIX))
45
+ return null;
46
+ const words = JSON.parse(readFileSync(real, 'utf8'));
47
+ rmSync(folder, { recursive: true, force: true });
48
+ return words && typeof words === 'object' ? words : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** The agent's own block window, as remediation sync stored it; undefined when none was synced. */
55
+ function syncedWindow(agent) {
56
+ return readRemediationInstructionsFile().agent_access_windows?.[approvalId(agent)];
57
+ }
58
+ /**
59
+ * Start the runner in the background, detached from the hook, on macOS only; true when it started.
60
+ * `words` are a tenant or email-domain block's, built by the gate; without them the runner reads the
61
+ * agent's own block window from the synced approvals. False when there is nothing to show (an
62
+ * approvals file synced before the window existed), so Cursor's hook keeps its own alert.
63
+ */
64
+ export function launchAgentAccessWindow(agent, words) {
65
+ if (process.platform !== 'darwin' || !AGENTS.includes(agent))
66
+ return false;
67
+ if (!words && !syncedWindow(agent))
68
+ return false;
69
+ try {
70
+ const args = [fileURLToPath(import.meta.url), agent, ...(words ? [writeWords(words)] : [])];
71
+ const options = { detached: true, stdio: 'ignore' };
72
+ // An argument list, no shell: an agent from the fixed list above, and the path of a file it wrote.
73
+ const child = spawn(process.execPath, args, options); // nosafe: SAFE801
74
+ child.unref();
75
+ return true;
76
+ }
77
+ catch (err) {
78
+ hookRunLog(`agent_access_window: could not start: ${err instanceof Error ? err.message : String(err)}`);
79
+ return false;
80
+ }
81
+ }
82
+ /** Draw the window and return what the user did; null when it could not open. */
83
+ export function showWindow(words) {
84
+ if (!words)
85
+ return null;
86
+ const dir = mkdtempSync(join(tmpdir(), 'optimus-window-'));
87
+ const payloadPath = join(dir, 'payload.json');
88
+ try {
89
+ writeFileSync(payloadPath, JSON.stringify({ words }));
90
+ const out = spawnSync('osascript', ['-l', 'JavaScript', WINDOW_SCRIPT, payloadPath], { encoding: 'utf8' });
91
+ if (out.status !== 0) {
92
+ hookRunLog(`agent_access_window: osascript failed: ${(out.stderr || '').trim()}`);
93
+ return null;
94
+ }
95
+ return JSON.parse(out.stdout.trim());
96
+ }
97
+ catch (err) {
98
+ hookRunLog(`agent_access_window: ${err instanceof Error ? err.message : String(err)}`);
99
+ return null;
100
+ }
101
+ finally {
102
+ rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ }
105
+ /** Open an app by bundle identifier; anything else is refused. */
106
+ export function openApp(bundleId) {
107
+ if (!BUNDLE_ID.test(bundleId))
108
+ return false;
109
+ // `open -b` with a checked bundle identifier as its only argument, no shell.
110
+ spawnSync('open', ['-b', bundleId]); // nosafe: SAFE801
111
+ return true;
112
+ }
113
+ /** Act on what the user chose in the window. */
114
+ export function actOn(result) {
115
+ if (result.action === 'open')
116
+ openApp(result.bundle_id ?? '');
117
+ }
118
+ export function runAgentAccessWindow(agent, wordsPath = '') {
119
+ const words = wordsPath ? readWords(wordsPath) : syncedWindow(agent);
120
+ const result = showWindow(words ?? undefined);
121
+ if (result)
122
+ actOn(result);
123
+ }
124
+ const isEntryPoint = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
125
+ if (isEntryPoint) {
126
+ const [agent, wordsPath] = process.argv.slice(2);
127
+ if (agent && AGENTS.includes(agent))
128
+ runAgentAccessWindow(agent, wordsPath);
129
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The blocked-agent window (docs/agent-exception-requests/DESIGN.md), run as
3
+ * `osascript -l JavaScript agent_block_window.jxa <payload.json> [--self-test]`.
4
+ * Kept as `.jxa` so TypeScript leaves it alone; the build copies it into dist.
5
+ *
6
+ * A native macOS window with a web view. Its words come from the backend (`payload.words`); this
7
+ * script only draws them and prints what the user did as one JSON line:
8
+ * {"action": "open", "agent", "bundle_id"} or {"action": "cancel"}
9
+ * Closing the window, or Escape, is "cancel". Nothing here talks to the network.
10
+ * `--self-test` makes the page press Cancel by itself once loaded, to check the page-to-script path
11
+ * without a person.
12
+ */
13
+ ObjC.import('Cocoa');
14
+ ObjC.import('WebKit');
15
+
16
+ const WIDTH = 540;
17
+
18
+ function readPayload(path) {
19
+ const text = $.NSString.stringWithContentsOfFileEncodingError(path, $.NSUTF8StringEncoding, null);
20
+ if (text.isNil()) throw new Error(`Cannot read ${path}`);
21
+ return JSON.parse(text.js);
22
+ }
23
+
24
+ /** Every payload string goes through this before it reaches the page. */
25
+ function esc(value) {
26
+ return String(value ?? '')
27
+ .replace(/&/g, '&amp;')
28
+ .replace(/</g, '&lt;')
29
+ .replace(/>/g, '&gt;')
30
+ .replace(/"/g, '&quot;')
31
+ .replace(/'/g, '&#39;');
32
+ }
33
+
34
+ const STYLE = `
35
+ :root { color-scheme: dark; }
36
+ * { box-sizing: border-box; }
37
+ html, body { margin: 0; background: transparent; }
38
+ body { font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; color: #e8e8ea;
39
+ -webkit-user-select: none; cursor: default; }
40
+ .card { background: #2b2d31; }
41
+ header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px;
42
+ border-bottom: 1px solid #3a3c41; }
43
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; color: #cfd0d3; }
44
+ .logo { flex: none; width: 24px; height: 24px; border-radius: 50%; background: #c9f2cf; color: #15361c;
45
+ display: grid; place-items: center; font: 700 12px/1 ui-rounded, -apple-system, sans-serif; }
46
+ .logo.big { width: 44px; height: 44px; font-size: 18px; }
47
+ .scope { font-size: 13px; color: #b7b8bc; }
48
+ main { padding: 22px 20px 18px; }
49
+ h1 { font-size: 22px; line-height: 1.25; font-weight: 700; margin: 0 0 16px; color: #fff; }
50
+ .box { border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
51
+ .reason { background: #3a2224; border: 1px solid #7a3d3d; display: flex; gap: 12px; }
52
+ .reason .icon { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1.6px solid #f0a8a8;
53
+ border-radius: 50%; color: #f0a8a8; display: grid; place-items: center; font: 700 11px/1 sans-serif; }
54
+ .reason strong { display: block; color: #fff; font-size: 15px; margin-bottom: 4px; }
55
+ .reason p { margin: 0; color: #dedee0; }
56
+ .reason hr { border: 0; border-top: 1px solid #7a3d3d; margin: 12px 0 10px; }
57
+ .findings { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
58
+ .findings dt { font-size: 12px; font-weight: 700; letter-spacing: .06em; color: #f0d58a; padding-top: 1px; }
59
+ .findings dt.critical, .findings dt.high { color: #f0a8a8; }
60
+ .findings dt.low, .findings dt.unknown { color: #b7b8bc; }
61
+ .findings dd { margin: 0; color: #dedee0; }
62
+ .path { background: #2c382f; border: 1px solid #3f6a47; }
63
+ .row { display: flex; align-items: center; gap: 16px; }
64
+ .grow { flex: 1; }
65
+ .label { font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #8fd39a; }
66
+ .path h2 { margin: 4px 0 2px; font-size: 16px; color: #fff; }
67
+ .path p { margin: 0; color: #b9bdb9; font-size: 13px; }
68
+ .path p.sub { color: #d4e7d7; font-size: 13.5px; margin-bottom: 4px; }
69
+ .path hr { border: 0; border-top: 1px solid #44574a; margin: 14px 0 12px; }
70
+ .also { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; color: #b9bdb9; font-size: 13px; }
71
+ .chip { background: #2f3134; border: 1px solid #4b4d52; border-radius: 7px; padding: 5px 12px; color: #e8e8ea; font-size: 13px; }
72
+ button { font: inherit; cursor: pointer; border-radius: 8px; }
73
+ .primary { background: #2f6fdb; color: #fff; border: 0; padding: 10px 22px; font-weight: 600; font-size: 14px; white-space: nowrap; }
74
+ .primary:hover { background: #3a7bea; }
75
+ .secondary { background: #3a3c41; color: #e8e8ea; border: 1px solid #4b4d52; padding: 8px 20px; font-size: 14px; }
76
+ .secondary:hover { background: #44464b; }
77
+ .link { background: none; border: 0; padding: 0; color: #7fb0ff; text-decoration: underline; font-size: 14px; }
78
+ footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
79
+ border-top: 1px solid #3a3c41; gap: 12px; }
80
+ `;
81
+
82
+ function header(w, withScope) {
83
+ return `<header><div class="brand"><span class="logo">o/</span>${esc(w.brand)}</div>${
84
+ withScope ? `<div class="scope">${esc(w.scope)}</div>` : ''
85
+ }</header>`;
86
+ }
87
+
88
+ function openButton(app) {
89
+ return app ? `<button class="primary" data-open="${esc(app.agent)}" data-bundle="${esc(app.bundle_id)}">${esc(app.open)}</button>` : '';
90
+ }
91
+
92
+ /** The other approved agents, as chips under their label; '' when there are none. */
93
+ function alsoApproved(w) {
94
+ const chips = (w.also_approved?.agents ?? []).map((a) => `<span class="chip">${esc(a.name)}</span>`).join('');
95
+ return chips ? `<div class="also">${esc(w.also_approved.label)} ${chips}</div>` : '';
96
+ }
97
+
98
+ function pathCard(w) {
99
+ const best = w.best_match;
100
+ // With nothing in the blocked agent's category to open, the other approved agents still show.
101
+ if (!best) {
102
+ const others = alsoApproved(w);
103
+ return others ? `<section class="box path">${others}</section>` : '';
104
+ }
105
+ const others = alsoApproved(w);
106
+ const also = others ? `<hr>${others}` : '';
107
+ // A login block names the account to use but has nothing to open, so no button.
108
+ const button = best.bundle_id ? openButton(best) : '';
109
+ const subtitle = best.subtitle ? `<p class="sub">${esc(best.subtitle)}</p>` : '';
110
+ return `<section class="box path"><div class="row"><div class="grow">
111
+ <div class="label">${esc(best.label)}</div><h2>${esc(best.title)}</h2>${subtitle}<p>${esc(best.body)}</p>
112
+ </div>${button}</div>${also}</section>`;
113
+ }
114
+
115
+ /** A skill's scan findings under the reason, by severity ({severity, label, text}); '' when none. */
116
+ function findings(reason) {
117
+ const rows = (reason?.findings ?? [])
118
+ .map((f) => {
119
+ const level = ['critical', 'high', 'medium', 'low'].includes(f.severity) ? f.severity : 'unknown';
120
+ return `<dt class="${level}">${esc(f.label)}</dt><dd>${esc(f.text)}</dd>`;
121
+ })
122
+ .join('');
123
+ return rows ? `<hr><dl class="findings">${rows}</dl>` : '';
124
+ }
125
+
126
+ function formView(w) {
127
+ const body = w.reason?.body ? `<p>${esc(w.reason.body)}</p>` : '';
128
+ return `${header(w, true)}<main>
129
+ <h1>${esc(w.title)}</h1>
130
+ <section class="box reason"><span class="icon">!</span>
131
+ <div class="grow"><strong>${esc(w.reason?.title)}</strong>${body}${findings(w.reason)}</div></section>
132
+ ${pathCard(w)}
133
+ </main>
134
+ <footer><span></span><button class="secondary" data-close="cancel">${esc(w.cancel)}</button></footer>`;
135
+ }
136
+
137
+ function pageHtml(p, selfTest) {
138
+ return `<!doctype html><html><head><meta charset="utf-8"><style>${STYLE}</style></head><body>
139
+ <div class="card" id="card">${formView(p.words)}</div>
140
+ <script>
141
+ const post = (m) => window.webkit.messageHandlers.optimus.postMessage(JSON.stringify(m));
142
+ const size = () => window.webkit.messageHandlers.optimus.postMessage(JSON.stringify({ type: 'size', height: document.getElementById('card').offsetHeight }));
143
+ document.querySelectorAll('[data-close]').forEach((b) => b.onclick = () => post({ action: b.dataset.close }));
144
+ document.querySelectorAll('[data-open]').forEach((b) => b.onclick = () => post({ action: 'open', agent: b.dataset.open, bundle_id: b.dataset.bundle }));
145
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') post({ action: 'cancel' }); });
146
+ window.addEventListener('load', size);
147
+ ${selfTest ? "window.addEventListener('load', () => setTimeout(() => post({ action: 'cancel' }), 300));" : ''}
148
+ </script></body></html>`;
149
+ }
150
+
151
+ function place(win, height) {
152
+ win.setContentSize($.NSMakeSize(WIDTH, Math.min(Math.max(height, 120), 900)));
153
+ win.center;
154
+ }
155
+
156
+ function run(argv) {
157
+ const payload = readPayload(argv[0]);
158
+ const app = $.NSApplication.sharedApplication;
159
+ app.setActivationPolicy($.NSApplicationActivationPolicyAccessory);
160
+ app.finishLaunching;
161
+
162
+ let result = null;
163
+ let win = null;
164
+ ObjC.registerSubclass({
165
+ name: 'OptimusBlockWindowHandler',
166
+ superclass: 'NSObject',
167
+ // Not declared as conforming to WKScriptMessageHandler: osascript cannot see that protocol, and
168
+ // WebKit only needs the method below.
169
+ methods: {
170
+ 'userContentController:didReceiveScriptMessage:': {
171
+ types: ['void', ['id', 'id']],
172
+ implementation: function (_controller, message) {
173
+ const m = JSON.parse(ObjC.unwrap(message.body));
174
+ if (m.type === 'size') {
175
+ place(win, m.height);
176
+ return;
177
+ }
178
+ result = m;
179
+ },
180
+ },
181
+ },
182
+ });
183
+
184
+ const config = $.WKWebViewConfiguration.alloc.init;
185
+ config.userContentController.addScriptMessageHandlerName($.OptimusBlockWindowHandler.alloc.init, 'optimus');
186
+
187
+ const width = WIDTH;
188
+ const style = $.NSWindowStyleMaskTitled | $.NSWindowStyleMaskClosable | $.NSWindowStyleMaskFullSizeContentView;
189
+ win = $.NSWindow.alloc.initWithContentRectStyleMaskBackingDefer($.NSMakeRect(0, 0, width, 480), style, $.NSBackingStoreBuffered, false);
190
+ win.titlebarAppearsTransparent = true;
191
+ win.titleVisibility = $.NSWindowTitleHidden;
192
+ win.movableByWindowBackground = true;
193
+ win.appearance = $.NSAppearance.appearanceNamed($.NSAppearanceNameDarkAqua);
194
+ win.backgroundColor = $.NSColor.colorWithSRGBRedGreenBlueAlpha(43 / 255, 45 / 255, 49 / 255, 1);
195
+ win.level = $.NSFloatingWindowLevel;
196
+ for (const button of [$.NSWindowMiniaturizeButton, $.NSWindowZoomButton]) {
197
+ win.standardWindowButton(button).hidden = true;
198
+ }
199
+
200
+ const web = $.WKWebView.alloc.initWithFrameConfiguration($.NSMakeRect(0, 0, width, 480), config);
201
+ web.setValueForKey($.NSNumber.numberWithBool(false), 'drawsBackground');
202
+ web.loadHTMLStringBaseURL($(pageHtml(payload, argv[1] === '--self-test')), $.NSURL.URLWithString('about:blank'));
203
+ win.contentView = web;
204
+ win.center;
205
+ win.makeKeyAndOrderFront(null);
206
+ app.activateIgnoringOtherApps(true);
207
+
208
+ // Pump events ourselves: osascript has no running NSApplication loop to hand them to the window.
209
+ // NSAnyEventMask, not NSEventMaskAny: the newer constant is all 64 bits, which a JavaScript number
210
+ // rounds to a mask matching no event at all; the old one arrives as -1, every bit set.
211
+ while (result === null) {
212
+ const event = app.nextEventMatchingMaskUntilDateInModeDequeue(
213
+ $.NSAnyEventMask,
214
+ $.NSDate.dateWithTimeIntervalSinceNow(0.1),
215
+ $.NSDefaultRunLoopMode,
216
+ true,
217
+ );
218
+ if (!event.isNil()) app.sendEvent(event);
219
+ if (!win.isVisible) result = { action: 'cancel' };
220
+ }
221
+ win.orderOut(null);
222
+ return JSON.stringify(result);
223
+ }
@@ -159,6 +159,81 @@ export function companyName(approved) {
159
159
  .map(nameOf));
160
160
  return names.size === 1 ? [...names][0] : '';
161
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
+ });
236
+ }
162
237
  const CLAUDE_PERSONAL_PLANS = new Set(['free', 'pro', 'max']);
163
238
  /** "a personal Max account (gmail.com)" or "the Beta Corp organization". */
164
239
  function claudeSignedInText(signedIn) {
@@ -184,14 +259,27 @@ function tenantIds(tenants) {
184
259
  * awaiting review and gets through, as an approved one does. Ids compare exactly, as the backend
185
260
  * does -- vendor ids are not ours to case-fold.
186
261
  */
187
- export function approvedTenantViolation(agent, approved, signedIn) {
262
+ export function approvedTenantBlock(agent, approved, signedIn) {
188
263
  if (!signedIn)
189
264
  return null;
190
265
  const policy = approved?.[agent];
191
266
  const signedInId = signedIn.tenant_id.trim();
192
267
  if (!tenantIds(policy?.unapproved_tenants).some((t) => t.tenant_id.trim() === signedInId))
193
268
  return null;
194
- return tenantBlockMessage(agent, tenantIds(policy?.tenants).map(nameOf), claudeSignedInText(signedIn), companyName(approved));
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;
195
283
  }
196
284
  /**
197
285
  * The tenants a Cursor login is in: its teams, or, on no team, `@` and its email domain. Mirrors the
@@ -227,7 +315,14 @@ function listedTenantViolation(policy, tenants) {
227
315
  const unapprovedList = tenantIds(policy?.unapproved_tenants);
228
316
  if (!tenants.length || !tenants.every((id) => isListed(unapprovedList, id)))
229
317
  return null;
230
- return { approved: tenantIds(policy?.tenants).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
+ };
231
326
  }
232
327
  function isListed(list, id) {
233
328
  return list.some((t) => t.tenant_id.trim() === id);
@@ -259,22 +354,27 @@ function codexSignedInText(signedIn, tenants, policy) {
259
354
  return name ? `the ${name} workspace` : `workspace ${tenant}`;
260
355
  }
261
356
  /** The block message when Cursor's login may not be used, else null. */
262
- export function cursorTeamViolation(approved, signedIn, teamDomains = []) {
357
+ export function cursorTeamBlock(approved, signedIn, teamDomains = []) {
263
358
  if (!signedIn)
264
359
  return null;
265
360
  const v = listedTenantViolation(approved?.cursor, cursorTenants(signedIn));
266
361
  if (!v)
267
362
  return null;
268
363
  const signedInText = cursorSignedInText(signedIn, approved?.cursor);
269
- // The one account to point at: the approved team, else the company's name. Several approved
270
- // teams, or no name known, keep the generic message.
271
- const account = v.approved.length === 1 ? v.approved[0] : v.approved.length ? '' : companyName(approved);
272
- if (!account)
273
- return tenantBlockMessage('cursor', v.approved, signedInText);
274
- const domains = teamDomains.length ? ` (${teamDomains.join(', ')})` : '';
275
- return (`${ORGANIZATION_LEAD} does not allow Cursor to be signed in to ${signedInText}. ` +
276
- `The approved organization's team account for Cursor is ${account}${domains}. ` +
277
- 'Please sign out and sign in with the approved account to use 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;
278
378
  }
279
379
  /**
280
380
  * Codex plans that are personal, mirroring the backend's `CodexPlans.PERSONAL`. On one of these
@@ -313,14 +413,27 @@ function codexEmailDomainTenant(signedIn) {
313
413
  return at >= 0 && at < email.length - 1 ? [`@${email.slice(at + 1).toLowerCase()}`] : [];
314
414
  }
315
415
  /** The block message when Codex's login may not be used, else null. */
316
- export function codexTenantViolation(approved, signedIn) {
416
+ export function codexTenantBlock(approved, signedIn) {
317
417
  const tenants = signedIn ? codexTenants(signedIn) : null;
318
418
  if (!tenants)
319
419
  return null;
320
420
  const v = listedTenantViolation(approved?.codex, tenants);
321
421
  if (!v)
322
422
  return null;
323
- return tenantBlockMessage('codex', v.approved, codexSignedInText(signedIn, tenants, approved?.codex), companyName(approved));
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
+ };
433
+ }
434
+ export function codexTenantViolation(approved, signedIn) {
435
+ const block = codexTenantBlock(approved, signedIn);
436
+ return block ? tenantBlockText(block) : null;
324
437
  }
325
438
  /**
326
439
  * The org Claude Desktop is signed into now, for its Code tab's prompts, or null when unknown.
@@ -346,24 +459,36 @@ export function readClaudeDesktopSignedInTenant(home = homedir(), env = process.
346
459
  plan: same ? fromClaudeCode.plan : '',
347
460
  };
348
461
  }
349
- /**
350
- * The block message when a login's email is on a domain the organization unapproved, else null.
351
- *
352
- * Unapproving a domain covers every agent and every address on it, and its subdomains
353
- * (`mail.acme.com` falls under `acme.com`), the way an approved domain covers them.
354
- */
355
- export function unapprovedEmailDomainViolation(email, unapproved) {
462
+ export function unapprovedEmailDomainBlock(email, unapproved) {
356
463
  const address = (email ?? '').trim().toLowerCase();
357
464
  const at = address.lastIndexOf('@');
358
465
  if (at < 0 || !unapproved?.length)
359
466
  return null;
360
467
  const domain = address.slice(at + 1);
361
468
  const hit = unapproved.find((d) => domain === d || domain.endsWith(`.${d}`));
362
- if (!hit)
469
+ return hit ? { domain: hit, address } : null;
470
+ }
471
+ export function unapprovedEmailDomainViolation(email, unapproved) {
472
+ const block = unapprovedEmailDomainBlock(email, unapproved);
473
+ if (!block)
363
474
  return null;
364
- return (`This device doesn't allow accounts on ${hit}. You're signed in as ${address}. ` +
475
+ return (`This device doesn't allow accounts on ${block.domain}. You're signed in as ${block.address}. ` +
365
476
  'Sign in with your work account.');
366
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
+ }
367
492
  /** The id an agent is approved under on the Agents page: Desktop's Code tab runs Claude Code. */
368
493
  const APPROVAL_ID = { claude_desktop: 'claude' };
369
494
  const AGENT_NAME = {