26 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 596 | pi-permission-system: carry the structured access intent onto the forwarded-permission wire |
Carry the structured access intent onto the forwarded-permission wire
Release Recommendation
Release: mid-batch — defer (batch "cross-session-intent"); confirm at ship time
This issue is Step 2 of Phase 12 Track A, the middle member of release batch "cross-session-intent" (Steps 1, 2, 3; tail = Step 3, #597).
The roadmap step (docs/architecture/architecture.md) tags it Release: batch "cross-session-intent", and Step 2 is not the batch tail.
Its plan-execution commits are feat:/test:/docs: on main; the whole batch ships together once #597 lands and cuts the release.
Problem Statement
When an ask-state permission arises in a subagent child with no UI, the extension forwards it up the session tree for a decision.
The gate that raised the ask computed a full AccessIntent — for a path-shaped ask, an AccessPath carrying the lexical ∪ canonical alias set (matchValues()) and the canonical boundary form (boundaryValue()) — and then discarded it.
PromptPermissionDetails and ForwardedPermissionRequest carry only display strings (surface/value), so by the time an ask reaches the serving node the structured facts it needs are unrecoverable.
ADR 0008 (docs/decisions/0008-cross-session-access-intent.md, Step 1) settled the contract: the child owns the facts; the parent owns the judgment.
The facts are fixed at the origin child and carried unchanged across every hop; no node re-derives them.
This step threads those child-fixed facts from the point of decision (the gate) through the escalation edge (ParentAuthorizer) and onto the forwarded wire as the ForwardedAccessIntent field ADR 0008 specifies.
It does not yet make serving consume the field — that is Step 3 (#597).
Threading the intent onto the wire is the structural half of dissolving #565 items 2–3 (path re-interpretation at the wrong node; undefined agent-scope semantics); the serving half lands in Step 3.
Goals
- Declare the
ForwardedAccessIntentwire schema (per ADR 0008 §2) insrc/authority/permission-forwarding.tsand add it as an optional field onForwardedPermissionRequest. - Carry the gate-fixed access facts (
surface,matchValues,boundaryValue) from every path and non-path gate ontoPromptPermissionDetails, so they flow through the runner into the escalation edge. - Stamp the requester-identity portion (
requesterCwd,principal) at the escalation edge (ParentAuthorizer), where session identity is known, and serialize the completeForwardedAccessIntentonto the forwarded request. - Read the new field tolerantly in
src/authority/forwarding-io.ts(version-skew: an older child's request without the field still reads and floors toaskas today). - Honor the ADR-0002 string boundary: the wire carries strings only, never an
AccessPathinstance. - This change is non-breaking: it adds an optional field with a tolerant read; no config, schema, default, or observable decision changes on upgrade.
Verify criterion (from the roadmap): grep -c ForwardedAccessIntent src/authority/permission-forwarding.ts goes 0 → ≥ 1.
Non-Goals
- Serving consumption of the intent — Step 3 (#597) reworks
ServingPolicy/forwarded-request-server.tsto resolve the forwarded intent directly and retires the legacy(surface, value)re-derivation. This step leavesservingPolicyinindex.tsandforwarded-request-server.tsuntouched; serving still re-derives from display strings, andgrep -c ForwardedAccessIntent forwarded-request-server.tsstays 0. - Removing the
hasDisplayFieldsfloor — the display-field escalation floor is Step 3's symptom to dissolve; the display fields (source/surface/value) continue to ride the wire unchanged. - Agent-scoped serving evaluation —
requesterAgentNamegraduates to decision-participating only when Step 3 resolves against it; this step carriesprincipal.agentNameon the wire but changes no resolution behavior. - Multi-surface fact set and multi-hop principal identity — ADR 0008's two explicitly deferred edges; out of scope here.
Background
Relevant existing modules and how they relate:
src/access-intent/access-path.ts— theAccessPathvalue object.matchValues()returns the lexical alias union ∪ canonical form (the #418/#486 match set);boundaryValue()returns the canonical (symlink-resolved) form or"";value()the lexical absolute form. Every path gate already builds one.src/access-intent/access-intent.ts— the gate-emittedAccessIntentunion (tool | access-path). Theaccess-pathvariant holds theAccessPath. ADR-0002 (docs/decisions/0002-path-values-string-boundary.md) keeps the manager string-based:AccessPathnever crosses into the manager or the wire; producers convert to strings.- Gate factories (
src/handlers/gates/) — each builds a pureGateDescriptor.path.ts,external-directory.ts(tool surfaces) andbash-path.ts,bash-external-directory.ts(bash surfaces) hold anAccessPath;tool.tsholds one for the per-tool path-bearing surfaces (accessPath?);skill-input.ts/skill-read.tsare non-path. src/handlers/gates/runner.ts—GateRunner.runDescriptorspreadsdescriptor.promptDetailsintothis.prompter.escalate({ requestId, ...descriptor.promptDetails, ... }). Anything onpromptDetailsreaches the escalation edge.src/authority/permission-prompter.ts—PromptPermissionDetailsis the ask payload.GateDescriptor.promptDetailsisOmit<PromptPermissionDetails, "requestId">, so a new optional field onPromptPermissionDetailsis automatically available on every descriptor'spromptDetails.src/authority/approval-escalator.ts—ParentAuthorizer.authorize(details)builds aForwardedPermissionRequestviabuildForwardedRequestand writes/polls it. It already computesrequesterSessionId(getSessionId(ctx)) andrequesterAgentName.src/authority/forwarder-context.ts—ForwarderContext, the narrow read-interfaceExtensionContextsatisfies structurally, plusgetSessionId(ctx).ExtensionContext.cwdexists (used atpermission-gate-handler.ts:73,lifecycle.ts:59), butForwarderContextdoes not currently expose it.src/authority/permission-forwarding.ts/forwarding-io.ts— theForwardedPermissionRequesttype and its tolerant readerreadForwardedPermissionRequest, which reconstructs an allowlist of known fields with per-fieldasXnarrowers (asUiPromptSource,asNullableDisplayString,asForwardedSessionApproval).
Constraint from AGENTS.md / the package skill applied here:
- ADR-0002 string boundary — the wire schema carries
string[], neverAccessPath; each gate converts viamatchValues()/boundaryValue()at emit. - Architecture-doc convention — module-tree entries describe current behavior; cite an issue only for an active constraint.
The roadmap Step 2 heading + Mermaid
S2node get✅+ aLanded:note at implementation completion (not deferred to ship). - Tolerant-reader touch point (#558) —
readForwardedPermissionRequestreconstructs an allowlist, so a new field is silently dropped unless the reader is extended. That extension is in scope.
Design Overview
The fact / identity split
ADR 0008 groups a forwarded ask into what is being accessed (fixed at the child gate) and who/where is requesting (a property of the requester session). This plan mirrors that split across the two layers that own each half:
- The gate emits the access facts —
surface,matchValues,boundaryValue. Only the gate can producematchValues/boundaryValue(they live on theAccessPath), and they must not be re-derived downstream. - The escalation edge stamps the requester identity —
requesterCwd(the session cwd,ctx.cwd) andprincipal(sessionId,agentName).ParentAuthorizeralready knows both.
This keeps each gate producing only what it genuinely fixes (no per-gate cwd threading) and localizes principal-stamping to the one edge that owns session identity.
Data shapes
Declared in src/authority/permission-forwarding.ts (strings only — ADR-0002):
/**
* The child-fixed facts a gate emits: the surface it evaluated and the match
* set it computed. `principal` and `requesterCwd` are stamped at the
* escalation edge, so a gate carries only what it alone can produce.
*/
export interface ForwardedAccessFacts {
/** Gate surface: "path", "external_directory", "bash", a tool name, or a skill name. */
surface: string;
/**
* Child-fixed match set. Path surface: AccessPath.matchValues() (absolute ∪
* cwd-relative ∪ canonical). Non-path surface: the already-portable single
* value as a one-element array. Strings only.
*/
matchValues: string[];
/** AccessPath.boundaryValue() for a path surface; null for a non-path surface. */
boundaryValue: string | null;
}
/** The forwarded-wire access intent (ADR 0008 §2): access facts + requester identity. */
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
/** Requester cwd, for provenance/disclosure — never for parent re-derivation. */
requesterCwd: string;
/** Who is requesting. */
principal: {
sessionId: string;
agentName: string;
};
}
ForwardedPermissionRequest gains an optional field:
export type ForwardedPermissionRequest = {
// …existing fields (id, createdAt, requesterSessionId, targetSessionId,
// requesterAgentName, message, source?, surface?, value?, sessionApproval?)…
/**
* The child-fixed access intent (ADR 0008 §2). Optional for version-skew
* tolerance: an older child omits it, and the serving node floors to `ask`
* (Step 3). Present on a current child's request for every gate surface.
*/
accessIntent?: ForwardedAccessIntent;
};
PromptPermissionDetails (permission-prompter.ts) gains the gate-facts half only:
export interface PromptPermissionDetails {
// …existing fields…
/**
* The child-fixed access facts the raising gate computed. Rides through the
* runner to the escalation edge, which completes them into a
* ForwardedAccessIntent (adding requesterCwd + principal). Absent for a
* serving-node local prompt reconstructed from a forwarded request.
*/
accessIntent?: ForwardedAccessFacts;
}
Gate emission (call sites)
Each gate sets promptDetails.accessIntent.
Because GateDescriptor.promptDetails is Omit<PromptPermissionDetails, "requestId">, no change to the GateDescriptor interface in descriptor.ts is needed — the facts ride on promptDetails, satisfying the issue's "onto the descriptor/details" target.
-
Path surfaces (
path.ts,external-directory.ts,bash-path.ts,bash-external-directory.ts): the gate already holds the decidingAccessPath(accessPath/worstEntry.path/ the worst uncovered entry's path). Emit:accessIntent: { surface: /* "path" | "external_directory" */, matchValues: accessPath.matchValues(), boundaryValue: accessPath.boundaryValue() || null, }For
bash-external-directory.ts, select theAccessPathof the uncovered entry whosecheck === worstCheck(the same entrypreCheckcame from), mirroring howbash-path.tsfindsworstEntry. -
Per-tool gate (
tool.ts,describeToolGate): whenaccessPathis present (path-bearing surfacesread/write/edit/grep/find/ls) emit the path-facts form withsurface: gateSurface; otherwise (bash / MCP / plain tool) emit the single-value form{ surface: gateSurface, matchValues: [decision.value], boundaryValue: null }, reusing the already-computeddescriptor.decision.value. -
Skill surfaces (
skill-input.ts,skill-read.ts): non-path —{ surface: "skill", matchValues: [skillName], boundaryValue: null }.
Escalation-edge completion (ParentAuthorizer)
forwarder-context.ts exposes the session cwd:
export interface ForwarderContext {
hasUI: boolean;
ui: PermissionDecisionUi;
cwd: string; // new — ExtensionContext already provides this
sessionManager: { /* …unchanged… */ };
}
/** Reads the current session cwd off `ctx`. */
export function getCwd(ctx: ForwarderContext): string {
return ctx.cwd;
}
ParentAuthorizer.authorize threads details.accessIntent into buildForwardedRequest, which completes it (Tell-Don't-Ask: the edge stamps identity from data it already holds; it never asks the wire object to compute anything):
// ParentAuthorizer.buildForwardedRequest — illustrative
const accessIntent: ForwardedAccessIntent | undefined = facts
? {
...facts, // surface, matchValues, boundaryValue (child-fixed)
requesterCwd: getCwd(ctx),
principal: { sessionId: requesterSessionId, agentName: requesterAgentName },
}
: undefined;
return {
id, createdAt, requesterSessionId, targetSessionId, requesterAgentName, message,
...(forwarded ? { source, surface, value } : {}),
...(sessionApproval ? { sessionApproval } : {}),
...(accessIntent ? { accessIntent } : {}),
};
The full ForwardedAccessIntent is what Step 3 will read off the request (request.accessIntent) and hand to resolver.resolve, using matchValues as-is with no parent-side PathNormalizer re-derivation.
Tolerant read (forwarding-io.ts)
Add an asForwardedAccessIntent(value): ForwardedAccessIntent | undefined narrower alongside the existing asX helpers, accepting only a well-formed shape (string surface, all-string matchValues array, string | null boundaryValue, string requesterCwd, principal with string sessionId/agentName); anything else → undefined.
Wire it into readForwardedPermissionRequest's reconstruction block: accessIntent: asForwardedAccessIntent(parsed.accessIntent).
Absent or malformed → undefined, which Step 3 floors to ask.
Edge cases
- Empty boundary —
AccessPath.boundaryValue()is""for a literal-only path (e.g. a relative bash token after a non-literalcd). Emitnullfor an empty boundary so the wire'sboundaryValue: string | nullis honest (accessPath.boundaryValue() || null). - Multiple external paths — the bash external-directory gate carries only the worst (deciding) path's facts; a multi-path/multi-surface fact set is an ADR-deferred edge and floors to
askat the serving node. unknownrequester identity —getSessionId/requesterAgentNamealready fall back to"unknown";principalcarries those fallbacks verbatim (no new behavior).
Module-Level Changes
src/authority/permission-forwarding.ts— addForwardedAccessFactsandForwardedAccessIntentinterfaces; add optionalaccessIntent?: ForwardedAccessIntenttoForwardedPermissionRequest. (Satisfies the roadmap grep verify.)src/authority/forwarding-io.ts— addasForwardedAccessIntentnarrower; importForwardedAccessIntent; wireaccessIntentintoreadForwardedPermissionRequest.src/authority/permission-prompter.ts— add optionalaccessIntent?: ForwardedAccessFactstoPromptPermissionDetails; importForwardedAccessFacts(module already importsForwardedSessionApprovalfrompermission-forwarding).src/authority/forwarder-context.ts— addcwd: stringtoForwarderContext; addgetCwd(ctx)helper.src/authority/approval-escalator.ts— threaddetails.accessIntentfromauthorizeintowaitForForwardedApproval→buildForwardedRequest; compose and serialize the fullForwardedAccessIntent(requesterCwdviagetCwd(ctx),principalfrom the already-computedrequesterSessionId/requesterAgentName).src/handlers/gates/path.ts— setpromptDetails.accessIntentfromaccessPath(surface"path").src/handlers/gates/external-directory.ts— setpromptDetails.accessIntentfromaccessPath(surface"external_directory").src/handlers/gates/bash-path.ts— setpromptDetails.accessIntentfromworstEntry.path(surface"path").src/handlers/gates/bash-external-directory.ts— select the worst uncovered entry'sAccessPath; setpromptDetails.accessIntent(surface"external_directory").src/handlers/gates/tool.ts— setpromptDetails.accessIntent: path-facts whenaccessPathpresent, else[decision.value]single-value form.src/handlers/gates/skill-input.ts,src/handlers/gates/skill-read.ts— setpromptDetails.accessIntentto the{ surface: "skill", matchValues: [skillName], boundaryValue: null }form.test/helpers/forwarding-fixtures.ts—makeForwarderContextgains acwd?option with a default (e.g."/repo"), so fakes built through it satisfy the widenedForwarderContext.- Inline
ForwarderContextfakes — greptest/for inline{ hasUI, ui, sessionManager }object literals that do not go throughmakeForwarderContextand addcwd. Candidate files (from theForwarderContextreference grep):test/authority/forwarding-manager.test.ts,test/authority/forwarded-request-server.test.ts,test/authority/authorizer.test.ts,test/authority/authorizer-selection.test.ts,test/composition-root.test.ts— verify each at implementation time; those usingmakeForwarderContextneed no edit. docs/architecture/architecture.md— mark Phase 12 Step 2 complete:✅on the Step 2 heading and the MermaidS2node, add aLanded:note. Update the module-tree entries that now name the mechanism to describe current behavior:permission-forwarding.ts(carries theForwardedAccessIntentwire schema),forwarding-io.ts(tolerant read ofaccessIntent),approval-escalator.ts(ParentAuthorizerstampsrequesterCwd/principaland serializes the intent),permission-prompter.ts(PromptPermissionDetailscarries the child-fixed access facts),forwarder-context.ts(addscwd/getCwd). TheForwarded-wire structured intentmetric row now reads ≥ 1; note it in theLanded:line but leave the fixedBaselinesnapshot column unedited (per the package skill).
Contradiction check: no file appears in both Module-Level Changes and Non-Goals — forwarded-request-server.ts and index.ts's servingPolicy are named in Non-Goals only and are not touched here.
Test Impact Analysis
- New unit tests enabled by the structured field:
forwarding-ioround-trip: a request with a well-formedaccessIntentreconstructs it; a malformed one drops toundefined; an absent one reads asundefined(version skew).approval-escalator:buildForwardedRequeststampsprincipal(sessionId/agentNamefrom the requester) andrequesterCwd(fromctx.cwd) ontorequest.accessIntent; adetailswithoutaccessIntentomits the field.- Per-gate emission: each gate's descriptor carries
promptDetails.accessIntentwith the expected surface and match set (path facts from theAccessPathfor path surfaces; single-value form for bash/MCP/skill/plain-tool). These assert on the pure descriptor (no runner needed).
- Existing tests that become redundant: none.
The display-field (
source/surface/value) andsessionApprovalforwarding tests continue to exercise the paths that remain unchanged; nothing is superseded until Step 3 reworks serving. - Existing tests that must stay as-is: the forwarding round-trip and display-field tolerance tests (they pin the display fields still riding the wire), and every gate's existing resolution/descriptor test (the
access-pathresolve call andpreCheckare untouched — the facts are read off the sameAccessPath).
Invariants at risk
This step touches surfaces earlier phase steps refactored; each invariant below has a pinning test.
- Display fields still ride the wire (the #557/#292 non-degraded-broadcast contract) — adding
accessIntentmust not dropsource/surface/value. Pinned by the existing forwarding display-field tests (test/authority/approval-escalator.test.ts,test/authority/permission-forwarding.test.ts). Add an assertion that both display fields andaccessIntentare present on the same request. - ADR-0002 string boundary — no
AccessPathcrosses onto the wire. Pinned by theno-restricted-importslint onpermission-manager.ts(untouched) plus a new test assertingrequest.accessIntent.matchValuesare strings andboundaryValueisstring | null. - Gate resolution unchanged — the
access-pathintent each gate emits to the resolver, and the resultingpreCheck/decision, are unchanged (the facts are a read-only projection of the sameAccessPath). Pinned by the existing per-gate resolution tests, which must stay green with no edits to their decision assertions.
TDD Order
test:→feat:— wire type + tolerant read. AddForwardedAccessFacts/ForwardedAccessIntentand the optionalForwardedPermissionRequest.accessIntentfield; addasForwardedAccessIntentand wire it intoreadForwardedPermissionRequest. Test surface:test/authority/permission-forwarding.test.ts(or the forwarding-io test) — round-trip well-formed / malformed / absent. The type and reader land together (the reader references the type). Commit:feat(pi-permission-system): declare ForwardedAccessIntent wire schema with tolerant read (#596).feat:— escalation-edge serialization + prompt-details facts + context cwd. Addcwd/getCwdtoforwarder-context.ts; addaccessIntent?: ForwardedAccessFactstoPromptPermissionDetails; threaddetails.accessIntentthroughParentAuthorizerand stamprequesterCwd/principalontorequest.accessIntent. UpdatemakeForwarderContextand every inlineForwarderContextfake in the same commit (the widened interface breaks them at compile time). Test surface:test/authority/approval-escalator.test.ts. Commit:feat(pi-permission-system): serialize the child-fixed access intent onto the forwarded request (#596).feat:— tool-surface path gates emit facts.path.ts+external-directory.ts. Test surface: their gate tests. Commit:feat(pi-permission-system): emit access-intent facts from the tool path gates (#596).feat:— bash-surface path gates emit facts.bash-path.ts+bash-external-directory.ts(worst-entryAccessPathselection). Commit:feat(pi-permission-system): emit access-intent facts from the bash path gates (#596).feat:— per-tool gate emits facts.tool.ts(path form whenaccessPathpresent; single-value form otherwise). Commit:feat(pi-permission-system): emit access-intent facts from the per-tool gate (#596).feat:— skill gates emit facts.skill-input.ts+skill-read.ts(single-value skill form). Commit:feat(pi-permission-system): emit access-intent facts from the skill gates (#596).docs:— mark Step 2 complete + refresh module-tree entries.docs/architecture/architecture.md:✅heading +S2node,Landed:note, updated module-tree entries for the touchedauthority/modules. Commit:docs(pi-permission-system): mark Phase 12 Step 2 complete (#596).
Each gate step (3–6) is independently type-safe: adding an optional promptDetails.accessIntent is additive, and the field is not yet consumed for any decision, so no cross-module compile break forces gates into one commit.
Step 2's cwd addition is the one interface tightening; its fixture updates ride the same commit.
Risks and Mitigations
- Silent field drop on read (#558) — the tolerant reader reconstructs an allowlist, so an unwired field never round-trips.
Mitigation: Step 1 wires
asForwardedAccessIntentand tests the round-trip explicitly. AccessPathleaking onto the wire (ADR-0002 violation) — mitigation: gates convert to strings at emit; a Step-1 test asserts the serialized shape is strings only; the existingpermission-manager.tsimport lint is unaffected.- Interface tightening breaks fakes — widening
ForwarderContextwith a requiredcwdbreaks inline fakes at compile time. Mitigation: centralize viamakeForwarderContext's default and greptest/for inline constructions in the same commit (the AGENTS.md fixture-grep rule for tightened shared types). - Over-reaching into Step 3 — the temptation is to also make serving consume the field.
Mitigation: Non-Goals fences
forwarded-request-server.ts/servingPolicy; the serving-read metric stays 0 until #597. - Empty boundary ambiguity — a literal-only path has
boundaryValue() === "". Mitigation: emitnullfor an empty boundary so the wire distinguishes "no canonical" cleanly.
Open Questions
- Whether
principalshould reuse the top-levelrequesterSessionId/requesterAgentNamerather than nest a copy. Resolved for this plan: nest a self-containedprincipalper ADR 0008 §2, since Step 3 readsintent.principal.agentNameand a self-contained fact object is cleaner than reaching across the request; the top-level fields remain for routing/display and backward compatibility. No follow-up needed. - No deferred follow-up issues: Step 3 (#597) already exists as the serving-consumption step, and the ADR's two deferred edges are recorded in ADR 0008.