23 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 558 | pi-permission-system: grant-scope selection on forwarded approvals |
Grant-scope selection on forwarded approvals
Release Recommendation
Release: ship independently
This is Phase 9 Step 4, tagged Release: independent in the roadmap — a feat: that leaves the package consistent on its own (no multi-step batch).
It cuts a release on landing.
Problem Statement
When a subagent hits an ask it cannot answer, it forwards the request up to the serving node (the parent/root), where a human decides.
Today, if that human approves "for this session," the ruling can land only on the requesting subagent: the response rides back to the child, whose GateRunner records the pattern into the child's own SessionRules.
The human has no way to record the ruling at the serving scope, so a grant that should cover the parent and all its subagents must be re-approved for each child.
This is resolved direction 4 of the authority model and the tail step of Phase 9.
It rides the spine that #557 finished: serving a forwarded request is already resolution against the serving node's recorded authority, so a whole-session grant recorded into the serving node's SessionRules suppresses future prompts for the parent and its children for free (the serving node auto-approves the next forward).
Goals
- Offer the human at the serving node a scope when approving a forwarded request "for this session": this subagent only (the default, least privilege) or the whole session.
- Ride the child's already-computed session-approval suggestion (
surface+ patterns) along with the forwarded request so the serving node can record the same pattern the child would. - Record a whole-session grant into the serving node's own
SessionRules— the single source of truth for that scope — so the parent and its children resolve it as recorded authority via the #557 serve-time evaluation. - Preserve today's behavior exactly for the subagent-only default and for version-skew requests that carry no suggestion.
Non-Goals
- The three-way scope (root / parent / requesting subagent). The tree is depth-2 today (pi-subagents' recursion guard removes the subagent tool from children), so "parent" and "root" coincide and the dialog offers two scopes. The three-way split is admitted-not-shipped, the same shape as the escalation chain — deferred with the multi-hop work, not filed as a new issue.
- Recording a whole-session grant on the requesting child as well. The operator chose serving-node-only recording (single source of truth); the child re-forwards its next identical action and the serving node auto-approves it.
- Cross-cwd path portability of a recorded pattern.
A whole-session path grant matches a child's later forward only when they share a cwd — the pre-existing single-surface/cross-cwd limitation documented in
docs/decisions/0005-serving-authorizer-provenance.mdand tracked in #565. No new work here. - The
authority/file moves (Phase 9 Step 5, #559) — this step touches the modules in place.
Background
The relevant flow after #557:
- Child (subagent, no UI).
GateRunner.runDescriptor(src/handlers/gates/runner.ts) computesdescriptor.sessionApproval(aSessionApprovalvalue object — surface + one-or-more patterns) and escalates the ask viaAskEscalator.escalate(details). Selection routes it toParentAuthorizer.authorize(details)(src/authority/approval-escalator.ts), which writes aForwardedPermissionRequestfile and polls for the response. When the response isapproved_for_session,applyPermissionGate(src/permission-gate.ts) returns the descriptor'ssessionApproval, and the runner records it into the child'sSessionRules. - Serving node (UI).
ForwardedRequestServer.processInbox(src/authority/forwarded-request-server.ts) resolves each request against recorded authority (ServingPolicy.check), and onaskescalates throughAskEscalatorto the serving session'sLocalUserAuthorizer, which shows the dialog viarequestPermissionDecisionFromUi(src/permission-dialog.ts). The forwarded provenance rides onPromptPermissionDetails.forwarding, so the broadcast stays non-degraded (#292). The server writes the decision'sstateback to the child and records nothing locally.
Key facts:
- The serving node's
SessionRulesis the single shared instance wired insrc/index.ts— thePermissionResolver, theGateRunnerrecorder, and (via the resolver) theServingPolicyall read it. Recording into it at serve time is immediately visible to the parent's own gates and to future forwarded resolutions. ForwardedPermissionRequest(src/permission-forwarding.ts) already carries optional display fields (source/surface/value) for version-skew tolerance; the child's session-approval suggestion is a natural sibling.PromptPermissionDetails(src/authority/permission-prompter.ts) is the only data channel fromGateRunnertoParentAuthorizer; the Step 3forwardingfield set the precedent for forwarded-only data on it.- Constraint (AGENTS.md): the
permission-manager.tsstring boundary must not importAccessPath; this change touches none of that — it operates on already-suggested patterns.
Design Overview
Data flow
The child rides its suggestion along; the serving node reads it, offers the scope, and records at the chosen scope:
child GateRunner
descriptor.sessionApproval ({surface, patterns})
→ details.sessionApproval (PromptPermissionDetails)
→ ParentAuthorizer.authorize → ForwardedPermissionRequest.sessionApproval (on disk)
→ ForwardedRequestServer.buildForwardedAskDetails → details.sessionApproval (serving)
→ LocalUserAuthorizer → two-step dialog scope choice
├─ "this subagent only" → state approved_for_session → child records (today's path)
└─ "the whole session" → state approved_for_serving_session
→ server records into serving SessionRules, responds `approved`
The new decision state
PermissionDecisionState (src/permission-dialog.ts) gains one member:
export type PermissionDecisionState =
| "approved"
| "approved_for_session"
| "approved_for_serving_session" // new
| "denied"
| "denied_with_reason";
approved_for_serving_session is serving-node-internal: it originates in the dialog, is read by ForwardedRequestServer, and is translated to approved before the response is written — it never reaches disk or the child.
It is added to isPermissionDecisionState (the guard is the union's validator; keeping it complete is correct) but the server's translation keeps the on-disk ForwardedPermissionResponse.state within the four legacy values.
No never-exhaustive switch over the state exists (grep-verified), so the only branch sites are the dialog and permission-gate.ts (which checks approved_for_session only, and is unaffected — the new state never reaches a child gate).
The two-step dialog
The operator chose a two-step dialog: the base 4-option prompt is unchanged; picking "Yes, for this session" triggers a second select for scope, but only when the ask was forwarded and carries a suggestion.
RequestPermissionOptions (src/permission-dialog.ts) gains:
export interface RequestPermissionOptions {
sessionLabel?: string;
/** Forwarded asks only: a "for this session" choice opens a second scope select. */
sessionScope?: { subagentLabel: string; servingSessionLabel: string };
}
requestPermissionDecisionFromUi, after the user selects the session option:
if (selected === sessionOption) {
if (options?.sessionScope) {
const scope = await ui.select(`${title}\nApply this session grant to:`, [
options.sessionScope.subagentLabel, // index 0 = least-privilege default
options.sessionScope.servingSessionLabel,
]);
return {
approved: true,
state:
scope === options.sessionScope.servingSessionLabel
? "approved_for_serving_session"
: "approved_for_session", // default; cancel (undefined) → least privilege
};
}
return { approved: true, state: "approved_for_session" };
}
The subagent option is listed first and is the fallback for a cancelled scope select — the least-privilege default the issue requires.
A local (non-forwarded) ask never sets sessionScope, so its dialog is byte-identical to today.
Building the scope labels
LocalUserAuthorizer.authorize (src/authority/local-user-authorizer.ts) sets sessionScope only when details.forwarding && details.sessionApproval are both present:
const options = buildRequestOptions(details); // sessionScope for forwarded+suggestion, else sessionLabel
return this.deps.requestPermissionDecisionFromUi(
this.deps.ui,
details.forwarding ? "Permission Required (Subagent)" : "Permission Required",
details.message,
options,
);
Labels come from a new buildForwardedScopeLabels(agentName, surface, pattern) in src/pattern-suggest.ts (the session-approval label home, beside buildLabel):
// e.g. { subagentLabel: "This subagent ('reviewer') only",
// servingSessionLabel: "The whole session (parent + all subagents)" }
Riding the suggestion along
SessionApproval (src/session-approval.ts) gains toForwardedData() so GateRunner tells the object for its data instead of reaching into surface/patterns:
export interface ForwardedSessionApproval {
surface: string;
patterns: readonly string[];
}
// on SessionApproval:
toForwardedData(): ForwardedSessionApproval {
return { surface: this.surface, patterns: [...this.patterns] };
}
ForwardedSessionApproval is defined once in src/permission-forwarding.ts (beside ForwardedPromptDisplay) and imported as a type into permission-prompter.ts and session-approval.ts (no runtime coupling; approval-escalator.ts already type-imports from permission-forwarding.ts).
GateRunner.runDescriptor populates it on the escalate call:
const decision = await this.prompter.escalate({
requestId: toolCallId,
...descriptor.promptDetails,
...(descriptor.sessionApproval
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
: {}),
});
ParentAuthorizer.authorize reads details.sessionApproval and threads it into buildForwardedRequest, which persists it onto the request (spread like today's source/surface/value).
Recording at the serving scope
ForwardedRequestServer gains one dependency and one private method:
export interface ForwardedRequestServerDeps {
// …existing: forwardingDir, logger, policy, escalator, registry
/** Serving node's SessionRules — records a whole-session grant. */
recorder: SessionApprovalRecorder;
}
buildForwardedAskDetails sets details.sessionApproval from request.sessionApproval (so the serving dialog can offer the scope).
processSingleForwardedRequest funnels the decision through a new applyGrantScope before recordForwardedDecision writes the response:
private applyGrantScope(
request: ForwardedPermissionRequest,
decision: PermissionPromptDecision,
): PermissionPromptDecision {
if (decision.state !== "approved_for_serving_session") return decision;
if (request.sessionApproval) {
this.recorder.recordSessionApproval(
SessionApproval.multiple(
request.sessionApproval.surface,
request.sessionApproval.patterns,
),
);
this.logger.review("forwarded_permission.session_recorded", { /* … */ });
}
// Translate to a plain grant: the child does NOT also record (single source
// of truth on the serving node); its next identical action re-forwards and
// resolves as recorded authority.
return { approved: true, state: "approved" };
}
Keeping applyGrantScope a separate method preserves the #557 processSingleForwardedRequest < 60 lines health target (one added call).
Edge cases
- Legacy/version-skew request (no
sessionApproval): the serving dialog offers no scope → single "for this session" →approved_for_session→ child records, exactly as today. - Scope select cancelled (
undefined): defaults toapproved_for_session(subagent only) — least privilege. - Whole-session grant, external-directory surface: the recorded surface (
external_directory) may differ from a later forward's surface (read); per the #557 single-surface best-effort rule such a forward lands onask→ prompt, never a silent grant. Not a regression — the whole-session grant is fully effective for the parent's own actions and for forwards whose surface matches.
Module-Level Changes
src/session-approval.ts— addtoForwardedData(): ForwardedSessionApproval; import the type.src/permission-forwarding.ts— addForwardedSessionApprovalinterface; add optionalsessionApproval?: ForwardedSessionApprovaltoForwardedPermissionRequest.src/authority/permission-prompter.ts— add optionalsessionApproval?: ForwardedSessionApprovaltoPromptPermissionDetails(type import frompermission-forwarding).src/handlers/gates/runner.ts— populatesessionApprovalon the escalate details fromdescriptor.sessionApproval.toForwardedData().src/authority/approval-escalator.ts—ParentAuthorizer.authorizethreadsdetails.sessionApprovalintobuildForwardedRequest, which persists it on the request.src/permission-dialog.ts— addapproved_for_serving_sessiontoPermissionDecisionStateandisPermissionDecisionState; addsessionScopetoRequestPermissionOptions; add the second scopeselectinrequestPermissionDecisionFromUi.src/pattern-suggest.ts— addbuildForwardedScopeLabels(agentName, surface, pattern).src/authority/local-user-authorizer.ts— buildsessionScopelabels for a forwarded ask carrying a suggestion; pass them torequestPermissionDecisionFromUi.src/authority/forwarded-request-server.ts— addrecorder: SessionApprovalRecorderdep; setdetails.sessionApprovalinbuildForwardedAskDetails; addapplyGrantScope(record + translate) called fromprocessSingleForwardedRequest; importSessionApproval.src/index.ts— passrecorder: sessionRulesto theForwardedRequestServerconstructor.
Docs (implementation commit, per the package skill — mark the roadmap step complete here, not at ship):
docs/decisions/0006-forwarded-grant-scope-selection.md— new ADR recording the serving-node-only-recording and two-step-dialog decisions and the serving-node-internalapproved_for_serving_sessiontranslation.docs/architecture/architecture.md— mark Phase 9 Step 4 complete (✅on the step heading and theS4Mermaid node); add aLanded:bullet; update the health-metric row if the flat-src/count or any tracked target is affected (it is not — no files added).docs/subagent-integration.md— extend theask-state-forwarding bullet / Permission Forwarding section with the grant-scope choice.
Tests (see TDD Order):
test/session-approval.test.ts,test/handlers/gates/runner.test.ts,test/authority/approval-escalator.test.ts— producer path.test/permission-dialog.test.ts,test/authority/local-user-authorizer.test.ts,test/authority/forwarded-request-server.test.ts— dialog + serving path.test/helpers/forwarding-fixtures.ts—makeServerDepsgains a defaultrecorder: { recordSessionApproval: vi.fn() }.test/composition-root.test.ts— round-trip tests for both scopes.
Grep-verified no other consumers: approved_for_session is read only by permission-dialog.ts and permission-gate.ts; isPermissionDecisionState by forwarding-io.ts (guard, unaffected by translation); PermissionDecisionState typed on ForwardedPermissionResponse.state (stays within legacy values).
No architecture-doc inline type listing names these states.
Test Impact Analysis
- New unit tests enabled.
SessionApproval.toForwardedData()(round-trips surface + patterns); the two-step dialog scope mapping (subagent →approved_for_session, whole →approved_for_serving_session, cancel →approved_for_session, no-sessionScope→ single option);LocalUserAuthorizersetssessionScopeiff forwarded + suggestion;ForwardedRequestServer.applyGrantScoperecords into the recorder on the new state and translates the response, and passesapproved_for_sessionthrough untouched. - Existing tests to update (same step as the change).
Runner tests asserting exact
escalate/promptargs (now carrysessionApproval);approval-escalator.test.tsrequest-shape assertions;permission-dialog.test.ts(new state in the guard + the two-step path);forwarding-fixtures.tsmakeServerDepsdefaultrecorder. No test becomes redundant — the change is additive. - Tests that must stay as-is.
The #292 non-degraded-broadcast tests (
local-user-authorizerforwarded-render + server-details mapping) — the emit still fires once inauthorizebefore the first select; the scope select is added after and must not perturb them.
Invariants at risk
The change touches surfaces #557 refactored; each documented outcome and its pinning test:
- Forwarded
permissions:ui_promptbroadcast stays non-degraded (#292,docs/cross-extension-api.md) — pinned by theLocalUserAuthorizerforwarded-render test and the server-details mapping test. The scope select runs after the single emit; verify these stay green unchanged. - One
permissions:ui_promptemit site (Step 3) — no new emit added. processSingleForwardedRequest < 60 lines(Step 3 health target) — hold it by addingapplyGrantScopeas a separate method; runpnpm fallow healthonforwarded-request-server.tsbefore declaring done (the #557 retro's explicit lesson:fallow dead-codedoes not measure LOC).- Uniform escalation /
canConfirm= 0 (Step 2) — the forwarded ask still flows throughPermissionPrompterbracketing; the scope choice lives insideLocalUserAuthorizer, adding no pre-check.
TDD Order
- Producer: ride the child's suggestion into the forwarded request.
Red→green across
test/session-approval.test.ts(toForwardedData),test/handlers/gates/runner.test.ts(escalate details carrysessionApproval),test/authority/approval-escalator.test.ts(request persistssessionApproval). Adds theForwardedSessionApprovaltype,toForwardedData, the two optional fields, and the runner/authorizer wiring. The server still ignores the new field — child records onapproved_for_sessionexactly as today (valid intermediate). Runpnpm run check(shared-type change). Commit:feat(pi-permission-system): forward the child's session-approval suggestion. - Consumer: serving-node scope selection end-to-end.
Red→green across
test/permission-dialog.test.ts(new state + guard + two-step mapping incl. cancel-defaults-to-subagent and no-sessionScope-single-option),test/authority/local-user-authorizer.test.ts(setssessionScopeiff forwarded + suggestion; label wiring),test/authority/forwarded-request-server.test.ts(records intorecorder+ translates on the new state; passesapproved_for_sessionthrough), updatingtest/helpers/forwarding-fixtures.ts(makeServerDepsdefaultrecorder). Adds the dialog change,buildForwardedScopeLabels,local-user-authorizerwiring, the serverrecorderdep +buildForwardedAskDetailsset +applyGrantScope, and theindex.tsrecorder: sessionRuleswiring (single call site — same commit). The feature is fully wired here; leaves the package consistent. Runpnpm run check. Commit:feat(pi-permission-system): offer whole-session scope on forwarded approvals. - Composition-root round-trip (cross-consumer).
test/composition-root.test.ts, two tests: (a) whole-session — child forwards, the serving UI picks "whole session" (two-step), the serving node records, the child gets a plain approve; a second child forward auto-approves with no second human prompt, and the parent's own identical action issession_approved; (b) subagent-only — the serving UI picks "this subagent only," the child records locally (its next action needs no forward), and the parent's own identical action still prompts (scope containment). Use a plain custom tool (configdemo: ask) so the forward surface equals the recorded surface (demo, pattern*) and the best-effort re-resolution matches. Commit:test(pi-permission-system): round-trip forwarded grant-scope selection. - Docs.
Add ADR
0006-forwarded-grant-scope-selection.md; mark Phase 9 Step 4✅(heading +S4node) with aLanded:bullet inarchitecture.md; extenddocs/subagent-integration.mdwith the scope choice. Commit:docs(pi-permission-system): record forwarded grant-scope selection (Phase 9 Step 4).
Risks and Mitigations
- Half-wired feature between steps. Step 1 is inert (server ignores the field); Step 2 wires dialog + server together so the new state is never producible without a handler. Mitigation: the step boundary is drawn exactly there.
- Response-state translation missed → grant recorded nowhere.
If
applyGrantScopefailed to translate, the child would receiveapproved_for_serving_session, treat it as a plain approve, and record nothing while the serving node also recorded nothing. Mitigation: the server unit test asserts both therecordercall and the translatedapprovedresponse on the new state. - Cross-cwd / cross-surface re-resolution. A whole-session path grant matches a child's later forward only when cwd and surface align (the #557 best-effort rule). Mitigation: documented in the ADR and Non-Goals; the round-trip test uses a surface-stable custom tool; worst case is a fail-safe re-prompt, never a silent grant.
PermissionDecisionStatewidening ripples. Mitigation: grep-verified the only branch sites are the dialog andpermission-gate.ts(unaffected); no exhaustive switch; guard updated.
Open Questions
- Whether to persist a whole-session grant as durable config ("always," not just this session) is out of scope — the "always" tier is a separate, later concern (principle 8: a future "always" writes config).
- The three-way scope (root / parent / requesting subagent) waits on multi-hop escalation; no follow-up filed (admitted-not-shipped, tracked by the roadmap's resolved-direction 4 and the multi-hop note, not a new issue).