import { join } from "node:path"; import { type ForwarderContext, getSessionId, } from "#src/authority/forwarder-context"; import type { PermissionPromptDecision } from "#src/authority/permission-dialog"; import { type ForwardedAccessIntent, type ForwardedPermissionRequest, type ForwardedPermissionResponse, isForwardedPermissionRequestForSession, type PermissionForwardingLocation, } from "#src/authority/permission-forwarding"; import type { SubagentSessionRegistry } from "#src/authority/subagent-registry"; import { SessionApproval } from "#src/session-approval"; import type { SessionApprovalRecorder } from "#src/session-approval-recorder"; import type { DebugReviewLogger } from "#src/session-logger"; import type { PermissionCheckResult } from "#src/types"; import type { AskEscalator } from "./authorizer-selection"; import { cleanupPermissionForwardingLocationIfEmpty, ensureDirectoryExists, getExistingPermissionForwardingLocation, listRequestFiles, logPermissionForwardingError, logPermissionForwardingWarning, readForwardedPermissionRequest, safeDeleteFile, writeJsonFileAtomic, } from "./forwarding-io"; import type { PromptPermissionDetails } from "./permission-prompter"; /** * Narrow seam describing what `ForwardingManager` needs from the server: a * single method that drains this session's forwarded-permission inbox. * * Depending on the interface (not the concrete `ForwardedRequestServer`) * keeps the manager's unit tests free of casts — they inject a plain * `{ processInbox: vi.fn() }` mock. */ export interface InboxProcessor { processInbox(ctx: ForwarderContext): Promise; } /** * Recorded-authority view the serving node resolves a forwarded request * against: answer one {@link ForwardedAccessIntent} query on the serving * session's composed ruleset, agent-scoped to the requester * (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are * used as-is, never re-derived through this session's `PathNormalizer`/cwd. * * Narrow by design (ISP): the server needs one decision, not the whole * resolver. The composition root satisfies it with * `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same * `resolve` entry point `LocalPermissionsService` composes. */ export interface ServingPolicy { resolve(intent: ForwardedAccessIntent): PermissionCheckResult; } /** Constructor config for `ForwardedRequestServer`. */ export interface ForwardedRequestServerDeps { forwardingDir: string; logger: DebugReviewLogger; /** Recorded-authority resolution for a forwarded `ForwardedAccessIntent`. */ policy: ServingPolicy; /** Escalation seam to the serving session's selected `Authorizer` on `ask`. */ escalator: AskEscalator; /** * The serving session's `SessionRules`. Records a whole-session grant when a * human approves a forwarded request for the entire serving session. */ recorder: SessionApprovalRecorder; /** In-process subagent registry, read only by the one-hop canary. */ registry?: SubagentSessionRegistry; } // ── Module-private helpers ──────────────────────────────────────────────── function formatForwardedPermissionPrompt( request: ForwardedPermissionRequest, ): string { const agentName = request.requesterAgentName || "unknown"; const sessionId = request.requesterSessionId || "unknown"; return [ `Subagent '${agentName}' requested permission.`, `Session ID: ${sessionId}`, "", request.message, ].join("\n"); } /** * Map a forwarded request onto the escalated ask's details, carrying the * forwarded provenance (requester agent/session + the child's original display * projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292). */ function buildForwardedAskDetails( request: ForwardedPermissionRequest, ): PromptPermissionDetails { return { requestId: request.id, source: request.source ?? "tool_call", agentName: request.requesterAgentName || null, message: formatForwardedPermissionPrompt(request), surface: request.surface ?? null, value: request.value ?? null, forwarding: { requesterAgentName: request.requesterAgentName || null, requesterSessionId: request.requesterSessionId || null, }, // Carries the child's suggestion so LocalUserAuthorizer can offer the // whole-session grant scope; absent for a legacy/version-skew request. ...(request.sessionApproval ? { sessionApproval: request.sessionApproval } : {}), }; } // ── ForwardedRequestServer ──────────────────────────────────────────────── /** * Owner of the serving-down role of the forwarded-permission behavior: * draining this session's forwarded-permission inbox and answering each * request the same way the session resolves a local action — resolving its * `ForwardedAccessIntent` against recorded authority (`ServingPolicy`), then * escalation to its selected `Authorizer` (`AskEscalator`) on `ask` (ADR * 0008). */ export class ForwardedRequestServer implements InboxProcessor { private readonly forwardingDir: string; private readonly logger: DebugReviewLogger; private readonly policy: ServingPolicy; private readonly escalator: AskEscalator; private readonly recorder: SessionApprovalRecorder; private readonly registry: SubagentSessionRegistry | undefined; constructor(deps: ForwardedRequestServerDeps) { this.forwardingDir = deps.forwardingDir; this.logger = deps.logger; this.policy = deps.policy; this.escalator = deps.escalator; this.recorder = deps.recorder; this.registry = deps.registry; } /** Drain and respond to this session's forwarded-permission inbox. */ async processInbox(ctx: ForwarderContext): Promise { const currentSessionId = getSessionId(ctx); const location = getExistingPermissionForwardingLocation( this.forwardingDir, currentSessionId, ); if (!location) { return; } const requestFiles = listRequestFiles(this.logger, location.requestsDir); if (requestFiles.length === 0) { return; } // Defensively recreate responses/ before writing any response — a // concurrent cleanup pass may have removed it between the requestsDir // existence check above and the write inside processSingleForwardedRequest // (the ENOENT write loop reported in issue #398). if ( !ensureDirectoryExists( this.logger, location.responsesDir, "permission forwarding responses", ) ) { return; } for (const fileName of requestFiles) { const requestPath = join(location.requestsDir, fileName); const request = readForwardedPermissionRequest(this.logger, requestPath); if (!request) { safeDeleteFile( this.logger, requestPath, `${location.label} forwarded permission request`, ); continue; } await this.processSingleForwardedRequest( request, location, requestPath, currentSessionId, ); } cleanupPermissionForwardingLocationIfEmpty(this.logger, location); } // ── Private methods ──────────────────────────────────────────────────── private async processSingleForwardedRequest( request: ForwardedPermissionRequest, location: PermissionForwardingLocation, requestPath: string, currentSessionId: string, ): Promise { if (!isForwardedPermissionRequestForSession(request, currentSessionId)) { logPermissionForwardingWarning( this.logger, `Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`, ); safeDeleteFile( this.logger, requestPath, `${location.label} forwarded permission request`, ); return; } this.warnOnMultiHop(request, currentSessionId); const forwardedPermissionLogDetails = { requestId: request.id, source: location.label, requesterAgentName: request.requesterAgentName, requesterSessionId: request.requesterSessionId, targetSessionId: request.targetSessionId, requestPath, }; const decision = await this.resolveDecision( request, forwardedPermissionLogDetails, ); this.recordForwardedDecision( request, location, requestPath, currentSessionId, this.applyGrantScope(request, decision, forwardedPermissionLogDetails), ); } /** * Apply the human's grant-scope choice on a forwarded approval. * * A whole-session grant (`approved_for_serving_session`) records the child's * suggested pattern into this serving node's `SessionRules` — the single * source of truth for the scope — and is then translated to a plain * `approved` so the child records nothing (its next identical action * re-forwards and resolves as recorded authority). Every other decision * passes through unchanged (`approved_for_session` → the child records). */ private applyGrantScope( request: ForwardedPermissionRequest, decision: PermissionPromptDecision, logDetails: Record, ): 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", { ...logDetails, surface: request.sessionApproval.surface, patterns: request.sessionApproval.patterns, }); } return { approved: true, state: "approved" }; } /** * Persist the served decision: write the response file the child polls for, * log the outcome, and delete the drained request. The symmetric "respond" * half to {@link resolveDecision}'s "decide" half. */ private recordForwardedDecision( request: ForwardedPermissionRequest, location: PermissionForwardingLocation, requestPath: string, currentSessionId: string, decision: PermissionPromptDecision, ): void { const responsePath = join(location.responsesDir, `${request.id}.json`); this.logger.review( decision.approved ? "forwarded_permission.approved" : "forwarded_permission.denied", { requestId: request.id, source: location.label, requesterAgentName: request.requesterAgentName, requesterSessionId: request.requesterSessionId, targetSessionId: request.targetSessionId, responsePath, resolution: decision.state, denialReason: decision.denialReason ?? null, }, ); try { writeJsonFileAtomic(this.logger, responsePath, { approved: decision.approved, state: decision.state, denialReason: decision.denialReason, responderSessionId: currentSessionId, respondedAt: Date.now(), } satisfies ForwardedPermissionResponse); } catch (error) { logPermissionForwardingError( this.logger, `Failed to write ${location.label} forwarded permission response '${responsePath}'`, error, ); return; } safeDeleteFile( this.logger, requestPath, `${location.label} forwarded permission request`, ); } /** * Resolve the request the same way the session resolves a local action: * recorded authority first (a request carrying an `accessIntent` — the * child-fixed facts, ADR 0008 §2 — resolves against the serving node's * composed ruleset — `allow`, including yolo-rewritten, auto-approves; * `deny` auto-denies), then escalate `ask` (or a request missing * `accessIntent`, the version-skew floor, ADR 0008 §4) to the selected * `Authorizer`. */ private async resolveDecision( request: ForwardedPermissionRequest, logDetails: Record, ): Promise { const state = request.accessIntent ? this.policy.resolve(request.accessIntent).state : "ask"; if (state === "allow") { this.logger.review("forwarded_permission.auto_approved", logDetails); return { approved: true, state: "approved" }; } if (state === "deny") { this.logger.review("forwarded_permission.auto_denied", logDetails); return { approved: false, state: "denied" }; } this.logger.review("forwarded_permission.prompted", logDetails); try { return await this.escalator.escalate(buildForwardedAskDetails(request)); } catch (error) { logPermissionForwardingError( this.logger, `Failed to escalate forwarded permission request '${request.id}'`, error, ); return { approved: false, state: "denied" }; } } /** * One-hop canary: forwarding is depth-1 (child → root). If the requester is * itself a registered subagent whose parent is not this serving session, the * request came through more than one hop (or was misrouted) — resolution is * still well-defined, so keep serving, but warn loudly so a future * recursion-guard break is visible rather than silent. Unregistered * (external file-based) requesters have no recorded parent and are silent. */ private warnOnMultiHop( request: ForwardedPermissionRequest, currentSessionId: string, ): void { const requesterInfo = this.registry?.get(request.requesterSessionId); if ( requesterInfo?.parentSessionId && requesterInfo.parentSessionId !== currentSessionId ) { logPermissionForwardingWarning( this.logger, `Forwarded permission request '${request.id}' violates the one-hop ` + `invariant: requester '${request.requesterSessionId}' is a registered ` + `subagent whose parent '${requesterInfo.parentSessionId}' is not this ` + `serving session '${currentSessionId}' (multi-hop or misrouted).`, ); } } }