30 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 635 | Forwarded accessIntent is dropped before Authorizer Chain escalation |
Carry the forwarded access facts through to Authorizer Chain escalation
Release Recommendation
Release: ship independently
Issue #635 is not a numbered step in docs/architecture/architecture.md's improvement roadmap, and that roadmap carries no Release: batch annotations, so there is no batch to hold this behind.
It is a self-contained defect fix in the serving-node escalation path.
It ships as a breaking change (fix!:), so it cuts a major release: 23.0.3 → 24.0.0.
Problem Statement
A subagent child that cannot answer an ask forwards the request to its parent (serving) session.
Since #596, the request carries a structured ForwardedAccessIntent — the child-fixed access facts (surface, matchValues, boundaryValue) plus requester identity (requesterCwd, principal).
Since #597, ForwardedRequestServer.resolveDecision() resolves that intent against the serving node's own composed ruleset.
But when resolution lands on ask, buildForwardedAskDetails() reconstructs PromptPermissionDetails from the request's display fields only — message, surface, value, forwarding, sessionApproval — and drops request.accessIntent on the floor.
PromptPermissionDetails already declares an optional accessIntent?: ForwardedAccessFacts field, which every local gate populates; only the serving-node reconstruction leaves it empty.
Two consequences follow, both defects against already-accepted design:
-
An Authorizer Chain link sees no structured evidence for a forwarded ask. ADR 0008's composition section states that once both tracks land, "a serving node's chain links … review forwarded asks against the child-fixed fact set — honest evidence, not a parent-side re-derivation." Today a link receives display strings and a formatted UI message. It cannot safely parse display text, and it must not reconstruct the child's path from the parent's cwd — the exact re-derivation ADR 0008 forbids. So a path-aware link has no option but to
deferevery forwardedwrite/edit, even when canonical path evidence would let it decide conservatively. -
The bounded-delegation checkpoint silently under-applies to forwarded asks.
delegation-envelope.tsdecides exclusion withdetails.accessIntent?.surface ?? details.surface, preferring the gate-authoritative surface and falling back to the display surface. For a forwarded ask the gate surface is absent, so the checkpoint reads the display surface — the child's tool name (write), neverpath/external_directory. A registered allow-capable link'sallowon a forwardedpath-gate ask is therefore honored, where the identical ask made locally in the parent is capped todefer(ADR 0007 §5). Forwarding is currently an escalation path around the operator's own delegation boundary.
The same line of code causes both: populating accessIntent fixes the evidence gap and closes the checkpoint escape together.
Goals
- Carry the child-fixed access facts (
surface,matchValues,boundaryValue) fromrequest.accessIntentonto thePromptPermissionDetailsthe serving node escalates. - Keep the disclosure boundary explicit:
requesterCwdandprincipaldo not cross onto the prompt details, and neither do raw tool arguments or change bodies. The requester identity a link legitimately needs is already ondetails.forwarding. - Accept, pin, and document the resulting bounded-delegation tightening: a chain link's
allowon a forwardedpath/external_directoryask is now capped todefer. This is a breaking change — it ships asfix(pi-permission-system)!:with aBREAKING CHANGE:footer and a migration note. - Record the cross-issue principle this fix instantiates — high-fidelity in-process seams, minimal-but-correlatable broadcasts — so #610's planning session inherits a decided frame rather than re-deliberating it.
Non-Goals
- #610 (make UI prompt decisions correlatable in the serving session).
It shares this issue's root cause — the serving node reconstructs a degraded projection of the forwarded request — but it changes the public
permissions:decisionevent contract, adds a parent-side emit point, and carries apkg:pi-subagentslabel. This plan contributes only the shared principle note; the event work stays with #610. - #620 (allow-capable opaque-bash adjudicator).
#620 will relax the whole-
pathexclusion to a secret-shaped one, which narrows the tightening this plan lands. That refinement is #620's to make; nothing here changesDELEGATION_EXCLUDED_SURFACES. - The
permissions:ui_promptbroadcast payload.buildUiPromptreads onlysurface/value/forwardingand never touchesaccessIntent, so the forwarded broadcast is byte-identical after this change.details.surfaceis not repointed at the gate surface — that would degrade the #292 non-degraded-broadcast contract. ServingPolicy.resolve/ recorded-authority resolution.resolveDecision'srequest.accessIntent-presence gate and itsaskfloor on absence (ADR 0008 §4) are untouched.- The multi-surface fact set.
ADR 0008 records it as an explicitly deferred edge; a multi-surface child decision still floors to
ask.
Background
Relevant modules, in the order a forwarded ask traverses them:
| Module | Role |
|---|---|
src/handlers/gates/*.ts |
Every gate populates promptDetails.accessIntent via accessFactsFromPath / accessFactsFromValue (gates/helpers.ts). |
src/authority/approval-escalator.ts |
ParentAuthorizer.authorize() reads details.accessIntent and buildForwardedRequest completes it into a ForwardedAccessIntent by stamping requesterCwd + principal. |
src/authority/permission-forwarding.ts |
Declares ForwardedAccessFacts (the three fields) and ForwardedAccessIntent extends ForwardedAccessFacts (plus identity). |
src/authority/forwarding-io.ts |
asForwardedAccessIntent tolerantly narrows the wire field on read. |
src/authority/forwarded-request-server.ts |
resolveDecision resolves the intent; buildForwardedAskDetails reconstructs the ask details — the defect site. |
src/authority/authorizer-selection.ts |
escalate composes the chain, wrapping each link in encloseInDelegationEnvelope. |
src/authority/delegation-envelope.ts |
The checkpoint; reads details.accessIntent?.surface ?? details.surface. |
Which gate raises a forwarded ask determines whether the tightening applies at all:
| Raising gate | accessIntent.surface |
Display surface |
Envelope before | Envelope after |
|---|---|---|---|---|
bash command gate |
bash |
bash |
not excluded | not excluded |
Per-tool gate (a write: rule fired) |
write |
write |
not excluded | not excluded |
Cross-cutting path gate |
path |
write |
not excluded | excluded |
external_directory gate |
external_directory |
tool name | not excluded | excluded |
Only the last two rows change, and only once an allow-capable link is registered and named in authorizerChain.
The shipped first-party link (packages/pi-permission-model-judge) is deny-first; ADR 0007 describes the checkpoint as dormant today.
Constraints from AGENTS.md and the package skill that bear on this plan:
- Breaking commits use
!after the scope:fix(pi-permission-system)!:. docs/architecture/anddocs/decisions/are not in the package'sfilesallowlist, so a link from a shipped doc (README.md,docs/configuration.md,docs/migration/*.md) into either must be an absolute GitHub URL.- Architecture module-tree entries describe current behavior; cite an issue only when the ref encodes an active constraint.
- Do not put
Closes #Nin commit messages; useRefs #635.
Design Overview
The projection
buildForwardedAskDetails gains one field, produced by an explicitly-typed module-private projection rather than a spread:
/**
* Project the request's `ForwardedAccessIntent` down to the child-fixed access
* facts an Authorizer may see.
*
* The disclosure boundary is deliberate and is why this is a field-by-field
* projection, not a spread: `requesterCwd` and `principal` stay off the ask
* details. A link that needs requester identity reads `details.forwarding`,
* which already carries the agent name and session id.
*
* The explicit `ForwardedAccessFacts` return type makes the boundary
* compile-checked: a field added to `ForwardedAccessFacts` fails `tsc` here
* until it is deliberately projected or deliberately withheld.
*/
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
return {
surface: intent.surface,
matchValues: intent.matchValues,
boundaryValue: intent.boundaryValue,
};
}
and the details builder adds a conditional spread matching the existing sessionApproval idiom, so a version-skew request without the field carries no accessIntent key at all (not an explicit undefined):
...(request.accessIntent
? { accessIntent: toAccessFacts(request.accessIntent) }
: {}),
Absence must stay absence: delegation-envelope.ts fail-safes an undetermined surface to excluded, and the ?? details.surface fallback depends on accessIntent?.surface being undefined rather than a half-populated object.
Consumer call site
A registered chain link's authorize is the consumer this exists for.
The interaction is Tell-Don't-Ask in the direction that matters — the link is handed the facts and asks the injected query for the policy; it never reaches back through the details for a collaborator:
async function authorize(details, query, log) {
const facts = details.accessIntent;
if (facts?.surface !== "external_directory") return { kind: "defer" };
const verdict = query.checkPermission("external_directory", facts.boundaryValue ?? undefined);
log.review("model_judge.reviewed", { requestId: details.requestId, surface: facts.surface });
return verdict.state === "deny" ? { kind: "deny", reason: "outside policy" } : { kind: "defer" };
}
facts.matchValues and facts.boundaryValue are plain strings fixed at the child, honoring the path-values string boundary (docs/decisions/0002-path-values-string-boundary.md) — the wire never carries an AccessPath, and the link never rebuilds one.
The bounded-delegation consequence
No code change in delegation-envelope.ts.
Its existing details.accessIntent?.surface ?? details.surface already prefers the gate-authoritative surface; it simply never had one for a forwarded ask.
After this change the forwarded path is treated exactly like the local path, which is what ADR 0007 §5 already specifies.
The checkpoint still only ever tightens — it converts allow → defer, never the reverse — so the invariant #599 landed is preserved, not merely unbroken.
The shared principle (the #610 frame)
A new short subsection in docs/architecture/architecture.md, under ## The authority model immediately after ### The recursion (where the courier hop is already described):
Reconstruction fidelity at the serving node. The courier hop carries facts, not judgment — but what the serving node reconstructs from the forwarded request differs by audience. An in-process seam (the
Authorizerchain, reached throughPromptPermissionDetails) receives the full child-fixed fact set, because a chain link is operator-opted-in viaauthorizerChainand must decide from evidence rather than parsed display text. A cross-extension broadcast (permissions:ui_prompt/permissions:decisiononpi.events) receives the minimum needed to be correlatable, because any loaded extension can observe it. Fidelity up, disclosure down: the two directions are the same rule applied to different trust boundaries. Requester identity (requesterCwd,principal) crosses to neither — it stays on the wire object, with the ask details carrying only theforwardingprovenance.
This is descriptive of decided architecture (ADR 0007 §5, ADR 0008 §2) rather than a new decision, so it belongs in the living architecture doc, not a new ADR. #610 can cite it for the broadcast half.
Module-Level Changes
Source
src/authority/forwarded-request-server.ts— add the module-privatetoAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFactshelper; add the conditionalaccessIntentspread tobuildForwardedAskDetails; extend that function's doc comment to name the disclosure boundary and the checkpoint coupling.ForwardedAccessFactsjoins the existing type import from#src/authority/permission-forwarding(ForwardedAccessIntentis already imported).src/authority/permission-prompter.ts— correct thePromptPermissionDetails.accessIntentdoc comment. Its current final sentence, "Absent for a serving-node local prompt reconstructed from a forwarded request," becomes false with this change; replace it with the version-skew condition (absent only when the forwarded request carried no intent).
Greps run to bound the file list:
grep -rn "accessIntent" packages/pi-permission-system/src— 8 gate sites (producers, unchanged),approval-escalator.ts(child side, unchanged),delegation-envelope.ts(consumer, unchanged),forwarding-io.ts(wire read, unchanged),permission-forwarding.ts(type, unchanged), plus the two files above.- No export is removed or renamed, so no cross-package or
docs/symbol sweep is triggered. grep -rn "Absent for a serving-node"— matchessrc/authority/permission-prompter.tsanddist/public.d.ts;dist/is gitignored and regenerated bybuild:types, so only the source comment is edited.
Tests
test/authority/forwarded-request-server.test.ts— the exact-object assertion in "escalates an ask through the AskEscalator with the forwarded provenance details" (currentlyexpect(escalate).toHaveBeenCalledWith({ … })) gains the projectedaccessIntent; this is the only exact-match call-site assertion onescalatein the suite (grep -rn "escalate).toHaveBeenCalledWith({" test/→ 1 hit). New cases cover thepath-surface projection, the exact-key disclosure boundary, version-skew absence, and the bounded-delegation composition.test/helpers/forwarding-fixtures.ts— no change;makeForwardedAccessIntentalready acceptsPartial<ForwardedAccessIntent>overrides and defaults to abashsurface with a worktree-shapedrequesterCwd.
Docs
docs/architecture/architecture.md— (a) theforwarded-request-server.tsmodule-tree entry gains a clause that the serving reconstruction projects the request's access facts onto the escalated ask; the clause carries the#635ref because the projection is an active constraint (the bounded-delegation checkpoint's exclusion decision depends on it), per the architecture-doc convention. (b) The new### Reconstruction fidelity at the serving nodesubsection under## The authority model. Thepermission-prompter.tsentry already reads "carries the child-fixedaccessIntentfacts a forwarded ask relays" and stays accurate.docs/configuration.md— the bounded-delegation checkpoint paragraph (in theAuthorizer chain — case-by-case decision linkssection) gains one sentence: the cap applies to forwarded subagent asks on the gate surface that raised them, not the tool name displayed.docs/migration/0635-forwarded-ask-delegation-envelope.md— new, following the shape ofdocs/migration/0644-project-trust-gating.md: what changed, who is affected (only operators running a third-party allow-capable link named inauthorizerChain), what to do (nothing for the shipped deny-first judge; expect a prompt where a link previously auto-allowed a forwarded path ask), and the forward pointer to #620's secret-shaped refinement.docs/migrationis in the package'sfilesallowlist, so any link it makes intodocs/decisions/must be an absolute GitHub URL..pi/skills/package-pi-permission-system/SKILL.md— theAuthorizerSelection.escalateparagraph's bounded-delegation sentence gains a clause that a forwarded ask carries the child-fixedaccessIntent, so the checkpoint reads the gate surface for forwarded and local asks alike. This is a reworded-behavior case with no removed symbol, so the skill grep is required by convention.
No README.md change: its one-sentence chain summary ("caps any link's allow on external_directory/path") stays true and is the right granularity.
No config schema, example-config, or permissions.schema.json change: no config field is added.
Test Impact Analysis
- Newly enabled tests.
This is a defect fix, not an extraction, so it enables no structurally-new test surface.
It does make one assertion newly meaningful: that a forwarded
path-gate ask reaches the chain withaccessIntent.surface === "path", which is the observable difference between the two envelope outcomes. The bounded-delegation composition test is new coverage of a real gap —delegation-envelope.test.tscovers the envelope over synthetic details, andforwarded-request-server.test.tscovers the server over a fake escalator, but nothing composed the two, which is precisely why the forwarded escape went unnoticed. - Redundant tests. None. No existing test is subsumed; the exact-match escalate assertion is extended, not replaced, because pinning the whole details object is exactly what guards the disclosure boundary.
- Tests that must stay as-is.
test/authority/forwarding-io.test.ts'sasForwardedAccessIntentnarrowing cases (the wire read is untouched),forwarded-request-server.test.ts's recorded-authority and version-skew cases (resolveDecisionis untouched), andcomposition-root.test.ts's forwarded non-degraded-broadcast and grant-scope round-trips (the display projection is untouched).
Invariants at Risk
| Invariant | Origin | Pinned by | Risk and handling |
|---|---|---|---|
A forwarded ask emits a non-degraded permissions:ui_prompt: the parent's broadcast carries the child's original surface/value. |
#292 | test/composition-root.test.ts, "forwarded non-degraded broadcast" |
Repointing details.surface at the gate surface would regress it. Measured, not argued: buildUiPrompt (src/permission-ui-prompt.ts) reads only requestId/source/surface/value/agentName/message/forwarding and never accessIntent, so the emitted payload is byte-identical after this change. The plan adds a field and repoints nothing. |
ServingPolicy resolution is intent-only; a request without accessIntent floors to ask without consulting the policy. |
#597, ADR 0008 §4 | forwarded-request-server.test.ts, "floors a request with no fields at all (fully legacy) to escalation without consulting the policy" |
resolveDecision is not edited. The version-skew test is extended to also assert the escalated details carry no accessIntent key, so absence stays absence. |
The wire read is tolerant: a malformed accessIntent narrows to undefined rather than throwing. |
#596 | test/authority/forwarding-io.test.ts |
Unchanged; the projection runs only on an already-narrowed value, so it can never see a partial object. |
| The bounded-delegation checkpoint only ever tightens a verdict. | #599, ADR 0007 §5 | test/authority/delegation-envelope.test.ts |
Preserved and strengthened: the change converts one allow into a defer and creates no path from defer/deny to allow. The new composition test pins it end to end. |
Requester identity (requesterCwd, principal) never crosses onto the prompt details. |
ADR 0008 §2 disclosure framing; the issue's own explicit ask | New — this invariant lives only in prose today | Add the pin: an exact-keys assertion (Object.keys(details.accessIntent).sort() equals ["boundaryValue", "matchValues", "surface"]) plus the compile-checked ForwardedAccessFacts return type on toAccessFacts. |
TDD Order
-
Red → Green: project the child-fixed facts onto the escalated ask, and pin the delegation consequence. Test surface:
test/authority/forwarded-request-server.test.ts. Red (four assertions, all failing against currentmain):- Extend the existing exact-object assertion in "escalates an ask through the AskEscalator with the forwarded provenance details" to include
accessIntent: { surface: "bash", matchValues: ["git push"], boundaryValue: null }. - New: a forwarded request whose
accessIntentis apath-surface intent (multi-aliasmatchValues, non-nullboundaryValue, and arequesterCwd/principaldistinct from the serving session) escalates with all three fact fields intact and with exactly those three keys — the disclosure-boundary pin. - New: a version-skew request with no
accessIntentescalates with the key absent (expect(details).not.toHaveProperty("accessIntent")). - New
describecomposing the two real units: run the details captured fromescalatethroughencloseInDelegationEnvelopeover an allow-returning link, and assert{ kind: "defer" }for a forwardedpath-surface ask and{ kind: "allow" }for a forwardedbash-surface ask (the scenario-A regression guard).
Green: add
toAccessFactsand the conditional spread insrc/authority/forwarded-request-server.ts; correct thePromptPermissionDetails.accessIntentdoc comment insrc/authority/permission-prompter.ts. Land the user-facing breaking-change docs in the same commit so theBREAKING CHANGE:footer points at a note that exists:docs/migration/0635-forwarded-ask-delegation-envelope.mdand thedocs/configuration.mdsentence. Verify:pnpm --filter @gotgenes/pi-permission-system run test,run check,run lint.Commit:
fix(pi-permission-system)!: carry forwarded access facts to the Authorizer Chain (#635), with aBREAKING CHANGE:footer describing the checkpoint tightening and pointing at the migration note, plusRefs #635. - Extend the existing exact-object assertion in "escalates an ask through the AskEscalator with the forwarded provenance details" to include
-
Docs: architecture module tree, the fidelity principle, and the package skill. No test cycle — documentation only. Edit
docs/architecture/architecture.md(theforwarded-request-server.tsmodule-tree clause and the new### Reconstruction fidelity at the serving nodesubsection) and.pi/skills/package-pi-permission-system/SKILL.md(the bounded-delegation sentence). Verify:pnpm exec rumdl checkon the edited files.Commit:
docs(pi-permission-system): record serving-node reconstruction fidelity (#635).
Risks and Mitigations
- Risk: a spread leaks
requesterCwd/principalonto the ask details.ForwardedAccessIntentis structurally assignable toForwardedAccessFacts, soaccessIntent: request.accessIntenttype-checks cleanly while carrying two extra fields at runtime — a silent disclosure widening thattsccannot catch. Mitigation: the field-by-fieldtoAccessFactsprojection with an explicitForwardedAccessFactsreturn type, plus the exact-keys test assertion. - Risk: a future field added to
ForwardedAccessFactsis silently not projected. Mitigation: the explicit return type makes the object literal incomplete, sotscfails attoAccessFactsuntil the new field is deliberately projected or deliberately withheld with a comment. - Risk: an operator running an allow-capable link is surprised by new prompts.
Mitigation:
fix!:+BREAKING CHANGE:footer + a migration note naming exactly who is affected. The blast radius is narrow by construction: it requires a third-party allow-capable link, named inauthorizerChain, on a forwarded ask raised by thepath/external_directorygate. The shipped first-party judge is deny-first. - Risk: a major version bump (
23.0.3→24.0.0) for a small change. Accepted deliberately (operator decision at planning): the tightening changes an authorization outcome, which is the category that most warrants a loud signal. - Risk: the tightening is later reversed by #620. Not a regression — #620 replaces the whole-surface exclusion with a secret-shaped one by design, for local and forwarded asks alike. The migration note names this so an operator reading it understands the trajectory.
- Risk: the new architecture subsection re-inflates the doc. Mitigation: it is one short paragraph under an existing section, stating current behavior, with no per-issue provenance trail in the module tree.
Open Questions
- Should the fidelity principle eventually graduate from the architecture doc into an ADR?
Deferred until #610 lands: an ADR is warranted if the broadcast half turns out to need real deliberation (e.g. whether
permissions:decisionmay carry a value projection at all), and premature if #610 is a mechanicalrequestIdaddition. - Should
ForwardedAccessFactsgrow arequesterCwdfor display (letting a prompt or a link show "the child resolved this against/worktree/issue-42")? Out of scope; no consumer asks for it, and adding it would widen the disclosure boundary this plan deliberately draws. Revisit only when a concrete consumer exists.