@optimuslabs/harness-map-staging 1.5.26 → 1.5.28

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.
@@ -218,6 +218,33 @@ export function codexTenantViolation(approved, signedIn) {
218
218
  (v.allowed ? `Approved: ${v.allowed}. ` : '') +
219
219
  'Run `codex logout`, then sign in to an approved workspace.');
220
220
  }
221
+ /**
222
+ * Whether a personal account's `@<domain>` tenant is on a team domain: an allowlisted domain that
223
+ * someone on an approved team or enterprise tenant signs in with (any vendor's). A subdomain counts.
224
+ */
225
+ export function onTeamDomain(tenant, teamDomains) {
226
+ if (!tenant.startsWith('@') || !teamDomains?.length)
227
+ return false;
228
+ const domain = tenant.slice(1);
229
+ return teamDomains.some((d) => domain === d || domain.endsWith(`.${d}`));
230
+ }
231
+ /**
232
+ * The block message for Cursor's login, else null. A login on a team is checked against the
233
+ * approved teams. A personal account is only checked once its email is on a team domain; then it
234
+ * is blocked unless an admin approved that personal tenant (`@<domain>`).
235
+ */
236
+ export function cursorLoginViolation(approved, signedIn, teamDomains) {
237
+ if (!signedIn)
238
+ return null;
239
+ const checked = signedIn.team_ids.length > 0 || cursorTenants(signedIn).some((t) => onTeamDomain(t, teamDomains));
240
+ return checked ? cursorTeamViolation(approved, signedIn) : null;
241
+ }
242
+ /** The block message for Codex's login, else null; a personal plan is checked as Cursor's is. */
243
+ export function codexLoginViolation(approved, signedIn, teamDomains) {
244
+ const tenants = signedIn ? codexTenants(signedIn) : null;
245
+ const checked = tenants?.some((t) => !t.startsWith('@') || onTeamDomain(t, teamDomains));
246
+ return checked ? codexTenantViolation(approved, signedIn) : null;
247
+ }
221
248
  /**
222
249
  * The org Claude Desktop is signed into now, for its Code tab's prompts, or null when unknown.
223
250
  *
@@ -24,7 +24,7 @@ import { tryResolveHardwareUuid } from './log_config_files/runtime/hardware_uuid
24
24
  import { cursorAutofixDefersInlineVerify, syncRemediations, } from './log_config_files/runtime/remediation_sync.js';
25
25
  import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
26
26
  import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
27
- import { agentApprovalViolation, approvedTenantViolation, codexTenantViolation, codexTenants, emailAllowlistViolation, cursorTeamViolation, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, } from './approved_tenant_gate.js';
27
+ import { agentApprovalViolation, approvedTenantViolation, codexLoginViolation, emailAllowlistViolation, cursorLoginViolation, readClaudeDesktopSignedInTenant, readClaudeSignedInTenant, readCodexSignedIn, readCursorSignedInTeams, } from './approved_tenant_gate.js';
28
28
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
29
29
  /** After verified (or failed verify), push enforcement + session log immediately — do not wait for background runner. */
