Files
pi-extensions/docs/research/ai-bash-context-ownership.md
SikongJueluo 1afcbd3118 refactor(permission): consume structured bash payload
- require @gotgenes/pi-permission-system >=25.3.0 and read the complete local bash command from PromptPermissionDetails.payload instead of session-walking recovery
- remove the @sikongjueluo/pi-permission-shared package
- pass the triggering command unit to handlers via HandlerContext.unit in place of details.command
- add shadow-only AI judge modules for evidence projection, structured verdict requests, and prompt building, with vitest coverage
- record ADR 0004 and mark the ADR 0001 recovery mechanism superseded
- exclude pi-permission-system 25.3.0 from the pnpm minimumReleaseAge guard
2026-08-16 23:08:45 +08:00

12 KiB

Research: context ownership for forwarded and subagent Bash asks

2026-08-16 update: This report characterizes permission-system 24.0.0. Version 25.3 added a required structured prompt payload. Local Bash asks now expose the complete input as full command evidence when it differs from the triggering unit; forwarded 25.3/25.4 asks still carry only legacy request prose. See ADR 0004 for the current command-input contract. The root/child ownership findings below remain applicable.

Scope

This report describes the installed runtime inspected on 2026-08-08:

  • @gotgenes/pi-permission-system 24.0.0
  • pi-subagents 0.43.0
  • the current packages/pi-permission-ai-judge/src/index.ts

Only first-party source, package documentation, and Pi API declarations are used. Product implementation remains out of scope.

Executive answer

An external permission-system Authorizer callback receives only (details, query, log). It receives no ask-time ExtensionContext. Any conversation it reads therefore belongs to the extension lifecycle context captured when that callback was registered.

For a direct primary-session Bash ask, the root permission-system composes its configured authorizer links before LocalUserAuthorizer. A Judge registered by the UI-present root may safely identify the captured sessionManager as the primary conversation.

A child ask has two distinct possible authorizer-chain phases:

  1. The child's own permission session composes child-local links before its terminal ParentAuthorizer.
  2. If the child reaches ParentAuthorizer, the request is written to the serving session. The serving ForwardedRequestServer resolves serving policy and, when the result is still ask, composes the serving session's links before its terminal authorizer.

A future Judge must therefore bind only from the UI-present root/service-owning lifecycle context. Otherwise a separately registered child link could judge with child context, or a registered in-process child could capture child context while accidentally registering its callback into the parent's process-global service.

For a forwarded ask handled by the root link, the captured conversation is the serving root's conversation, not the requester's child transcript. Link-visible child provenance is details.forwarding; there is no cross-session API for reading the child's conversation.

Proven call flows

Direct primary Bash ask

  1. The primary tool call reaches the primary permission gate.
  2. Deterministic policy returns ask.
  3. AuthorizerSelection.escalate() resolves configured links and composes them before the selected terminal.
  4. The root Judge link may return allow, deny, or defer.
  5. defer continues to LocalUserAuthorizer; it is not a denial.

The Judge callback may use a root ExtensionContext captured at session_start, but that context is closure state rather than an argument to authorize().

Child ask forwarded to the root

  1. The child's deterministic policy returns ask.
  2. The child's AuthorizerSelection first runs links registered in that child session, if any.
  3. If all links defer or no link exists, the child terminal ParentAuthorizer resolves a target session, writes a ForwardedPermissionRequest, and polls for a response.
  4. The UI-present root's ForwardingManager drains the target session inbox.
  5. ForwardedRequestServer resolves the child-fixed access intent against serving policy.
  6. A serving-policy allow or deny completes without the root chain. A serving-policy ask is projected into PromptPermissionDetails and passed to the root AuthorizerSelection.
  7. Root links run before LocalUserAuthorizer. A root Judge therefore sees root-captured conversation plus forwarded provenance, not child conversation.
  8. The root writes the decision; the child receives it and resumes or blocks.

ParentAuthorizer always uses filesystem request/response transport, including registered in-process children. The registry resolves identity and parentage; it is not an in-memory response fast path.

Nested children

Depth alone does not determine the result. A request targeting a headless immediate parent will normally remain unserved and time out. A request routed directly to the UI-present root can still be served. The server warns on a detected multi-hop mismatch but deliberately continues processing a correctly targeted request.

Evidence Direct primary ask Parent-side forwarded ask
details.forwarding absent requester agent/session, possibly nullable for version-skew data
details.agentName active primary agent requesting child agent
details.surface / details.value usually derived fields are absent on the internal details object child's explicit display projection
details.accessIntent gate surface, match values, boundary value same child-fixed facts projected by the server
details.command winning Bash command unit absent
details.message formatted prompt; includes full Bash input text when it differs from the winning unit same child-formatted prompt, prefixed with child identity by the server
raw tool input unavailable unavailable
wire principal / requesterCwd unavailable deliberately withheld from chain links
ask-time ExtensionContext unavailable unavailable

For a Bash ask, details.accessIntent.matchValues[0] and direct details.command identify the permission-system command unit that caused ask. The full original Bash input is not preserved as a structured authorizer field; when it differs, it survives only inside the formatted details.message text. The later input-contract decision must not pretend that details.command is the full raw Bash invocation or silently parse an authorization-critical command from prose.

