Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
lookup: () =>
probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
Effect.provideService(Path.Path, path),
Effect.provideService(FileSystem.FileSystem, fileSystem),
),
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
Expand Down
138 changes: 138 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { readClaudeRestrictedModels } from "./ClaudeEntitlements.ts";

const writeClaudeConfig = Effect.fn(function* (configDir: string, contents: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.makeDirectory(configDir, { recursive: true });
yield* fs.writeFileString(path.join(configDir, ".claude.json"), contents);
});

const makeConfigDir = Effect.fn(function* (name: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-entitlements-" });
return path.join(tempDir, name);
});

it.layer(NodeServices.layer)("readClaudeRestrictedModels", (it) => {
it.effect("returns only the models the organization has disallowed", () =>
Effect.gen(function* () {
const configDir = yield* makeConfigDir("claude-home");
// The real file carries dozens of unrelated keys around the cache, and
// names older models by dated API id where the catalog uses the bare
// slug.
yield* writeClaudeConfig(
configDir,
`{
"numStartups": 12,
"oauthAccount": { "emailAddress": "dev@example.com" },
"modelAccessCache": [
{ "apiName": "claude-fable-5", "entitled": false },
{ "apiName": "claude-fable-5-1", "entitled": false },
{ "apiName": "claude-haiku-4-5-20251001", "entitled": false },
{ "apiName": "claude-opus-4-5-20251101", "entitled": true },
{ "apiName": "claude-opus-5", "entitled": true },
{ "apiName": "claude-sonnet-5", "entitled": true }
]
}`,
);

const restricted = yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: configDir });

assert.deepEqual([...restricted], ["claude-fable-5", "claude-fable-5-1", "claude-haiku-4-5"]);
}),
);

it.effect("reads ~/.claude.json of the home the CLI is spawned with", () =>
Effect.gen(function* () {
const home = yield* makeConfigDir("home");
yield* writeClaudeConfig(
home,
`{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`,
);

// An instance environment may override HOME; the reader has to follow
// it to the same file the child reads rather than the server's own.
const restricted = yield* readClaudeRestrictedModels({ HOME: home });

assert.deepEqual([...restricted], ["claude-fable-5"]);
}),
);

it.effect("restricts nothing for a relative config dir or home", () =>
Effect.gen(function* () {
// The CLI resolves a relative CLAUDE_CONFIG_DIR or HOME against each
// session's own working directory, so no single file speaks for the
// environment. The file must not even be consulted: this filesystem
// would answer every read with a restriction.
const reads: Array<string> = [];
const restrictiveFileSystem = FileSystem.layerNoop({
readFileString: (filePath) =>
Effect.sync(() => {
reads.push(filePath);
return `{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`;
}),
});

for (const environment of [{ CLAUDE_CONFIG_DIR: "./claude" }, { HOME: "home" }]) {
const restricted = yield* readClaudeRestrictedModels(environment).pipe(
Effect.provide(restrictiveFileSystem),
);
assert.deepEqual([...restricted], []);
}
assert.deepEqual(reads, []);
}),
);

it.effect("restricts nothing when the config is missing or malformed", () =>
Effect.gen(function* () {
const absent = yield* makeConfigDir("absent-home");
assert.deepEqual([...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: absent }))], []);

const brokenJson = yield* makeConfigDir("broken-json");
yield* writeClaudeConfig(brokenJson, "{ not json");
assert.deepEqual(
[...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: brokenJson }))],
[],
);

const brokenCache = yield* makeConfigDir("broken-cache");
yield* writeClaudeConfig(brokenCache, `{ "modelAccessCache": { "claude-fable-5": false } }`);
assert.deepEqual(
[...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: brokenCache }))],
[],
);
}),
);

it.effect("ignores entries that carry no usable model id or verdict", () =>
Effect.gen(function* () {
const configDir = yield* makeConfigDir("partial-home");
yield* writeClaudeConfig(
configDir,
`{
"modelAccessCache": [
null,
"claude-fable-5",
{ "entitled": false },
{ "apiName": " ", "entitled": false },
{ "apiName": "claude-opus-5" },
{ "apiName": "claude-sonnet-4-6", "entitled": false }
]
}`,
);

const restricted = yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: configDir });

// Only an explicit `false` restricts: an absent verdict is unknown, not
// disallowed, and one odd entry does not cost the others.
assert.deepEqual([...restricted], ["claude-sonnet-4-6"]);
}),
);
});
105 changes: 105 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeEntitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* ClaudeEntitlements — reads which models the account's organization allows.
*
* Enterprise and team organizations can disallow individual models. Claude
* Code records the resolved per-model entitlements in its global config file
* under `modelAccessCache`, the list its own `/model` menu is built from, and
* falls back to the org default when a disallowed model is requested —
* emitting only an `informational` notice mid-turn, after the user already
* picked it.
*
* The Agent SDK is not a usable substitute: its init model list is the CLI's
* curated picker with restricted rows already dropped, so a model can be
* absent from it and still run (`claude-opus-4-8` is absent yet answers
* normally), and the field that would carry them is internal to the VS Code
* extension.
*
* Reading is best effort in both directions: an unreadable, malformed, or
* absent cache yields no restrictions, so the picker degrades to today's
* behavior rather than hiding models the org actually allows.
*
* @module provider/Drivers/ClaudeEntitlements
*/
import * as NodeOS from "node:os";

