mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
1023 lines
32 KiB
TypeScript
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();
|
|
});
|
|
});
|