Registration and context-ownership hazards

The permissions service is stored under Symbol.for(...) on globalThis and registration stores an authorizer callback in the service owner's AuthorizerRegistry.

  • A normal UI root publishes its service.
  • A registered in-process child does not publish its own service, but still emits the ready event.
  • Consequently, a consumer extension loaded in that child can resolve the parent's process-global service. If it registers, its child-captured closure is stored in the parent's registry. If the root already registered the same name, duplicate registration throws.
  • A separate child process has separate globalThis state and may have its own service lifecycle; registering there would create a child-local link with child-captured context.

Therefore successful getPermissionsService() lookup does not prove that the current extension instance owns that service's session context.

Safe v0.1 ownership contract

  1. Register the AI Judge only from a lifecycle context proven to be the UI-present, non-subagent root. Do not register from headless or subagent lifecycle contexts.
  2. Snapshot and retain that root context/session identity as registration provenance. The callback must not infer ownership merely from the process-global service lookup.
  3. For details.forwarding absent, treat the captured root conversation as the requester's current conversation.
  4. For details.forwarding present, label the origin as forwarded and treat the captured conversation only as serving-root user-intent context. Never describe it as the child's conversation and never infer child-local dialogue that is not present.
  5. Preserve details.forwarding.requesterSessionId and requesterAgentName as provenance. If forwarded provenance is missing, blank, or unknown, the Judge must return { kind: "defer" }.
  6. If registration provenance is not demonstrably the UI root, the captured session changed unexpectedly, or context ownership otherwise cannot be established, return { kind: "defer" }.
  7. Judge defer means continue through the authorizer chain to human or terminal authority. The Judge must not synthesize confirmationUnavailable; only terminal authority owns unavailable-authority denial.
  8. Ensure a logical forwarded request produces at most one Judge prediction. Root-only registration is the v0.1 mechanism that avoids child-then-parent duplicate judgments.

Whether serving-root conversation is sufficient evidence to grant a later Enforce-mode allow for a forwarded child request remains a product decision for Define the exact authorization input and conversation contract. The facts established here are that root user intent is available, child conversation is not, and the two must not be conflated.

Implications for the later input-contract ticket

  • Add an explicit origin such as local or forwarded_subagent.
  • Record the conversation owner separately from requester identity.
  • For forwarded asks, carry only the link-visible projection: details.forwarding, details.surface/value, details.accessIntent, and the formatted message. Do not claim access to the raw ForwardedPermissionRequest, principal, or requesterCwd.
  • Define structured handling for both the triggering Bash command unit and the unavailable full raw Bash invocation. If v0.1 requires the full invocation as structured data, permission-system must expose it; prose parsing is not an equivalent contract.
  • Keep the user-intent priority explicit: root user messages are authoritative; child agent identity and the command are evidence, not instructions.

Evidence index

Claim Primary source
Link callback receives only details, query, and log @gotgenes/pi-permission-system/src/authority/authorizer.ts, Authorizer.authorize
Links compose before each session's terminal src/authority/authorizer-selection.ts, AuthorizerSelection.escalate; src/authority/authorizer-chain.ts, composeAuthorizerChain
defer continues; deny is decisive src/authority/authorizer-chain.ts, decideFromVerdict
Child terminal writes and polls forwarded request src/authority/approval-escalator.ts, ParentAuthorizer.waitForForwardedApproval and pollForForwardedResponse
Root policy resolves before root escalation src/authority/forwarded-request-server.ts, resolveDecision
Forwarded details projection and disclosure boundary src/authority/forwarded-request-server.ts, buildForwardedAskDetails and toAccessFacts
Link-visible details fields src/authority/permission-prompter.ts, PromptPermissionDetails
Full Bash command appears only in formatted message when distinct src/permission-prompts.ts, formatAskPrompt; src/handlers/gates/tool.ts, describeToolGate
UI display derives from winning details.command src/permission-ui-prompt.ts, buildUiPrompt and directValue
Service is process-global src/service.ts, SERVICE_KEY, publishPermissionsService, getPermissionsService
Registered in-process child skips publication but emits ready src/service-lifecycle.ts, PermissionServiceLifecycle.activate
Duplicate authorizer names throw src/authority/authorizer-registry.ts, AuthorizerRegistry.register
Only UI non-subagent sessions poll forwarded inboxes src/authority/forwarding-manager.ts, ForwardingManager.start
Registry-first, environment-second target resolution src/authority/permission-forwarding.ts, resolvePermissionForwardingTargetSessionId
Multi-hop mismatch warns but continues src/authority/forwarded-request-server.ts, warnOnMultiHop
Current Judge captures no context packages/pi-permission-ai-judge/src/index.ts, current session_start handler and registered callback

Residual verification gap

The source establishes the registration hazard, but no runtime probe was run to observe extension lifecycle ordering when the future AI Judge is loaded into a registered in-process child. Before implementation acceptance, run one focused probe that logs, without conversation content:

  • registering extension session ID and hasUI;
  • service-owning session ID if exposed;
  • callback invocation origin (local/forwarded);
  • forwarded requester session ID.

The probe should confirm that the root-only guard prevents child registration and that one forwarded Bash ask creates exactly one Judge prediction at the serving root.