/** * Permission gate (P4b scope §2 + §3) — the I/O orchestration layer around the * pure {@link evaluate} policy engine. * * The gate is what a gated tool calls before it is allowed to mutate disk or * run a shell command. Consultation order (E017 pick 7, omp approval.ts model * — deliberately revisiting the session-only-`always` decision, see the E017 * agent-features dossier pick 7): * 1. plan mode — the outermost refusal (read-only; fail-closed); * 2. the policy engine via {@link evaluateWithReason} — `allow` approves and * the deny FLOOR is terminal: no override below ever lifts a floor deny; * 3. the persisted per-tool policy (`tools.approval.` in * ~/.openkai/config.json, consulted live through the injected * {@link ToolPolicySource}) — `"allow"` approves, `"deny"` refuses, a * missing key means prompt-by-default. This layer sits ABOVE the autonomy * axis, so a `"deny"` pin holds even at autonomy `high`; * 4. the autonomy axis (operator's live posture: med auto-approves in-cwd * writes, high also bash); * 5. the session-scoped `always` cache — a prior `always` for the *same* * call signature suppresses the re-prompt (scope §6 test); * 6. otherwise it emits a {@link permission_request} event (through the * injected `pushEvent` callback) and awaits the matching {@link respond} * call; an `always` answer is recorded in the in-memory cache. * * Persistence itself is the CLI's job (config.ts `readToolApprovals` / * `writeToolApproval`); the gate stays storage-agnostic — it is handed a * live {@link ToolPolicySource} and re-consults it per request, so a key * written mid-session (the overlay's "always (this project)" stop) takes * effect immediately. * * The gate owns no queue of its own — it borrows the transport's event push so * the consumer's `events()` stream sees `permission_request` in order with * the surrounding `tool_call` / `tool_result` frames. {@link SessionPermissionGate} * is constructed by {@link InProcessTransport}, which delegates * {@link SessionTransport.respond} to {@link SessionPermissionGate.respond}. * * Deadlock safety (scope §9): the gate's `request()` awaits a promise stored in * a `Map`. The agent loop is paused at `await tool.execute(...)`, but the * transport's event pump (the consumer's `for await … events()` loop) keeps * draining because `pushEvent` is a synchronous non-blocking push and the * consumer runs concurrently. `respond()` is called from the operator input * path (a separate event-loop task), resolving the awaited promise — so the * pump drains while a tool awaits approval. There is no shared turn. No * auto-approving timeout is bolted on (scope §9): an approval that is never * answered blocks until the run is aborted — `abort()`/`close()` settle every * pending request as `reject` via {@link SessionPermissionGate.rejectAll}, so * a blocked tool unblocks as refused; it can never resolve to `allow` on its * own (fail-closed). Plan mode ({@link SessionPermissionGate.setPlanMode}) * adds a gate-level read-only refusal covering in-flight turns whose tool * snapshot predates the plan/act toggle. */ import { type PermissionDecision } from "./permissions.js"; import type { PermissionPreview } from "./transport.js"; /** A gated tool's request outcome. */ export type PermissionOutcome = /** Approved (either a one-shot `once` or a session-scoped `always` cache hit). */ { decision: "approve"; } /** Refused — by policy (deny floor / out-of-cwd) or by the operator. */ | { decision: "reject"; reason: string; }; /** The surface a gated tool codes against. */ export interface PermissionGate { /** * Request approval for a gated tool call. `buildPreview` is invoked lazily — * only when the decision is `ask` and the cache misses — so an `allow` or * `deny` (e.g. a deny-floor `.env` write) never pays the preview cost. */ request(toolName: string, toolCallId: string, args: unknown, buildPreview: () => PermissionPreview | Promise): Promise; /** Answer a pending request (called from the operator input path). */ respond(requestId: string, decision: "once" | "always" | "reject"): void; /** * Plan mode (read-only): while on, every {@link request} that reaches the * gate is rejected without prompting. This covers in-flight turns whose * tool snapshot predates the plan/act toggle — the transport swaps the * tool set for the NEXT turn, but a tool already executing still calls * the gate, and the gate is the last line (fail-closed). */ setPlanMode(on: boolean): void; /** * Settle every pending request as `reject` with `reason`. Called by the * transport on abort()/close() so a tool awaiting approval never hangs * past the run's lifetime. */ rejectAll(reason: string): void; } /** A stripped event the gate asks the transport to stamp + push onto its queue. */ export type PushPermissionEvent = (event: { kind: "permission_request"; requestId: string; toolCallId: string; toolName: string; args: unknown; preview: PermissionPreview; rule: string; }) => void; /** * A persisted per-tool approval policy value (`tools.approval.` in * ~/.openkai/config.json). The absence of a key means "prompt by default". */ export type ToolApprovalPolicy = "allow" | "deny"; /** * Live reader for the persisted per-tool policy map. Called on every gated * request (step 3 of the consultation order), so config edits — including * the permission overlay's "always (this project)" stop — take effect * without a restart. Storage lives in the CLI (config.ts); the gate only * knows this shape. */ export type ToolPolicySource = () => Record; /** Options for {@link SessionPermissionGate}. */ export interface SessionPermissionGateOptions { /** Working directory — the policy root and the diff preview base. */ cwd: string; /** Transport callback that stamps + pushes the event onto the session queue. */ pushEvent: PushPermissionEvent; /** * Persisted per-tool policy reader (E017 pick 7). Consulted after the deny * floor and BEFORE the autonomy axis; undefined means no persisted layer * (prompt-by-default everywhere the engine says `ask`). */ toolPolicy?: ToolPolicySource; } /** * Session-scoped permission gate. The `always` cache lives in this instance * only — a new session gets a new transport → new gate → fresh prompts * (scope §6 `always`-scoping test). The per-tool POLICY layer is persisted * (via the injected {@link ToolPolicySource}; E017 pick 7 — omp's * `tools.approval.` model); the cache itself is never written to disk. */ export declare class SessionPermissionGate implements PermissionGate { private readonly cwd; private readonly pushEvent; /** Live reader for the persisted per-tool policy (config.json tools.approval). */ private toolPolicy; /** Pending approvals: requestId → resolver (payload carries the reject reason). */ private readonly pending; /** Session-scoped `always` cache: toolName + args signature. In memory only. */ private readonly alwaysCache; /** * Plan mode (read-only): while on, every request is rejected without * prompting — the gate-side backstop for in-flight turns whose tool * snapshot predates the plan/act toggle. */ private planMode; /** * The autonomy axis (droid's coarse visible layer over the fine rules): * off/low = default posture; med auto-approves in-cwd write/edit; high * auto-approves bash too. The deny FLOOR is terminal at every level. */ private autonomy; constructor(options: SessionPermissionGateOptions); /** Set / replace the persisted per-tool policy reader (post-construction wiring). */ setToolPolicySource(source: ToolPolicySource | undefined): void; /** Set the autonomy axis (operator's live choice; default `low`). */ setAutonomy(level: "off" | "low" | "med" | "high"): void; get autonomyLevel(): "off" | "low" | "med" | "high"; /** Toggle plan mode: while on, every request is rejected (read-only). */ setPlanMode(on: boolean): void; /** Settle every pending request as reject — abort/close unblock path. */ rejectAll(reason: string): void; request(toolName: string, toolCallId: string, args: unknown, buildPreview: () => PermissionPreview | Promise): Promise; /** * Doom-loop tracking (E019): a sliding window of recent gated calls. The * same (tool, args) three times consecutively trips the guard; any * different call resets the run. Args identity is a cheap stable stringify * — the gate's hot path stays allocation-light. */ private doomKey; private doomCount; private noteDoomLoop; respond(requestId: string, decision: "once" | "always" | "reject"): void; /** Test accessor: number of unanswered pending requests. */ get pendingCount(): number; } /** Read the current file content for a diff preview (empty if absent/unreadable). */ export declare function readForPreview(absPath: string): Promise; /** Resolve a tool `path` arg against the gate cwd. */ export declare function resolvePreviewPath(cwd: string, rawPath: string): string; /** Truncate a diff side to head + elision + tail (scope §9 diff-rendering cost). */ export declare function truncateDiff(text: string, maxLines?: number): string; /** * Build a diff preview payload. Returns `before` / `after` so the overlay * renders token-coloured removed/added lines itself (scope §5); the engine * never formats display strings. */ export declare function buildDiffPreview(absPath: string, before: string, after: string): { kind: "diff"; path: string; before: string; after: string; }; /** Re-export the decision type for consumers. */ export type { PermissionDecision }; //# sourceMappingURL=permission-gate.d.ts.map