30
30
  async function flushRemediationOutcomeToServer(complianceLogPath) {
@@ -265,23 +265,18 @@ function loginEmail(agent) {
265
265
  }
266
266
  /** Why this agent's signed-in tenant is not approved, or null when it is (or cannot be read). */
267
267
  function tenantViolation(agent) {
268
- const approved = readRemediationInstructionsFile().approved_tenants;
268
+ const { approved_tenants: approved, team_email_domains: teamDomains } = readRemediationInstructionsFile();
269
269
  if (agent === 'claude')
270
270
  return approvedTenantViolation('claude', approved, readClaudeSignedInTenant());
271
271
  if (agent === 'claude_desktop') {
272
272
  return approvedTenantViolation('claude_desktop', approved, readClaudeDesktopSignedInTenant());
273
273
  }
274
- // A personal account (Cursor on no team, Codex on a personal plan) has no tenant to approve: only
275
- // the email allowlist decides it.
276
- if (agent === 'codex') {
277
- const signedIn = readCodexSignedIn();
278
- const tenants = signedIn ? codexTenants(signedIn) : null;
279
- return tenants?.some((t) => !t.startsWith('@')) ? codexTenantViolation(approved, signedIn) : null;
280
- }
281
- if (agent === 'cursor') {
282
- const signedIn = readCursorSignedInTeams();
283
- return signedIn?.team_ids.length ? cursorTeamViolation(approved, signedIn) : null;
284
- }
274
+ // A personal account (Cursor on no team, Codex on a personal plan) is left to the email allowlist
275
+ // until its domain is a team domain; then it needs its personal tenant approved.
276
+ if (agent === 'codex')
277
+ return codexLoginViolation(approved, readCodexSignedIn(), teamDomains);
278
+ if (agent === 'cursor')
279
+ return cursorLoginViolation(approved, readCursorSignedInTeams(), teamDomains);
285
280
  return null;
286
281
  }
287
282
  /** The block for a login: which check refused it and why, or null when every check passes. */
@@ -1,6 +1,7 @@
1
1
  import { closeSync, existsSync, fstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { readClaudeLoginPlan } from './claude_login_plan.js';
4
5
  /**
5
6
  * Every org Claude Desktop's signed-in account has used, so both tenants are recorded when one
6
7
  * login belongs to a team org and a personal org and switches between them in Desktop.
@@ -20,7 +21,8 @@ import { homedir } from 'node:os';
20
21
  * Reads `lastKnownAccountUuid` from config.json and nothing else from it -- the same file holds
21
22
  * Desktop's encrypted token caches, which are never touched or uploaded. The email, and the active
22
23
  * org's name and plan, come from ~/.claude.json only when it is the same account (and, for the name
23
- * and plan, the same org): Desktop's own files name neither. macOS only.
24
+ * and plan, the same org): Desktop's own files name neither. The plan prefers Claude Code's stored
25
+ * login over that cached profile (see claude_login_plan.ts). macOS only.
24
26
  */
25
27
  const FILE_TYPE = 'claude_desktop_orgs';
26
28
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -124,7 +126,7 @@ function claudeJsonDetails(home, account, orgUuids) {
124
126
  const name = text(oauth.organizationName);
125
127
  if (name)
126
128
  details.orgNames[org] = name;
127
- const plan = claudeCodePlan(claudeJson, oauth);
129
+ const plan = readClaudeLoginPlan(home) || claudeCodePlan(claudeJson, oauth);
128
130
  if (plan)
129
131
  details.orgPlans[org] = plan;
130
132
  return details;
@@ -0,0 +1,72 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ /**
6
+ * The plan of the login Claude Code is actually using, from its stored OAuth credentials.
7
+ *
8
+ * `~/.claude.json`'s `oauthAccount` is a cached profile and can disagree with the login: a Team
9
+ * login has been seen there as `organizationType: "claude_pro"` on the person's personal org, while
10
+ * `/status` (which reads the credentials) said "Claude Team account". The credentials'
11
+ * `claudeAiOauth.subscriptionType` is what Claude Code bills and runs prompts against, so it wins.
12
+ *
13
+ * Credentials live in `~/.claude/.credentials.json` on Linux and Windows, and in the macOS login
14
+ * keychain as "Claude Code-credentials". Only `subscriptionType` is read out; the tokens beside it
15
+ * are never kept, logged or uploaded. The keychain is only read for the real home directory: it
16
+ * belongs to the signed-in macOS user, not to any other home a scan is pointed at.
17
+ */
18
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
19
+ const PLAN = /^[a-z0-9_-]{1,64}$/i;
20
+ function planFrom(credentials) {
21
+ try {
22
+ const plan = JSON.parse(credentials)?.claudeAiOauth
23
+ ?.subscriptionType;
24
+ return typeof plan === 'string' && PLAN.test(plan.trim()) ? plan.trim() : '';
25
+ }
26
+ catch {
27
+ return '';
28
+ }
29
+ }
30
+ function readCredentialsFile(home) {
31
+ try {
32
+ return readFileSync(join(home, '.claude', '.credentials.json'), 'utf8');
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ function readKeychainCredentials(home) {
39
+ if (process.platform !== 'darwin' || home !== homedir())
40
+ return '';
41
+ try {
42
+ return execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], {
43
+ encoding: 'utf8',
44
+ stdio: ['ignore', 'pipe', 'ignore'],
45
+ timeout: 3000,
46
+ });
47
+ }
48
+ catch {
49
+ return '';
50
+ }
51
+ }
52
+ const isObject = (value) => typeof value === 'object' && value !== null;
53
+ /** Claude Code's login plan (e.g. "team", "pro"), or '' when no stored login names one. */
54
+ export function readClaudeLoginPlan(home = homedir()) {
55
+ return planFrom(readCredentialsFile(home)) || planFrom(readKeychainCredentials(home));
56
+ }
57
+ /**
58
+ * Sets the login plan as the top-level `subscriptionType` of each uploaded `~/.claude.json` with a
59
+ * claude.ai login, the field the backend reads first for Claude's plan. Files are returned as given
60
+ * when there is no such upload or no plan.
61
+ */
62
+ export function withClaudeLoginPlan(files, home = homedir()) {
63
+ const claudeJsons = files.filter((f) => basename(f.file_path) === '.claude.json' && isObject(f.raw_content?.oauthAccount));
64
+ if (claudeJsons.length === 0)
65
+ return files;
66
+ const plan = readClaudeLoginPlan(home);
67
+ if (!plan)
68
+ return files;
69
+ for (const file of claudeJsons)
70
+ file.raw_content.subscriptionType = plan;
71
+ return files;
72
+ }
@@ -26,6 +26,7 @@ import { canonicalCursorUserStateVscdbPath } from './remediation_config_path.js'
26
26
  import { ensureWorkspaceRepoEnv, resolveProjectRoot } from './workspace_repo.js';
27
27
  import { scanActiveWorktrees, activeWorktreeRootSet, filterConfigFilesToActiveWorktrees, } from './worktree_scanner.js';
28
28
  import { createSignature, canonicalizePayload } from '../sender/signing.js';
29
+ import { withClaudeLoginPlan } from '../collection/claude_login_plan.js';
29
30
  const PROJECT_ROOT = resolveProjectRoot();
30
31
  const configFileKey = (c) => `${c.file_type}\t${c.file_path}`;
31
32
  /** Appends the entries not in the batch yet (by file type and path), recording each as seen. */
@@ -70,7 +71,7 @@ async function fetchPatterns(endpointBase) {
70
71
  function scanPatterns(patternsResponse) {
71
72
  persistVscdbComposerContractFromPatternsResponse(patternsResponse);
72
73
  hookRunLog(`scanning config files`);
73
- return collectConfigFilesFromPatterns(patternsResponse.patterns, PROJECT_ROOT, hookRunLog, {
74
+ const files = collectConfigFilesFromPatterns(patternsResponse.patterns, PROJECT_ROOT, hookRunLog, {
74
75
  vscdbEntrySpecs: patternsResponse.vscdb_entry_specs,
75
76
  vscdb_read_queries: patternsResponse.vscdb_read_queries,
76
77
  vscdb_merge_from_composer_state: patternsResponse.vscdb_merge_from_composer_state,
@@ -80,6 +81,7 @@ function scanPatterns(patternsResponse) {
80
81
  absolute_path_prefixes: patternsResponse.absolute_path_prefixes,
81
82
  mcp_tool_glob_spec: patternsResponse.mcp_tool_glob_spec ?? null,
82
83
  });
84
+ return withClaudeLoginPlan(files, HOME_DIR);
83
85
  }
84
86
  /** MCP tool caches: the global cache, Cursor's per-project caches and its workspace vscdb files. */
85
87
  function addMcpToolCaches(patternsResponse, configFiles, seen) {
@@ -133,20 +133,29 @@ export function writeRemediationInstructionsFile(file) {
133
133
  }
134
134
  function writeInstructionsFile(file, remediationsWrittenAt) {
135
135
  // Most callers write `{ remediations }` only; keep the approval lists they didn't pass.
136
- const onDisk = readRemediationInstructionsFile();
137
- const approved_tenants = 'approved_tenants' in file ? file.approved_tenants : onDisk.approved_tenants;
138
- const unapproved_email_domains = 'unapproved_email_domains' in file ? file.unapproved_email_domains : onDisk.unapproved_email_domains;
139
- const allowed_email_domains = 'allowed_email_domains' in file ? file.allowed_email_domains : onDisk.allowed_email_domains;
140
- const approved_agents = 'approved_agents' in file ? file.approved_agents : onDisk.approved_agents;
141
136
  atomicWriteJson(getRemediationInstructionsPath(), {
142
137
  ...file,
143
- ...(approved_tenants === undefined ? {} : { approved_tenants }),
144
- ...(unapproved_email_domains === undefined ? {} : { unapproved_email_domains }),
145
- ...(allowed_email_domains === undefined ? {} : { allowed_email_domains }),
146
- ...(approved_agents === undefined ? {} : { approved_agents }),
138
+ ...keptApprovalLists(file, readRemediationInstructionsFile()),
147
139
  remediations_written_at: remediationsWrittenAt,
148
140
  });
149
141
  }
142
+ const APPROVAL_LISTS = [
143
+ 'approved_tenants',
144
+ 'unapproved_email_domains',
145
+ 'allowed_email_domains',
146
+ 'approved_agents',
147
+ 'team_email_domains',
148
+ ];
149
+ /** Each approval list from `file` when it names one, else the one on disk; absent ones left out. */
150
+ function keptApprovalLists(file, onDisk) {
151
+ const kept = {};
152
+ for (const key of APPROVAL_LISTS) {
153
+ const value = key in file ? file[key] : onDisk[key];
154
+ if (value !== undefined)
155
+ kept[key] = value;
156
+ }
157
+ return kept;
158
+ }
150
159
  /** When the remediations on disk were written: recorded, or for an older file its mtime. */
151
160
  function remediationsWrittenAtOf(onDisk) {
152
161
  if (onDisk.remediations_written_at)
@@ -167,6 +176,10 @@ export function writeUnapprovedEmailDomains(unapproved_email_domains) {
167
176
  export function writeApprovedAgents(approved_agents) {
168
177
  writeApprovalList({ approved_agents });
169
178
  }
179
+ /** Replace only the team email domains, keeping everything else on disk. */
180
+ export function writeTeamEmailDomains(team_email_domains) {
181
+ writeApprovalList({ team_email_domains });
182
+ }
170
183
  /** Replace only the email allowlist, keeping everything else on disk. */
171
184
  export function writeAllowedEmailDomains(allowed_email_domains) {
172
185
  writeApprovalList({ allowed_email_domains });
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { executeBody } from '../../endpoint_client/http_transport.js';
5
5
  import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
6
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
- import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeApprovedAgents, writeRemediationInstructionsFile, } from './management_storage.js';
7
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeTeamEmailDomains, writeApprovedAgents, writeRemediationInstructionsFile, } from './management_storage.js';
8
8
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
9
9
  import { ensureAuthentication } from '../auth/auth_flow.js';
10
10
  import { createSignature } from '../sender/signing.js';
@@ -1653,6 +1653,9 @@ function storeApprovals(syncResult) {
1653
1653
  const allowedDomains = syncResult.allowed_email_domains;
1654
1654
  if (Array.isArray(allowedDomains))
1655
1655
  writeAllowedEmailDomains(lowercaseStrings(allowedDomains));
1656
+ const teamDomains = syncResult.team_email_domains;
1657
+ if (Array.isArray(teamDomains))
1658
+ writeTeamEmailDomains(lowercaseStrings(teamDomains));
1656
1659
  }
1657
1660
  async function reconcileInstructions(endpointBase, machineUuid, want) {
1658
1661
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimuslabs/harness-map-staging",
3
- "version": "1.5.26",
3
+ "version": "1.5.28",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {