/** * Race-free grant propagation. * * The first implementation wrote each child's computed grant into `process.env` inside the `tool_call` * handler. The environment is process-global, so two concurrent spawns could read each other's values — * a real hole in a security control. * * The fix removes the need for a per-child channel entirely, rather than trying to build one: * * 1. Everything pushed to children is a property of the PARENT, not of the individual spawn — the * parent's own grant, the child depth (parent depth + 1), and the configured bounds. Those are * identical for every sibling, so concurrent spawns cannot disagree and there is nothing to race. * `ENV_APPROVED` (approvals a human granted at or above this level) is safe on the same global * channel for the same reason: it is intersected with the parent's own grant before being written, * which is itself a parent-level fact, so it too is identical for every sibling. * 2. Each child derives ITS OWN grant on arrival: `inheritedParentGrant ∩ ownObservedTools`, where the * observed set comes from the `tools` array of its first provider request — authoritative, because * it is literally what pi sent the model. * * The environment is therefore written ONCE per session, before any spawn can occur (the first provider * request precedes any tool call), and never mutated per spawn. * * The invariant still holds transitively: own = observed ∩ inheritedParent ⊆ inheritedParent, so no * descendant can exceed the root. It is also defence in depth — even if a spawn slipped past the * interceptor and pi handed the child more than the parent held, the intersection clamps it back. */ import type { Capability } from "./resolve.ts"; import { type WorkspacePins } from "./workspace-pin.ts"; import { type InheritableApproval } from "./approval.ts"; export { ENV_GRANT, ENV_FANOUT, ENV_PARENT_ID, ENV_EXECUTION_ID, ENV_DEPTH, ENV_MAX_DEPTH, ENV_GATED, ENV_LEDGER, ENV_APPROVED, } from "./env-names.ts"; /** * Total descendants this session may create in its whole subtree (`src/kernel/fanout.ts`). * * In `GRANT_ENV_KEYS` and therefore stripped from a child's environment and re-supplied only by the spawn * plan — like depth, and for the same reason: it is capability state that must ATTENUATE downward, not an * operator preference that should inherit. `PI_DADDY_CHILD_TIMEOUT` is deliberately the other kind. */ /** * This session's ledger id, so a child's records name their real parent (review finding F8). * * Without it every level restarts at `d0` and the ledger cannot be joined into a tree across processes. */ /** Unique identity of this governed execution occurrence; unlike ENV_PARENT_ID it is never reused. */ /** * Every variable this package uses to push governance state at a child. * * Named as a set so `mergeChildEnv` can guarantee that none of them survives from the parent's own * environment into a child's — a governance variable a child inherits by accident is one nobody decided * to give it. */ export declare const GRANT_ENV_KEYS: readonly ["PI_DADDY_ADVISOR_KEY", "PI_DADDY_ADVISOR", "PI_DADDY_GRANT", "PI_DADDY_DEPTH", "PI_DADDY_MAX_DEPTH", "PI_DADDY_GATED", "PI_DADDY_LEDGER", "PI_DADDY_APPROVED", "PI_DADDY_FANOUT", "PI_DADDY_PARENT_ID", "PI_DADDY_EXECUTION_ID", "PI_DADDY_WORKSPACE_PIN"]; export declare const parseList: (raw: string | undefined) => Capability[]; /** * Derive this session's own grant from what it inherited and what pi actually gave it. * * `observedTools` is the bare tool-name list from the session's own provider payload, or null when it * has not been observed yet — in which case the inherited grant is used unchanged (it is already an * upper bound, so this is safe, just less tight). * * A wildcard holder stays a wildcard holder: an enumerated observation must not silently downgrade an * explicitly unlimited grant, or a root session would lose the authority it was configured with. * * **R-36 / ADR-0017 step 1: only tool-shaped capabilities are filtered.** The observation is a list of * TOOLS, so it is evidence about `tool:` and `ext:` and about nothing else. Until this was fixed, a child * inheriting `tool:read, skill:review` held only `tool:read` from its first provider request onward — it * still *had* the skill (it arrives as `--skill`), but could not re-grant it and `/grants` stopped listing * it. Silently, and in the narrowing direction, which is why it survived: nothing fails when a grant * quietly shrinks. It also made ADR-0017's `agent:` prerequisite unsatisfiable below the root. */ export declare function deriveOwnGrant(inheritedParentGrant: Capability[], observedTools: string[] | null): Capability[]; /** * Parse a bound from the environment, distinguishing **absent** from **malformed**. * * G7 / A-S4 + B-I4. `Number.parseInt` is the wrong tool for reading configuration: it accepts a numeric * prefix (`parseInt("2abc")` is `2`), returns `NaN` for anything else, and `NaN` silently passes every * comparison as false — so a malformed `PI_DADDY_MAX_DEPTH` did not tighten the limit, it removed it. * * The three-way return is the point. `undefined` means "not configured, use the documented default"; * `null` means "configured wrongly", which callers must treat as a failure rather than a default, * because a value someone tried to set and mistyped is not the same as one they never set. */ export declare function parseBound(raw: string | undefined): number | null | undefined; export interface DepthConfig { depth: number; maxDepth: number; /** Names of the variables that were set but unreadable, for an operator-facing warning. */ malformed: string[]; } /** * Resolve this session's depth bounds, failing closed on anything malformed. * * **Malformed input disables spawning entirely (`maxDepth: 0`)** rather than falling back to a default, * and that applies to a bad `PI_DADDY_DEPTH` just as much as a bad `PI_DADDY_MAX_DEPTH`. The old * `|| 0` guard on depth failed open in a subtler way than the missing guard on maxDepth: a session that * could not read its own depth was treated as a **root**, which is the most permissive answer available * and precisely the value an attacker would choose. If we do not know how deep we are, we must not spawn. */ export declare function depthConfig(depthRaw: string | undefined, maxDepthRaw: string | undefined): DepthConfig; /** The documented default child-depth bound when `PI_DADDY_MAX_DEPTH` is not set. */ export declare const DEFAULT_MAX_DEPTH = 2; /** * Gated by default in a governed session (ADR-0012). * * `bash` is not one capability among others; it is an execution primitive. A child holding it can run * `env -u PI_DADDY_GRANT pi …` and obtain a completely **ungoverned** descendant — measured, not * theorised (probe `g5-bash-escape`). Handing that down silently is the thing worth changing. * * Subsumption-aware gating (also ADR-0012) means this single entry covers `write`, `edit`, `read`, * `grep`, `find` and `ls` as well, since `bash` confers all of them. */ export declare const DEFAULT_GATED: Capability[]; /** * Read the gate list, distinguishing **absent** from **explicitly empty**. * * `parseList` alone cannot: it maps both `undefined` and `""` to `[]`. That distinction is the operator's * only way to turn the default off — without it, someone who wants no gates would have to stop governing * altogether, which is strictly worse than the thing they were trying to avoid. */ export declare function gatedFromEnv(raw: string | undefined): Capability[]; export interface ChildEnvInput { /** This session's own grant — becomes the child's inherited parent grant. */ ownGrant: Capability[]; /** * This session's destination pins (ADR-0042), narrowed here to what the child's grant names. * * Passed in rather than read from the environment for the reason `inheritableGrant`'s comment records: a rule * with two spellings gets the guard on the quieter one. Absent means this session established none, and the * child then inherits no pin and can route nowhere — which is the fail-closed direction. */ workspacePin?: WorkspacePins; /** This session's depth; children are one deeper. */ depth: number; maxDepth: number; gated: Capability[]; /** * Gated capabilities a human approved at or above this level. * * Safe to push on the GLOBAL channel because it is intersected with THIS session's own grant, which is * a parent-level fact — identical for every sibling, so there is nothing to race on. Each child then * re-intersects with its own grant on arrival, exactly as it does for the grant itself. */ /** * Approvals eligible to cross the boundary (ADR-0014): capability, subject and scope. `once` is * dropped by `inheritApprovals`, and the subject is preserved so it cannot satisfy another one. */ approved?: InheritableApproval[]; ledgerPath?: string; /** * Whether THIS session is governed — i.e. `PI_DADDY_GRANT` was set for it. * * G7 / B-I8. Governance is opt-in: with the variable unset the README promises "nothing is blocked". * That was true of the session itself and false of its children, because this function still exported * a grant, a depth and a bound, so an ungoverned parent silently started governing its descendants — * and the grant it exported was its own observed tool surface, which is a real restriction arrived at * by accident. An ungoverned session must be transparent, not a source of policy. * * Defaults to `true` so that every existing caller keeps publishing; only the extension, which alone * knows whether the variable was set, passes `false`. */ governed?: boolean; } /** * The environment a child should inherit. Constant across all of this session's children by * construction, which is what makes concurrent spawning safe. * * **The wildcard is never inherited.** A root may HOLD `tool:*` — that is authority to grant anything — * but handing it down would let every descendant reacquire the full catalog, which makes attenuation * meaningless below the root. Children therefore inherit the ENUMERATED grant only. * * Consequence, deliberate: a wildcard root that has not yet observed its own tools hands children an * empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow, * because a session's first provider request always precedes its first tool call. */ /** * The part of a grant a child may inherit: everything but the wildcards that are HELD, never handed down. * * `tool:*` for R-26's reason — a root that handed it down let every descendant reacquire the full catalog. * `workspace:*` for the same reason one namespace over (R-131): a descendant holding it could route anywhere * the registry lists, which is the attenuation failure ADR-0035 exists to close. `agent:*` is deliberately * NOT stripped — ADR-0023 decided that a session authorised for any definition passes that on, because * definitions are ceilings rather than roots. * * **Exported, and there is exactly one spelling of this rule on purpose.** It shipped as a filter inline in * `childEnv` and nowhere else, so a grant travelling the OTHER path — `delegate.ts` building a child's * `PI_DADDY_GRANT` from `result.effective` — carried `workspace:*` straight down. The test written beside * that fix exercised `childEnv`, which is not the path a delegated child's grant travels, so it could not * catch it. R-28's shape: two routes for one rule, with the guard on the quieter one. Both call this now. */ export declare function inheritableGrant(grant: readonly Capability[]): Capability[]; export declare function childEnv(input: ChildEnvInput): Record; /** * The destination-pin half of a child's environment (ADR-0042), spelled ONCE for both spawn paths. * * **There are two places a child's environment is built**, and this file already carries the scar: the * "held but never inherited" rule for `workspace:*` lived only in `childEnv`, so `delegate.ts` — the path a * delegated child's grant actually travels — handed the wildcard straight down, and the test written beside * that fix exercised the wrong path. The pin reached the same fork. The ADR is explicit that "the process and * Herdr paths must share one propagation builder", so this is that builder and both callers use it. * * Written even when it narrows to nothing, and omitted only when this session has no pin at all: an omitted * key does not overwrite, so the child would inherit the parent's UNNARROWED pin through the process-global * publication path. Empty parses back as "a pin exists and you got none of it", which refuses at routing. */ export declare function workspacePinEnv(pins: WorkspacePins | undefined, inheritable: readonly Capability[]): Record; /** * The environment for a child this process spawns itself: the parent's environment with every governance * variable stripped, then the per-child plan applied. * * Spreading `{ ...process.env, ...plan.env }` is not enough. `plan.env` omits keys that do not apply to * this child (an empty approval set, no gated list), and an omitted key does not overwrite — so the * parent's own value survives into the child. Stripping first makes the plan the ONLY source of every * governance variable, which is what `delegate.ts`'s "nothing is written to the shared `process.env`" * claim actually requires. Consumers re-clamp anyway; this is the defence in depth behind that. */ export declare function mergeChildEnv(parentEnv: NodeJS.ProcessEnv, planEnv: Record): NodeJS.ProcessEnv; /** Extract bare tool names from a provider payload's tool array, tolerating provider shape differences. */ export declare function observeToolNames(payload: unknown): string[] | null; //# sourceMappingURL=propagation.d.ts.map