24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 597 | pi-permission-system: serving resolves the forwarded access intent at gate parity |
Serving resolves the forwarded access intent at gate parity
Release Recommendation
Release: ship now — batch "cross-session-intent" tail (this issue completes the batch)
This is Phase 12 Track A Step 3 (#597), the tail of the cross-session-intent batch (Steps 1 #595, 2 #596, 3 #597).
Steps 1 and 2 already landed on main as docs:/feat: without cutting a release (Step 1 is docs-only; Step 2 deferred as mid-batch).
This step carries a feat: behavior change and is the batch tail, so it cuts the release that ships all three steps together.
Problem Statement
Serving a forwarded permission request still re-derives the child's path from a bare display string through the parent's PathNormalizer and cwd.
ServingPolicy.check(surface, value) in forwarded-request-server.ts builds an AccessIntent via buildAccessIntentForSurface(surface, value, session.getPathNormalizer(), undefined) — the path's meaning is re-interpreted at the wrong node.
Because a subagent in a worktree resolves paths against a different root, a parent allow that would match the child's alias set can silently miss (and vice versa), and any multi-alias fidelity floors to ask.
This is #565 item 3 (single-(surface, value) re-resolution lossiness) and item 2 (undefined agent-scope semantics — serving resolves with agentName undefined).
Step 2 (#596) already put the structured ForwardedAccessIntent on the wire (child-fixed matchValues ∪ boundaryValue, requesterCwd, principal), read tolerantly by forwarding-io.ts.
This step makes the serving node consume that intent: resolve the parent's recorded authority directly against the values the child fixed, agent-scoped to the requester, at parity with the child's own gate.
Goals
- Serving resolves a forwarded request against the child-fixed
matchValuesfromrequest.accessIntent, never re-derived through the parent'sPathNormalizer/cwd (ADR 0008 §1, §2). - Serving is agent-scoped: it resolves against the parent's ruleset scoped to
principal.agentName, applying the parent's per-agent overrides for the requesting agent (ADR 0008 §3). This is a deliberatefeat:behavior change (confirmed non-breaking, per the #557 serving-is-resolution precedent): it changes an outcome only when the parent holds per-agent rules for that agent, and is a strict superset of the agent-neutral serving it replaces (identical configs still prompt). ForwardedAccessIntentis the sole resolution path: a request without it floors toask→ escalate (ADR 0008 §4). The legacy display-only(surface, value)resolution branch and thehasDisplayFieldsfloor are retired.grep -c ForwardedAccessIntent packages/pi-permission-system/src/authority/forwarded-request-server.tsgoes 0 → ≥ 1.- Mark Phase 12 Track A Step 3 complete in
docs/architecture/architecture.md(step heading✅, Mermaid node✅,Landed:note).
Non-Goals
- Closing #565. Step 3 structurally dissolves #565 items 2–3, but #565 stays open through Phase 12 by roadmap decision and closes at phase end (after Track B) with the item-1 best-effort note.
- Track B (the Authorizer chain, Steps 4–6 / #598–#600). Disjoint files; not touched here.
- The local
LocalPermissionsServicepath query.permissions-service.tskeeps usingbuildAccessIntentForSurfacefor theSymbol.for()service (#503) — it answers against the parent's own cwd for a local query, which is correct; only the serving closure inindex.tsstops re-deriving. - Editing ADR 0005. ADR 0008 §3 already records that it revises ADR 0005's "agent-neutral resolution" section; an accepted ADR is a historical record superseded by ADR 0008, not edited in place.
- The two ADR-0008-deferred edges — the single-surface fact set (#565 item 3's multi-surface case still floors to
ask) and multi-hop principal identity. Both recorded in ADR 0008; no code here. - No config, schema, example, or default change — the wire and its tolerant read shipped in Step 2; this step only changes how serving consumes them.
Background
Relevant modules and how they relate:
src/authority/forwarded-request-server.ts—ForwardedRequestServer(the serving-down role).processInbox→processSingleForwardedRequest→resolveDecision, which today callsthis.policy.check(request.surface, request.value)gated onhasDisplayFields, then escalatesask/field-less requests throughAskEscalator.ServingPolicyis the narrow recorded-authority seam (check(surface, value)).src/index.ts— the composition root buildsconst resolver = new PermissionResolver(...)and wiresservingPolicyas a closure overresolver.resolve+buildAccessIntentForSurface(..., session.getPathNormalizer(), undefined)(the re-derivation this step removes).src/permission-resolver.ts—PermissionResolver.resolve(intent: AccessIntent)reduces a gate-emittedAccessIntent(tool | access-path) to aResolvedAccessIntent(tool | path-values) viatoResolvedIntent, composing the session ruleset, then callsmanager.check. Theaccess-path→path-valuesunwrap (matchValues()) is the ADR-0002 string boundary.src/access-intent/input-normalizer.ts—buildAccessIntentForSurface(surface, value, normalizer, agentName)(the re-derivation builder) and the privatebuildInputForSurface(surface, value)(builds a surface-appropriatetoolinput);PATH_SURFACESclassifies path surfaces.src/authority/permission-forwarding.ts—ForwardedAccessIntent({ surface, matchValues, boundaryValue, requesterCwd, principal: { sessionId, agentName } }) and the optionalaccessIntentfield onForwardedPermissionRequest.src/authority/forwarding-io.ts—asForwardedAccessIntentalready reconstructsaccessIntenton the tolerant read (Step 2); no change needed here.
Constraints from AGENTS.md / package SKILL that apply:
- ADR-0002 string boundary — the manager stays string-based (
no-restricted-importsonpermission-manager.ts); the wire carries strings, and serving feeds pre-fixedmatchValuesas apath-valuesintent — noAccessPathreconstruction. - Mark the roadmap step complete in the implementation doc-update commit —
✅on both the step heading and its Mermaid node, plus theLanded:note (not deferred to ship). - Architecture module-tree entries describe current behavior — update the
forwarded-request-server.ts,permission-resolver.ts, andaccess-intent.tsentries. - Least privilege — a request without resolvable facts floors to
ask, never a silent grant.
Design Overview
Decision model
Per ADR 0008, the child owns the facts; the parent owns the judgment. Serving resolves the forwarded request as:
resolveDecision(request):
state = request.accessIntent ? policy.resolve(request.accessIntent).state : "ask"
allow → auto-approve
deny → auto-deny
ask → escalate through AskEscalator (also the missing-intent floor)
The single change to the branch condition: gate on request.accessIntent presence, not hasDisplayFields(request).
The display fields (request.surface/request.value) survive only for the escalated prompt's disclosure (buildForwardedAskDetails, unchanged).
ServingPolicy becomes intent-shaped
// src/authority/forwarded-request-server.ts
export interface ServingPolicy {
/**
* Resolve a forwarded access intent against the serving node's recorded
* authority, agent-scoped to the requester (ADR 0008 §3). Match values are
* used as fixed by the child — never re-derived through the parent's
* PathNormalizer/cwd.
*/
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
}
ForwardedAccessIntent is a cohesive fact value object, not a dependency bag — the closure reads surface, matchValues, and principal.agentName; boundaryValue/requesterCwd ride for provenance/disclosure.
boundaryValue is not needed for rule matching: matchValues() already contains the canonical form (absolute ∪ cwd-relative ∪ canonical), so evaluateAnyValue matches the parent's rules — including a /tmp/** external_directory allow — against the child's aliases directly.
The composition-root wiring (Tell-Don't-Ask, no re-derivation)
The serving closure hands the child-fixed values straight to the resolver:
// src/index.ts — the servingPolicy closure
const servingPolicy: ServingPolicy = {
resolve: (intent) =>
resolver.resolve(
buildResolvedIntentFromMatchValues(
intent.surface,
intent.matchValues,
intent.principal.agentName,
),
),
};
No session.getPathNormalizer() read, no buildAccessIntentForSurface — the deferred-binding comment and the normalizer re-derivation both go away.
The wire → resolved-intent mapping
A new sibling of buildAccessIntentForSurface in input-normalizer.ts, taking primitives (no ForwardedAccessIntent import, keeping access-intent/ decoupled from authority/):
// src/access-intent/input-normalizer.ts
export function buildResolvedIntentFromMatchValues(
surface: string,
matchValues: readonly string[],
agentName: string,
): ResolvedAccessIntent {
if (PATH_SURFACES.has(surface)) {
return {
kind: "path-values",
surface,
values: [...matchValues],
agentName,
};
}
return {
kind: "tool",
surface,
input: buildInputForSurface(surface, matchValues[0]),
agentName,
};
}
A path surface produces a path-values intent carrying the child's fixed aliases as-is (this is the whole point — the parent never rebuilds an AccessPath).
A non-path surface (bash command, MCP target, skill name) produces a tool intent from its single portable value.
agentName is always principal.agentName (ADR 0008 §3, agent-scoped).
Resolver accepts a pre-fixed path-values intent
buildResolvedIntentFromMatchValues returns a ResolvedAccessIntent (tool | path-values), but PermissionResolver.resolve today accepts only AccessIntent (tool | access-path).
Widen the concrete PermissionResolver.resolve (and the module-private toResolvedIntent) parameter to AccessIntent | PathValuesAccessIntent; the path-values case falls through toResolvedIntent's existing else-branch unchanged (it is already a ResolvedAccessIntent).
The gate-facing ScopedPermissionResolver interface stays narrow (resolve(intent: AccessIntent)): gates never emit path-values, and implementing with a wider parameter still satisfies the narrower interface.
The serving closure holds the concrete PermissionResolver (const resolver = new PermissionResolver(...)), so it can pass path-values.
This makes the forwarded-serving wire a second, legitimate producer of pre-fixed match values — coherent with ADR-0002: the wire crosses as strings and stays strings; the manager still consumes ResolvedAccessIntent and never imports AccessPath.
Call-site sketch (extracted-module upstream check): resolve delegates to manager.check(toResolvedIntent(intent), sessionRules.getRuleset()) — no reverse-search, no output argument, no Tell-Don't-Ask violation carried in; the path-values branch is a pure passthrough.
Edge cases
- Missing
accessIntent(older child, version skew) →resolveDecisionfloors toask→ escalate (ADR 0008 §4). - Malformed
accessIntent→asForwardedAccessIntent(Step 2) already yieldsundefinedon any malformed shape, so it is indistinguishable from absent and floors toask. - Empty
principal.agentName→ agent-scoped resolution with an empty agent name resolves against the base ruleset (no per-agent override), which is the safe agent-neutral outcome. external_directorysurface → resolves the parent'sexternal_directoryrules against the child-fixedmatchValues(the containment decision was already made at the child gate; serving only matches recorded authority).
Module-Level Changes
src/access-intent/input-normalizer.ts- Add exported
buildResolvedIntentFromMatchValues(surface, matchValues, agentName): ResolvedAccessIntent. - Import
ResolvedAccessIntentfrom./access-intent.PATH_SURFACESand the privatebuildInputForSurfaceare already present.
- Add exported
src/permission-resolver.ts- Widen
PermissionResolver.resolveparameter toAccessIntent | PathValuesAccessIntent; widen the privatetoResolvedIntentparameter identically (thepath-values/toolelse-branch already returns aResolvedAccessIntent). - Import
PathValuesAccessIntentfrom./access-intent/access-intent. - Update the
resolvedoc comment to note the pre-fixedpath-valuesacceptance (the forwarded-serving producer). ScopedPermissionResolverinterface unchanged (staysresolve(intent: AccessIntent)).
- Widen
src/authority/forwarded-request-server.ts- Change
ServingPolicyfromcheck(surface, value)toresolve(intent: ForwardedAccessIntent); importForwardedAccessIntent. resolveDecision: gate onrequest.accessIntent(callthis.policy.resolve(request.accessIntent)); floor to"ask"when absent.- Remove the private
hasDisplayFieldstype guard (sole call site removed) and its now-unused type import if any. - Update the class-level and
resolveDecisiondoc comments (recorded authority now viaServingPolicy.resolveagainst child-fixed facts).
- Change
src/index.ts- Rewire
servingPolicytoresolve: (intent) => resolver.resolve(buildResolvedIntentFromMatchValues(intent.surface, intent.matchValues, intent.principal.agentName)). - Remove the
buildAccessIntentForSurfaceimport (no longer used here — still used bypermissions-service.ts) and thegetPathNormalizer-deferral comment; add thebuildResolvedIntentFromMatchValuesimport.
- Rewire
test/helpers/forwarding-fixtures.tsmakeServerDepsdefaultpolicychanges from{ check: vi.fn(...) }to{ resolve: vi.fn(() => makeCheckResult({ state: "ask" })) }.- Add a
makeForwardedAccessIntent(overrides?)builder returning a well-formedForwardedAccessIntentfor request/policy fixtures.
test/authority/forwarded-request-server.test.ts— rewritten per Test Impact Analysis (mockpolicy.resolve; requests carryaccessIntent).test/access-intent/input-normalizer.test.ts(or the existing input-normalizer test file) — new unit tests forbuildResolvedIntentFromMatchValues.test/permission-resolver.test.ts— new test for thepath-valuespassthrough.docs/architecture/architecture.md— doc updates (below), landed in the implementation commit.
Documentation updates (implementation commit)
- Module-tree
forwarded-request-server.tsentry (line ~775):ServingPolicyis intent-shaped (resolve(intent)against child-fixed facts), no(surface, value)re-derivation. - Module-tree
permission-resolver.tsentry (line ~669):resolvealso accepts a pre-fixedpath-valuesintent (the forwarded-serving producer), not only a gate-emittedAccessIntent. - Module-tree
access-intent.tsentry (line ~678):path-valuesis produced by the resolver'saccess-pathunwrap and the forwarded-serving wire — still not gate-emitted, still the ADR-0002 boundary. - Step 3 heading (line ~867): append
✅; add aLanded:note recording the serving-read metric moved 0 → ≥ 1 and the agent-scoped/ask-floor behavior. - Mermaid node
S3(line ~918): prefix✅. - Do not edit the fixed
Baseline (2026-07-15)health-metric column.
Grep confirmation performed at plan time: ServingPolicy/servingPolicy appears only in src/index.ts, src/authority/forwarded-request-server.ts, the architecture doc, prior plans/retros, and ADRs; no other src/ consumer of ServingPolicy.check.
Test Impact Analysis
- New lower-level tests the change enables:
buildResolvedIntentFromMatchValuesunit tests: a path surface →path-valueswithvalues === matchValues(multi-alias) and the givenagentName; a non-path surface (bash/skill/external_directory/extension) →toolwith the right input shape;agentNamethreaded in every case.PermissionResolver.resolvepassthrough test: apath-valuesintent reachesmanager.checkunchanged (nomatchValues()unwrap, since there is noAccessPath), with the composed session ruleset.
- Existing tests that change:
forwarded-request-server.test.ts— everypolicy: { check }mock becomespolicy: { resolve }, and the resolve/auto-deny/ask tests attach a well-formedaccessIntentto the written request (viamakeForwardedAccessIntent). Assertions shift fromexpect(check).toHaveBeenCalledWith("bash", "git status")toexpect(resolve).toHaveBeenCalledWith(<intent>). The "floors a request without display fields" test becomes "floors a request withoutaccessIntent" (write a request withsurface/valuebut noaccessIntent; assertresolvenot called, escalate called). Grant-scope, one-hop-canary, and inbox-mechanics tests keep their intent — addaccessIntentwhere they must reach the resolve branch, or leave it off where they exercise escalation.
- Tests that must stay as-is (they exercise the layer being resolved):
- The composition-root
forwarded grant-scope selection round-triptests exercise the real end-to-end serving path (realParentAuthorizerstampsaccessIntent; realForwardedRequestServerresolves it). They use thedemosurface with parent config{ "*": "allow", demo: "ask" }and no per-agent rules, so agent-scoped resolution lands on the sameask→ prompt as today; they must stay green unchanged as the behavior-parity anchor. - The
service path queries evaluate the supplied path (#503)composition-root test exercisesLocalPermissionsService(a Non-Goal path), untouched.
- The composition-root
Invariants at risk
This step touches the serving surface #557 (Phase 9 Step 3) and #558 (grant-scope) refactored. Documented outcomes to preserve, and the test that pins each:
- Serving is resolution: recorded
allowauto-approves,denyauto-denies,askescalates (#557Outcome:— zero yolo checks outside the composed ruleset). Pinned byforwarded-request-server.test.ts"recorded-authority resolution" describe block — kept (adapted topolicy.resolve+accessIntent). - Whole-session grant records into the serving
SessionRules; subagent-only grant passes through (#558). Pinned by thegrant-scope selectiondescribe block (unit) and the composition-root round-trip (e2e) — both kept; the round-trip is the cross-consumer anchor. - One-hop canary warns on a multi-hop/misrouted requester (#557).
Pinned by the
one-hop canarydescribe block — unchanged (independent of the resolution branch). - A field-less request never silently grants — was
hasDisplayFields-floored toask; nowaccessIntent-floored toask. The renamed floor test pins the same least-privilege invariant.
TDD Order
buildResolvedIntentFromMatchValues+ resolverpath-valuesacceptance (test:→feat:).- Red: add
test/access-intent/input-normalizer.test.tscases forbuildResolvedIntentFromMatchValues(path surface →path-valueswith the alias set + agentName; non-path surfaces →toolwith correct input; agentName threaded). Addtest/permission-resolver.test.tscase:resolver.resolveon apath-valuesintent callsmanager.checkwith that intent unchanged + the composed ruleset. - Green: add
buildResolvedIntentFromMatchValuestoinput-normalizer.ts; widenPermissionResolver.resolve+toResolvedIntentto acceptAccessIntent | PathValuesAccessIntent. - Both new symbols are exercised by the new tests (no dead code);
ScopedPermissionResolverstays narrow. - Commit:
feat(pi-permission-system): accept pre-fixed path-values intents for forwarded serving. - Run
pnpm run checkafter this step (shared-signature widening).
- Red: add
- Serving resolves the forwarded intent, agent-scoped; retire the legacy branch (atomic
feat:).- This step changes the
ServingPolicyinterface,resolveDecision,index.tswiring, themakeServerDepsdefault, and theforwarded-request-server.test.tssuite together — the interface rename cascades and cannot land across separate commits. - Red: update
forwarding-fixtures.ts(makeServerDeps→policy: { resolve }; addmakeForwardedAccessIntent); rewriteforwarded-request-server.test.tsto mockpolicy.resolveand attachaccessIntentto requests, and rename the floor test to key on a missingaccessIntent. - Green: change
ServingPolicytoresolve(intent: ForwardedAccessIntent); reworkresolveDecisionto gate onrequest.accessIntent; removehasDisplayFields; rewireindex.tsservingPolicyviabuildResolvedIntentFromMatchValuesand drop thebuildAccessIntentForSurfaceimport + normalizer-deferral comment. - Commit:
feat(pi-permission-system): serving resolves the forwarded access intent at gate parity (#597). - Run
pnpm run checkafter this step.
- This step changes the
- Architecture doc: mark Step 3 complete + refresh module-tree entries (
docs:).- Update the three module-tree entries; append
✅+Landed:to the Step 3 heading;✅theS3Mermaid node. - Commit:
docs(pi-permission-system): mark Phase 12 Step 3 complete (#597). - Note: a
docs:commit ishidden; it does not itself cut a release — the Step-2feat:above is what cuts the batch release.
- Update the three module-tree entries; append
Full-suite + root lint + fallow dead-code run after Step 2 and again before push.
Risks and Mitigations
- Risk: the atomic Step 2 rewrite drops a test assertion silently.
Mitigation: the composition-root round-trip tests are untouched and exercise the real serving path end-to-end, catching a resolution-branch regression
tscwould miss; run the full suite after Step 2. - Risk: agent-scoped serving loosens a decision unexpectedly.
Mitigation: it changes an outcome only when the parent holds per-agent rules for the requesting agent (ADR 0008 §3, verified against the manager's
agentNamecomposition); identical configs still prompt, pinned by the round-trip anchor. - Risk: widening
PermissionResolver.resolveleakspath-valuesinto gate call sites. Mitigation: the gate-facingScopedPermissionResolverinterface stays narrow; only the concrete class accepts the wider union, and only the serving closure (holding the concrete type) passespath-values. - Risk:
boundaryValueis silently needed forexternal_directorymatching. Mitigation:matchValues()already includes the canonical alias;evaluateAnyValuematches the parent's rules against it. A newinput-normalizertest asserts the multi-aliasvaluesarray is preserved. - Risk: a stale doc claims serving re-derives from display strings. Mitigation: the plan enumerates the three module-tree entries plus the roadmap step-mark; the pre-completion reviewer checks doc staleness.
Open Questions
None outstanding.
Two design decisions were resolved at plan time via the operator ask_user gate, because the issue body and the accepted ADR 0008 conflicted:
- Legacy
(surface, value)fallback — the issue body said "keep it for version skew"; ADR 0008 §4 retired it with anaskfloor. Resolved: follow ADR 0008 — retire the legacy branch; a request missing the intent floors toask. - Commit type / breaking classification — agent-scoped serving can change a decision on upgrade without a config edit.
Resolved: ship as
feat:(non-breaking), per the #557 serving-is-resolution precedent that shipped the analogous serving behavior change asfeat:.
No follow-up issues filed — Track B (#598–#600) already exists, and ADR 0008 records the two deferred edges.