Files
my-pi/pi-permission-system/src/authority/permission-forwarding.ts
T

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { join } from "node:path";
import type { DecisionSource } from "#src/authority/decision-source";
import type { PermissionUiPromptSource } from "#src/permission-events";
import type { PromptPayload } from "#src/presentation/prompt-payload";
import type { PermissionDecisionState } from "./permission-dialog";
import type { SubagentSessionRegistry } from "./subagent-registry";
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
/**
* How long an in-process forwarding target may go unserved before the child
* gives up on it — eight poll ticks.
*
* A window rather than a single check because `ForwardingManager` withdraws and
* re-announces across a session switch, and a request that arrives inside that
* gap is about to be picked up. Not configurable: the operator-facing knob is
* the overall timeout, and this only decides how fast a hopeless wait ends.
*/
export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
export const SUBAGENT_ENV_HINT_KEYS = [
// pi-agent-router (original)
"PI_IS_SUBAGENT",
"PI_SUBAGENT_SESSION_ID",
"PI_AGENT_ROUTER_SUBAGENT",
// nicobailon/pi-subagents
"PI_SUBAGENT_CHILD",
"PI_SUBAGENT_RUN_ID",
"PI_SUBAGENT_CHILD_AGENT",
"PI_SUBAGENT_DEPTH",
// HazAT/pi-interactive-subagents
"PI_SUBAGENT_NAME",
"PI_SUBAGENT_ID",
"PI_SUBAGENT_SESSION",
"PI_SUBAGENT_ACTIVITY_FILE",
] as const;
/** Ordered list of env var names to check for the parent session ID. First match wins. */
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
// pi-agent-router (original)
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
// Shared convention for CLI-based subagent extensions
// (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
"PI_SUBAGENT_PARENT_SESSION",
] as const;
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
/**
* Display fields relayed from a forwarding child to the parent UI so the parent
* can emit a non-degraded `permissions:ui_prompt` event.
*
* Carried separately from the prompt payload because the parent reconstructs
* the original event from the escalated ask's details (`buildUiPrompt`), not
* from the payload's own facts.
*/
export interface ForwardedPromptDisplay {
source: PermissionUiPromptSource;
surface: string | null;
value: string | null;
}
/**
* The child's session-approval suggestion, relayed to the serving node so a
* human who grants "the whole session" records the same pattern the child
* would have recorded locally.
*
* A plain data shape (not the `SessionApproval` value object) so it serializes
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
* from it via `SessionApproval.multiple`.
*/
export interface ForwardedSessionApproval {
surface: string;
patterns: readonly string[];
}
/**
* The child-fixed facts a gate emits: the surface it evaluated and the match
* set it computed. `requesterCwd` and `principal` are stamped at the escalation
* edge (`ParentAuthorizer`), so a gate carries only what it alone can produce.
*
* Strings only — an `AccessPath` never crosses onto the wire
* (`docs/decisions/0002-path-values-string-boundary.md`).
*/
export interface ForwardedAccessFacts {
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */
surface: string;
/**
* The child-fixed match set. Path surface: `AccessPath.matchValues()`
* (absolute cwd-relative canonical), computed at the child. Non-path
* surface: the already-portable single value as a one-element array.
*/
matchValues: string[];
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
boundaryValue: string | null;
}
/**
* The forwarded-wire access intent (ADR 0008 §2): the child-fixed access facts
* plus the requester identity the escalation edge stamps.
*
* The serving node resolves against this intent directly (Step 3, [#597]),
* using `matchValues` as-is — it never re-derives a path through its own
* `PathNormalizer`/cwd. See
* `docs/decisions/0008-cross-session-access-intent.md`.
*/
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
/** The requester's cwd, for provenance/disclosure — never for parent re-derivation. */
requesterCwd: string;
/** Who is requesting. */
principal: {
sessionId: string;
agentName: string;
};
}
export type ForwardedPermissionRequest = {
id: string;
createdAt: number;
requesterSessionId: string;
targetSessionId: string;
requesterAgentName: string;
/**
* The child's complete prompt payload (ADR 0011 §2), so the serving node
* renders the child's own facts under the *parent's* budget rather than
* relaying a sentence the child assembled under its own configuration.
*
* Optional for version-skew tolerance: an older child omits it, and the
* serving node renders from the display fields it does carry (ADR 0011 §9).
*/
payload?: PromptPayload;
/**
* Original prompt display fields, persisted so the parent emits a
* non-degraded event. Optional for version-skew tolerance: a parent on a
* newer version may read a request written by an older child during an
* upgrade, in which case the reader defaults `source` to `"tool_call"`.
*/
source?: PermissionUiPromptSource;
surface?: string | null;
value?: string | null;
/**
* The child's session-approval suggestion. Present when the child computed a
* "for this session" pattern for the ask; lets the serving node record a
* whole-session grant. Optional for version-skew tolerance (an older child
* omits it, and the serving dialog then offers no scope choice).
*/
sessionApproval?: ForwardedSessionApproval;
/**
* 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;
};
export type ForwardedPermissionResponse = {
approved: boolean;
state: PermissionDecisionState;
denialReason?: string;
responderSessionId: string;
respondedAt: number;
/**
* What decided, inside the responding session (#726).
*
* `responderSessionId` names *where* the decision was made; this names
* *what* made it, which is the difference between a human at the parent's
* dialog and the parent's policy answering on their behalf.
*
* Optional for version-skew tolerance: an older responder omits it, and the
* requester records the hop with a `null` inner decision rather than
* rejecting the answer.
*/
decidedBy?: DecisionSource;
};
export type PermissionForwardingLocation = {
sessionId: string;
sessionRootDir: string;
requestsDir: string;
responsesDir: string;
label: "primary";
};
export function normalizePermissionForwardingSessionId(
value: unknown,
): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed || trimmed.toLowerCase() === "unknown") {
return null;
}
return trimmed;
}
/**
* Make a session id safe to name a path segment.
*
* Exported because the forwarding tree has two layouts keyed by session id —
* `sessions/<id>/` and the serving-heartbeat records beside it — and a second
* encoding would be a silent way for the two to disagree about which file
* belongs to which session.
*/
export function encodeSessionIdForPath(sessionId: string): string {
return encodeURIComponent(sessionId);
}
export function createPermissionForwardingLocation(
forwardingRootDir: string,
sessionId: string,
): PermissionForwardingLocation {
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
if (!normalizedSessionId) {
throw new Error(
"Permission forwarding session id must be a non-empty string.",
);
}
const sessionRootDir = join(
forwardingRootDir,
SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
encodeSessionIdForPath(normalizedSessionId),
);
return {
sessionId: normalizedSessionId,
sessionRootDir,
requestsDir: join(
sessionRootDir,
SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME,
),
responsesDir: join(
sessionRootDir,
SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME,
),
label: "primary",
};
}
/**
* How a forwarding target was resolved.
*
* `"registry"` is the load-bearing value: it means the requester is an
* **in-process** child of `sessionId`, so the two share a `globalThis` and the
* requester may consult the serving-session registry to decide whether anyone
* is draining its inbox. `"env"` means the target lives in another process,
* where that signal is unavailable; `"self"` is the UI host owning its own
* forwarding location.
*/
export type PermissionForwardingTargetSource = "self" | "registry" | "env";
/** The resolved forwarding target together with how it was found. */
export interface PermissionForwardingTarget {
sessionId: string;
source: PermissionForwardingTargetSource;
}
export function resolvePermissionForwardingTarget(options: {
hasUI: boolean;
isSubagent: boolean;
currentSessionId?: string | null;
env?: NodeJS.ProcessEnv;
/** Child session id for registry lookup. */
sessionId?: string;
/** In-process subagent session registry (checked before env vars). */
registry?: SubagentSessionRegistry;
}): PermissionForwardingTarget | null {
if (options.hasUI) {
const own = normalizePermissionForwardingSessionId(
options.currentSessionId,
);
return own === null ? null : { sessionId: own, source: "self" };
}
if (!options.isSubagent) {
return null;
}
// 1. Registry — in-process subagents register parentSessionId explicitly.
if (options.registry && options.sessionId) {
const entry = options.registry.get(options.sessionId);
const resolved = normalizePermissionForwardingSessionId(
entry?.parentSessionId,
);
if (resolved) return { sessionId: resolved, source: "registry" };
}
// 2. Env vars — process-based subagent extensions.
const env = options.env ?? process.env;
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
const resolved = normalizePermissionForwardingSessionId(env[key]);
if (resolved) return { sessionId: resolved, source: "env" };
}
return null;
}
export function isForwardedPermissionRequestForSession(
request: Pick<ForwardedPermissionRequest, "targetSessionId">,
sessionId: string | null | undefined,
): boolean {
const normalizedRequestSessionId = normalizePermissionForwardingSessionId(
request.targetSessionId,
);
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
return (
normalizedRequestSessionId !== null &&
normalizedRequestSessionId === normalizedSessionId
);
}