mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
import { join } from "node:path";
|
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
import {
|
|
type ForwarderContext,
|
|
getSessionId,
|
|
} from "#src/authority/forwarder-context";
|
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
import {
|
|
type ForwardedAccessFacts,
|
|
type ForwardedAccessIntent,
|
|
type ForwardedPermissionRequest,
|
|
type ForwardedPermissionResponse,
|
|
isForwardedPermissionRequestForSession,
|
|
type PermissionForwardingLocation,
|
|
} from "#src/authority/permission-forwarding";
|
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
|
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,
|
|
formatUnknownErrorMessage,
|
|
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<void>;
|
|
}
|
|
|
|
/**
|
|
* 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 ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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),
|
|
* plus the child-fixed access facts so the serving node's `Authorizer` chain
|
|
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
|
|
*
|
|
* The display `surface` and the fact `surface` are distinct and both belong
|
|
* here: the former is the child's tool name (what the UI shows), the latter the
|
|
* gate surface the rule fired on (what the bounded-delegation checkpoint
|
|
* excludes on).
|
|
*/
|
|
function buildForwardedAskDetails(
|
|
request: ForwardedPermissionRequest,
|
|
): PromptPermissionDetails {
|
|
const payload = buildForwardedAskPayload(request);
|
|
return {
|
|
requestId: request.id,
|
|
source: request.source ?? "tool_call",
|
|
agentName: request.requesterAgentName || null,
|
|
payload,
|
|
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 }
|
|
: {}),
|
|
// Absent for a version-skew request that carried no intent — which the
|
|
// delegation envelope reads as "surface undetermined" and fail-safes to
|
|
// excluded, so absence must stay absence rather than become `undefined`.
|
|
...(request.accessIntent
|
|
? { accessIntent: toAccessFacts(request.accessIntent) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Project the wire intent down to the child-fixed access facts an `Authorizer`
|
|
* may see.
|
|
*
|
|
* Field-by-field rather than a spread, because this is a disclosure boundary:
|
|
* `requesterCwd` and `principal` are requester identity for the serving node's
|
|
* own resolution (ADR 0008 §3) and stay off the ask details. A link that needs
|
|
* requester identity reads `details.forwarding`. `ForwardedAccessIntent`
|
|
* extends `ForwardedAccessFacts`, so a spread would type-check while widening
|
|
* disclosure at runtime; the explicit return type makes any future field on
|
|
* `ForwardedAccessFacts` a compile error here until it is deliberately
|
|
* projected or deliberately withheld.
|
|
*/
|
|
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
|
return {
|
|
surface: intent.surface,
|
|
matchValues: intent.matchValues,
|
|
boundaryValue: intent.boundaryValue,
|
|
};
|
|
}
|
|
|
|
// ── 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<void> {
|
|
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<void> {
|
|
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).
|
|
*
|
|
* The translation rewrites the grant's *scope*, never its decider: the human
|
|
* who chose the wider scope is still the one who decided (#726).
|
|
*/
|
|
private applyGrantScope(
|
|
request: ForwardedPermissionRequest,
|
|
decision: PermissionPromptDecision,
|
|
logDetails: Record<string, unknown>,
|
|
): 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",
|
|
decidedBy: decision.decidedBy,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
decidedBy: decision.decidedBy,
|
|
},
|
|
);
|
|
try {
|
|
writeJsonFileAtomic(this.logger, responsePath, {
|
|
approved: decision.approved,
|
|
state: decision.state,
|
|
denialReason: decision.denialReason,
|
|
responderSessionId: currentSessionId,
|
|
respondedAt: Date.now(),
|
|
// Carried onto the wire so the requester can name what decided inside
|
|
// this session, not merely that this session answered (#726).
|
|
decidedBy: decision.decidedBy,
|
|
} 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<string, unknown>,
|
|
): Promise<PermissionPromptDecision> {
|
|
const check = request.accessIntent
|
|
? this.policy.resolve(request.accessIntent)
|
|
: null;
|
|
|
|
if (check && check.state !== "ask") {
|
|
// The rule is carried in full rather than left to the event name: the
|
|
// response file has no surface, pattern, or origin column for the
|
|
// requester's record to lean on.
|
|
const decidedBy: DecisionSource = {
|
|
kind: "rule",
|
|
surface: request.accessIntent?.surface ?? check.toolName,
|
|
pattern: check.matchedPattern ?? null,
|
|
origin: check.origin,
|
|
};
|
|
const approved = check.state === "allow";
|
|
this.logger.review(
|
|
approved
|
|
? "forwarded_permission.auto_approved"
|
|
: "forwarded_permission.auto_denied",
|
|
{ ...logDetails, decidedBy },
|
|
);
|
|
return approved
|
|
? { approved: true, state: "approved", decidedBy }
|
|
: { approved: false, state: "denied", decidedBy };
|
|
}
|
|
|
|
this.logger.review("forwarded_permission.prompted", logDetails);
|
|
try {
|
|
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
|
} catch (error) {
|
|
const reason = formatUnknownErrorMessage(error);
|
|
logPermissionForwardingError(
|
|
this.logger,
|
|
`Failed to escalate forwarded permission request '${request.id}'`,
|
|
error,
|
|
);
|
|
// Nobody denied this; the escalation broke and the node failed closed.
|
|
return {
|
|
approved: false,
|
|
state: "denied",
|
|
decidedBy: { kind: "gate_error", reason },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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).`,
|
|
);
|
|
}
|
|
}
|
|
}
|