/** * Governed delegation planning. Owning the spawn makes the grant an argument; each child receives its own * environment, and `tool:delegate` determines whether it is a delegator or a leaf. */ import { planSpawn } from "./spawn.ts"; import { ceilingForDefinition, digestDefinition, type DefinitionDigest, type SkillDefinition } from "./definitions.ts"; import { assertNarrowing, type Capability, type ResolveResult, expandSubsumed } from "./resolve.ts"; import { checkRoutingAuthority, checkWorkspaceWildcardRequest } from "./routing-authority.ts"; import { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts"; // Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and // four modules plus the test suite name it. Moving the definitions without moving the door would be churn // charged to every caller for a line count they did not cause. export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts"; import { ENV_APPROVED, ENV_DEPTH, ENV_EXECUTION_ID, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID, inheritableGrant, workspacePinEnv, } from "./propagation.ts"; import { inheritApprovals, type InheritableApproval } from "./approval.ts"; import { explainDoubledNamespace, suggestForUnknown, unknownCapabilities, type Catalog } from "./catalog.ts"; import { GovernanceRefusal, refusal, type RefusalCode, type StructuredRefusal } from "./refusals.ts"; import { contextCapability, isContextCapability, parseContextRequest, type ContextRequest } from "./context-handoff.ts"; import { digestTask, normaliseCorrelation, type ApprovalBinding, type CorrelationMetadata } from "./correlation.ts"; import { resolveDelegationApproval } from "./delegation-approval.ts"; import type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts"; import { assertCapabilitiesArePropagatable } from "./capabilities.ts"; export type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts"; import { GOVERNANCE_ENV_KEYS } from "./env-names.ts"; export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation { const childDepth = ctx.depth + 1; const denied = (plan: Delegation, code: RefusalCode): Delegation => plan.reason ? { ...plan, refusal: refusal(code, plan.reason) } : plan; // G6 / B-I3: every refusal carries a result, including the four below that return before `resolve()` // is ever called. The extension guarded its ledger write with `if (ledgerPath && plan.result)`, so // those four governance decisions — disabled, too deep, no task, unknown capability — were never // audited at all. An empty result is the honest record: nothing was resolved, and that is the fact. // Bounded/whitelisted correlation is a REFUSAL, not an exception escaping the planner. It is reachable // from a model-facing tool parameter on all three delegation tools, and throwing from here produced a // governed refusal with no code and no ledger line at all — the ledger file was never even created // (R-112). Caught here so it becomes an ordinary recorded decision. let correlation: CorrelationMetadata | undefined; let correlationRefused: StructuredRefusal | undefined; try { correlation = normaliseCorrelation(request.correlation); } catch (error) { correlationRefused = error instanceof GovernanceRefusal ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } : refusal("CORRELATION_INVALID", String(error instanceof Error ? error.message : error)); } const taskDigest = digestTask(request.task ?? ""); const empty: Delegation = { ok: false, args: [], env: {}, effective: [], childDepth, requested: [], taskDigest, ...(correlation ? { correlation } : {}), result: { effective: [], denied: [], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] }, }; if (correlationRefused) { return { ...empty, reason: correlationRefused.message, refusal: correlationRefused }; } if (ctx.maxDepth <= 0) { return denied({ ...empty, reason: "delegation is disabled (maxDepth 0)" }, "DEPTH_EXCEEDED"); } if (childDepth > ctx.maxDepth) { return denied({ ...empty, reason: `delegation depth limit reached (${ctx.maxDepth})` }, "DEPTH_EXCEEDED"); } if (!request.task?.trim()) return denied({ ...empty, reason: "a delegation needs a task" }, "TASK_MISSING"); // ADR-0035's routing guards live in `routing-authority.ts` with their rationale; both are checked here, // before anything is said about the target, because they are governance questions about the SESSION. const routing = checkRoutingAuthority(request.boundWorkspaceId, ctx.ownGrant); if (routing) { return denied( { ...empty, ...(routing.denied ? { requested: routing.denied, result: { ...empty.result, denied: routing.denied } } : {}), reason: routing.reason, }, routing.code, ); } // ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling. let requested: Capability[]; let systemPrompt: string | undefined; let definitionDigest: DefinitionDigest | undefined; /** The definition being spawned, hoisted so the gate below can name its authorising id (ADR-0024). */ let spawned: SkillDefinition | undefined; if (request.agent) { const definition = ctx.definitions?.get(request.agent); spawned = definition; // No fallback, deliberately. pi-subagents resolves an unknown type to `general-purpose`, whose // omitted tool list means EVERY tool — so a typo there granted the full surface. An unknown name // here is simply an error. if (!definition) { const known = [...(ctx.definitions?.keys() ?? [])].sort(); return denied( { ...empty, reason: `unknown agent "${request.agent}"` + (known.length > 0 ? ` — known definitions: ${known.join(", ")}` : " — no definitions were found"), }, "UNKNOWN_DEFINITION", ); } // ADR-0017: authorisation comes BEFORE anything is said about the file. Which definitions this // session may spawn is a governance question about the SESSION; whether the file declares its tools // properly is a diagnostic about the DEFINITION, and answering the second one first would report a // malformed-file error to a caller who was never allowed to spawn it either way. // // Recorded as a denial rather than a bare refusal, deliberately: `denied` is the escalation signal // ADR-0008 designates, and asking to run a definition this session was not granted IS an attempt to // exceed the grant. A refusal that left `denied` empty would keep it out of every audit query. if (!maySpawnDefinition(ctx.ownGrant, definition.name)) { const authorising = agentCapability(definition.name); const held = ctx.ownGrant.filter((c) => c.startsWith("agent:")).sort(); return denied( { ...empty, requested: [authorising], result: { ...empty.result, denied: [authorising] }, reason: `cannot spawn "${definition.name}" — this session does not hold ${authorising} ` + `(the definition lives at ${definition.source}). ` + (held.length > 0 ? `It may spawn: ${held.join(", ")}.` : `It may spawn no definitions at all; add ${authorising} to its grant to allow this one.`), }, "DEFINITION_NOT_AUTHORIZED", ); } // ADR-0018. Recorded from here on — after authorisation, because the digest is a fact about a file // this caller was allowed to read, and before every remaining outcome, because a spawn refused for a // malformed declaration is still a spawn of THIS version of the definition. // // Assigned into `empty`, which every subsequent refusal spreads. That is the R-28 discipline applied // to a record field rather than an argument: instead of eight `definitionDigest` spellings that a // ninth return could forget, there is one, and forgetting it is not expressible. The success return // does not spread `empty`, so it names the field explicitly. definitionDigest = digestDefinition(definition); Object.assign(empty, { definitionDigest }); const ceiling = ceilingForDefinition(definition); if (ceiling.undeclared) { return denied( { ...empty, reason: `agent "${definition.name}" declares no \`allowed-tools\`, so it cannot be spawned — add one ` + `to ${definition.source}. An undeclared capability set is treated as NONE, never as everything.`, }, "UNDECLARED_TOOLS", ); } if (ceiling.patterns.length > 0) { return denied( { ...empty, reason: `agent "${definition.name}" restricts a tool with a pattern (${ceiling.patterns.join(", ")}), ` + `which pi's --tools cannot express — it matches whole tool names only. Granting the bare tool ` + `would widen the declaration and dropping it would silently narrow, so neither is done.`, }, "CEILING_PATTERNS_UNRESOLVED", ); } requested = ceiling.capabilities; systemPrompt = definition.body; } else { requested = (request.tools ?? []).map(normaliseCapability); } // ADR-0078. A declared `context:` id is a CEILING, not a request: a definition that permits forking must not // fork on every spawn. So the declared modes come out of `requested` and exactly the one this call asked for // goes back in. // // **The ceiling is checked HERE, not by `resolve`.** On the `agent` path `requested` IS the ceiling, so simply // appending the asked-for mode would replace the ceiling rather than be bounded by it — measured during review: // a definition declaring `context:files` handed a child `context:fork`, and a definition naming no context at // all handed one `context:files`. `resolve` would then have clamped only against the PARENT's grant, which is // not what the definition, this file's own comment, the README or the ADR say. A definition that says nothing // about context permits nothing, which is why the declared set is consulted even when it is empty. // // The `tools:` path has no definition and therefore no ceiling; the parent's grant is the only bound there, as // it is for every other capability on that path. const parsedContext = parseContextRequest(request.context); if ("refusal" in parsedContext) return denied({ ...empty, requested, reason: parsedContext.refusal }, "CONTEXT_REQUEST_INVALID"); const handoff: ContextRequest = parsedContext.request; const declaredContext = requested.filter(isContextCapability); requested = requested.filter((capability) => !isContextCapability(capability)); if (handoff.mode !== "none") { const wanted = contextCapability(handoff.mode); // Subsumed, so declaring the strongest mode permits asking for a weaker one — the same relation the grant has. const permitted = new Set(expandSubsumed([...declaredContext])); if (request.agent !== undefined && !permitted.has(wanted)) return denied( { ...empty, requested, reason: `context: ${request.agent} may not receive ${wanted} — its allowed-tools ` + (declaredContext.length === 0 ? "declares no context: capability, so it receives none" : `permits ${declaredContext.join(", ")}`), }, "CONTEXT_REQUEST_INVALID", ); requested = [...requested, wanted]; } // Unknown is reported before denied, and separately: "does not exist here" and "you lack authority" // have different causes and different fixes. Collapsing them hides typos and stale grants. if (ctx.catalog) { const unknown = unknownCapabilities(requested, ctx.catalog); if (unknown.length > 0) { // Name the likely intent where there is one. `ceilingForDefinition` refuses to TRANSLATE names // (lowercasing and no more), so an author who wrote Claude Code's `Glob` gets `tool:glob` and a // refusal — correct, and previously unhelpful, because pi's equivalent is `find` and no amount of // staring at "not present in this session's catalog" says so. The hint changes nothing about the // refusal; it just stops the author having to guess which of nine built-ins was meant. // A doubled namespace is answered first and instead: it is not a guess about what the author meant // but a statement of what the field did, and "did you mean tool:read?" beside `tool:tool:read` shows // the author a correction without ever saying that `allowed-tools` supplies the `tool:` itself. const hints = unknown .map((c) => { const doubled = explainDoubledNamespace(c); if (doubled !== null) return doubled; const s = suggestForUnknown(c, ctx.catalog!); return s === null ? null : `${c} → did you mean ${s}?`; }) .filter((h): h is string => h !== null); return denied( { ...empty, requested, reason: `unknown capabilit${unknown.length === 1 ? "y" : "ies"}: ${unknown.join(", ")} — not present in ` + `this session's catalog (typo, or an uninstalled package?)` + (hints.length > 0 ? ` — ${hints.join("; ")}` : ""), }, "UNKNOWN_TOOL", ); } } // See `checkWorkspaceWildcardRequest`: refused for a HOLDER (the ledger must not record authority the // child will not receive), and left to `resolve()` for anyone else, so the probe lands in `denied`. const wildcardRequest = checkWorkspaceWildcardRequest(requested, ctx.ownGrant); if (wildcardRequest) { return denied({ ...empty, requested, reason: wildcardRequest.reason }, wildcardRequest.code); } const { result, approvalBinding, bindingMismatch } = resolveDelegationApproval({ task: request.task, agent: request.agent, boundWorkspaceId: request.boundWorkspaceId, boundContextId: request.boundContextId, requested, parentGrant: ctx.ownGrant, gated: ctx.gated, approved: ctx.approved, spawned, definitionDigest, correlation, parentId: ctx.spawnId ?? `d${ctx.depth}`, }); if (approvalBinding) Object.assign(empty, { approvalBinding }); if (result.denied.length > 0) { return denied( { ...empty, requested, result, reason: `cannot grant ${result.denied.join(", ")} — this session does not hold it (capability escalation blocked)`, }, "CAPABILITY_ESCALATION", ); } // ADR-0011: narrowing is checked BEFORE the gate, and the order is load-bearing rather than // stylistic. `assertNarrowing` refuses regardless of approval, so with the old order this returned // "requires explicit approval" for a delegation that could never be approved — telling the operator // to go and find a human who cannot help. `shouldSeekApproval` now also refuses to prompt in this // case; this reordering makes the reported *reason* agree with what actually blocks the spawn. try { assertNarrowing(result); } catch (error) { // ADR-0011's narrowing invariant is the hardest rule this package enforces, and it was the one // refusal an external controller could not identify by code (R-109). return denied( { ...empty, requested, result, reason: String(error instanceof Error ? error.message : error) }, "NARROWING_VIOLATED", ); } if (result.gatedBlocked.length > 0) { const code = bindingMismatch ? "APPROVAL_SCOPE_MISMATCH" : "GATED_UNAPPROVED"; const suffix = bindingMismatch ? " (an approval exists, but its task/workspace/context scope does not match)" : ""; return denied( { ...empty, requested, result, reason: `${result.gatedBlocked.join(", ")} requires explicit approval${suffix}` }, code, ); } const grantedHandoff = handoff.mode !== "none" && result.effective.includes(contextCapability(handoff.mode)) ? handoff : undefined; const canSubDelegate = result.effective.includes(DELEGATE_CAPABILITY); // Only for a handoff that survived, so a refused mode reads no file and forks no session. const staged = grantedHandoff ? ctx.stageHandoff?.(grantedHandoff, ctx.handoffTurnIds ? { keepTurnIds: ctx.handoffTurnIds } : {}) : undefined; if (staged?.refusal) return denied({ ...empty, requested, result, reason: staged.refusal }, "CONTEXT_REQUEST_INVALID"); const plan = planSpawn({ effective: result.effective, prompt: request.task, model: request.model, provider: request.provider, thinking: request.thinking, skillPaths: ctx.skillPaths, contextFiles: ctx.contextFiles, systemPrompt, // A fork replaces the session file rather than joining it: pi refuses `--fork` beside `--session`. ...(staged?.forkFrom ? { forkFrom: staged.forkFrom } : { sessionFile: ctx.sessionFile }), ...(staged?.contextPrompt ? { contextPrompt: staged.contextPrompt } : {}), print: ctx.interactive ? false : undefined, }); // R-32. A `skill:` capability the catalog cannot place is refused rather than dropped. Dropping it // would hand back a child whose grant claims a skill it does not have — the ledger would record a // capability that never reached the process, which is precisely the kind of lie an audit trail must // not contain. `unknownCapabilities` above catches names absent from the catalog entirely; this // catches one that is known but whose path we could not resolve, which is a different fault. if (plan.unresolvedSkills.length > 0) { return denied( { ...empty, requested, result, reason: `cannot locate ${plan.unresolvedSkills.join(", ")} on disk — granted but unresolvable, so the ` + `child would silently lack it`, }, "DEFINITION_UNREADABLE", ); } // `-e` loads even under `--no-extensions`. A delegate-capable child loads the governance extension; // a leaf gets only the no-tool observer. Neither path alters --tools or the computed grant. const args = [...plan.args]; const observer = canSubDelegate ? ctx.extensionPath : ctx.observerExtensionPath; if (observer) args.splice(args.length - 1, 0, "-e", observer); // `inheritableGrant`, not `result.effective` directly: this is the path a DELEGATED child's grant // actually travels, and the "held but never inherited" rule for `tool:*` and `workspace:*` was enforced // only in `childEnv`. A parent holding `workspace:*` could request it for its child and this line handed // it over, so the rule ADR-0035 advertises held by accident — masked downstream rather than enforced // here. One spelling of the rule, called from both paths. const inheritable = inheritableGrant(result.effective); const env: Record = { [ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")), [ENV_DEPTH]: String(childDepth), [ENV_MAX_DEPTH]: String(ctx.maxDepth), }; // The child's own share of the subtree budget, and its identity. Both attenuate downward like depth: a // child can never be handed more budget than its parent had left, so the total bound holds across // process boundaries with no shared state. if (ctx.fanoutBudget !== undefined) env[ENV_FANOUT] = String(ctx.fanoutBudget); if (ctx.childSpawnId) env[ENV_PARENT_ID] = ctx.childSpawnId; if (ctx.childExecutionId) env[ENV_EXECUTION_ID] = ctx.childExecutionId; if (ctx.gated.length > 0) env[ENV_GATED] = ctx.gated.join(","); // Approvals ride down with the grant, but only ever for what this child actually received — so // `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states // the child's approval set outright rather than leaving it to whatever the caller merges over; see // `mergeChildEnv`, which is what actually stops the parent's value leaking through. // Clamped to what the child actually INHERITS, not to what it was granted. The two differ only for a // non-inheritable wildcard, and passing down an approval for a capability the child does not hold would // leave banked authority with nothing to spend it on — `childEnv` clamps to `inheritable` for the same // reason on the other path. env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(","); if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath; // ADR-0042, through the SAME builder `childEnv` uses. This is the fork the comment above is about: a rule // spelled once in `childEnv` and not here is a rule that does not hold on the path a delegated child // actually takes. Without this a routed grandchild inherits no pin and can route nowhere — fail-closed, // but wrong, and silently so. Object.assign(env, workspacePinEnv(ctx.workspacePin, inheritable)); // Composition-supplied per-child environment (the activity timeline's observation identity today). // Never process-global grant state: a key in the governance namespace is a programming error in the // caller, refused loudly rather than letting a product widen what the child inherits. for (const [key, value] of Object.entries(ctx.childEnv?.({ childExecutionId: ctx.childExecutionId }) ?? {})) { if (GOVERNANCE_ENV_KEYS.includes(key) || key in env) throw new Error(`childEnv may not set governance key ${key}`); env[key] = value; } return { ok: true, args, env, effective: result.effective, result, childDepth, requested, // The mode that SURVIVED, not the one asked for: a ceiling or a gate may have narrowed it to nothing. ...(grantedHandoff ? { handoff: grantedHandoff } : {}), ...(staged?.record ? { handoffRecord: staged.record } : {}), ...(staged?.dispose ? { disposeHandoff: staged.dispose } : {}), childId: ctx.childSpawnId, executionId: ctx.childExecutionId, taskDigest, ...(correlation ? { correlation } : {}), ...(approvalBinding ? { approvalBinding } : {}), ...(definitionDigest ? { definitionDigest } : {}), }; }