import { TrimmedNonEmptyString } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

/**
* The `.claude.json` the spawned CLI reads, given the environment it is
* spawned with (see `makeClaudeEnvironment`, which exports an instance's
* `homePath` as `CLAUDE_CONFIG_DIR`). Verified against the CLI: with
* `CLAUDE_CONFIG_DIR` set it reads `$CLAUDE_CONFIG_DIR/.claude.json`; without
* it, `~/.claude.json` beside the `~/.claude` directory rather than inside it.
*
* A relative `CLAUDE_CONFIG_DIR` or `HOME` resolves against each session's
* own working directory, so no single file speaks for the whole environment;
* `undefined` here means restrict nothing.
*/
function resolveClaudeConfigFilePath(
path: Path.Path,
environment: NodeJS.ProcessEnv,
): string | undefined {
const configDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
const home = environment.HOME?.trim() ?? "";
const root = configDir.length > 0 ? configDir : home.length > 0 ? home : NodeOS.homedir();
return path.isAbsolute(root) ? path.join(root, ".claude.json") : undefined;
}

// Entries are validated one at a time, as the CLI does, so a single odd entry
// costs only itself rather than every restriction in the list.
const ClaudeGlobalConfig = Schema.fromJsonString(
Schema.Struct({ modelAccessCache: Schema.optional(Schema.Array(Schema.Unknown)) }),
);
const decodeClaudeGlobalConfig = Schema.decodeUnknownOption(ClaudeGlobalConfig);

const ModelAccessEntry = Schema.Struct({
apiName: TrimmedNonEmptyString,
entitled: Schema.Boolean,
});
const decodeModelAccessEntry = Schema.decodeUnknownOption(ModelAccessEntry);

/**
* The cache names models by API id, which for older models carries a release
* date (`claude-haiku-4-5-20251001`) that the catalog slug (`claude-haiku-4-5`)
* does not. Dropping the date is the same normalization the CLI applies before
* matching, and it is what lets the entry meet the slug.
*/
function toCatalogSlug(apiName: string): string {
return apiName.replace(/-\d{8}$/, "");
}

/**
* Model ids the organization has explicitly disallowed, as catalog slugs
* (`claude-fable-5`), for the account the given environment spawns the CLI
* as. Entries the cache marks entitled, and models it does not mention at
* all, are omitted — only an explicit `entitled: false` restricts.
*/
export const readClaudeRestrictedModels = Effect.fn("readClaudeRestrictedModels")(function* (
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlySet<string>, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const restricted = new Set<string>();

const configFilePath = resolveClaudeConfigFilePath(path, environment);
if (configFilePath === undefined) return restricted;

const contents = yield* fileSystem
.readFileString(configFilePath)
.pipe(Effect.orElseSucceed(() => undefined));
const parsed = contents === undefined ? Option.none() : decodeClaudeGlobalConfig(contents);
if (Option.isNone(parsed)) return restricted;

for (const entry of parsed.value.modelAccessCache ?? []) {
const decoded = decodeModelAccessEntry(entry);
if (Option.isSome(decoded) && !decoded.value.entitled) {
restricted.add(toCatalogSlug(decoded.value.apiName));
}
}
return restricted;
});
15 changes: 15 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
}).catch(() => undefined),
),
);
// Point the probe at an isolated config dir so entitlements come from
// this fixture rather than the developer's real ~/.claude.json.
const claudeConfigDir = path.join(tempDir, "claude-config");
yield* fs.makeDirectory(claudeConfigDir, { recursive: true });
yield* fs.writeFileString(
path.join(claudeConfigDir, ".claude.json"),
`{
"modelAccessCache": [
{ "apiName": "claude-fable-5", "entitled": false },
{ "apiName": "claude-opus-5", "entitled": true }
]
}`,
);

yield* fs.writeFileString(
executablePath,
Expand Down Expand Up @@ -141,6 +154,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
...process.env,
T3_PROBE_INVOCATION_PATH: invocationPath,
ENABLE_CLAUDEAI_MCP_SERVERS: "true",
CLAUDE_CONFIG_DIR: claudeConfigDir,
},
workspaceCwd,
);
Expand All @@ -157,6 +171,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
input: { hint: "[path]" },
},
],
restrictedModels: new Set(["claude-fable-5"]),
usage: {
rate_limits_available: true,
rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } },
Expand Down
Loading
Loading