Files
my-pi/pi-permission-system/test/authority/forwarded-request-server.test.ts

1023 lines
32 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { Authorizer } from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
import { ForwardedRequestServer } from "#src/authority/forwarded-request-server";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type {
ForwardedPermissionRequest,
ForwardedPermissionResponse,
} from "#src/authority/permission-forwarding";
import {
PermissionPrompter,
type PromptPermissionDetails,
} from "#src/authority/permission-prompter";
import type { PermissionQuery } from "#src/service";
import {
makeAuthorizerSelectionDeps,
registerLink,
} from "#test/helpers/authorizer-fixtures";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import {
createForwardingTempDir,
type ForwardingTempDir,
makeForwardedAccessIntent,
makeForwarderContext,
makeServerDeps,
makeSubagentRegistry,
} from "#test/helpers/forwarding-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
let temp: ForwardingTempDir | undefined;
afterEach(() => {
temp?.cleanup();
temp = undefined;
vi.unstubAllEnvs();
});
function readResponse(
dir: ForwardingTempDir,
requestId: string,
): ForwardedPermissionResponse {
const raw = readFileSync(
join(dir.location.responsesDir, `${requestId}.json`),
"utf-8",
);
return JSON.parse(raw) as ForwardedPermissionResponse;
}
/**
* An approving `AskEscalator` that records the details it was handed.
*
* The reconstructed `PromptPermissionDetails` is itself the subject of the
* access-facts and bounded-delegation cases below: they assert its exact shape,
* and hand it to the real delegation envelope — a collaborator the server never
* touches, but one that reads the details the server builds.
*/
function makeCapturingEscalator() {
const escalated: PromptPermissionDetails[] = [];
return {
escalate: vi.fn((details: PromptPermissionDetails) => {
escalated.push(details);
return Promise.resolve<PermissionPromptDecision>({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
}),
/** The details of the most recent escalation. */
lastDetails(): PromptPermissionDetails {
const details = escalated.at(-1);
if (!details) {
throw new Error("no ask was escalated");
}
return details;
},
};
}
/** Drive one forwarded ask to escalation and return the details the server built. */
async function escalateForwardedAsk(
request: Partial<ForwardedPermissionRequest>,
): Promise<PromptPermissionDetails> {
temp = createForwardingTempDir("parent-session");
temp.writeRequest(request);
const escalator = makeCapturingEscalator();
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve: vi.fn(() => makeCheckResult({ state: "ask" })) },
escalator,
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
return escalator.lastDetails();
}
describe("processInbox — recorded-authority resolution", () => {
test("auto-approves and writes an approved response when the serving policy allows", async () => {
temp = createForwardingTempDir("parent-session");
const accessIntent = makeForwardedAccessIntent({
matchValues: ["git status"],
});
temp.writeRequest({
id: "req-allow",
source: "tool_call",
surface: "bash",
value: "git status",
accessIntent,
});
const resolve = vi.fn(() =>
makeCheckResult({
state: "allow",
matchedPattern: "git *",
origin: "global",
}),
);
const escalate = vi.fn();
const logger = { review: vi.fn(), debug: vi.fn() };
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
logger,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(resolve).toHaveBeenCalledWith(accessIntent);
expect(escalate).not.toHaveBeenCalled();
// The serving node's own rule decided, and the response names it: the
// child's log entry would otherwise say only that the parent approved.
expect(readResponse(temp, "req-allow")).toMatchObject({
approved: true,
state: "approved",
decidedBy: {
kind: "rule",
surface: "bash",
pattern: "git *",
origin: "global",
},
});
expect(logger.review).toHaveBeenCalledWith(
"forwarded_permission.auto_approved",
expect.objectContaining({
requestId: "req-allow",
decidedBy: expect.objectContaining({ kind: "rule" }),
}),
);
});
test("auto-denies and writes a denied response when the serving policy denies", async () => {
temp = createForwardingTempDir("parent-session");
const accessIntent = makeForwardedAccessIntent({
matchValues: ["rm -rf /"],
});
temp.writeRequest({
id: "req-deny",
source: "tool_call",
surface: "bash",
value: "rm -rf /",
accessIntent,
});
const resolve = vi.fn(() =>
makeCheckResult({
state: "deny",
matchedPattern: "rm *",
origin: "project",
}),
);
const escalate = vi.fn();
const logger = { review: vi.fn(), debug: vi.fn() };
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
logger,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(resolve).toHaveBeenCalledWith(accessIntent);
expect(escalate).not.toHaveBeenCalled();
expect(readResponse(temp, "req-deny")).toMatchObject({
approved: false,
state: "denied",
decidedBy: {
kind: "rule",
surface: "bash",
pattern: "rm *",
origin: "project",
},
});
expect(logger.review).toHaveBeenCalledWith(
"forwarded_permission.auto_denied",
expect.objectContaining({
requestId: "req-deny",
decidedBy: expect.objectContaining({ kind: "rule" }),
}),
);
});
test("relays the escalated decision's own decider onto the response", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({ id: "req-human", accessIntent: undefined });
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
escalator: {
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: { kind: "user", via: "dialog" },
}),
},
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
// This is the reported case: the child could not tell a human approval at
// the parent from the parent's policy auto-approving on its behalf.
expect(readResponse(temp, "req-human")).toMatchObject({
decidedBy: { kind: "user", via: "dialog" },
});
});
test("attributes a failed escalation to the error, not to a denial anyone made", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({ id: "req-boom", accessIntent: undefined });
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
escalator: {
escalate: vi.fn().mockRejectedValue(new Error("prompt exploded")),
},
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(readResponse(temp, "req-boom")).toMatchObject({
approved: false,
state: "denied",
decidedBy: { kind: "gate_error", reason: "prompt exploded" },
});
});
test("escalates an ask through the AskEscalator with the forwarded provenance details", async () => {
temp = createForwardingTempDir("parent-session");
const accessIntent = makeForwardedAccessIntent({
matchValues: ["git push"],
});
temp.writeRequest({
id: "req-ask",
source: "tool_call",
surface: "bash",
value: "git push",
accessIntent,
payload: makePromptPayload({
kind: "bash",
request: {
...makePromptPayload().request,
surface: "bash",
toolName: "bash",
value: "git push",
},
}),
});
const resolve = vi.fn(() => makeCheckResult({ state: "ask" }));
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(resolve).toHaveBeenCalledWith(accessIntent);
expect(escalate).toHaveBeenCalledWith({
requestId: "req-ask",
source: "tool_call",
agentName: "Explore",
surface: "bash",
value: "git push",
forwarding: {
requesterAgentName: "Explore",
requesterSessionId: "child-session",
},
accessIntent: {
surface: "bash",
matchValues: ["git push"],
boundaryValue: null,
},
payload: {
kind: "bash",
request: {
requester: {
agentName: "Explore",
forwarded: true,
sessionId: "child-session",
},
surface: "bash",
toolName: "bash",
invokedToolName: null,
value: "git push",
matchedPattern: null,
commandContext: null,
executedUnit: null,
},
evidence: [],
annotations: [],
},
});
expect(readResponse(temp, "req-ask")).toMatchObject({
approved: true,
state: "approved",
});
});
test("keeps requester cwd and principal out of the escalated payload (#635)", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-ask",
surface: "bash",
value: "git push",
accessIntent: makeForwardedAccessIntent({
requesterCwd: "/child/cwd",
principal: { sessionId: "child-session", agentName: "Explore" },
}),
});
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve: () => makeCheckResult({ state: "ask" }) },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
// The payload is a disclosure boundary too: requester identity reaches an
// Authorizer through `details.forwarding`, never smuggled in as evidence.
const serialized = JSON.stringify(escalate.mock.calls[0][0].payload);
expect(serialized).not.toContain("/child/cwd");
expect(serialized).not.toContain("principal");
});
test("floors a request with no fields at all (fully legacy) to escalation without consulting the policy", async () => {
temp = createForwardingTempDir("parent-session");
// Legacy / version-skew request: no source/surface/value/accessIntent.
temp.writeRequest({ id: "req-legacy" });
const resolve = vi.fn(() => makeCheckResult({ state: "allow" }));
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(resolve).not.toHaveBeenCalled();
expect(escalate).toHaveBeenCalledWith(
expect.objectContaining({
requestId: "req-legacy",
source: "tool_call",
surface: null,
value: null,
}),
);
});
test("floors a version-skew request with display fields but no accessIntent to escalation without consulting the policy", async () => {
temp = createForwardingTempDir("parent-session");
// An older child populated the display fields but never computed the
// structured intent (ADR 0008 §4: accessIntent is the sole resolution
// path — a request missing it floors to `ask`, never a silent grant).
temp.writeRequest({
id: "req-skew",
source: "tool_call",
surface: "bash",
value: "git push",
});
const resolve = vi.fn(() => makeCheckResult({ state: "allow" }));
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(resolve).not.toHaveBeenCalled();
expect(escalate).toHaveBeenCalledWith(
expect.objectContaining({
requestId: "req-skew",
surface: "bash",
value: "git push",
}),
);
});
test("denies when the escalator rejects", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-throw",
surface: "bash",
value: "git push",
accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }),
});
const resolve = vi.fn(() => makeCheckResult({ state: "ask" }));
const escalate = vi.fn().mockRejectedValue(new Error("ui gone"));
const logger = { review: vi.fn(), debug: vi.fn() };
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
logger,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(readResponse(temp, "req-throw")).toMatchObject({
approved: false,
state: "denied",
});
expect(logger.review).toHaveBeenCalledWith(
"permission_forwarding.error",
expect.objectContaining({
message: expect.stringContaining("escalate"),
}),
);
});
});
describe("processInbox — child-fixed access facts on the escalated ask", () => {
test("carries the request's access facts onto the escalated ask details", async () => {
const details = await escalateForwardedAsk({
id: "req-path-facts",
source: "tool_call",
// The display projection is the child's *tool* name, which is what the UI
// shows — never the gate surface the rule fired on.
surface: "write",
value: "/worktree/issue-42/src/foo.ts",
accessIntent: makeForwardedAccessIntent({
surface: "path",
matchValues: [
"/worktree/issue-42/src/foo.ts",
"src/foo.ts",
"/canonical/src/foo.ts",
],
boundaryValue: "/canonical/src/foo.ts",
}),
});
// Exactly the three fact fields: `requesterCwd` and `principal` stay on the
// wire object and never reach an Authorizer. A link that needs requester
// identity reads `details.forwarding`.
expect(details.accessIntent).toEqual({
surface: "path",
matchValues: [
"/worktree/issue-42/src/foo.ts",
"src/foo.ts",
"/canonical/src/foo.ts",
],
boundaryValue: "/canonical/src/foo.ts",
});
});
test("omits accessIntent entirely for a version-skew request that carried none", async () => {
const details = await escalateForwardedAsk({
id: "req-skew-facts",
source: "tool_call",
surface: "bash",
value: "git push",
});
// Absence, not an explicit `undefined`: the delegation envelope's
// `accessIntent?.surface ?? surface` fallback reads the display surface only
// when the key is genuinely absent.
expect(details).not.toHaveProperty("accessIntent");
});
});
describe("processInbox — the child's payload on the escalated ask", () => {
test("escalates the child's own payload with the requester re-stamped as forwarded", async () => {
const childPayload = makePromptPayload({
kind: "bash",
request: {
requester: { agentName: "Explore", forwarded: false, sessionId: null },
surface: "bash",
toolName: "bash",
invokedToolName: null,
value: "git push",
matchedPattern: "git *",
commandContext: null,
executedUnit: null,
},
evidence: [
{ label: "full command", text: "git push --force", detail: null },
],
});
const details = await escalateForwardedAsk({
id: "req-child-payload",
requesterAgentName: "Explore",
requesterSessionId: "child-session",
source: "tool_call",
surface: "bash",
value: "git push",
payload: childPayload,
});
// The child's kind and facts pass through untouched — a forwarded bash ask
// renders `command : …` exactly as a local one does. Only the requester is
// re-stamped: the serving node is the only party that knows the ask arrived
// over the wire, and the request's own provenance is authoritative (#292).
expect(details.payload).toEqual({
...childPayload,
request: {
...childPayload.request,
requester: {
agentName: "Explore",
forwarded: true,
sessionId: "child-session",
},
},
});
});
test("escalates a degraded forwarded payload for a request carrying none", async () => {
const details = await escalateForwardedAsk({
id: "req-skew-payload",
requesterAgentName: "scout",
requesterSessionId: "child-session",
source: "tool_call",
surface: "read",
value: "/tmp/x",
// `JSON.stringify` drops the key, so the written request genuinely
// carries no payload — an older child's request.
payload: undefined,
});
// `kind: "forwarded"` now means exactly one thing: this ask arrived without
// a payload, so it is rendered from the display fields it does carry.
expect(details.payload).toEqual({
kind: "forwarded",
request: {
requester: {
agentName: "scout",
forwarded: true,
sessionId: "child-session",
},
surface: "read",
toolName: null,
invokedToolName: null,
value: "/tmp/x",
matchedPattern: null,
commandContext: null,
executedUnit: null,
},
evidence: [],
annotations: [],
});
});
});
describe("processInbox — bounded delegation over forwarded asks", () => {
const query: PermissionQuery = {
checkPermission: vi.fn(),
getToolPermission: vi.fn(),
};
const log = makeAuthorizerLog();
const allowingLink: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "allow" });
test("caps a link's allow on a forwarded path ask to defer", async () => {
const details = await escalateForwardedAsk({
id: "req-envelope-path",
source: "tool_call",
surface: "write",
value: "/worktree/issue-42/.ssh/config",
accessIntent: makeForwardedAccessIntent({
surface: "path",
matchValues: ["/worktree/issue-42/.ssh/config"],
boundaryValue: "/worktree/issue-42/.ssh/config",
}),
});
const enclosed = encloseInDelegationEnvelope(allowingLink);
// The gate surface, not the displayed tool name, decides exclusion — so a
// forwarded path ask is capped exactly like the same ask made locally.
expect(await enclosed(details, query, log)).toEqual({ kind: "defer" });
});
test("passes a link's allow on a forwarded bash ask through", async () => {
const details = await escalateForwardedAsk({
id: "req-envelope-bash",
source: "tool_call",
surface: "bash",
value: "npm test",
accessIntent: makeForwardedAccessIntent({ matchValues: ["npm test"] }),
});
const enclosed = encloseInDelegationEnvelope(allowingLink);
expect(await enclosed(details, query, log)).toEqual({ kind: "allow" });
});
});
describe("processInbox — the serving node's chain adjudicates a forwarded ask", () => {
/** A serving node with UI: its terminal is the human prompt. */
function makeServingCtx(): ExtensionContext {
return {
hasUI: true,
mode: "tui",
ui: { select: vi.fn(), input: vi.fn(), custom: vi.fn() },
} as unknown as ExtensionContext;
}
test("a link registered on the serving node decides the forwarded ask before its terminal", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-chain",
source: "tool_call",
surface: "bash",
value: "rm -rf /tmp/scratch",
accessIntent: makeForwardedAccessIntent({
matchValues: ["rm -rf /tmp/scratch"],
}),
});
const registry = new AuthorizerRegistry();
registerLink(registry, "model-judge", {
kind: "deny",
reason: "destructive",
});
const requestPermissionDecision = vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const logger = makeAuthorizerLog();
// The real chain owner, wired exactly as index.ts wires it: the same
// AuthorizerSelection is both the gate's AskEscalator and the forwarded
// request server's, so a child's ask is judged by the serving node's chain.
const selection = new AuthorizerSelection(
makeAuthorizerSelectionDeps({
prompter: new PermissionPrompter({ logger }),
authorizerRegistry: registry,
getAuthorizerChain: () => ["model-judge"],
requestPermissionDecision,
logger,
}),
);
selection.activate(makeServingCtx());
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
logger,
policy: { resolve: vi.fn(() => makeCheckResult({ state: "ask" })) },
escalator: selection,
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
// The link's deny is what the child receives, and the human terminal was
// never reached — the chain adjudicated the forwarded ask. The child can
// now see that from its own record: the link is named on the wire.
expect(readResponse(temp, "req-chain")).toEqual({
approved: false,
state: "denied_with_reason",
denialReason: "destructive",
responderSessionId: "parent-session",
respondedAt: expect.any(Number),
decidedBy: {
kind: "authorizer",
name: "model-judge",
verdict: "deny",
reason: "destructive",
},
});
expect(requestPermissionDecision).not.toHaveBeenCalled();
});
});
describe("processInbox — grant-scope selection", () => {
test("records a whole-session grant into the serving recorder and translates the response to a plain approve", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-whole",
source: "tool_call",
surface: "bash",
value: "git push",
accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }),
sessionApproval: { surface: "bash", patterns: ["git *"] },
});
const resolve = vi.fn(() => makeCheckResult({ state: "ask" }));
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_serving_session",
decidedBy: { kind: "user", via: "dialog" },
});
const recordSessionApproval = vi.fn();
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
escalator: { escalate },
recorder: { recordSessionApproval },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(recordSessionApproval).toHaveBeenCalledWith(
expect.objectContaining({ surface: "bash", patterns: ["git *"] }),
);
// Translated: the child receives a plain approve and records nothing.
// The translation rewrites the scope, never the decider.
expect(readResponse(temp, "req-whole")).toMatchObject({
approved: true,
state: "approved",
decidedBy: { kind: "user", via: "dialog" },
});
});
test("offers the request's sessionApproval to the escalated dialog details", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-scope-details",
source: "tool_call",
surface: "bash",
value: "git push",
accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }),
sessionApproval: { surface: "bash", patterns: ["git *"] },
});
const resolve = vi.fn(() => makeCheckResult({ state: "ask" }));
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
escalator: { escalate },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(escalate).toHaveBeenCalledWith(
expect.objectContaining({
sessionApproval: { surface: "bash", patterns: ["git *"] },
}),
);
});
test("passes a subagent-only grant through untouched without recording on the serving node", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-subagent",
source: "tool_call",
surface: "bash",
value: "git push",
accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }),
sessionApproval: { surface: "bash", patterns: ["git *"] },
});
const resolve = vi.fn(() => makeCheckResult({ state: "ask" }));
const escalate = vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
});
const recordSessionApproval = vi.fn();
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
escalator: { escalate },
recorder: { recordSessionApproval },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(recordSessionApproval).not.toHaveBeenCalled();
// Passed through: the child records its own pattern (today's behavior).
expect(readResponse(temp, "req-subagent")).toMatchObject({
approved: true,
state: "approved_for_session",
});
});
});
describe("processInbox — one-hop canary", () => {
test("warns when the requester is a registered subagent whose parent is not this serving session", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({ id: "req-hop", surface: "bash", value: "git push" });
const logger = { review: vi.fn(), debug: vi.fn() };
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "some-other-session",
});
const server = new ForwardedRequestServer(
makeServerDeps({ forwardingDir: temp.forwardingDir, logger, registry }),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(logger.review).toHaveBeenCalledWith(
"permission_forwarding.warning",
expect.objectContaining({
message: expect.stringContaining("one-hop"),
}),
);
});
test("stays silent for an unregistered (external file-based) requester", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({ id: "req-ext", surface: "bash", value: "git push" });
const logger = { review: vi.fn(), debug: vi.fn() };
const registry = makeSubagentRegistry("child-session"); // no entry
const server = new ForwardedRequestServer(
makeServerDeps({ forwardingDir: temp.forwardingDir, logger, registry }),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(logger.review).not.toHaveBeenCalledWith(
"permission_forwarding.warning",
expect.anything(),
);
});
test("stays silent for a registered one-hop child whose parent is this serving session", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({ id: "req-ok", surface: "bash", value: "git push" });
const logger = { review: vi.fn(), debug: vi.fn() };
const registry = makeSubagentRegistry("child-session", {
parentSessionId: "parent-session",
});
const server = new ForwardedRequestServer(
makeServerDeps({ forwardingDir: temp.forwardingDir, logger, registry }),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(logger.review).not.toHaveBeenCalledWith(
"permission_forwarding.warning",
expect.anything(),
);
});
});
describe("processInbox — inbox mechanics", () => {
test("recreates a missing responses/ directory and still writes the response", async () => {
// Simulate the race: requests/ exists with a pending file, but
// responses/ was removed by a concurrent cleanup pass (#398).
temp = createForwardingTempDir("parent-session", {
createResponsesDir: false,
});
temp.writeRequest({
id: "req-race",
surface: "bash",
value: "cat x",
accessIntent: makeForwardedAccessIntent({ matchValues: ["cat x"] }),
});
const logger = { review: vi.fn(), debug: vi.fn() };
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
logger,
policy: { resolve: vi.fn(() => makeCheckResult({ state: "allow" })) },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(logger.review).not.toHaveBeenCalledWith(
"permission_forwarding.error",
expect.anything(),
);
expect(readResponse(temp, "req-race")).toMatchObject({
approved: true,
state: "approved",
});
});
test("ignores and deletes a request targeting a different session", async () => {
temp = createForwardingTempDir("parent-session");
temp.writeRequest({
id: "req-mismatch",
targetSessionId: "other-session",
surface: "bash",
value: "git push",
accessIntent: makeForwardedAccessIntent({ matchValues: ["git push"] }),
});
const resolve = vi.fn(() => makeCheckResult({ state: "allow" }));
const server = new ForwardedRequestServer(
makeServerDeps({
forwardingDir: temp.forwardingDir,
policy: { resolve },
}),
);
await server.processInbox(
makeForwarderContext({ hasUI: true, sessionId: "parent-session" }),
);
expect(resolve).not.toHaveBeenCalled();
});
});