/** * The v2 permission-guard layer (#7). * * v1 enforced two things in code that v2 can enforce one level lower — at the * host's own `permission.hook("evaluate")`, whose `effect` is mutable and whose * `resources` carry the concrete command line or URL (live-probed: `action` * arrives as shell / read / edit / external_directory with the real payload). * * 1. THE EGRESS RED LINE ON THE HOST'S OWN WEB TOOL. v1 never had to think * about this because native `webfetch` was denied in the whitelist; on v2 a * Build-class agent has it, and it has no notion of `169.254.169.254` — the * cloud metadata endpoint whose response is temporary credentials. The * domain allowlist answers "is this host on the list"; only `checkWebUrl` * asks the address question, and that question has to be asked of the native * path too or the port becomes a way around it. * 2. R6's per-command precision. v2's closest thing to v1's pattern-object * escalation is currently "ask on every shell command", which is honest but * coarse. The classifier can restore the per-command shape — but only if * the host actually calls `evaluate` for shell, which has not been proven on * a live 2.0.16 yet. So the strictening hook is installed either way and * COUNTS what it sees, while the config-level coarse ask stays in force * until `TM_R6_FINE_ASK=on` says otherwise. A guard that fails open because * we trusted an unproven hook is the opposite of this product. * * 3. THE SHELL TIMEOUT CLAMP (issue #6), ported from the retired v1 * `src/tm/bash-timeout.ts`. The host's shell tool resolves * `flags.bashDefaultTimeoutMs ?? 2 * 60 * 1e3`, and a model that passes a * timeout at all passes 120000+ for a `Get-ChildItem` — three serialised * probes then cost the user six minutes of dead air for a reason no model * ever had. v1 clamped it in a composed `tool.execute.before` hook; v2 * gives the same mutable `input` to `tool.hook("execute.before")`, which is * where `applyV2BackgroundForce` already writes, so the protection is not * something the cut lost — it is the same lever at the same seam. * The discipline is unchanged and load-bearing: it clamps a number the * model ALREADY volunteered, never invents one it omitted, applies the * probe ceiling only to a command the P3 read-only allowlist accepts, * never rewrites the command, never widens what may run, and never throws * into the hook — a failure there means the call goes through untouched * and the attempt is counted. * * The hook only ever makes a decision STRICTER (allow -> ask, anything -> deny on * a red line). It never loosens what the host or the user's own rules chose. */ import { type EnvProtectMode } from "../envprotect.js"; import type { TeamScope } from "./v2-scope.js"; import type { V2Context, V2Registration } from "./v2-types.js"; export interface GuardDecision { effect: "allow" | "deny" | "ask"; message?: string; /** the rule that answered, in words the agent reads back — never a bare code */ why: string; } /** The address question, asked of the host's native web path. */ export declare function webGuard(resources: readonly string[]): GuardDecision | null; /** * R6's FILE-PATH face. v1 enforced this in a `tool.execute.before` hook factory * (`createEnvProtectHook`) that was deleted with the v1 personality; v2 never * picked it up, so a Team role could read `.env` through the native `read` tool * while the docs claimed R6 covered "the environment or an env file". The same * classifier now rides `permission.hook("evaluate")`, whose `resources` carry the * concrete path (live: `action:"read"` reaches evaluate with the real path). * * `isEnvFilePath` is the shared matcher and is already pattern-aware: it strips * glob asterisks (so a `*.env` include is caught), cuts URL query tails, and * refuses code identifiers like `process.env` — so an ordinary grep pattern * (`TODO`) is never mistaken for a path. `.env.example` and friends are * checked-in templates and pass (see `ENV_TEMPLATE_SUFFIX`). * * A RED LINE, not a gate: v2 cannot raise a dialog, so there is no consent path * to name — telling the agent to "ask for approval" would be a window that never * appears. */ export declare function pathGuard(action: string, resources: readonly string[], mode: EnvProtectMode): GuardDecision | null; /** R6's env face and R2's danger face, per command line. */ export declare function shellGuard(command: string | undefined, mode: EnvProtectMode): GuardDecision | null; export interface GuardReport { seen: number; byAction: Record; strictened: number; denied: number; /** what the classifier WOULD have asked for, counted even while the coarse * config-level ask is still in force — this is the evidence that decides * whether TM_R6_FINE_ASK can become the default. */ shellMatched: number; /** evaluations skipped because the session belongs to a non-Team agent (#22) — * the number that tells the user how much of this floor is not ours to hold */ foreignSkipped: number; /** file-path evaluations the R6 classifier flagged as an env file (read / * write / edit / glob / grep) — the evidence that the file-path face is * actually live, not just installed */ envFileDenied: number; /** R6's file-path face caught at `execute.before`, where the FULL input is * visible — the seam `permission.evaluate` cannot cover for `grep`/`glob`, * whose resource is the PATTERN, not the search path (#58). A separate * counter from `envFileDenied` on purpose: the two seams fail independently, * and one number for both would hide which one went dark. */ envFileDeniedByInput: number; /** `execute.before` file-tool calls the input guard looked at (Team only). */ envFileInputSeen: number; /** …skipped because the session is not ours (#22). */ envFileInputForeignSkipped: number; /** …where the guard itself threw and the call was allowed through. */ envFileInputThrew: number; } export interface BackgroundForceReport { seen: number; forced: number; } /** * Every sub-agent dispatch runs in the background. * * The user's standing instruction (2026-09-25). It is not a preference tweak: a * foreground `subagent` call blocks the lead for the whole child run, which is * the one thing the throughput mandate cannot survive — and on v2 background is * a first-class host capability (`subagent {background:true}` returns * immediately and the parent is notified when the child finishes), so nothing * has to be opted into at the environment level the way v1 needed * `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`. * * `execute.before` takes a mutable `input` (docs say "inspect or REPLACE"; a * live host confirmed the write lands), so this is the honest place to enforce * it — a prompt rule thousands of tokens earlier is not the same thing. An * input that is not an object is left alone rather than invented. */ export declare function applyV2BackgroundForce(ctx: V2Context, opts?: { scope?: TeamScope; }): Promise<{ registrations: V2Registration[]; report: BackgroundForceReport; }>; /** * How long an in-flight admission may hold a slot without anything to show for * it. A dispatch whose `execute.after` never fires (an interrupted turn, a host * that dropped the call) would otherwise hold a slot forever, and a leaked slot * is indistinguishable from a busy team: the cap would silently become stricter * than the number the user configured. The TTL is the last release path, and a * release that comes from it is counted on its own so a leak is a number rather * than a mystery. */ export declare const SUBAGENT_PENDING_TTL_MS = 60000; /** The ceiling when nothing usable was configured (see `normalizeCapLimit`). * 8 is the registry default; a hook that forgot to pass the key must land on the * documented ceiling, NOT on "unlimited" — a fail-open cap is not a cap. */ export declare const DEFAULT_SUBAGENT_CONCURRENCY = 8; /** * The staleness window for the `running` side (3× the in-flight TTL). * * The in-flight side always had a TTL; the RUNNING side did not, and it is fed from * `tm_join`'s registry, whose rows only ever settle through an observation * (`v2-subagent`'s completion scan, or a `session.idle` the v2 feed does not get). * A row nothing ever observes — an `adopted` row, or a `` that never * enters the parent's history — therefore stayed 运行中 FOREVER: once `limit` such rows * accumulated, every dispatch was refused and the refusal pointed at a snapshot that * kept saying 运行中. That is a permanent lock, i.e. the cap turning into an outage. * So a running row past this age is not counted (and is COUNTED as excluded, so the * under-count is a number in `tm_stats` rather than a silent loosening). * * Known, accepted trade: a child that legitimately runs longer than this stops counting * against the ceiling. The alternative is a lock no operator action can clear. */ export declare const SUBAGENT_RUNNING_STALE_MS: number; /** The row shape the cap reads off the registry — only these three fields matter. */ export interface RunningChildLike { state?: string; /** Rebuilt from the host's session tree, not observed live. */ adopted?: boolean; startedAt?: number; } export interface SubagentCapCounts { /** `permission.evaluate` calls for `action:"subagent"` (Team only). */ seen: number; /** admissions refused — this is the number that proves the cap is live. */ denied: number; /** in-flight dispatch slots currently held, AFTER the TTL sweep. */ pending: number; /** high-water mark of `pending` — a burst shows up here even if it settles. */ pendingMax: number; /** slots reclaimed because nothing ever released them (a leak, counted). */ ttlReleased: number; /** slots released by a real signal (a child registered, or a call that * produced none). Kept apart from `ttlReleased` on purpose. */ released: number; /** running rows that DID count toward the ceiling on the last read. */ runningCounted: number; /** running rows excluded as stale (older than the window) on the last read. */ runningStale: number; /** running rows excluded because the row was adopted, not observed (last read). */ runningAdopted: number; /** the configured ceiling (0 = unlimited). */ limit: number; } export interface SubagentCapVerdict { ok: boolean; /** The admission id, so a release can name THIS call's slot instead of "the first * slot of that caller" — a replay that released by caller freed somebody else's * slot and quietly loosened the ceiling. 0 = no slot was taken. */ id: number; running: number; pending: number; limit: number; /** the refusal sentence, when `ok` is false */ message?: string; } export interface SubagentCapGate { /** Ask for a slot. `limit <= 0` answers yes WITHOUT judging or holding one. */ admit(sessionID: string): SubagentCapVerdict; /** Pair the next unpaired admission of `sessionID` with the call that is about to * run, returning its id (0 when there is none). The id is what `release` takes. */ pair(sessionID: string): number; /** Give ONE admission back, by id. An already-released id is a no-op, so a host * that replays `execute.after` cannot release a second slot. */ release(id: number, why: "register" | "call-end"): boolean; /** Give back the OLDEST slot of a caller — the fallback for a call that carried no * admission id (a plugin reload replaying hooks). */ releaseOldest(sessionID: string, why: "register" | "call-end"): boolean; counts(): SubagentCapCounts; /** Live pending slots for one caller (tests + diagnostics). */ pendingOf(sessionID: string): number; } /** * Turn whatever reached us into a ceiling. * * Out-of-range values FALL BACK to the default rather than clamping: `-1` used to be * clamped to `0`, and `0` means UNLIMITED here — a typo silently switching the cap off * is the opposite of what a typo should do. */ export declare function normalizeCapLimit(raw: unknown): number; /** * The sub-agent concurrency cap (restored as `subagentConcurrency`). * * Why it counts PENDING dispatches and not just running children: the cap used * to be decided at `permission.evaluate`, while a child was only registered * AFTER its call completed — so 24 dispatches in one message all evaluated * against "0 running" and all passed, and only the NEXT dispatch was refused. * A limit that cannot stop the burst it exists for is not a limit. Counting the * admission itself closes that window: each passing evaluation holds a slot * until (a) the child is registered, (b) the call ended without a child, or * (c) the TTL reclaims it. * * `0` means unlimited and takes NO decision at all — refusing everything at * limit 0 would be the opposite of what the user asked for. */ export declare function createSubagentCapGate(opts: { limit: number; /** Running children — read from tm_join's registry, never guessed. Rows are * filtered here so the adopted/stale policy lives with the thing it protects. */ running?: () => ReadonlyArray; now?: () => number; ttlMs?: number; /** Running rows older than this are not counted (0 disables the age test; * adopted rows are always excluded). */ staleMs?: number; }): SubagentCapGate; export declare function applyV2PermissionGuards(ctx: V2Context, opts: { envProtectMode: EnvProtectMode; scope?: TeamScope; /** `subagentConcurrency` — the sub-agent concurrency ceiling. Omitted / invalid * falls back to `DEFAULT_SUBAGENT_CONCURRENCY` (8); 0 = unlimited. */ subagentConcurrency?: number; /** Running children of this process (tm_join's registry), as ROWS so the gate can * leave out the ones nothing will ever settle. */ subagentRunning?: () => ReadonlyArray; /** Staleness window for the running side (default `SUBAGENT_RUNNING_STALE_MS`). */ subagentStaleMs?: number; /** Test / wiring injection: share one gate between the guard and the * release sites instead of minting a private one. */ subagentCap?: SubagentCapGate; }): Promise<{ registrations: V2Registration[]; report: GuardReport; installed: boolean; cap: SubagentCapGate; }>; /** * R6's file-path face, on the seam that sees the WHOLE input (#58). * * `pathGuard` rides `permission.evaluate`, whose `resources` for `grep`/`glob` * are the pattern — so the red line had a hole exactly the width of those two * tools. `execute.before` hands us the raw args the model sent, so the search * path (`path` / `include` / `filePath`) is visible there and the same * `isEnvFilePath` matcher can answer. * * A refusal is THROWN, not returned: `execute.before` has no `effect` field, and * silently rewriting a path the model chose is the guesswork this product * refuses — the model has to see the refusal. Same shape as the browser gate's * red line. An internal failure is swallowed and counted (`envFileInputThrew`), * never thrown into the host: a guard that cannot decide must not break the call * it is inspecting. Team-scoped through the same `scope.decide` gate as every * other writer, and the counters land on `v2-surface` / `v2-shutdown`. */ export declare function applyV2EnvFileInputGuard(ctx: V2Context, opts: { envProtectMode: EnvProtectMode; scope?: TeamScope; report: GuardReport; }): Promise<{ registrations: V2Registration[]; }>; /** Does the config still need the coarse `shell -> ask` escalation? Only while * the fine path is switched off; with the classifier in charge, escalating every * command would mask it. * Was the coarse default until a live host proved the seam exists: a * `--standalone` run of the v2 personality recorded * `{action:"shell", resourceCount:1, hasUrl:false}` reaching `evaluate` for a real * `git status --short` (2026-09-25), which is the observation this function was * waiting for. So the classifier is in charge by default and the escalation is * the fallback again — `TM_R6_FINE_ASK=off` restores "every command asks", which is * what a user should reach for if their host build turns out not to fire the hook. * An absent hook still forces coarse: no seam, no per-command judgement. */ export declare function needsCoarseShellAsk(config: { r6FineAsk: string; }, guardsInstalled: boolean): boolean; /** Parse the model-supplied timeout defensively: the host schema wants a positive * integer of milliseconds, but an LLM hands us `"120000"`, `120000.0`, or omits * it entirely. Anything unreadable means "no timeout was supplied", and a * supplied-none is NEVER turned into one by us. */ export declare function parseShellTimeout(raw: unknown): number | null; export type ShellTimeoutVerdict = { changed: false; via: "none" | "already-short"; } | { changed: true; via: "probe" | "max"; from: number; to: number; }; /** * Decide the clamped timeout for one shell call. `probeMs` / `maxMs` of 0 * disable that ceiling. `probeMs` applies ONLY to a command the P3 read-only * allowlist already accepts; `maxMs` is the operator's explicit global cap and * ships at 0 (off), because silently killing a build is worse than a slow one. */ export declare function resolveShellTimeout(opts: { command: string; timeoutMs: number | null; probeMs: number; maxMs: number; readonlyAllowed: readonly string[]; }): ShellTimeoutVerdict; export interface ShellTimeoutReport { seen: number; clamped: number; /** clamps skipped because the session is not one of ours (#22) — a build * session's timeout is the host's business, not a plugin's */ foreignSkipped: number; /** attempts that threw (including a classifier throw) and therefore * left the call byte-exact — counted so "the clamp is silent" is * distinguishable from "the clamp is broken" */ threw: number; } /** * The `tool.hook("execute.before")` installation of the clamp. Returns the * registrations plus the counters that ride `v2-surface` / `v2-shutdown`. * `onClamp` is how the caller writes the trajectory row; a throw inside it is * swallowed there, never here. */ export declare function applyV2ShellTimeoutClamp(ctx: V2Context, opts: { probeMs: number; maxMs: number; readonlyAllowed: readonly string[]; scope?: TeamScope; onClamp?: (info: { via: string; from: number; to: number; sessionID?: string; }) => void; }): Promise<{ registrations: V2Registration[]; report: ShellTimeoutReport; }>; //# sourceMappingURL=v2-guard.d.ts.map