mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
329 lines
23 KiB
Markdown
329 lines
23 KiB
Markdown
---
|
|
issue: 558
|
|
issue_title: "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](../architecture/architecture.md#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.md` and 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`) computes `descriptor.sessionApproval` (a `SessionApproval` value object — surface + one-or-more patterns) and escalates the ask via `AskEscalator.escalate(details)`.
|
|
Selection routes it to `ParentAuthorizer.authorize(details)` (`src/authority/approval-escalator.ts`), which writes a `ForwardedPermissionRequest` file and polls for the response.
|
|
When the response is `approved_for_session`, `applyPermissionGate` (`src/permission-gate.ts`) returns the descriptor's `sessionApproval`, and the runner records it into the **child's** `SessionRules`.
|
|
- **Serving node (UI).**
|
|
`ForwardedRequestServer.processInbox` (`src/authority/forwarded-request-server.ts`) resolves each request against recorded authority (`ServingPolicy.check`), and on `ask` escalates through `AskEscalator` to the serving session's `LocalUserAuthorizer`, which shows the dialog via `requestPermissionDecisionFromUi` (`src/permission-dialog.ts`).
|
|
The forwarded provenance rides on `PromptPermissionDetails.forwarding`, so the broadcast stays non-degraded ([#292]).
|
|
The server writes the decision's `state` back to the child and records **nothing** locally.
|
|
|
|
Key facts:
|
|
|
|
- The serving node's `SessionRules` is the single shared instance wired in `src/index.ts` — the `PermissionResolver`, the `GateRunner` recorder, and (via the resolver) the `ServingPolicy` all 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 from `GateRunner` to `ParentAuthorizer`; the Step 3 `forwarding` field set the precedent for forwarded-only data on it.
|
|
- Constraint (AGENTS.md): the `permission-manager.ts` string boundary must not import `AccessPath`; 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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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`):
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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 to `approved_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 on `ask` → 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` — add `toForwardedData(): ForwardedSessionApproval`; import the type.
|
|
- `src/permission-forwarding.ts` — add `ForwardedSessionApproval` interface; add optional `sessionApproval?: ForwardedSessionApproval` to `ForwardedPermissionRequest`.
|
|
- `src/authority/permission-prompter.ts` — add optional `sessionApproval?: ForwardedSessionApproval` to `PromptPermissionDetails` (type import from `permission-forwarding`).
|
|
- `src/handlers/gates/runner.ts` — populate `sessionApproval` on the escalate details from `descriptor.sessionApproval.toForwardedData()`.
|
|
- `src/authority/approval-escalator.ts` — `ParentAuthorizer.authorize` threads `details.sessionApproval` into `buildForwardedRequest`, which persists it on the request.
|
|
- `src/permission-dialog.ts` — add `approved_for_serving_session` to `PermissionDecisionState` and `isPermissionDecisionState`; add `sessionScope` to `RequestPermissionOptions`; add the second scope `select` in `requestPermissionDecisionFromUi`.
|
|
- `src/pattern-suggest.ts` — add `buildForwardedScopeLabels(agentName, surface, pattern)`.
|
|
- `src/authority/local-user-authorizer.ts` — build `sessionScope` labels for a forwarded ask carrying a suggestion; pass them to `requestPermissionDecisionFromUi`.
|
|
- `src/authority/forwarded-request-server.ts` — add `recorder: SessionApprovalRecorder` dep; set `details.sessionApproval` in `buildForwardedAskDetails`; add `applyGrantScope` (record + translate) called from `processSingleForwardedRequest`; import `SessionApproval`.
|
|
- `src/index.ts` — pass `recorder: sessionRules` to the `ForwardedRequestServer` constructor.
|
|
|
|
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-internal `approved_for_serving_session` translation.
|
|
- `docs/architecture/architecture.md` — mark Phase 9 Step 4 complete (`✅` on the step heading and the `S4` Mermaid node); add a `Landed:` 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 the `ask`-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` — `makeServerDeps` gains a default `recorder: { 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
|
|
|
|
1. **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); `LocalUserAuthorizer` sets `sessionScope` iff forwarded + suggestion; `ForwardedRequestServer.applyGrantScope` records into the recorder on the new state and translates the response, and passes `approved_for_session` through untouched.
|
|
2. **Existing tests to update (same step as the change).**
|
|
Runner tests asserting exact `escalate`/`prompt` args (now carry `sessionApproval`); `approval-escalator.test.ts` request-shape assertions; `permission-dialog.test.ts` (new state in the guard + the two-step path); `forwarding-fixtures.ts` `makeServerDeps` default `recorder`.
|
|
No test becomes redundant — the change is additive.
|
|
3. **Tests that must stay as-is.**
|
|
The [#292] non-degraded-broadcast tests (`local-user-authorizer` forwarded-render + server-details mapping) — the emit still fires once in `authorize` before 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_prompt` broadcast stays non-degraded** ([#292], `docs/cross-extension-api.md`) — pinned by the `LocalUserAuthorizer` forwarded-render test and the server-details mapping test.
|
|
The scope select runs after the single emit; verify these stay green unchanged.
|
|
- **One `permissions:ui_prompt` emit site** (Step 3) — no new emit added.
|
|
- **`processSingleForwardedRequest < 60 lines`** (Step 3 health target) — hold it by adding `applyGrantScope` as a separate method; run `pnpm fallow health` on `forwarded-request-server.ts` before declaring done (the #557 retro's explicit lesson: `fallow dead-code` does not measure LOC).
|
|
- **Uniform escalation / `canConfirm` = 0** (Step 2) — the forwarded ask still flows through `PermissionPrompter` bracketing; the scope choice lives inside `LocalUserAuthorizer`, adding no pre-check.
|
|
|
|
## TDD Order
|
|
|
|
1. **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 carry `sessionApproval`), `test/authority/approval-escalator.test.ts` (request persists `sessionApproval`).
|
|
Adds the `ForwardedSessionApproval` type, `toForwardedData`, the two optional fields, and the runner/authorizer wiring.
|
|
The server still ignores the new field — child records on `approved_for_session` exactly as today (valid intermediate).
|
|
Run `pnpm run check` (shared-type change).
|
|
Commit: `feat(pi-permission-system): forward the child's session-approval suggestion`.
|
|
2. **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` (sets `sessionScope` iff forwarded + suggestion; label wiring), `test/authority/forwarded-request-server.test.ts` (records into `recorder` + translates on the new state; passes `approved_for_session` through), updating `test/helpers/forwarding-fixtures.ts` (`makeServerDeps` default `recorder`).
|
|
Adds the dialog change, `buildForwardedScopeLabels`, `local-user-authorizer` wiring, the server `recorder` dep + `buildForwardedAskDetails` set + `applyGrantScope`, and the `index.ts` `recorder: sessionRules` wiring (single call site — same commit).
|
|
The feature is fully wired here; leaves the package consistent.
|
|
Run `pnpm run check`.
|
|
Commit: `feat(pi-permission-system): offer whole-session scope on forwarded approvals`.
|
|
3. **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 is `session_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 (config `demo: 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`.
|
|
4. **Docs.**
|
|
Add ADR `0006-forwarded-grant-scope-selection.md`; mark Phase 9 Step 4 `✅` (heading + `S4` node) with a `Landed:` bullet in `architecture.md`; extend `docs/subagent-integration.md` with 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 `applyGrantScope` failed to translate, the child would receive `approved_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 the `recorder` call and the translated `approved` response 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.
|
|
- **`PermissionDecisionState` widening ripples.**
|
|
Mitigation: grep-verified the only branch sites are the dialog and `permission-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).
|
|
|
|
[#292]: https://github.com/gotgenes/pi-packages/issues/292
|
|
[#557]: https://github.com/gotgenes/pi-packages/issues/557
|
|
[#559]: https://github.com/gotgenes/pi-packages/issues/559
|
|
[#565]: https://github.com/gotgenes/pi-packages/issues/565
|