anthropic model-access modules: manager (refreshable-grant) and consumer
Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the
|
||||
// token or any API — it lives in a sibling CLI state file, `~/.claude.json` →
|
||||
// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the
|
||||
// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence
|
||||
// account refuses rather than guesses, because delivering an access token to the wrong account is the
|
||||
// exact fault this exists to catch.
|
||||
//
|
||||
// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded
|
||||
// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does
|
||||
// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied,
|
||||
// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export type IdentityVerdict =
|
||||
| { state: "verified"; accountUuid: string }
|
||||
| { state: "no-identity-file" }
|
||||
| { state: "licence-not-adopted" }
|
||||
| { state: "wrong-account"; found: string; expected: string };
|
||||
|
||||
interface ClaudeJson {
|
||||
oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string };
|
||||
}
|
||||
|
||||
/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */
|
||||
export function readAccountUuid(path: string): string | null {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson;
|
||||
return raw.oauthAccount?.accountUuid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its
|
||||
* inputs so the fail-closed logic is tested without a filesystem.
|
||||
*
|
||||
* TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the
|
||||
* control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the
|
||||
* port map's five states. Until then only the two safe verdicts are reachable: verified when an
|
||||
* expected uuid is provided and matches, refuse otherwise.
|
||||
*/
|
||||
export function check(found: string | null, expected: string | null): IdentityVerdict {
|
||||
if (found === null) return { state: "no-identity-file" };
|
||||
if (!expected) return { state: "licence-not-adopted" };
|
||||
if (found === expected) return { state: "verified", accountUuid: found };
|
||||
return { state: "wrong-account", found, expected };
|
||||
}
|
||||
Reference in New Issue
Block a user