@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.
- package/dist/agent_access/decision.js +20 -0
- package/dist/agent_access/window_runner.js +129 -0
- package/dist/agent_block_window/agent_block_window.jxa +223 -0
- package/dist/approved_tenant_gate.js +196 -105
- package/dist/compliance_prompt_gate.js +89 -26
- package/dist/log_config_files/collection/skill_symlink.js +9 -0
- package/dist/log_config_files/runtime/management_storage.js +34 -0
- package/dist/log_config_files/runtime/remediation_sync.js +64 -1
- package/dist/log_config_files/runtime/skill_block.js +253 -72
- package/dist/log_config_files/runtime/skill_block_sessions.js +76 -20
- package/package.json +2 -2
|
@@ -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
|
-
/**
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
|
212
|
-
if (tenants.
|
|
315
|
+
const unapprovedList = tenantIds(policy?.unapproved_tenants);
|
|
316
|
+
if (!tenants.length || !tenants.every((id) => isListed(unapprovedList, id)))
|
|
213
317
|
return null;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
93
|
-
|
|
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:
|
|
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
|
|
268
|
-
function
|
|
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
|
|
282
|
+
return approvedTenantBlock('claude', approved, readClaudeSignedInTenant());
|
|
272
283
|
if (agent === 'claude_desktop') {
|
|
273
|
-
return
|
|
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
|
|
287
|
+
return codexTenantBlock(approved, readCodexSignedIn());
|
|
279
288
|
if (agent === 'cursor')
|
|
280
|
-
return
|
|
289
|
+
return cursorTeamBlock(approved, readCursorSignedInTeams(), teamDomains);
|
|
281
290
|
return null;
|
|
282
291
|
}
|
|
283
|
-
/**
|
|
284
|
-
|
|
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
|
|
292
|
-
if (
|
|
293
|
-
return {
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
299
|
-
*
|
|
300
|
-
*
|
|
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
|
|
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
|
-
|
|
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) {
|