// account-scope — the single fail-closed cross-account resolver. // // Task 1440 landed this logic inline in // platform/ui/app/lib/claude-agent/account.ts. Task 1458 relocates the two // pure primitives here so BOTH the UI lib (account.ts, which re-exports them) // and MCP plugins (the `filesystem` plugin) import ONE definition — "one // resolver, no drift", the same privilege-boundary discipline as the 1383/1385 // account-manager escalation. This module is pure: no filesystem access, no // throw at module load, so it is safe to import into an MCP server that boots // with empty env during plugin-system `tools/list` enumeration. export type EffectiveAccount = | { accountId: string } | { reject: | "cross-account-denied" | "cross-account-invalid-target" | "cross-account-target-not-provisioned"; }; /** * Resolve the account a call acts on. Fail-closed: only a house-scoped session * may set `targetAccountId`, and only to a valid account. There is no house * fallback — an unscoped session that names a target is denied, never silently * downgraded to its own account. * * `isValidAccountId` is injected (required, no default): the UI passes its * fs-backed `isValidAccountId` (reads disk fresh every call); the `memory` and * `filesystem` plugins pass `isProvisionedAccount` (cached-hit, re-scan-on-miss). * Keeping validity injected is what lets this module stay pure. * * `notProvisionedReason` names the reject emitted when the injected check fails. * It defaults to `cross-account-invalid-target` (the UI path, byte-identical to * the 1440 landing). The MCP plugins pass `cross-account-target-not-provisioned` * because their injected check re-scans disk on a miss (Task 1471) — so a false * from it means the id is genuinely not on disk, distinct from a stale-cache * miss (which no longer rejects). The distinct reason lets an operator tell a * wrong/typo id from the (now-eliminated) created-after-boot case in the * `[xacct] op=call … reason=` line. */ export function resolveEffectiveAccountCore( input: { ownAccountId: string; houseScope: string | null; targetAccountId?: string | null }, isValidAccountId: (id: string) => boolean, notProvisionedReason: | "cross-account-invalid-target" | "cross-account-target-not-provisioned" = "cross-account-invalid-target", ): EffectiveAccount { const { ownAccountId, houseScope, targetAccountId } = input; if (!targetAccountId) return { accountId: ownAccountId }; if (!houseScope) return { reject: "cross-account-denied" }; if (!isValidAccountId(targetAccountId)) return { reject: notProvisionedReason }; return { accountId: targetAccountId }; } /** * Boot-reader that consumers import to learn their house scope from the spawn * env. An empty value reads as null — a non-house session carries no scope. */ export function readHouseAdminScope(env: NodeJS.ProcessEnv = process.env): string | null { return env.HOUSE_ADMIN_SCOPE ? env.HOUSE_ADMIN_SCOPE : null; }