feat: require explicit opt-in for Claude Code use

This commit is contained in:
amosblomqvist
2026-10-28 21:37:03 +02:00
parent ab76e4ff50
commit c9c8cead57
16 changed files with 417 additions and 88 deletions
+8 -1
View File
@@ -7,7 +7,7 @@ import {
type TeachingAgent,
} from "../agent/teacher-agent.ts";
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
@@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts";
import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
import { providerKeyPresent } from "./keys.ts";
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
import {
extractionEnabled,
graphToolsEnabled,
@@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts";
* for write-ahead persistence, turn liveness, and the turn services.
*/
/** Fresh check only at new background-run admission; never cancel a run
* already in progress when the opt-in is switched off. */
function connectionBackgroundAllowed(provider: string): boolean {
return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider));
}
function settlementErrorText(error: unknown): string {
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
}
+13
View File
@@ -0,0 +1,13 @@
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { settingsStore } from "../store/settings.ts";
/** Fresh admission state, not a process-wide cache. Existing runs retain
* their engines; only new chat/job/extraction/compaction admissions consult it. */
export function claudeCodeEnabled(): boolean {
return settingsStore.readSettings()?.claudeCode?.enabled === true;
}
/** Installation/opt-in availability only, never authenticated readiness. */
export function claudeCodeAvailable(): boolean {
return claudeCodeEnabled() && getClaudeBinary() !== null;
}
+3
View File
@@ -1,6 +1,7 @@
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
import { initAuthEnv } from "./keys.ts";
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
import { claudeCodeEnabled } from "./claude-code-config.ts";
/** The Settings status seam: initialize provenance before any child env is copied. */
export async function claudeCodeInstallationStatus() {
@@ -13,6 +14,7 @@ export async function claudeCodeInstallationStatus() {
export async function claudeCodeInstallationResponse(
request: Request,
inspect = claudeCodeInstallationStatus,
enabled = claudeCodeEnabled,
): Promise<Response> {
const origin = subscriptionRequestOrigin(request);
const supplied = request.headers.get("origin");
@@ -23,5 +25,6 @@ export async function claudeCodeInstallationResponse(
|| (supplied !== null && supplied !== origin)) {
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
}
if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers });
return Response.json(await inspect(), { headers });
}
+4 -3
View File
@@ -53,7 +53,7 @@ import {
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
@@ -189,8 +189,9 @@ export function keyValue(id: string): string | undefined {
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
// Presence only, not authentication readiness. Launch performs the version gate.
if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null;
// Explicit opt-in + installation only, never authentication readiness.
// Consulted at admission, not inside already-running agent loops.
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
+3 -2
View File
@@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
]);
}
function connectionModel(provider: string, id: string): Model<Api> | undefined {
if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined;
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
}
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
@@ -48,7 +49,7 @@ import type {
} from "../core/contracts/models.ts";
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts";
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
import { claudeCodeAvailable } from "./claude-code-config.ts";
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts";
@@ -143,7 +144,7 @@ export function catalogProviders(): CatalogProvider[] {
keyPresent: keyValue(p.envVar) !== undefined,
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null },
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
]).sort((a, b) => a.id.localeCompare(b.id));
}
+10
View File
@@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
return { ok: true, settings };
}
/** claudeCode: explicit app-wide opt-in. Empty section resets to off. */
export function parseClaudeCodeSettingsBody(body: unknown): SettingsBodyResult<{ enabled?: boolean }> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
if (raw.enabled === undefined) return { ok: true, settings: {} };
if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
return { ok: true, settings: { enabled: raw.enabled } };
}
/** agent.models: only the six known background roles; each override's
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
@@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = {
compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody,
dictation: parseDictationSettingsBody,
claudeCode: parseClaudeCodeSettingsBody,
} as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;