import { OAuthProviderConfig, CredentialStoreConfig, ResolvedCredential, ensureFreshCredential, OpenAIDeviceConfig, openaiDeviceLogin, OAuthTokens, DeviceDeps } from '@theokit/sdk/auth'; export { CredentialError, CredentialStoreConfig, DeviceCodeGrant, DeviceDeps, DeviceOAuthConfig, OAuthProviderConfig, OAuthTokens, OpenAIDeviceConfig, ResolveCredentialOptions, ResolvedCredential, StoredCredential, StoredOAuthCredential, assertSecureModes, authFilePath, credentialHome, deviceLogin, ensureFreshCredential, extractAccountId, openaiDeviceLogin, persistOAuthTokens, pollDeviceToken, providerFromApiKeyPrefix, readAuthFile, readStoredOAuth, refreshOAuthTokens, requestDeviceCode, writeCredential } from '@theokit/sdk/auth'; import { TheokitAgentError } from '@theokit/sdk/errors'; import { PermissionMode } from '@theokit/sdk'; export { PermissionMode } from '@theokit/sdk'; /** * M60 — `AuthProvider`, the OO contract that unifies the SDK's free OAuth-lifecycle functions * (`openaiDeviceLogin` → `persistOAuthTokens` → `ensureFreshCredential`). Those are stateful across a * SHARED `config` (`OAuthProviderConfig`) + `store` (`CredentialStoreConfig`); this class holds that * state so a consumer authors `new AuthProvider(config, store).persist(...)` / `.ensureFresh(...)` * instead of threading `config`/`store` through every call — the `SDK → Theokit → AgentBuilder` * boundary applied to the auth domain (ENRICH, per blueprint D2: auth carries orchestration + state). * * It DELEGATES, never reimplements (parsimony Rung 9): each method forwards verbatim to the SDK * function, so login → persist → refresh produces byte-identical state to calling the SDK directly. * * SECRET-SAFETY (hard rule): this class NEVER logs, stringifies, or otherwise surfaces token material. * Methods return exactly what the SDK returns and add no observability — a token is data that flows * through, never something this layer emits. The parity/secret tests pin both properties. */ /** The HTTP deps of `ensureFreshCredential` (`{ fetch, now }`) — typed off the SDK to avoid drift. */ type EnsureFreshHttpDeps = Parameters[2]; /** The device-flow deps + prompt hook of `openaiDeviceLogin` — typed off the SDK. */ type DeviceLoginDeps = Parameters[1]; type DeviceLoginHooks = Parameters[2]; declare class AuthProvider { private readonly config; private readonly store; constructor(config: OAuthProviderConfig, store: CredentialStoreConfig); /** * Refresh a resolved credential if stale. Delegates to `ensureFreshCredential` with the held * `config` + `store`; `env` (for reading the store's env overrides) + the `{ fetch, now }` HTTP deps * thread through. Returns the fresh `ResolvedCredential` — never logs the rotated token. */ ensureFresh(resolved: ResolvedCredential, deps: EnsureFreshHttpDeps, env?: Record): Promise; /** In-flight refresh per store path — the key is the file, not the instance. */ private static readonly refreshInFlight; /** * The refresh itself, serialized across PROCESSES and with a re-read. * * The re-read is not a detail: without it the lock merely serializes, and the second process * decides using the state it read BEFORE waiting — refreshing again and invalidating the token the * first one just wrote. It is classic double-checked locking, and it is what the two-process test * catches. */ private refreshUnderLock; /** * Run the headless OpenAI device-login flow. Delegates to `openaiDeviceLogin` (which JWT-extracts the * account id). `deviceConfig` is passed per-call because it is a distinct endpoint set from the * refresh `config`. Returns `OAuthTokens` — the caller persists them via {@link AuthProvider.persist}. */ deviceLogin(deviceConfig: OpenAIDeviceConfig, deps: DeviceLoginDeps, hooks: DeviceLoginHooks): Promise; /** * Persist freshly-obtained tokens through the held `store`. Delegates to `persistOAuthTokens` and * returns the credential-file path (never the token). `env` selects the store's home override. */ persist(provider: string, tokens: OAuthTokens, env?: Record): string; } /** * M111 — device auth plug-and-play: a provider is an object with LABELLED methods, and a login fits * in one call. * * ## The problem, measured * * M110 made the RFC 8628 device flow cross this layer. It did not touch the ergonomics: to * authenticate against Codex, the consumer had to know that **two** device-flow shapes exist, copy a * `clientId` and three OpenAI URLs into its own code, assemble `{ fetch, sleep, now }`, call * `deviceLogin` and **remember** to call `persist` — and forgetting the last one costs a full OAuth * round-trip that stores nothing. The `AuthProvider` docblock instructed exactly that: * *"the caller persists them via `AuthProvider.persist`"*. * * ## The shape, and the discriminant that was rejected * * `loginWithDevice` returns `()` — nothing comes out for the caller to persist, and the two granular * halves stay public. A provider is an object with `methods: [{ label, type, authorize }]`, and the * label is what the UI shows: it turns a protocol choice into a choice between readable phrases. * * **REJECTED — a `kind` discriminant.** `type` classifies the **kind of credential**, not the * protocol: a browser method and a headless one can both be `type: 'oauth'`. A `kind` with internal * dispatch would be a `switch`, exactly the defect this milestone removes from the consumer. Here, * each method points at **its own** function. * * ## Why the public identity lives HERE * * The client identity belongs beside the flow that uses it rather than in the consumer, so a surface * cannot hold a stale copy of it. * */ /** * A labelled way of obtaining a credential within a provider. * * A DISCRIMINATED UNION, not an optional `authorize?` field. With an optional field, * `{ label, type: 'oauth' }` would be representable — an OAuth method that cannot authorize, * detected only at runtime, in the middle of the user's login. It is the alternative M110 already * rejected in writing when it refused "one type with optional fields": it would make an invalid * config representable and move detection out of the compiler * para o runtime. */ type AuthMethod = { /** What the interface shows the user. The piece that makes the flow choosable without knowing the protocol. */ readonly label: string; readonly type: 'oauth'; /** THIS method's function. No discriminant: the method points at its own, not at a `switch`. */ readonly authorize: (deps: DeviceDeps, hooks: PromptHooks) => Promise; } | { readonly label: string; readonly type: 'api'; }; /** What the consumer wires into its UI to show the code the user types on the other device. */ interface PromptHooks { onPrompt: (p: { userCode: string; verificationUri: string; expiresIn?: number; }) => void; } /** An authentication provider: public identity + the labelled ways of authenticating with it. */ interface DeviceAuthProvider { readonly name: string; readonly oauth: OAuthProviderConfig; readonly methods: readonly AuthMethod[]; } /** * Environment override for `clientId`. It dissolves the false dilemma between a fixed constant * (inflexible) and a mandatory parameter (which hands the copy back to the consumer): a default in * the package, an escape for whoever needs one. */ declare const CODEX_CLIENT_ID_ENV_VAR = "THEOKIT_CODEX_CLIENT_ID"; /** * The Codex provider, assembled and **frozen**. * * Frozen because it is a public identity SHARED across the process: a consumer that mutated it * would change everyone else's login. `Object.freeze` is shallow, so the method list is frozen * separately — without that, `CODEX_PROVIDER.methods.push(...)` would go through. */ declare const CODEX_PROVIDER: DeviceAuthProvider; /** * Facade options. `deps` and `env` travel together in one object rather than as two positional * parameters: both are optional and rarely used, and six positionals is a signature callers get * wrong in silence (the monorepo lint enforces 5 as the ceiling — the ceiling exists for this reason). */ interface LoginWithDeviceOptions { /** I/O injection for tests. Omitted, it uses the real `fetch`/`setTimeout`/`Date.now`. */ readonly deps?: Partial; /** The environment the store reads to resolve the credential directory. */ readonly env?: Record; } /** * Authorizes **and** persists, in one call. Returns where the credential landed and the account it * was attributed to — **never** token material. * * Nothing comes out for the caller, so there is no step it can forget. The two halves stay public on * `AuthProvider` (`deviceLogin` / `persist`) for whoever needs the granularity, alongside this * facade. * * It delegates verbatim: `method.authorize` runs the flow and `AuthProvider.persist` writes. Copying * the sequence instead of calling it would create a second oracle over the same fact, and two oracles * diverge on the first fix applied to only one side. */ declare function loginWithDevice(provider: DeviceAuthProvider, method: AuthMethod, store: CredentialStoreConfig, hooks: PromptHooks, opts?: LoginWithDeviceOptions): Promise<{ path: string; accountId?: string; }>; /** * M79 — "given an env, a home and a model, WHICH credential do I use, and WHERE did it come from?" * * ## The gap this closes * * The genuinely hard half was already supplied: RFC 8628 device flow, refresh under a cross-process * lock, persistence, account-id extraction. The half every consumer meets FIRST was answered twice * inside the framework and exposed neither time — `resolveProvider()` locked behind * `internal-api.ts`, and `resolveCredential` deliberately withheld from `@theokit/agents/auth`. * * The "app policy" framing defends **which** providers exist. It does not defend the precedence * chain, the prefix↔provider consistency check, or the provenance record: those are mechanism. A * consumer forced to rewrite mechanism wrote a 70-line dotenv parser for the single question "shell * or `.env`?". * * ## Why the descriptors are a PARAMETER, and why this name is safe here * * Two functions already share the name `resolveCredential` with divergent semantics — sync vs async, * throws vs `undefined`, reads env vs does not, infers the provider vs refuses — which is exactly * why `auth-entry.ts` withholds the SDK's. A third under the same name in the same scope would * invite importing the wrong one. * * Taking the descriptor list as an ARGUMENT is what makes this one distinguishable at the call site * rather than by luck: it is the only one whose signature says which providers it is talking about. * The SDK's symbol stays unexported from this subpath, so only one is reachable. */ /** Where a credential came from — data, so provenance is formatting rather than parsing. */ type SourceOrigin = { readonly kind: 'env'; readonly varName: string; } | { readonly kind: 'file'; readonly path: string; } | { readonly kind: 'oauth'; readonly provider: string; }; /** One provider the app is willing to use. WHICH providers exist stays app policy. */ interface ProviderDescriptor { /** Name used in provenance, telemetry and messages. */ readonly name: string; /** Environment variable carrying the API key. */ readonly envKey: string; /** Lower wins. Declared, so the caller's array order is not a hidden second policy. */ readonly priority: number; /** Model-id prefix this provider claims (`openai/`). Absent ⇒ it claims none. */ readonly modelPrefix?: string; /** * Prefix this provider stamps on its API keys (`sk-ant-`, `sk-or-`, `sk-`). * * Enables the coherence check: a key sitting in `ANTHROPIC_API_KEY` that starts with `sk-proj-` * is a paste into the wrong variable, caught here for free instead of as a 401 from the wrong * endpoint whose message says nothing about the mismatch. Absent means "this provider stamps no * recognisable prefix", and nothing is checked — never a silent pass disguised as a check. */ readonly keyPrefix?: string; } /** * The answer, with its provenance attached. * * NOT called `ResolvedCredential`: the SDK already publishes a type under that name from this same * subpath, and the collision surfaced the moment this shipped. Two shapes sharing one name in one * scope is the failure this whole milestone is about — so the name says what it is, a resolution. */ interface CredentialResolution { readonly kind: 'api-key' | 'oauth'; readonly provider: string; readonly apiKey: string; readonly source: SourceOrigin; /** * Whether the resolver PICKED the provider rather than the caller naming it. * * Without this, "why is it calling Anthropic?" has no answer in the data — the caller cannot tell * a user's explicit choice from a precedence fallback. */ readonly inferred: boolean; } /** Raised when the model's prefix names a provider that has no credential. */ declare class ProviderPrefixMismatchError extends TheokitAgentError { readonly name = "ProviderPrefixMismatchError"; constructor(provider: string, envKey: string, model: string); } /** Raised when a key's prefix contradicts the provider it was declared for. */ declare class ProviderKeyMismatchError extends TheokitAgentError { readonly provider: string; readonly where: string; readonly expectedPrefix: string; readonly name = "ProviderKeyMismatchError"; constructor(provider: string, where: string, expectedPrefix: string); } /** Raised when a pinned provider has no credential, or names one the app never declared. */ declare class DeclaredProviderError extends TheokitAgentError { readonly name = "DeclaredProviderError"; constructor(message: string); } /** * Raised by {@link requireCredential} when nothing is configured, carrying WHERE it looked. * * The list is the point. "No credential found" without it is the least useful sentence available, * and it is why the closest measured consumer carried its own `attempts` array. */ declare class CredentialNotFoundError extends TheokitAgentError { readonly attempts: readonly string[]; readonly name = "CredentialNotFoundError"; constructor(attempts: readonly string[]); } interface ResolveCredentialInput { /** The environment as the process sees it — already loaded, already interpolated. */ readonly env: Readonly>; /** Directory whose `.env` is consulted for PROVENANCE only. Omitted ⇒ everything reads as shell. */ readonly home?: string; /** The providers this app accepts. App policy, hence a parameter. */ readonly providers: readonly ProviderDescriptor[]; /** Model id, when known. Its prefix is checked against the resolved provider. */ readonly model?: string; /** * Where the persisted credential store lives, when the app has one. * * OPT-IN, and the reason is compatibility: absent, this resolver behaves byte-identically to * before it could read a store. Present, an OAuth credential written by `theokit auth login` * becomes reachable — which is what makes the published `kind: 'oauth'` variant producible instead * of a promise the type made and no code path kept. * * The environment still wins: it is the more explicit and more immediate signal, and it matches * the precedence a consumer already implements (declared env → per-provider env → file). */ readonly store?: CredentialStoreConfig; /** * Name of the variable that PINS a provider (`PROVIDER`, `THEOCODE_PROVIDER`, …). * * When set and present, the pinned provider is the only one considered: if it has no credential, * resolution THROWS rather than falling back. Falling back would send the request — and the bill, * and the data — to a provider the operator did not choose, which is the one outcome a pin exists * to prevent. * * The variable NAME is the app's (products disagree on it); the semantics are not. */ readonly declaredProviderEnvVar?: string; } /** What `credentialSources` needs — the same declarations the resolver consults. */ interface CredentialSourcesInput { readonly providers: readonly ProviderDescriptor[]; /** Present only when the app configured a persisted store, exactly as for `resolveCredential`. */ readonly store?: CredentialStoreConfig; } /** * Every place `resolveCredential` would look, in the order it looks. * * `resolveCredential` returns `undefined` when nothing is configured, and that stays — a missing key * is the ordinary first-run state, and a throw makes the caller's next move harder. What `undefined` * cannot say is WHERE it looked, so a product rendering "no credential found" either prints exactly * that — the least useful sentence available — or rebuilds the resolver's precedence to name the * places. The measured consumer built the second, carrying its own `attempts` list. * * Reporting is deliberately a SECOND question rather than a richer return type: changing the * resolver's shape would break every existing caller to serve an error path, and the answer here is * pure — no filesystem, no environment — so a caller can render it before or after a failed resolve. * * The order is the resolution order, because the list is printed: any other order reads as a * precedence claim the resolver does not honour. */ declare function credentialSources(input: CredentialSourcesInput): readonly string[]; declare function resolveCredential(input: ResolveCredentialInput): CredentialResolution | undefined; /** * Like {@link resolveCredential}, but THROWS when nothing is configured — carrying where it looked. * * Two functions rather than a flag: the non-throwing shape is what a first-run path wants ("no key * yet" is the ordinary state, and the next move is `theokit auth login`), and the throwing shape is * what a path that cannot continue wants. A boolean parameter would make the caller's intent * invisible at the call site, and the two intents are genuinely different. * * The attempts list is the reason this exists: "no credential found" without saying WHERE it looked * is the least useful sentence a CLI can print, and it is why the closest measured consumer carried * its own. */ declare function requireCredential(input: ResolveCredentialInput): CredentialResolution; /** * The providers a TheoKit agent app starts with — so a new app writes no table at all. * * Measured against the closest real consumer: it opens with three hand-written tables (`PROVIDERS`, * `PREFIXES`, `ENV_KEYS`) saying what every terminal agent app says. That is not its domain; it is * the cost of shipping a resolver whose only input is the argument the app must build. * * ## Why the prefixes live here as well as in the SDK * * `providerFromApiKeyPrefix` owns the same knowledge, and one table would be better. It cannot be * the only one today: the symbol is exported at runtime and absent from the SDK's `auth/index.d.ts` * (measured against 4.52.0), so a typed import does not resolve. Rather than block, the table is * here AND a drift-guard test fails when the two disagree — the same hand-maintained-table hazard * that produced the longest-prefix bug is caught by CI instead of hoped away. * * ## Priorities, and why they are spaced * * Distinct and 10 apart: an app that wants to slot a provider between two defaults can, without * renumbering. Order is `openrouter → anthropic → openai`, matching the consumer's measured chain. */ declare const DEFAULT_PROVIDERS: readonly ProviderDescriptor[]; /** Everything {@link resolveAgentCredential} needs, and nothing an app has to invent. */ interface AgentCredentialInput { readonly env: Readonly>; readonly home?: string; readonly model?: string; readonly store?: CredentialStoreConfig; /** * Providers to accept. Defaults to {@link DEFAULT_PROVIDERS}. * * Present so an app can NARROW (a product that only talks to Anthropic) or EXTEND (a self-hosted * gateway) — the two real reasons to disagree with the default. Which providers exist stays app * policy; not having to state it to get started is the point. */ readonly providers?: readonly ProviderDescriptor[]; /** Variable that pins a provider. Defaults to `THEOKIT_PROVIDER`. */ readonly declaredProviderEnvVar?: string; } /** * The one call a new app makes: given the environment, which credential do I use? * * `resolveCredential` takes the full policy as arguments, which is right for an app that has * opinions and wrong as a starting point — a new app should not have to state a provider table, a * precedence order and a pin variable before it can read a key. This applies the defaults and keeps * every one of them overridable. * * Throws when nothing is configured, carrying WHERE it looked: a new app's first run is exactly the * case that needs the list, and printing the error is the whole handling it needs. */ declare function resolveAgentCredential(input: AgentCredentialInput): CredentialResolution; interface RunCredentialHelperOptions { /** * Milliseconds before the helper is killed and the call fails. * * A helper that hangs stalls every request that needs the credential, and "the agent is slow" is * the hardest symptom to trace back to a command in a policy file. Defaults to 10 s, which is * generous for minting a token and short enough to name the cause. */ readonly timeoutMs?: number; /** Working directory for the helper. Defaults to the process's own. */ readonly cwd?: string; } /** * The credential an operator's `apiKeyHelper` produces, or `undefined` when none is declared. * * This — not {@link runCredentialHelper} — is what a consumer calls, and the narrowing is the point. * A function taking an arbitrary command string puts the choice of WHAT to execute in the caller's * hands, which is exactly the decision the operator tier exists to take away from them. Here the * command can only come from the policy file, so "this runs an operator-declared command" is a * property of the code rather than a promise in a docblock. * * `runCredentialHelper` stays exported for the tests that exercise its failure modes directly; a * runner whose timeout and redaction could not be tested in isolation would be a guard nobody can * check. */ declare function resolveOperatorApiKey(warn: (message: string) => void, options?: RunCredentialHelperOptions): Promise; /** What a caller asks about: may THIS tool run THIS command in THIS place? */ interface PermissionQuery { readonly tool: string; /** Directory the call runs in. Canonicalised before it becomes part of the key. */ readonly scope: string; readonly command: string; } interface GrantOptions { /** Milliseconds from now. Absent ⇒ the grant does not expire. */ readonly ttlMs?: number; } /** One standing grant, as persisted. */ interface Grant { readonly tool: string; readonly scope: string; readonly command: string; readonly grantedAt: string; /** Epoch millis. Absent ⇒ never expires. */ readonly expiresAt?: number; } interface PermissionStoreOptions { /** Root under which `.theokit/tool-permissions.json` lives. Injectable so tests never touch `~`. */ readonly home: string; /** Injectable clock — a store whose expiry cannot be tested is a store whose expiry is untested. */ readonly now?: () => number; } declare class PermissionStore { #private; /** Absolute path of the backing file. Public so a test can corrupt it deliberately. */ readonly path: string; /** Why the last read failed, when it did. Carried, never thrown — a bad store must not end a turn. */ lastReadError?: Error; constructor(options: PermissionStoreOptions); isGranted(query: PermissionQuery): boolean; grant(query: PermissionQuery, options?: GrantOptions): void; revoke(query: PermissionQuery): void; /** Every grant on record, expired ones included — `list()` reports, it does not judge. */ list(): readonly Grant[]; /** * Grants that have expired. * * Surfaced rather than swept: an expired grant that vanishes silently is a permission the user * believes they still have. The same discipline the repository applies to allowlist sunsets. */ expired(): readonly Grant[]; } /** * Turns a {@link PermissionStore} into a `pre_tool_call` handler — the seam that can actually refuse * a tool. * * ## Why an adapter, and not a gate of its own * * The store shipped with a careful key, a deny-by-default posture and no reader: measured * 2026-09-10, `isGranted` had zero callers outside its own unit test, and `PermissionStore` appeared * in zero files across six sibling repositories. It described a control that was not in force. * * What was missing was never a mechanism. `pre_tool_call` is documented in `bridge/hook-handlers.ts` * as the ONLY hook with veto power, and it runs before the tool by construction — so a refusal here * is a refusal before the side effect. This file is the two lines that connect the two, plus the one * decision neither of them could make alone. * * The alternative — a required `granted` argument mirroring `BuildHookHandlersOptions.approved` — * was rejected because it breaks every consumer's compile for a store none of them uses. The hook * path could afford a required argument because it shipped WITH one; retrofitting is a different * proposition. * * ## The decision only the consumer can make * * A `PermissionQuery` is keyed by `(tool, scope, command)`. A `PreToolCallContext` carries * `{ name, args }` — no scope, and no notion of which argument IS the command, which differs per * tool. So the mapping is the consumer's, and it is passed in. * * `classify` returns a query OR an explicit `{ governed: false }`, never a bare `undefined`. A * mapper returning `undefined` would say "this tool needs no permission" and "I forgot this tool" in * the same word, and the second one would silently pass — on a security gate. The discriminated * result makes forgetting a compile error while keeping "not governed" expressible, which a * deny-everything default would not: a gate that refuses ordinary work is a gate somebody disables. * * ## Composing with a `pre_tool_call` you already have * * `HookHandlers.pre_tool_call` is a SINGLE field, so assigning this gate over an existing handler * loses one of the two, silently — the failure `bridge/delegation-hooks.ts` records for delegated * hooks, *"nothing throws, nothing warns"*, and worse here because the handler that loses may be the * one that refuses. Compose them explicitly: * * ```ts * pre_tool_call: async (ctx) => (await gate(ctx)) ?? (await mine(ctx)) * ``` * * First veto wins; `undefined` falls through. No helper ships for this: it is one line, and a * `composePreToolCall` would be a second way to do what `??` already does. */ /** * Minimal shape mirrored from `@theokit/sdk`. * * The reason used to read "type-only, no runtime import, so the SDK peer stays optional", and that * does not justify mirroring anything: `bridge/hook-handlers.ts` imports these very two SDK types * (`PreToolCallContext`, `PreToolCallDecision`) with `import type` and costs the optional peer * nothing. A type-only import IS type-only. * * The real reason is the NAME, and it is written out three paragraphs down: `@theokit/sdk` already * exports `PermissionGateContext` with a different shape, so a consumer importing both would get a * duplicate identifier or silently the wrong one. What is mirrored here is also a deliberate * SUBSET — the fields this gate reads — rather than a copy kept in step with the SDK's. * * A mirror can drift into a consumer-side compile error that no gate here would see first, so the * assignment this docblock instructs a consumer to make is pinned by * `tests/type/the-grant-gate-fits-the-hook-it-is-for.test-d.ts`. * * NOT named `PermissionGateContext`: `@theokit/sdk` already exports a type by that name, with a * different shape (`{ toolName, mode }`), alongside `PermissionGate`, `PermissionGateDecision`, * `PermissionEngine` and `PermissionPlugin`. A consumer importing both would get a duplicate * identifier, or silently the wrong shape. * * The rename says something true rather than merely avoiding a clash. This gates on a STANDING * GRANT the operator made; the SDK's permission engine is a separate system with its own rules, and * the two do not consult each other — an engine `allow` does not satisfy a missing grant, and a * grant does not satisfy the engine. */ interface GrantGateContext { readonly name: string; readonly args: Record; readonly agentId: string; readonly runId: string; /** * The run's resolved permission mode, carried by the real context. Mirrored so the shapes match * and so its absence from the gate's logic is a decision rather than an oversight: `bypass` does * NOT disable this gate, because a standing grant is the operator's, not the run's. * * B-057 — the SDK's own `PermissionMode`, not a bare `string` and not a union restated here. The * loose type was the half that made this look like an oversight: a mirror that accepts any word * mirrors nothing in particular, and `"readonly"` — what somebody writes when they mean `plan` — * would have sat here looking applied. * * Restating the union was the first attempt and the type test refused it: this shape must fit * `PreToolCallContext` structurally, and a hand-written copy that drifts by one member stops * fitting. Importing the source makes it a mirror BY CONSTRUCTION — it narrows when the SDK * narrows, and cannot disagree. * * This layer's own three-mode surface reaches these through `approvalModeToPermissionMode`, so a * reader of either vocabulary meets the other. */ readonly permissionMode?: PermissionMode; } /** This tool is deliberately outside the gate's remit — distinct from "I forgot to map it". */ interface NotGoverned { readonly governed: false; } /** This tool IS governed, by the grant key inside. */ interface Governed { readonly governed: true; readonly query: PermissionQuery; } /** * BOTH arms carry `governed`, and that is the whole point. * * The first version was `PermissionQuery | NotGoverned` — discriminated by whether the `governed` * KEY was present. It failed open, measured against the built artifact: a consumer who writes a * policy record `{ governed: true, scope }` and spreads it into the query — the most natural * reading of "yes, govern this" — got `undefined` back, and an ungranted destructive command ran. * `governed: undefined` did the same. TypeScript does not stop it: a fresh literal with an excess * property errors, the same object through a variable or a spread does not. * * The presence check arrived by obeying a linter. `'governed' in c && c.governed === false` was * flagged as "always true given the type", which was true of the DECLARED type and false of every * value that reaches it. A type-based lint rule cannot see excess properties, and the safety of a * gate is not a type-level fact. * * With the discriminant on both arms there is no key to be accidentally present: `governed` is * declared either way, the check is on its VALUE, and `governed: true` without a `query` is a * compile error rather than a silent pass. */ type Classification = Governed | NotGoverned; /** * A veto, in the shape `pre_tool_call` returns. * * Exported because it appears in {@link grantGate}'s public signature — an unexported type there * leaves a consumer unable to name the return value. `hooks/secure-store.ts` documents the same * case. */ interface Veto { readonly block: true; readonly message: string; } /** * @param store the grants to consult; re-read per call, so a grant made mid-run is seen * @param classify maps a tool call to the grant key it needs, or declares it ungoverned */ declare function grantGate(store: PermissionStore, classify: (ctx: GrantGateContext) => Classification): (ctx: GrantGateContext) => Promise; /** * B-080 — a run in which the agent may plan and may not act. * * ## The mode already had a name, and nothing honoured it * * The SDK declares `PermissionMode = "default" | "plan" | "acceptEdits" | "bypass" | * "bypassPermissions"`. Measured 2026-09-14 across `packages/agents/src`: `default` 4 times, * `acceptEdits` once, `bypass` once, and **`plan` zero times** — of the five modes the type carries, * the one meaning "do not act" is the single one this layer never mentioned. Accepted by the type * and consulted by nobody, which is the shape this backlog keeps closing. * * So nothing here invents a mode. It makes the declared one refuse. * * ## Refusing, never skipping * * A silently skipped call leaves the agent believing it succeeded, so the plan reads as executed. * That is the one outcome nobody can detect afterwards — worse than an error, which is why this * throws a typed error naming both the mode and the tool (`rules/error-handling.md` § 2). * * ## Compose, never assign * * `auth/permission-gate.ts` states the hazard in its own docblock: `HookHandlers.pre_tool_call` is a * SINGLE field, so assigning this over an existing handler loses one of the two silently — *"and * worse here because the handler that loses may be the one that refuses."* The documented shape is: * * ```ts * pre_tool_call: async (ctx) => (await planOnly(ctx)) ?? (await mine(ctx)) * ``` * * First veto wins; `undefined` falls through. No helper ships for that: it is one line, and a * `composePreToolCall` would be a second way to do what `??` already does. * * ## What counts as state-changing is the CONSUMER's decision * * `permission-gate.ts` already established why: a context carries `{ name, args }` with no notion of * which argument is the command, and that differs per tool. The classifier is passed in rather than * guessed, and this module does not invent a second taxonomy of dangerous tools. */ /** Refused because the run is in `plan` mode. Typed so a consumer can catch this and only this. */ declare class PlanOnlyRefusalError extends TheokitAgentError { readonly name = "PlanOnlyRefusalError"; /** The tool whose call was refused, so a handler can report it without parsing the message. */ readonly tool: string; constructor(tool: string); } /** The slice of a pre-tool-call context this gate reads. Structural, so it fits the SDK's shape. */ interface PlanOnlyContext { readonly name: string; readonly permissionMode?: string; } interface PlanOnlyGateOptions { /** * Does calling this tool change state that outlives the turn? * * The consumer's decision, for the reason `permission-gate.ts` records: the package cannot know * which argument is the command, and a guessed taxonomy would be wrong per tool. */ readonly isStateChanging: (toolName: string) => boolean; } /** * A `pre_tool_call` handler that refuses state-changing calls while the run is in `plan` mode. * * Returns `undefined` in every other case, so it composes with `??` rather than replacing whatever * handler a consumer already has. */ declare function createPlanOnlyGate(options: PlanOnlyGateOptions): (ctx: PlanOnlyContext) => Promise; export { type AgentCredentialInput, type AuthMethod, AuthProvider, CODEX_CLIENT_ID_ENV_VAR, CODEX_PROVIDER, type Classification, CredentialNotFoundError, type ProviderDescriptor as CredentialProviderDescriptor, type CredentialResolution, type CredentialSourcesInput, DEFAULT_PROVIDERS, DeclaredProviderError, type DeviceAuthProvider, type Governed, type Grant, type GrantGateContext, type GrantOptions, type NotGoverned, type PermissionQuery, PermissionStore, type PermissionStoreOptions, type PlanOnlyContext, type PlanOnlyGateOptions, PlanOnlyRefusalError, type PromptHooks, ProviderKeyMismatchError, ProviderPrefixMismatchError, type ResolveCredentialInput, type RunCredentialHelperOptions, type SourceOrigin, type Veto, createPlanOnlyGate, credentialSources, grantGate, loginWithDevice, requireCredential, resolveAgentCredential, resolveCredential, resolveOperatorApiKey };