/** * A TTL cache for approval verdicts the AI reviewer already produced. * * `/approval auto` hands every shape the rule table cannot judge to a * subagent-configured model: one `llm.stream` call, up to 15 seconds, and the * user's quota. The same shape repeats inside one session — `pytest -q`, * `npm install`, `docker compose up` — and each repeat paid the full review * again. This module remembers a verdict for a bounded time so an identical * request is decided from the cache. * * Two rules keep that safe, and both are structural rather than heuristic: * * 1. Only requests the classifier marked `ask` are ever looked up or stored. * Rule allows and denials are free, and `DANGER_PATTERNS` returns before the * cache is consulted at all, so a remembered verdict can never widen what * the rule table refuses. * 2. The key is an exact identity, not a prefix: the tool, the canonical * command, its raw arguments, the sandbox mode, the workspace, the locale, * the reviewer that produced the verdict, and a fingerprint of the user * message that authorized it. A verdict handed out while `npm publish` was * authorized never answers `npm publish --tag next`, and an escalation * justified in one message is not replayed for the next one. * * Opaque interpreter payloads (`bash -c`, `python -e`) are excluded on purpose: * the review result there depends entirely on a script the key cannot see. * @module dsh-ssh-tui/approval-cache */ import type { ReviewVerdict } from './approval-reviewer.js'; /** How long one remembered verdict stays usable. */ export declare const DEFAULT_VERDICT_TTL_MS: number; /** How many verdicts are remembered at once; the least recently used go first. */ export declare const DEFAULT_VERDICT_CAPACITY = 32; /** * Everything the reviewer's verdict actually depends on. * * `args` and `authorization` are load-bearing: the `workspace-write` escalation * is allowed purely because the model read a justification out of the request, * and `latestUserAuthorizationText` is what the reviewer weighs when it decides * whether the user asked for this. Dropping either would let one authorization * cover a later, different call. */ export interface VerdictKeyInput { toolName: string; command: string; args?: string | undefined; reason?: string | undefined; sandboxMode?: string | undefined; /** The agent the request came from: a subagent's authorization is its own. */ agentId: string; workspaceCwd: string; locale: string; /** The model that produced the verdict; a different reviewer may differ. */ reviewer: { provider: string; model: string; }; /** One-way fingerprint of the user message that authorized the request. */ authorization: string; } /** Whitespace-insensitive form of a command, so spacing alone cannot miss. */ export declare function canonicalCommand(command: string): string; /** * Whether a verdict for this request may be remembered. * * A request without a command has nothing to key on, and an interpreter * wrapper hides its payload behind `-c`/`-e`, so its review says nothing about * the next invocation of the same wrapper. * @param input - the identity a verdict would be stored under. * @returns whether the cache may serve and store this shape. */ export declare function cacheableShape(input: VerdictKeyInput): boolean; /** * The cache key for one reviewed request. * @param input - the identity a verdict would be stored under. * @returns a stable, collision-resistant digest. */ export declare function verdictKey(input: VerdictKeyInput): string; export interface CachedVerdict { verdict: ReviewVerdict; storedAt: number; } /** * A bounded, time-limited map from a reviewed request to its verdict. * * Both limits are injectable so tests can drive the clock instead of sleeping. * The instance is per TUI: a fresh session starts with an empty cache, and * nothing is written to disk. */ export declare class ApprovalVerdictCache { private readonly options; private readonly entries; private hitCount; constructor(options?: { ttlMs?: number; capacity?: number; now?: () => number; }); private get ttlMs(); private get capacity(); private get now(); /** * The remembered verdict for a key, if it is still inside its TTL. * @param key - {@link verdictKey} of the request being decided. * @returns the verdict and its age, or `undefined` for a miss or an expiry. */ lookup(key: string): { verdict: ReviewVerdict; ageMs: number; } | undefined; /** * Remember one reviewed verdict, evicting the least recently used entry past * the capacity. * @param key - {@link verdictKey} of the reviewed request. * @param verdict - what the reviewer concluded. */ store(key: string, verdict: ReviewVerdict): void; /** Drop every remembered verdict; returns how many were held. */ clear(): number; /** How many verdicts are held right now. */ get size(): number; /** How many lookups were served from the cache over this instance's life. */ get hits(): number; /** The TTL as whole minutes, for the status line. */ get ttlMinutes(): number; }