mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor(permission-system): simplify policy and autoreview flow
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -47,16 +47,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("external_directory surface ignores input.path and returns ['*']", () => {
|
||||
const result = normalizeInput(
|
||||
"external_directory",
|
||||
{ path: "/other/project" },
|
||||
[],
|
||||
);
|
||||
expect(result.surface).toBe("external_directory");
|
||||
expect(result.values).toEqual(["*"]);
|
||||
expect(result.resultExtras).toEqual({});
|
||||
});
|
||||
|
||||
it("read surface ignores input.path and returns ['*']", () => {
|
||||
const result = normalizeInput("read", { path: ".env" }, []);
|
||||
@@ -68,7 +58,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
||||
it("missing path also returns ['*'] (unchanged fallback)", () => {
|
||||
for (const surface of [
|
||||
"path",
|
||||
"external_directory",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
@@ -249,19 +238,6 @@ describe("buildAccessIntentForSurface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("emits an access-path intent for the external_directory surface", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"external_directory",
|
||||
"/outside/dir",
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("access-path");
|
||||
if (intent.kind === "access-path") {
|
||||
expect(intent.surface).toBe("external_directory");
|
||||
expect(intent.path.value()).toBe("/outside/dir");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits an access-path intent for a path-bearing tool surface (read)", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
@@ -315,15 +291,6 @@ describe("buildAccessIntentForSurface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to a tool intent for a whitespace-only path value", () => {
|
||||
const intent = buildAccessIntentForSurface(
|
||||
"external_directory",
|
||||
" ",
|
||||
normalizer,
|
||||
undefined,
|
||||
);
|
||||
expect(intent.kind).toBe("tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResolvedIntentFromMatchValues", () => {
|
||||
@@ -348,19 +315,6 @@ describe("buildResolvedIntentFromMatchValues", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a path-values intent for the external_directory surface", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues(
|
||||
"external_directory",
|
||||
["/tmp/x", "/real/tmp/x"],
|
||||
"Explore",
|
||||
);
|
||||
expect(intent).toEqual({
|
||||
kind: "path-values",
|
||||
surface: "external_directory",
|
||||
values: ["/tmp/x", "/real/tmp/x"],
|
||||
agentName: "Explore",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a tool intent from the single portable value for a non-path surface (bash)", () => {
|
||||
const intent = buildResolvedIntentFromMatchValues(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
PATH_BEARING_TOOLS,
|
||||
PATH_SURFACES,
|
||||
READ_ONLY_PATH_BEARING_TOOLS,
|
||||
} from "#src/access-intent/path-surfaces";
|
||||
|
||||
describe("PATH_BEARING_TOOLS", () => {
|
||||
@@ -19,21 +18,9 @@ describe("PATH_BEARING_TOOLS", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
|
||||
test("contains read, find, grep, ls", () => {
|
||||
for (const tool of ["read", "find", "grep", "ls"]) {
|
||||
expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not contain write or edit", () => {
|
||||
expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
|
||||
expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATH_SURFACES", () => {
|
||||
test("contains the path-bearing tools plus the cross-cutting gates", () => {
|
||||
test("contains the path-bearing tools plus the path gate", () => {
|
||||
for (const surface of [
|
||||
"read",
|
||||
"write",
|
||||
@@ -41,7 +28,6 @@ describe("PATH_SURFACES", () => {
|
||||
"find",
|
||||
"grep",
|
||||
"ls",
|
||||
"external_directory",
|
||||
"path",
|
||||
]) {
|
||||
expect(PATH_SURFACES.has(surface)).toBe(true);
|
||||
|
||||
@@ -48,7 +48,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
} as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
/** Details whose gate-computed surface drives the delegation envelope. */
|
||||
/** Details carrying the gate-computed surface supplied to authorizers. */
|
||||
function makeDetailsOn(surface: string): PromptPermissionDetails {
|
||||
return makeDetails({
|
||||
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
|
||||
@@ -340,29 +340,8 @@ describe("AuthorizerSelection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("caps a link's allow on an excluded surface, falling through to the terminal", async () => {
|
||||
const registry = new AuthorizerRegistry();
|
||||
register(registry, "judge", { kind: "allow" });
|
||||
const selection = new AuthorizerSelection(
|
||||
makeDeps({
|
||||
prompter: makeInvokingPrompter(),
|
||||
authorizerRegistry: registry,
|
||||
getAuthorizerChain: () => ["judge"],
|
||||
}),
|
||||
);
|
||||
// No UI, not a subagent → the terminal is DenyingAuthorizer.
|
||||
selection.activate(makeCtx({ hasUI: false }));
|
||||
|
||||
const decision = await selection.escalate(
|
||||
makeDetailsOn("external_directory"),
|
||||
);
|
||||
|
||||
// The envelope downgraded the link's allow to defer, so the terminal
|
||||
// (denying) owns the decision — the allow did not leak through.
|
||||
expect(decision.approved).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a link's allow through on a non-excluded surface", async () => {
|
||||
it("lets a configured authorizer approve the path surface", async () => {
|
||||
const registry = new AuthorizerRegistry();
|
||||
register(registry, "judge", { kind: "allow" });
|
||||
const selection = new AuthorizerSelection(
|
||||
@@ -374,10 +353,10 @@ describe("AuthorizerSelection", () => {
|
||||
);
|
||||
selection.activate(makeCtx({ hasUI: false }));
|
||||
|
||||
const decision = await selection.escalate(makeDetailsOn("bash"));
|
||||
const decision = await selection.escalate(makeDetailsOn("path"));
|
||||
|
||||
// bash is not excluded, so the link's allow stands (a non-persistent
|
||||
// approved grant) — the denying terminal is never reached.
|
||||
// A configured authorizer's allow is final for this ask and does not
|
||||
// create a persistent session grant. The terminal is never reached.
|
||||
expect(decision).toEqual({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Authorizer, AuthorizerVerdict } from "#src/authority/authorizer";
|
||||
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
|
||||
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
||||
import type { PermissionQuery } from "#src/service";
|
||||
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
|
||||
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
|
||||
|
||||
function makeQuery(): PermissionQuery {
|
||||
return { checkPermission: vi.fn(), getToolPermission: vi.fn() };
|
||||
}
|
||||
|
||||
/** Build details whose gate-computed surface is `accessIntentSurface`. */
|
||||
function makeDetails(
|
||||
accessIntentSurface: string | undefined,
|
||||
displaySurface?: string | null,
|
||||
toolName?: string,
|
||||
): PromptPermissionDetails {
|
||||
return makePromptDetails({
|
||||
surface: displaySurface,
|
||||
toolName,
|
||||
accessIntent:
|
||||
accessIntentSurface === undefined
|
||||
? undefined
|
||||
: {
|
||||
surface: accessIntentSurface,
|
||||
matchValues: ["/some/value"],
|
||||
boundaryValue: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A link whose fixed verdict the envelope may cap. */
|
||||
function makeLink(verdict: AuthorizerVerdict): Authorizer["authorize"] {
|
||||
return vi.fn<Authorizer["authorize"]>().mockResolvedValue(verdict);
|
||||
}
|
||||
|
||||
describe("encloseInDelegationEnvelope", () => {
|
||||
const query = makeQuery();
|
||||
const log = makeAuthorizerLog();
|
||||
|
||||
describe("caps an allow verdict on an excluded surface to defer", () => {
|
||||
it("downgrades an allow on external_directory", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory"),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
it.each(["write", "edit", "bash", "custom-tool"] as const)(
|
||||
"downgrades an allow on external_directory for %s",
|
||||
async (toolName) => {
|
||||
const enclosed = encloseInDelegationEnvelope(
|
||||
makeLink({ kind: "allow" }),
|
||||
);
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory", undefined, toolName),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
},
|
||||
);
|
||||
|
||||
it("downgrades an allow on the path surface", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails("path"), query, log);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
it("downgrades an allow when the surface is undetermined (fail-safe)", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails(undefined, null), query, log);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("passes verdicts through unchanged", () => {
|
||||
it("keeps an allow on a non-excluded surface (bash)", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails("bash"), query, log);
|
||||
expect(verdict).toEqual({ kind: "allow" });
|
||||
});
|
||||
|
||||
it("keeps an allow on a per-tool surface (read)", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails("read"), query, log);
|
||||
expect(verdict).toEqual({ kind: "allow" });
|
||||
});
|
||||
|
||||
it.each(["read", "find", "grep", "ls"] as const)(
|
||||
"keeps an allow on external_directory for the built-in read-only tool %s",
|
||||
async (toolName) => {
|
||||
const enclosed = encloseInDelegationEnvelope(
|
||||
makeLink({ kind: "allow" }),
|
||||
);
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory", undefined, toolName),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "allow" });
|
||||
},
|
||||
);
|
||||
|
||||
it("never caps a deny, even on an excluded surface", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(
|
||||
makeLink({ kind: "deny", reason: "wrong path" }),
|
||||
);
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory"),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "deny", reason: "wrong path" });
|
||||
});
|
||||
|
||||
it("never caps a defer", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "defer" }));
|
||||
const verdict = await enclosed(makeDetails("path"), query, log);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the gate-computed accessIntent surface over the display surface", async () => {
|
||||
// accessIntent.surface (external_directory) is authoritative even when the
|
||||
// display-surface override says otherwise.
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory", "bash"),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
it("forwards details, the injected query, and the review-log seam to the wrapped link", async () => {
|
||||
const link = makeLink({ kind: "defer" });
|
||||
const enclosed = encloseInDelegationEnvelope(link);
|
||||
const details = makeDetails("bash");
|
||||
await enclosed(details, query, log);
|
||||
expect(link).toHaveBeenCalledWith(details, query, log);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,8 @@ 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 {
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
PermissionPrompter,
|
||||
type PromptPermissionDetails,
|
||||
} from "#src/authority/permission-prompter";
|
||||
import type { PermissionQuery } from "#src/service";
|
||||
import {
|
||||
makeAuthorizerSelectionDeps,
|
||||
registerLink,
|
||||
@@ -57,9 +54,8 @@ function readResponse(
|
||||
* 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.
|
||||
* access-facts cases below: they assert the exact evidence passed to the
|
||||
* serving node's configured authorizer chain.
|
||||
*/
|
||||
function makeCapturingEscalator() {
|
||||
const escalated: PromptPermissionDetails[] = [];
|
||||
@@ -550,9 +546,8 @@ describe("processInbox — child-fixed access facts on the escalated ask", () =>
|
||||
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.
|
||||
// Version-skew requests can omit access intent entirely; preserve that
|
||||
// absence instead of manufacturing evidence.
|
||||
expect(details).not.toHaveProperty("accessIntent");
|
||||
});
|
||||
});
|
||||
@@ -640,49 +635,6 @@ describe("processInbox — the child's payload on the escalated ask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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. */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,6 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
@@ -28,11 +26,6 @@ import {
|
||||
type ExtensionAPI,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPermissionForwardingLocation,
|
||||
type ForwardedPermissionRequest,
|
||||
} from "#src/authority/permission-forwarding";
|
||||
import { getServingSessionRegistry } from "#src/authority/serving-registry";
|
||||
import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/authority/subagent-lifecycle-events";
|
||||
import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
|
||||
import { getGlobalConfigPath } from "#src/config-paths";
|
||||
@@ -40,7 +33,6 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
||||
import piPermissionSystemExtension from "#src/index";
|
||||
import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
|
||||
import { getPermissionsService } from "#src/service";
|
||||
import { publishServingHeartbeat } from "#test/helpers/forwarding-fixtures";
|
||||
import { makeFakePi } from "#test/helpers/make-fake-pi";
|
||||
|
||||
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
||||
@@ -171,8 +163,6 @@ function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } {
|
||||
return { ctx };
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Drive the registered `session_start` handler with a ctx. */
|
||||
function fireSessionStart(
|
||||
@@ -182,53 +172,6 @@ function fireSessionStart(
|
||||
return pi.fire("session_start", { reason: "start" }, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the parent UI session responding to a forwarded permission request.
|
||||
*
|
||||
* Polls the parent's requests directory for the child's request file, then
|
||||
* writes an approval response so the child's forwarding poll resolves quickly
|
||||
* instead of waiting out the 10-minute timeout.
|
||||
*/
|
||||
async function approveForwardedRequest(
|
||||
forwardingDir: string,
|
||||
parentSessionId: string,
|
||||
): Promise<ForwardedPermissionRequest> {
|
||||
const location = createPermissionForwardingLocation(
|
||||
forwardingDir,
|
||||
parentSessionId,
|
||||
);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = readdirSync(location.requestsDir).filter((f) =>
|
||||
f.endsWith(".json"),
|
||||
);
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
const requestFile = files[0];
|
||||
if (requestFile) {
|
||||
const request = JSON.parse(
|
||||
readFileSync(join(location.requestsDir, requestFile), "utf8"),
|
||||
) as ForwardedPermissionRequest;
|
||||
mkdirSync(location.responsesDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(location.responsesDir, `${request.id}.json`),
|
||||
JSON.stringify({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
responderSessionId: parentSessionId,
|
||||
respondedAt: Date.now(),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return request;
|
||||
}
|
||||
await sleep(5);
|
||||
}
|
||||
throw new Error("Timed out waiting for the forwarded permission request");
|
||||
}
|
||||
|
||||
describe("event-handler registration completeness", () => {
|
||||
it("registers a handler for every required event exactly once", () => {
|
||||
@@ -239,208 +182,7 @@ describe("event-handler registration completeness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent registry sharing across factory instances", () => {
|
||||
// The #296 regression class: two factory invocations on *different* event
|
||||
// buses must still resolve the same process-global SubagentSessionRegistry,
|
||||
// so a child registered via the parent's bus detects itself as a subagent and
|
||||
// forwards (rather than blocking) an external-directory `ask`.
|
||||
it("lets a child instance forward an ask it received via the parent's bus", async () => {
|
||||
writeGlobalConfig({
|
||||
permission: { "*": "allow", external_directory: "ask" },
|
||||
});
|
||||
|
||||
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
|
||||
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
|
||||
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
|
||||
const parentSessionId = "parent-session-1";
|
||||
const childSessionId = "child-session-1";
|
||||
|
||||
// Two factory instances, each wired to its own event bus (as in production:
|
||||
// every session's ResourceLoader creates a separate bus).
|
||||
const parentBus = createEventBus();
|
||||
const childBus = createEventBus();
|
||||
piPermissionSystemExtension(
|
||||
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
|
||||
);
|
||||
const childPi = makeFakePi({
|
||||
events: childBus,
|
||||
toolNames: ["read"],
|
||||
});
|
||||
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
|
||||
|
||||
// The child session is announced on the *parent's* bus only; the parent's
|
||||
// lifecycle subscription writes it into the shared global registry.
|
||||
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
||||
sessionId: childSessionId,
|
||||
parentSessionId,
|
||||
});
|
||||
|
||||
// This test answers the forwarded request by hand instead of running the
|
||||
// parent's poll timer, so state what that timer would have published:
|
||||
// the parent is draining its inbox. Without it the child correctly
|
||||
// abandons the request as unserved (#719).
|
||||
getServingSessionRegistry().markServing(parentSessionId);
|
||||
|
||||
// The child fires an external-directory read with no UI. With the shared
|
||||
// registry it detects itself as a subagent and forwards; the simulated
|
||||
// parent approves.
|
||||
const firePromise = childPi.fire(
|
||||
"tool_call",
|
||||
{
|
||||
toolName: "read",
|
||||
toolCallId: "child-external-read",
|
||||
input: { path: join(externalDir, "secret.txt") },
|
||||
},
|
||||
makeChildCtx(childCwd, childSessionId),
|
||||
);
|
||||
|
||||
const request = await approveForwardedRequest(
|
||||
forwardingDir,
|
||||
parentSessionId,
|
||||
);
|
||||
expect(request.targetSessionId).toBe(parentSessionId);
|
||||
expect(request.requesterSessionId).toBe(childSessionId);
|
||||
// The child persists the original display fields so the parent emits a
|
||||
// non-degraded `permissions:ui_prompt` event (forwarded non-degradation).
|
||||
expect(request.source).toBe("tool_call");
|
||||
expect(request.surface).toBe("read");
|
||||
expect(request.value).toBe(join(externalDir, "secret.txt"));
|
||||
|
||||
const result = (await firePromise) as { block?: true };
|
||||
expect(result.block).toBeUndefined();
|
||||
|
||||
rmSync(childCwd, { recursive: true, force: true });
|
||||
rmSync(externalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The #719 failure mode: the child forwards correctly, but nothing drains
|
||||
// the parent's inbox. Before the serving registry it waited out the full
|
||||
// ten-minute timeout and reported the block as a user denial.
|
||||
it("blocks promptly when no session is draining the parent's inbox", async () => {
|
||||
writeGlobalConfig({
|
||||
permission: { "*": "allow", external_directory: "ask" },
|
||||
});
|
||||
|
||||
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
|
||||
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
|
||||
const parentSessionId = "parent-session-2";
|
||||
const childSessionId = "child-session-2";
|
||||
|
||||
const parentBus = createEventBus();
|
||||
piPermissionSystemExtension(
|
||||
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
|
||||
);
|
||||
const childPi = makeFakePi({
|
||||
events: createEventBus(),
|
||||
toolNames: ["read"],
|
||||
});
|
||||
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
|
||||
|
||||
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
||||
sessionId: childSessionId,
|
||||
parentSessionId,
|
||||
});
|
||||
// Deliberately no markServing: nobody is polling the parent's inbox.
|
||||
|
||||
const result = (await childPi.fire(
|
||||
"tool_call",
|
||||
{
|
||||
toolName: "read",
|
||||
toolCallId: "child-external-read",
|
||||
input: { path: join(externalDir, "secret.txt") },
|
||||
},
|
||||
makeChildCtx(childCwd, childSessionId),
|
||||
)) as { block?: true; reason?: string };
|
||||
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.reason).toContain("no interactive UI is available");
|
||||
expect(result.reason).toContain(
|
||||
`Session '${parentSessionId}' is not serving forwarded permission requests.`,
|
||||
);
|
||||
expect(result.reason).not.toContain("User denied");
|
||||
|
||||
rmSync(childCwd, { recursive: true, force: true });
|
||||
rmSync(externalDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("out-of-process forwarding liveness", () => {
|
||||
// A child spawned as its own `pi` process resolves its parent from the
|
||||
// environment and shares no `globalThis` with it, so the serving registry the
|
||||
// tests above rely on is invisible to it. The filesystem heartbeat is the
|
||||
// only signal it has (#721, #735 scenario 1).
|
||||
const externalAsk = {
|
||||
permission: { "*": "allow", external_directory: "ask" },
|
||||
};
|
||||
|
||||
function fireChildRead(childCwd: string, externalDir: string) {
|
||||
const childPi = makeFakePi({
|
||||
events: createEventBus(),
|
||||
toolNames: ["read"],
|
||||
});
|
||||
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
|
||||
return childPi.fire(
|
||||
"tool_call",
|
||||
{
|
||||
toolName: "read",
|
||||
toolCallId: "child-external-read",
|
||||
input: { path: join(externalDir, "secret.txt") },
|
||||
},
|
||||
makeChildCtx(childCwd, "child-session-oop"),
|
||||
);
|
||||
}
|
||||
|
||||
it("blocks promptly when the out-of-process parent published no heartbeat", async () => {
|
||||
writeGlobalConfig(externalAsk);
|
||||
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
|
||||
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
|
||||
const parentSessionId = "parent-session-oop-gone";
|
||||
// The env-var path a process-based subagent extension uses: the hint makes
|
||||
// the child detect itself, and the parent id names a session that exited.
|
||||
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
|
||||
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", parentSessionId);
|
||||
|
||||
const result = (await fireChildRead(childCwd, externalDir)) as {
|
||||
block?: true;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.reason).toContain(
|
||||
`Session '${parentSessionId}' is not serving forwarded permission requests.`,
|
||||
);
|
||||
expect(result.reason).not.toContain("User denied");
|
||||
|
||||
rmSync(childCwd, { recursive: true, force: true });
|
||||
rmSync(externalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("waits for an out-of-process parent whose heartbeat is fresh", async () => {
|
||||
writeGlobalConfig(externalAsk);
|
||||
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
|
||||
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
|
||||
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
|
||||
const parentSessionId = "parent-session-oop-live";
|
||||
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
|
||||
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", parentSessionId);
|
||||
// What the parent's poll timer would have published; this test answers the
|
||||
// request by hand instead of running that timer.
|
||||
publishServingHeartbeat(forwardingDir, parentSessionId);
|
||||
|
||||
const firePromise = fireChildRead(childCwd, externalDir);
|
||||
const request = await approveForwardedRequest(
|
||||
forwardingDir,
|
||||
parentSessionId,
|
||||
);
|
||||
expect(request.targetSessionId).toBe(parentSessionId);
|
||||
|
||||
const result = (await firePromise) as { block?: true };
|
||||
expect(result.block).toBeUndefined();
|
||||
|
||||
rmSync(childCwd, { recursive: true, force: true });
|
||||
rmSync(externalDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shutdown teardown chain", () => {
|
||||
it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => {
|
||||
@@ -727,7 +469,7 @@ describe("project trust gates project-scoped config (#644)", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lets a trusted project's `bash: allow` override global `bash: deny`", async () => {
|
||||
it("ignores project policy even when the project is trusted", async () => {
|
||||
writeGlobalConfig({ permission: { "*": "ask", bash: "deny" } });
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-trusted-cwd-"));
|
||||
writeProjectConfig(cwd, { permission: { bash: "allow" } });
|
||||
@@ -739,10 +481,10 @@ describe("project trust gates project-scoped config (#644)", () => {
|
||||
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
|
||||
);
|
||||
|
||||
// The trusted project override applies (last-match-wins).
|
||||
// Project policy files are not part of the permission configuration.
|
||||
expect(
|
||||
getPermissionsService()!.checkPermission("bash", "echo hi").state,
|
||||
).toBe("allow");
|
||||
).toBe("deny");
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
@@ -883,65 +625,6 @@ describe("bash bare-token path gating (#509, #645)", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("denies a bare symlink escaping the working tree under external_directory deny (#645)", async () => {
|
||||
// The issue's headline repro:
|
||||
// printf 'test' > /tmp/…-secret ; ln -s /tmp/…-secret outside-link
|
||||
// cat outside-link (under a permissive `cat *` bash rule)
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-escape-cwd-"));
|
||||
const outside = mkdtempSync(join(tmpdir(), "pi-perm-escape-target-"));
|
||||
const secret = join(outside, "pi-permission-test-secret");
|
||||
writeFileSync(secret, "test");
|
||||
symlinkSync(secret, join(cwd, "outside-link"));
|
||||
writeGlobalConfig({
|
||||
permission: {
|
||||
"*": "allow",
|
||||
bash: { "cat *": "allow" },
|
||||
external_directory: "deny",
|
||||
},
|
||||
});
|
||||
|
||||
const pi = makeFakePi({ events: createEventBus() });
|
||||
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
||||
const ctx = makeChildCtx(cwd, "bare-escape-session-deny");
|
||||
await fireSessionStart(pi, ctx);
|
||||
|
||||
const result = await fireBashToolCall(pi, ctx, "cat outside-link");
|
||||
expect(result.block).toBe(true);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("denies a path embedded in a long option under external_directory deny (#645)", async () => {
|
||||
// The issue's second repro: `grep --file=/tmp/…` under an allowing
|
||||
// `grep *` bash rule. The flag token never reached the path surfaces.
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-flagpath-cwd-"));
|
||||
const outside = mkdtempSync(join(tmpdir(), "pi-perm-flagpath-target-"));
|
||||
const patterns = join(outside, "pi-permission-patterns");
|
||||
writeFileSync(patterns, "secret\n");
|
||||
writeGlobalConfig({
|
||||
permission: {
|
||||
"*": "allow",
|
||||
bash: { "grep *": "allow" },
|
||||
external_directory: "deny",
|
||||
},
|
||||
});
|
||||
|
||||
const pi = makeFakePi({ events: createEventBus() });
|
||||
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
||||
const ctx = makeChildCtx(cwd, "flag-path-session-deny");
|
||||
await fireSessionStart(pi, ctx);
|
||||
|
||||
const result = await fireBashToolCall(
|
||||
pi,
|
||||
ctx,
|
||||
`grep --file=${patterns} target`,
|
||||
);
|
||||
expect(result.block).toBe(true);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-instance global service interplay", () => {
|
||||
|
||||
@@ -245,7 +245,6 @@ describe("loadUnifiedConfig", () => {
|
||||
"*": "ask",
|
||||
read: "allow",
|
||||
bash: { "*": "ask", "git *": "allow" },
|
||||
external_directory: "ask",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -256,7 +255,6 @@ describe("loadUnifiedConfig", () => {
|
||||
"*": "ask",
|
||||
read: "allow",
|
||||
bash: { "*": "ask", "git *": "allow" },
|
||||
external_directory: "ask",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,32 +399,6 @@ describe("loadUnifiedConfig", () => {
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses piInfrastructureReadPaths when a valid string array is present", () => {
|
||||
const configPath = join(tempDir, "config.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ piInfrastructureReadPaths: ["/extra/path"] }),
|
||||
);
|
||||
const result = loadUnifiedConfig(configPath);
|
||||
expect(result.config.piInfrastructureReadPaths).toEqual(["/extra/path"]);
|
||||
});
|
||||
|
||||
it("parses piInfrastructureReadPaths as empty array when set to []", () => {
|
||||
const configPath = join(tempDir, "config.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ piInfrastructureReadPaths: [] }),
|
||||
);
|
||||
const result = loadUnifiedConfig(configPath);
|
||||
expect(result.config.piInfrastructureReadPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits piInfrastructureReadPaths when absent", () => {
|
||||
const configPath = join(tempDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
|
||||
const result = loadUnifiedConfig(configPath);
|
||||
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["string", "not-an-array"],
|
||||
@@ -446,7 +418,7 @@ describe("loadUnifiedConfig", () => {
|
||||
});
|
||||
|
||||
describe("mergeUnifiedConfigs", () => {
|
||||
it("deep-merges permission objects so project overrides global per-key", () => {
|
||||
it("deep-merges permission objects so the later config overrides per key", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
{
|
||||
permission: {
|
||||
@@ -496,7 +468,7 @@ describe("mergeUnifiedConfigs", () => {
|
||||
expect(merged.permission).toEqual({ bash: "deny" });
|
||||
});
|
||||
|
||||
it("replaces scalar runtime knobs (project wins)", () => {
|
||||
it("replaces scalar runtime knobs with later values", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
{
|
||||
debugLog: true,
|
||||
@@ -513,7 +485,7 @@ describe("mergeUnifiedConfigs", () => {
|
||||
expect(merged.doublePressToConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the prompt-budget scalars (project wins)", () => {
|
||||
it("replaces prompt-budget scalars with later values", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
{ promptMaxRows: 24, promptFieldMaxWidth: 400 },
|
||||
{ promptMaxRows: 10 },
|
||||
@@ -523,7 +495,7 @@ describe("mergeUnifiedConfigs", () => {
|
||||
expect(merged.promptFieldMaxWidth).toBe(400);
|
||||
});
|
||||
|
||||
it("replaces the review-log field width (project wins)", () => {
|
||||
it("replaces the review-log field width with a later value", () => {
|
||||
expect(
|
||||
mergeUnifiedConfigs(
|
||||
{ reviewLogFieldMaxWidth: 1000 },
|
||||
@@ -612,34 +584,6 @@ describe("mergeUnifiedConfigs", () => {
|
||||
expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
|
||||
});
|
||||
|
||||
it("override piInfrastructureReadPaths replaces base array", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
{ piInfrastructureReadPaths: ["/base/path"] },
|
||||
{ piInfrastructureReadPaths: ["/override/path"] },
|
||||
);
|
||||
expect(merged.piInfrastructureReadPaths).toEqual(["/override/path"]);
|
||||
});
|
||||
|
||||
it("base piInfrastructureReadPaths survives when override omits it", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
{ piInfrastructureReadPaths: ["/kept/path"] },
|
||||
{ debugLog: true },
|
||||
);
|
||||
expect(merged.piInfrastructureReadPaths).toEqual(["/kept/path"]);
|
||||
});
|
||||
|
||||
it("piInfrastructureReadPaths is absent when both base and override omit it", () => {
|
||||
const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
|
||||
expect(merged).not.toHaveProperty("piInfrastructureReadPaths");
|
||||
});
|
||||
|
||||
it("override piInfrastructureReadPaths as empty array replaces non-empty base", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
{ piInfrastructureReadPaths: ["/base/path"] },
|
||||
{ piInfrastructureReadPaths: [] },
|
||||
);
|
||||
expect(merged.piInfrastructureReadPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it("override authorizerChain replaces base array", () => {
|
||||
const merged = mergeUnifiedConfigs(
|
||||
@@ -764,27 +708,6 @@ describe("loadAndMergeConfigs", () => {
|
||||
writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content));
|
||||
}
|
||||
|
||||
it("merges global and project new-layout configs", () => {
|
||||
writeGlobal({
|
||||
debugLog: true,
|
||||
permission: { "*": "ask", read: "allow" },
|
||||
});
|
||||
writeProject({
|
||||
permission: { "*": "allow", write: "deny" },
|
||||
});
|
||||
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
||||
// The merged config leaves a permissive top-level '*' with no bash '*' policy,
|
||||
// so the bash-fallback footgun warning is expected.
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0]).toContain("bash");
|
||||
expect(result.merged.debugLog).toBe(true);
|
||||
expect(result.merged.permission).toEqual({
|
||||
"*": "allow",
|
||||
read: "allow",
|
||||
write: "deny",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects legacy global policy and emits migration issue", () => {
|
||||
writeLegacyGlobalPolicy({
|
||||
@@ -800,18 +723,6 @@ describe("loadAndMergeConfigs", () => {
|
||||
expect(result.merged.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects legacy project policy and emits migration issue", () => {
|
||||
writeLegacyProjectPolicy({
|
||||
bash: { "git status": "allow" },
|
||||
});
|
||||
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0]).toContain(".pi/agent/pi-permissions.jsonc");
|
||||
expect(result.issues[0]).toContain(".pi/extensions/pi-permission-system");
|
||||
// Legacy file has no flat-format permission key — no rules extracted
|
||||
expect(result.merged.permission).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects legacy extension runtime config and emits migration issue", () => {
|
||||
writeLegacyExtensionConfig({
|
||||
@@ -878,57 +789,15 @@ describe("loadAndMergeConfigs", () => {
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
describe("includeProjectScope", () => {
|
||||
it("omits the new project config when includeProjectScope is false", () => {
|
||||
writeGlobal({
|
||||
permission: { "*": "ask", bash: "deny" },
|
||||
});
|
||||
writeProject({
|
||||
permission: { bash: "allow" },
|
||||
});
|
||||
it("ignores new and legacy project policy files", () => {
|
||||
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
|
||||
writeProject({ permission: { bash: "allow" } });
|
||||
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
|
||||
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
|
||||
includeProjectScope: false,
|
||||
});
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
||||
|
||||
// The untrusted project's `bash: allow` must not override global `deny`.
|
||||
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
|
||||
expect(result.project).toEqual({});
|
||||
});
|
||||
|
||||
it("omits the legacy project policy when includeProjectScope is false", () => {
|
||||
writeGlobal({ permission: { "*": "ask" } });
|
||||
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
|
||||
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
|
||||
includeProjectScope: false,
|
||||
});
|
||||
|
||||
expect(result.merged.permission).toEqual({ "*": "ask" });
|
||||
expect(
|
||||
result.issues.some((i) => i.includes("pi-permissions.jsonc")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("includes the project config when includeProjectScope is true", () => {
|
||||
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
|
||||
writeProject({ permission: { bash: "allow" } });
|
||||
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
|
||||
includeProjectScope: true,
|
||||
});
|
||||
|
||||
expect(result.merged.permission).toEqual({ "*": "ask", bash: "allow" });
|
||||
});
|
||||
|
||||
it("includes the project config by default (option omitted)", () => {
|
||||
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
|
||||
writeProject({ permission: { bash: "allow" } });
|
||||
|
||||
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
||||
|
||||
expect(result.merged.permission).toEqual({ "*": "ask", bash: "allow" });
|
||||
});
|
||||
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
describe("deprecated tool-preview caps", () => {
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
getGlobalLogsDir,
|
||||
getLegacyExtensionConfigPath,
|
||||
getLegacyGlobalPolicyPath,
|
||||
getLegacyProjectPolicyPath,
|
||||
getProjectAgentsDir,
|
||||
getProjectConfigPath,
|
||||
REVIEW_LOG_FILENAME,
|
||||
} from "#src/config-paths";
|
||||
|
||||
@@ -38,11 +36,6 @@ describe("config-paths", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("getProjectConfigPath returns .pi/extensions/pi-permission-system/policy.jsonc under cwd", () => {
|
||||
expect(getProjectConfigPath(cwd)).toBe(
|
||||
join(cwd, ".pi", "extensions", "pi-permission-system", "policy.jsonc"),
|
||||
);
|
||||
});
|
||||
|
||||
it("getProjectAgentsDir returns .pi/agents under cwd", () => {
|
||||
expect(getProjectAgentsDir(cwd)).toBe(join(cwd, ".pi", "agents"));
|
||||
@@ -56,11 +49,6 @@ describe("config-paths", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("getLegacyProjectPolicyPath returns .pi/agent/pi-permissions.jsonc under cwd", () => {
|
||||
expect(getLegacyProjectPolicyPath(cwd)).toBe(
|
||||
join(cwd, ".pi", "agent", "pi-permissions.jsonc"),
|
||||
);
|
||||
});
|
||||
|
||||
it("getLegacyExtensionConfigPath returns config.json under extensionRoot", () => {
|
||||
expect(getLegacyExtensionConfigPath(extensionRoot)).toBe(
|
||||
|
||||
@@ -94,19 +94,6 @@ describe("config pipeline seam", () => {
|
||||
expect(config).not.toHaveProperty("toolTextSummaryMaxLength");
|
||||
});
|
||||
|
||||
it("project config still overrides a global deprecated cap in the merge", () => {
|
||||
writeGlobal({ toolInputPreviewMaxLength: 200 });
|
||||
const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, "policy.jsonc"),
|
||||
JSON.stringify({ toolInputPreviewMaxLength: 500 }),
|
||||
);
|
||||
|
||||
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
||||
|
||||
expect(mergeResult.merged.toolInputPreviewMaxLength).toBe(500);
|
||||
});
|
||||
|
||||
it("defaults apply when config file is absent", () => {
|
||||
// No config files written — agentDir and cwd directories don't exist.
|
||||
|
||||
@@ -13,82 +13,23 @@ import { createPermissionSystemLogger } from "#src/logging";
|
||||
import type { ResolvedPolicyPaths } from "#src/permission-manager";
|
||||
import { PermissionManager } from "#src/permission-manager";
|
||||
|
||||
test("buildResolvedConfigLogEntry includes policy paths and legacy detection flags", () => {
|
||||
test("buildResolvedConfigLogEntry reports global and agent policy paths", () => {
|
||||
const policyPaths: ResolvedPolicyPaths = {
|
||||
globalConfigPath:
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
globalConfigPath: "/home/user/.pi/agent/extensions/pi-permission-system/policy.jsonc",
|
||||
globalConfigExists: true,
|
||||
projectConfigPath:
|
||||
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
|
||||
projectConfigExists: false,
|
||||
agentsDir: "/home/user/.pi/agent/agents",
|
||||
agentsDirExists: true,
|
||||
projectAgentsDir: "/projects/my-app/.pi/agent/agents",
|
||||
projectAgentsDirExists: false,
|
||||
};
|
||||
|
||||
const result = buildResolvedConfigLogEntry({ policyPaths });
|
||||
|
||||
expect(result.globalConfigPath).toBe(
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
);
|
||||
expect(result.globalConfigExists).toBe(true);
|
||||
expect(result.projectConfigPath).toBe(
|
||||
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
|
||||
);
|
||||
expect(result.projectConfigExists).toBe(false);
|
||||
expect(result.agentsDir).toBe("/home/user/.pi/agent/agents");
|
||||
expect(result.agentsDirExists).toBe(true);
|
||||
expect(result.projectAgentsDir).toBe("/projects/my-app/.pi/agent/agents");
|
||||
expect(result.projectAgentsDirExists).toBe(false);
|
||||
expect(result.legacyGlobalPolicyDetected).toBe(false);
|
||||
expect(result.legacyProjectPolicyDetected).toBe(false);
|
||||
expect(result.legacyExtensionConfigDetected).toBe(false);
|
||||
});
|
||||
|
||||
test("buildResolvedConfigLogEntry handles null project paths", () => {
|
||||
const policyPaths: ResolvedPolicyPaths = {
|
||||
globalConfigPath:
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
globalConfigExists: false,
|
||||
projectConfigPath: null,
|
||||
projectConfigExists: false,
|
||||
agentsDir: "/home/user/.pi/agent/agents",
|
||||
agentsDirExists: false,
|
||||
projectAgentsDir: null,
|
||||
projectAgentsDirExists: false,
|
||||
};
|
||||
|
||||
const result = buildResolvedConfigLogEntry({ policyPaths });
|
||||
|
||||
expect(result.projectConfigPath).toBe(null);
|
||||
expect(result.projectConfigExists).toBe(false);
|
||||
expect(result.projectAgentsDir).toBe(null);
|
||||
expect(result.projectAgentsDirExists).toBe(false);
|
||||
});
|
||||
|
||||
test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
|
||||
const policyPaths: ResolvedPolicyPaths = {
|
||||
globalConfigPath:
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
globalConfigExists: true,
|
||||
projectConfigPath: null,
|
||||
projectConfigExists: false,
|
||||
agentsDir: "/home/user/.pi/agent/agents",
|
||||
agentsDirExists: false,
|
||||
projectAgentsDir: null,
|
||||
projectAgentsDirExists: false,
|
||||
};
|
||||
|
||||
const result = buildResolvedConfigLogEntry({
|
||||
policyPaths,
|
||||
legacyGlobalPolicyDetected: true,
|
||||
legacyExtensionConfigDetected: true,
|
||||
expect(result).toEqual({
|
||||
...policyPaths,
|
||||
legacyGlobalPolicyDetected: false,
|
||||
legacyExtensionConfigDetected: false,
|
||||
});
|
||||
|
||||
expect(result.legacyGlobalPolicyDetected).toBe(true);
|
||||
expect(result.legacyProjectPolicyDetected).toBe(false);
|
||||
expect(result.legacyExtensionConfigDetected).toBe(true);
|
||||
});
|
||||
|
||||
test("config.resolved entry appears in review log via logger", () => {
|
||||
@@ -135,12 +76,9 @@ test("config.resolved entry appears in review log via logger", () => {
|
||||
expect(parsed.globalConfigExists).toBe(true);
|
||||
expect(parsed.agentsDir).toBe(agentsDir);
|
||||
expect(parsed.agentsDirExists).toBe(false);
|
||||
expect(parsed.projectConfigPath).toBe(null);
|
||||
expect(parsed.projectConfigExists).toBe(false);
|
||||
expect(parsed.projectAgentsDir).toBe(null);
|
||||
expect(parsed.projectAgentsDirExists).toBe(false);
|
||||
expect(parsed.legacyGlobalPolicyDetected).toBe(false);
|
||||
expect(parsed.legacyProjectPolicyDetected).toBe(false);
|
||||
expect(parsed.legacyExtensionConfigDetected).toBe(false);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -29,7 +29,6 @@ describe("unifiedConfigSchema", () => {
|
||||
yoloMode: true,
|
||||
toolInputPreviewMaxLength: 1000,
|
||||
toolTextSummaryMaxLength: 120,
|
||||
piInfrastructureReadPaths: ["/extra/path"],
|
||||
permission: {
|
||||
"*": "ask",
|
||||
read: "allow",
|
||||
|
||||
@@ -76,8 +76,6 @@ function makePolicyPathProvider(
|
||||
(): ResolvedPolicyPaths => ({
|
||||
globalConfigPath: "/agent/config.json",
|
||||
globalConfigExists: false,
|
||||
projectConfigPath: null,
|
||||
projectConfigExists: false,
|
||||
agentsDir: "/agent/agents",
|
||||
agentsDirExists: false,
|
||||
projectAgentsDir: null,
|
||||
@@ -161,25 +159,23 @@ describe("ConfigStore", () => {
|
||||
// ── refresh() ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("refresh()", () => {
|
||||
it("uses the passed ctx cwd for loadAndMergeConfigs and includes the project scope when trusted", () => {
|
||||
it("uses the passed ctx cwd for global-only config loading", () => {
|
||||
const { store } = makeStore();
|
||||
store.refresh(makeCtx({ cwd: "/my/project" }), true);
|
||||
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
||||
"/test/agent",
|
||||
"/my/project",
|
||||
expect.any(String),
|
||||
{ includeProjectScope: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("withholds the project scope when the project is untrusted", () => {
|
||||
it("does not add a project config scope when the project is untrusted", () => {
|
||||
const { store } = makeStore();
|
||||
store.refresh(makeCtx({ cwd: "/my/project" }), false);
|
||||
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
||||
"/test/agent",
|
||||
"/my/project",
|
||||
expect.any(String),
|
||||
{ includeProjectScope: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,7 +186,6 @@ describe("ConfigStore", () => {
|
||||
"/test/agent",
|
||||
"",
|
||||
expect.any(String),
|
||||
{ includeProjectScope: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -307,17 +302,6 @@ describe("ConfigStore", () => {
|
||||
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries piInfrastructureReadPaths from merged config into current()", () => {
|
||||
const { store } = makeStore();
|
||||
mockLoadAndMergeConfigs.mockReturnValue({
|
||||
merged: { piInfrastructureReadPaths: ["/extra/path"] },
|
||||
issues: [],
|
||||
});
|
||||
store.refresh(undefined, true);
|
||||
expect(store.current().piInfrastructureReadPaths).toEqual([
|
||||
"/extra/path",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── save() ─────────────────────────────────────────────────────────────
|
||||
@@ -459,13 +443,12 @@ describe("ConfigStore", () => {
|
||||
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
legacyGlobalPolicyDetected: expect.any(Boolean),
|
||||
legacyProjectPolicyDetected: expect.any(Boolean),
|
||||
legacyExtensionConfigDetected: expect.any(Boolean),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not check project legacy path when no cwd is provided", () => {
|
||||
it("checks only supported global legacy paths", () => {
|
||||
const { store } = makeStore();
|
||||
store.logResolvedPaths(); // no cwd
|
||||
// existsSync called for global and ext-config legacy paths only (not project)
|
||||
|
||||
@@ -49,7 +49,6 @@ describe("detectMisplacedPermissionKeys", () => {
|
||||
mcp: {},
|
||||
skills: {},
|
||||
special: {},
|
||||
external_directory: {},
|
||||
});
|
||||
expect(result).toEqual([
|
||||
"defaultPolicy",
|
||||
@@ -58,7 +57,6 @@ describe("detectMisplacedPermissionKeys", () => {
|
||||
"mcp",
|
||||
"skills",
|
||||
"special",
|
||||
"external_directory",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,107 +1,26 @@
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockDiscoverGlobalNodeModulesRoot } = vi.hoisted(() => ({
|
||||
mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/node-modules-discovery", () => ({
|
||||
discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
|
||||
}));
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getGlobalLogsDir } from "#src/config-paths";
|
||||
import { computeExtensionPaths } from "#src/extension-paths";
|
||||
|
||||
describe("computeExtensionPaths", () => {
|
||||
beforeEach(() => {
|
||||
mockDiscoverGlobalNodeModulesRoot.mockReset();
|
||||
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
|
||||
"/mock/global/node_modules",
|
||||
);
|
||||
it("derives only extension-owned runtime paths from agentDir", () => {
|
||||
const paths = computeExtensionPaths("/agent");
|
||||
|
||||
expect(paths).toEqual({
|
||||
agentDir: "/agent",
|
||||
sessionsDir: "/agent/sessions",
|
||||
subagentSessionsDir: "/agent/sessions/subagents",
|
||||
forwardingDir: "/agent/sessions/permission-forwarding",
|
||||
globalLogsDir: getGlobalLogsDir("/agent"),
|
||||
});
|
||||
expect(paths).not.toHaveProperty("piInfrastructureDirs");
|
||||
});
|
||||
|
||||
it("sets agentDir from argument", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.agentDir).toBe("/test/agent");
|
||||
});
|
||||
|
||||
it("derives sessionsDir as agentDir/sessions", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.sessionsDir).toBe("/test/agent/sessions");
|
||||
});
|
||||
|
||||
it("derives subagentSessionsDir as agentDir/subagent-sessions", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.subagentSessionsDir).toBe("/test/agent/subagent-sessions");
|
||||
});
|
||||
|
||||
it("derives forwardingDir as sessionsDir/permission-forwarding", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.forwardingDir).toBe(
|
||||
join("/test/agent/sessions", "permission-forwarding"),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives globalLogsDir via getGlobalLogsDir(agentDir)", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
|
||||
});
|
||||
|
||||
it("includes agentDir in piInfrastructureDirs", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.piInfrastructureDirs).toContain("/test/agent");
|
||||
});
|
||||
|
||||
it("includes agentDir/git in piInfrastructureDirs", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
|
||||
});
|
||||
|
||||
it("includes discovered global node_modules root in piInfrastructureDirs", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.piInfrastructureDirs).toContain("/mock/global/node_modules");
|
||||
});
|
||||
|
||||
it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
|
||||
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.piInfrastructureDirs).toHaveLength(2);
|
||||
expect(paths.piInfrastructureDirs).toContain("/test/agent");
|
||||
expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
|
||||
});
|
||||
|
||||
it("all entries in piInfrastructureDirs are strings (no null)", () => {
|
||||
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
for (const dir of paths.piInfrastructureDirs) {
|
||||
expect(typeof dir).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes piPackageDir in piInfrastructureDirs when provided", () => {
|
||||
const paths = computeExtensionPaths("/test/agent", "/pi/install");
|
||||
expect(paths.piInfrastructureDirs).toContain("/pi/install");
|
||||
});
|
||||
|
||||
it("omits piPackageDir when not provided (current behavior preserved)", () => {
|
||||
const paths = computeExtensionPaths("/test/agent");
|
||||
expect(paths.piInfrastructureDirs).toEqual([
|
||||
"/test/agent",
|
||||
"/test/agent/git",
|
||||
"/mock/global/node_modules",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits piPackageDir when given an empty string", () => {
|
||||
const paths = computeExtensionPaths("/test/agent", "");
|
||||
expect(paths.piInfrastructureDirs).not.toContain("");
|
||||
});
|
||||
|
||||
it("two calls with different agentDirs produce independent results", () => {
|
||||
it("does not retain values between agent directories", () => {
|
||||
const a = computeExtensionPaths("/agent/a");
|
||||
const b = computeExtensionPaths("/agent/b");
|
||||
expect(a.agentDir).toBe("/agent/a");
|
||||
expect(b.agentDir).toBe("/agent/b");
|
||||
|
||||
expect(a.sessionsDir).toBe("/agent/a/sessions");
|
||||
expect(b.sessionsDir).toBe("/agent/b/sessions");
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AgentPrepHandler,
|
||||
shouldExposeTool,
|
||||
} from "#src/handlers/before-agent-start";
|
||||
import type { ToolRegistry } from "#src/tool-registry";
|
||||
import { AgentPrepHandler } from "#src/handlers/before-agent-start";
|
||||
|
||||
import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
|
||||
import {
|
||||
@@ -12,325 +8,51 @@ import {
|
||||
makeRealSession,
|
||||
} from "#test/helpers/session-fixtures";
|
||||
|
||||
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
||||
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
|
||||
return {
|
||||
...original,
|
||||
isToolCallEventType: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeEvent(systemPrompt = "You are an assistant.") {
|
||||
return { systemPrompt };
|
||||
}
|
||||
|
||||
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
||||
return {
|
||||
getAll: vi.fn().mockReturnValue([]),
|
||||
getActive: vi.fn().mockReturnValue([]),
|
||||
setActive: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSetup(opts?: {
|
||||
toolPermission?: "allow" | "deny" | "ask";
|
||||
toolRegistry?: Partial<ToolRegistry>;
|
||||
}) {
|
||||
function makeSetup() {
|
||||
const { session, permissionManager, sessionRules, configStore, forwarding } =
|
||||
makeRealSession();
|
||||
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
||||
if (opts?.toolPermission !== undefined) {
|
||||
vi.mocked(permissionManager.getToolPermission).mockReturnValue(
|
||||
opts.toolPermission,
|
||||
);
|
||||
}
|
||||
// Default check returns allow (for skill-prompt sanitizer via resolver.checkPermission)
|
||||
vi.mocked(permissionManager.check).mockReturnValue(makeCheckResult());
|
||||
const toolRegistry = makeToolRegistry(opts?.toolRegistry);
|
||||
const warmParser = vi.fn();
|
||||
const handler = new AgentPrepHandler(
|
||||
session,
|
||||
resolver,
|
||||
toolRegistry,
|
||||
warmParser,
|
||||
);
|
||||
const handler = new AgentPrepHandler(session, resolver, warmParser);
|
||||
return {
|
||||
handler,
|
||||
session,
|
||||
resolver,
|
||||
permissionManager,
|
||||
configStore,
|
||||
forwarding,
|
||||
toolRegistry,
|
||||
warmParser,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shouldExposeTool (pure helper) ─────────────────────────────────────────
|
||||
|
||||
describe("shouldExposeTool", () => {
|
||||
it("returns true when tool permission is allow", () => {
|
||||
const getter = vi.fn().mockReturnValue("allow");
|
||||
expect(shouldExposeTool("read", null, getter)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when tool permission is ask", () => {
|
||||
const getter = vi.fn().mockReturnValue("ask");
|
||||
expect(shouldExposeTool("bash", "agent-x", getter)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when tool permission is deny", () => {
|
||||
const getter = vi.fn().mockReturnValue("deny");
|
||||
expect(shouldExposeTool("write", null, getter)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes agentName through to getToolPermission", () => {
|
||||
const getter = vi.fn().mockReturnValue("allow");
|
||||
shouldExposeTool("read", "my-agent", getter);
|
||||
expect(getter).toHaveBeenCalledWith("read", "my-agent");
|
||||
});
|
||||
|
||||
it("converts null agentName to undefined for getToolPermission", () => {
|
||||
const getter = vi.fn().mockReturnValue("allow");
|
||||
shouldExposeTool("read", null, getter);
|
||||
expect(getter).toHaveBeenCalledWith("read", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ── AgentPrepHandler.handle ────────────────────────────────────────────────
|
||||
|
||||
describe("AgentPrepHandler.handle", () => {
|
||||
it("activates the session with ctx", async () => {
|
||||
describe("AgentPrepHandler", () => {
|
||||
it("warms the parser and activates the permission session", async () => {
|
||||
const { handler, forwarding, warmParser } = makeSetup();
|
||||
const ctx = makeCtx();
|
||||
const { handler, forwarding } = makeSetup();
|
||||
await handler.handle(makeEvent(), ctx);
|
||||
// Real session.activate calls forwarding.start
|
||||
|
||||
await handler.handle({ systemPrompt: "You are an assistant." }, ctx);
|
||||
|
||||
expect(warmParser).toHaveBeenCalledTimes(1);
|
||||
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it("triggers the bash-parser warm-up", async () => {
|
||||
const { handler, warmParser } = makeSetup();
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
expect(warmParser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes config with ctx, gated on project trust", async () => {
|
||||
const ctx = makeCtx();
|
||||
it("refreshes global permission configuration", async () => {
|
||||
const { handler, configStore } = makeSetup();
|
||||
await handler.handle(makeEvent(), ctx);
|
||||
expect(configStore.refresh).toHaveBeenCalledWith(ctx, true);
|
||||
});
|
||||
const ctx = makeCtx({ isProjectTrusted: () => false });
|
||||
|
||||
await handler.handle({ systemPrompt: "You are an assistant." }, ctx);
|
||||
|
||||
it("withholds the project scope when the project is untrusted", async () => {
|
||||
const ctx = makeCtx({
|
||||
isProjectTrusted: vi.fn<() => boolean>().mockReturnValue(false),
|
||||
});
|
||||
const { handler, configStore } = makeSetup();
|
||||
await handler.handle(makeEvent(), ctx);
|
||||
expect(configStore.refresh).toHaveBeenCalledWith(ctx, false);
|
||||
});
|
||||
|
||||
it("resolves agent name using systemPrompt", async () => {
|
||||
const ctx = makeCtx();
|
||||
it("does not rewrite a prompt without skill entries", async () => {
|
||||
const { handler, session } = makeSetup();
|
||||
const spy = vi.spyOn(session, "resolveAgentName");
|
||||
await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
|
||||
expect(spy).toHaveBeenCalledWith(ctx, "<active_agent name='x'>");
|
||||
});
|
||||
|
||||
it("filters out denied tools from allowed list", async () => {
|
||||
const { handler, toolRegistry } = makeSetup({
|
||||
toolPermission: "deny",
|
||||
toolRegistry: {
|
||||
getActive: vi.fn().mockReturnValue(["write", "read"]),
|
||||
},
|
||||
});
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
expect(toolRegistry.setActive).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("includes allowed and ask tools in the active list", async () => {
|
||||
const { handler, toolRegistry } = makeSetup({
|
||||
toolRegistry: {
|
||||
getActive: vi.fn().mockReturnValue(["read", "write"]),
|
||||
},
|
||||
});
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
|
||||
});
|
||||
|
||||
it("does not activate registered tools pi left inactive (find/grep/ls)", async () => {
|
||||
// Regression for #385: the active set is the base, not the full registry.
|
||||
const { handler, toolRegistry } = makeSetup({
|
||||
toolRegistry: {
|
||||
getActive: vi.fn().mockReturnValue(["read", "bash", "edit", "write"]),
|
||||
getAll: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: "read" },
|
||||
{ name: "bash" },
|
||||
{ name: "edit" },
|
||||
{ name: "write" },
|
||||
{ name: "find" },
|
||||
{ name: "grep" },
|
||||
{ name: "ls" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
expect(toolRegistry.setActive).toHaveBeenCalledWith([
|
||||
"read",
|
||||
"bash",
|
||||
"edit",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
it("calls setActive on every turn (no dedup gate)", async () => {
|
||||
const { handler, toolRegistry } = makeSetup({
|
||||
toolRegistry: {
|
||||
getActive: vi.fn().mockReturnValue(["read"]),
|
||||
},
|
||||
});
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
expect(toolRegistry.setActive).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("filters a denied skill from the systemPrompt on every turn, not just the first", async () => {
|
||||
const systemPrompt = [
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>secret</name>",
|
||||
" <description>A denied skill</description>",
|
||||
" <location>/skills/secret/SKILL.md</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
].join("\n");
|
||||
const { handler, permissionManager } = makeSetup();
|
||||
vi.mocked(permissionManager.check).mockImplementation((intent) =>
|
||||
intent.surface === "skill"
|
||||
? makeCheckResult({ state: "deny" })
|
||||
: makeCheckResult(),
|
||||
const result = await handler.handle(
|
||||
{ systemPrompt: "You are an assistant." },
|
||||
makeCtx(),
|
||||
);
|
||||
|
||||
const first = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
||||
const second = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
||||
|
||||
expect(first).toHaveProperty("systemPrompt");
|
||||
expect((first as { systemPrompt: string }).systemPrompt).not.toContain(
|
||||
"secret",
|
||||
);
|
||||
expect(second).toHaveProperty("systemPrompt");
|
||||
expect((second as { systemPrompt: string }).systemPrompt).not.toContain(
|
||||
"secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty object on repeated calls with unchanged inputs", async () => {
|
||||
const { handler } = makeSetup();
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
const result = await handler.handle(makeEvent(), makeCtx());
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("stores resolved skill entries on the session", async () => {
|
||||
const { handler, session } = makeSetup();
|
||||
const spy = vi.spyOn(session, "setActiveSkillEntries");
|
||||
await handler.handle(makeEvent(), makeCtx());
|
||||
expect(spy).toHaveBeenCalledWith(expect.any(Array));
|
||||
});
|
||||
|
||||
it("returns modified systemPrompt when prompt changes", async () => {
|
||||
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
|
||||
const { handler } = makeSetup();
|
||||
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
||||
expect(result).toHaveProperty("systemPrompt");
|
||||
});
|
||||
|
||||
it("returns empty object when systemPrompt is unchanged", async () => {
|
||||
const prompt = "No tools section here.";
|
||||
const { handler } = makeSetup();
|
||||
const result = await handler.handle(makeEvent(prompt), makeCtx());
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("narrows a denied tool out of the Available tools listing without removing the section", async () => {
|
||||
const systemPrompt = [
|
||||
"Available tools:",
|
||||
"- read: Read file contents",
|
||||
"- bash: Run shell commands",
|
||||
].join("\n");
|
||||
const { handler, permissionManager } = makeSetup({
|
||||
toolRegistry: {
|
||||
getActive: vi.fn().mockReturnValue(["read", "bash"]),
|
||||
},
|
||||
});
|
||||
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
|
||||
tool === "bash" ? "deny" : "allow",
|
||||
);
|
||||
|
||||
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
|
||||
|
||||
expect(result.systemPrompt).toBeDefined();
|
||||
const out = result.systemPrompt ?? "";
|
||||
expect(out).toContain("Available tools:");
|
||||
expect(out).toContain("- read: Read file contents");
|
||||
expect(out).not.toContain("- bash");
|
||||
});
|
||||
|
||||
it("keeps the wire system prompt byte-stable across the tool-listing drift between turns", async () => {
|
||||
const fullProse = [
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"Available tools:",
|
||||
"- bash: Run shell commands",
|
||||
"- read: Read file contents",
|
||||
"- edit: Edit a file",
|
||||
"- write: Write a file",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- use bash for file operations like ls, rg, find",
|
||||
"- use read to examine files instead of cat or sed.",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
const narrowedProse = [
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"Available tools:",
|
||||
"- read: Read file contents",
|
||||
"- edit: Edit a file",
|
||||
"- write: Write a file",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- use read to examine files instead of cat or sed.",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
const { handler, permissionManager } = makeSetup({
|
||||
toolRegistry: {
|
||||
getActive: vi.fn().mockReturnValue(["bash", "read", "edit", "write"]),
|
||||
},
|
||||
});
|
||||
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
|
||||
tool === "bash" ? "deny" : "allow",
|
||||
);
|
||||
|
||||
// Turn 1: Pi feeds the full default listing.
|
||||
const first = await handler.handle(makeEvent(fullProse), makeCtx());
|
||||
// Turn 2: Pi's setActive rebuild means the event now carries the narrowed
|
||||
// listing, so the override the handler returns must still match turn 1.
|
||||
const second = await handler.handle(makeEvent(narrowedProse), makeCtx());
|
||||
|
||||
const wire1 = first.systemPrompt ?? fullProse;
|
||||
const wire2 = second.systemPrompt ?? narrowedProse;
|
||||
expect(wire1).toBe(narrowedProse);
|
||||
expect(wire2).toBe(narrowedProse);
|
||||
expect(session.getActiveSkillEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
/**
|
||||
* Integration tests for external_directory tool_call enforcement.
|
||||
*
|
||||
* These tests exercise PermissionGateHandler.handleToolCall with the
|
||||
* external-directory gate, verifying the full descriptor→runner pipeline
|
||||
* while mocking only the PermissionSession boundary.
|
||||
*
|
||||
* Regression guard: importing the four external-directory message helpers
|
||||
* ensures the test file fails to load if any helper is removed.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { EXTENSION_TAG } from "#src/presentation/agent-renderer";
|
||||
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import {
|
||||
ALL_PATH_BEARING_TOOLS,
|
||||
ALL_TOOLS,
|
||||
blockReviewEntries,
|
||||
EXT_DIR_CWD,
|
||||
EXTERNAL_PATH,
|
||||
findExtDirDecision,
|
||||
makeApprovingPrompter,
|
||||
makeDenyingPrompter,
|
||||
makeExtDirCheck,
|
||||
makeUnavailablePrompter,
|
||||
OPTIONAL_PATH_TOOLS,
|
||||
} from "#test/helpers/external-directory-fixtures";
|
||||
import {
|
||||
getDecisionEvents,
|
||||
makeCtx,
|
||||
makeHandler,
|
||||
makeToolCallEvent,
|
||||
} from "#test/helpers/handler-fixtures";
|
||||
|
||||
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
||||
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
|
||||
return { ...original };
|
||||
});
|
||||
|
||||
// ── Regression guard: helper presence ──────────────────────────────────────
|
||||
|
||||
describe("external_directory helper regression guard", () => {
|
||||
it("the external-directory ask names the path it gates", () => {
|
||||
expect(
|
||||
buildExternalDirectoryAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "/outside/file",
|
||||
cwd: "/project",
|
||||
agentName: null,
|
||||
}).request.value,
|
||||
).toBe("/outside/file");
|
||||
});
|
||||
|
||||
it("EXTENSION_TAG is the expected value", () => {
|
||||
expect(EXTENSION_TAG).toBe("[pi-permission-system]");
|
||||
});
|
||||
|
||||
// formatExternalDirectoryDenyReason, formatExternalDirectoryUserDeniedReason,
|
||||
// and formatExternalDirectoryHardStopHint are now renders over the prompt
|
||||
// payload. Their behavior is tested in presentation/agent-renderer.test.ts.
|
||||
});
|
||||
|
||||
// ── Path scope: gate applicability ────────────────────────────────────────
|
||||
|
||||
describe("external_directory path scope", () => {
|
||||
it("skips external_directory check when path is inside CWD", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", {
|
||||
input: { path: `${EXT_DIR_CWD}/src/index.ts` },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
// Should not be blocked — the external_directory gate is skipped,
|
||||
// and the tool gate sees "allow" (default toolState in makeExtDirCheck)
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
|
||||
it("fires external_directory check when path is outside CWD", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
});
|
||||
|
||||
it("skips external_directory check for non-path-bearing tool (bash)", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny", "allow") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("bash", {
|
||||
input: { command: `cat ${EXTERNAL_PATH}` },
|
||||
});
|
||||
// bash is not in PATH_BEARING_TOOLS, so the external_directory gate
|
||||
// for tool path does not fire (bash-external-directory gate is separate)
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
// bash-external-directory gate MAY fire separately, but the tool-path
|
||||
// external_directory gate does NOT fire for bash
|
||||
// We verify the checkPermission was not called with "external_directory"
|
||||
// from the tool-path gate by checking the result is not blocked by it
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(
|
||||
ALL_PATH_BEARING_TOOLS,
|
||||
)("blocks %s with an out-of-cwd path when external_directory is deny", async (toolName) => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent(toolName, {
|
||||
input: { path: EXTERNAL_PATH },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
});
|
||||
|
||||
it.each(
|
||||
OPTIONAL_PATH_TOOLS,
|
||||
)("skips external_directory check for %s when path is omitted", async (toolName) => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
// No path in input — external_directory gate should not fire
|
||||
const event = makeToolCallEvent(toolName);
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Policy state matrix: allow and deny ────────────────────────────────────
|
||||
|
||||
describe("external_directory policy state — allow", () => {
|
||||
it("falls through to tool gate when external_directory is allow", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
|
||||
it("emits decision event with policy_allow on external_directory surface", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
surface: "external_directory",
|
||||
result: "allow",
|
||||
resolution: "policy_allow",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write a block review-log entry when external_directory is allow", async () => {
|
||||
const { handler, logger } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(blockReviewEntries(logger)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// #144: allow external reads, gate external writes
|
||||
describe("external_directory — allow external reads, gate external writes (#144)", () => {
|
||||
it("allows read of external path when external_directory and read are both allow", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow", "allow") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
|
||||
it("prompts for write to external path when external_directory allows but write is ask", async () => {
|
||||
const { handler, prompter } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow", "ask") },
|
||||
prompter: makeApprovingPrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("write", {
|
||||
input: { path: EXTERNAL_PATH },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
// external_directory passes; write gate prompts and user approves
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
expect(prompter.escalate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks write to external path when external_directory allows but write is deny", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow", "deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("write", {
|
||||
input: { path: EXTERNAL_PATH },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
});
|
||||
|
||||
it("emits separate decision events for external_directory and write surfaces", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow", "deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("write", {
|
||||
input: { path: EXTERNAL_PATH },
|
||||
});
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
const decisions = getDecisionEvents(events);
|
||||
const writeDecision = decisions.find((d) => d.surface === "write");
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
surface: "external_directory",
|
||||
result: "allow",
|
||||
resolution: "policy_allow",
|
||||
});
|
||||
expect(writeDecision).toMatchObject({
|
||||
surface: "write",
|
||||
result: "deny",
|
||||
resolution: "policy_deny",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("external_directory policy state — deny", () => {
|
||||
it("blocks with reason containing the external path", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
expect((result as { reason?: string }).reason).toContain(EXTERNAL_PATH);
|
||||
});
|
||||
|
||||
it("block reason contains extension attribution", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect((result as { reason?: string }).reason).toContain(
|
||||
"[pi-permission-system]",
|
||||
);
|
||||
expect((result as { reason?: string }).reason).not.toContain("Hard stop");
|
||||
});
|
||||
|
||||
it("writes review-log entry with resolution policy_denied", async () => {
|
||||
const { handler, logger } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
const entries = blockReviewEntries(logger);
|
||||
expect(entries.length).toBeGreaterThanOrEqual(1);
|
||||
expect(entries[0][1]).toMatchObject({
|
||||
resolution: "policy_denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits decision event with policy_deny on external_directory surface", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
surface: "external_directory",
|
||||
result: "deny",
|
||||
resolution: "policy_deny",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Policy state matrix: ask ────────────────────────────────────────────────
|
||||
|
||||
describe("external_directory policy state — ask", () => {
|
||||
it("does not block when user approves", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeApprovingPrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
|
||||
it("emits user_approved decision when user approves", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeApprovingPrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
surface: "external_directory",
|
||||
result: "allow",
|
||||
resolution: "user_approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks when user denies", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeDenyingPrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
});
|
||||
|
||||
it("emits user_denied decision when user denies", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeDenyingPrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
surface: "external_directory",
|
||||
result: "deny",
|
||||
resolution: "user_denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("block reason includes denialReason when user provides one", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeDenyingPrompter("not needed"),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
expect((result as { reason?: string }).reason).toContain("not needed");
|
||||
});
|
||||
|
||||
it("blocks with confirmation_unavailable when no UI is available", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeUnavailablePrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result = await handler.handleToolCall(
|
||||
event,
|
||||
makeCtx({ hasUI: false }),
|
||||
);
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
// The gate surface names the boundary; an unavailable verdict states only
|
||||
// that approval was unreachable, since no retry shape changes that.
|
||||
expect((result as { reason?: string }).reason).toBe(
|
||||
`${EXTENSION_TAG} This 'external_directory' call for tool 'read' for path '${EXTERNAL_PATH}' requires approval, but no interactive UI is available.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits confirmation_unavailable decision when no UI", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("ask") },
|
||||
prompter: makeUnavailablePrompter(),
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
surface: "external_directory",
|
||||
result: "deny",
|
||||
resolution: "confirmation_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Per-agent override ─────────────────────────────────────────────────────
|
||||
|
||||
describe("external_directory per-agent override", () => {
|
||||
it("honors per-agent override of external_directory policy", async () => {
|
||||
// checkPermission varies by agentName: allow for "special-agent", deny otherwise
|
||||
const agentAwareCheck = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
surface: string,
|
||||
_input: unknown,
|
||||
agentName?: string,
|
||||
): PermissionCheckResult => {
|
||||
if (surface === "external_directory") {
|
||||
const state =
|
||||
agentName === "special-agent" ? "allow" : ("deny" as const);
|
||||
return {
|
||||
state,
|
||||
toolName: surface,
|
||||
source: "tool",
|
||||
origin: agentName === "special-agent" ? "agent" : "global",
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "allow",
|
||||
toolName: surface,
|
||||
source: "tool",
|
||||
origin: "builtin",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// With agent override → allowed
|
||||
const { handler: handler1, events: events1 } = makeHandler({
|
||||
session: {
|
||||
checkPermission: agentAwareCheck,
|
||||
resolveAgentName: vi.fn().mockReturnValue("special-agent"),
|
||||
},
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
const result1 = await handler1.handleToolCall(event, makeCtx());
|
||||
expect(result1).toEqual({ action: "allow" });
|
||||
|
||||
expect(findExtDirDecision(events1)).toMatchObject({
|
||||
result: "allow",
|
||||
resolution: "policy_allow",
|
||||
agentName: "special-agent",
|
||||
});
|
||||
|
||||
// Without agent override → denied
|
||||
const { handler: handler2 } = makeHandler({
|
||||
session: {
|
||||
checkPermission: agentAwareCheck,
|
||||
resolveAgentName: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const result2 = await handler2.handleToolCall(event, makeCtx());
|
||||
expect(result2).toMatchObject({ action: "block" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Decision event surface and value ──────────────────────────────────────
|
||||
|
||||
describe("external_directory decision event fields", () => {
|
||||
it("decision event value is the external path", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("deny") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
const extDirDecision = findExtDirDecision(events);
|
||||
expect(extDirDecision).toBeDefined();
|
||||
expect(extDirDecision!.value).toBe(EXTERNAL_PATH);
|
||||
});
|
||||
|
||||
it("decision event includes agentName when present", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeExtDirCheck("allow"),
|
||||
resolveAgentName: vi.fn().mockReturnValue("my-agent"),
|
||||
},
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
agentName: "my-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("decision event agentName is null when no agent", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
session: { checkPermission: makeExtDirCheck("allow") },
|
||||
tools: ALL_TOOLS,
|
||||
});
|
||||
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
expect(findExtDirDecision(events)).toMatchObject({
|
||||
agentName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* Integration tests verifying that sequential tool calls to the same
|
||||
* external path only prompt once — the session-approval recorded by the
|
||||
* first call covers the second.
|
||||
*
|
||||
* Uses real PermissionSession + PermissionResolver + SessionRules so the
|
||||
* stateful approval-tracking path is exercised end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
makeApprovingPrompter,
|
||||
makeDeduplicatingHandler,
|
||||
makeDedupWiring,
|
||||
makeExtDirBashEvent,
|
||||
makeExtDirToolEvent,
|
||||
} from "#test/helpers/external-directory-fixtures";
|
||||
import { makeCtx } from "#test/helpers/handler-fixtures";
|
||||
|
||||
// ── SDK stub ───────────────────────────────────────────────────────────────
|
||||
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
|
||||
return { ...original };
|
||||
});
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("external-directory session dedup", () => {
|
||||
describe("path-bearing tools (read, write, edit)", () => {
|
||||
it("does not re-prompt for the same external path after session approval", async () => {
|
||||
const { handler, prompter } = makeDeduplicatingHandler();
|
||||
const ctx = makeCtx();
|
||||
const externalPath = "/outside/project/data.txt";
|
||||
|
||||
// First call — should prompt
|
||||
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
|
||||
const result1 = await handler.handleToolCall(event1, ctx);
|
||||
expect(result1).toEqual({ action: "allow" });
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — same path, should hit session rule, no prompt
|
||||
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
|
||||
const result2 = await handler.handleToolCall(event2, ctx);
|
||||
expect(result2).toEqual({ action: "allow" });
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not re-prompt for a different file in the same external directory", async () => {
|
||||
const { handler, prompter } = makeDeduplicatingHandler();
|
||||
const ctx = makeCtx();
|
||||
|
||||
// First call — prompt for /outside/project/a.txt
|
||||
const event1 = makeExtDirToolEvent(
|
||||
"read",
|
||||
"/outside/project/a.txt",
|
||||
"tc-1",
|
||||
);
|
||||
await handler.handleToolCall(event1, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — /outside/project/b.txt is in the same directory
|
||||
const event2 = makeExtDirToolEvent(
|
||||
"read",
|
||||
"/outside/project/b.txt",
|
||||
"tc-2",
|
||||
);
|
||||
await handler.handleToolCall(event2, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does prompt for a file in a different external directory", async () => {
|
||||
const { handler, prompter } = makeDeduplicatingHandler();
|
||||
const ctx = makeCtx();
|
||||
|
||||
// First call — /outside/alpha/file.txt
|
||||
const event1 = makeExtDirToolEvent(
|
||||
"read",
|
||||
"/outside/alpha/file.txt",
|
||||
"tc-1",
|
||||
);
|
||||
await handler.handleToolCall(event1, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — /outside/beta/file.txt is a different directory
|
||||
const event2 = makeExtDirToolEvent(
|
||||
"read",
|
||||
"/outside/beta/file.txt",
|
||||
"tc-2",
|
||||
);
|
||||
await handler.handleToolCall(event2, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-prompts when user approved once (not for session)", async () => {
|
||||
const approveOnce = makeApprovingPrompter();
|
||||
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
|
||||
const ctx = makeCtx();
|
||||
const externalPath = "/outside/project/data.txt";
|
||||
|
||||
// First call — prompt, approved once
|
||||
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
|
||||
await handler.handleToolCall(event1, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — no session rule recorded, should prompt again
|
||||
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
|
||||
await handler.handleToolCall(event2, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bash commands with external paths", () => {
|
||||
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
|
||||
const { handler, prompter } = makeDeduplicatingHandler();
|
||||
const ctx = makeCtx();
|
||||
|
||||
// First call — bash referencing /tmp/out.txt
|
||||
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
|
||||
const result1 = await handler.handleToolCall(event1, ctx);
|
||||
expect(result1).toEqual({ action: "allow" });
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — different bash command, same external path
|
||||
const event2 = makeExtDirBashEvent("cat /tmp/out.txt", "tc-2");
|
||||
const result2 = await handler.handleToolCall(event2, ctx);
|
||||
expect(result2).toEqual({ action: "allow" });
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not re-prompt for read after bash already approved the same directory", async () => {
|
||||
const { handler, prompter } = makeDeduplicatingHandler();
|
||||
const ctx = makeCtx();
|
||||
|
||||
// First call — bash writes to /tmp/out.txt
|
||||
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
|
||||
await handler.handleToolCall(event1, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call — read from /tmp/out.txt (same directory, different tool)
|
||||
const event2 = makeExtDirToolEvent("read", "/tmp/out.txt", "tc-2");
|
||||
await handler.handleToolCall(event2, ctx);
|
||||
expect(prompter.escalate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Moved from permission-system.test.ts catch-all (#342)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("session shutdown clears external-directory approvals", () => {
|
||||
it("re-prompts for the same path after session shutdown", async () => {
|
||||
const { handler, prompter, session } = makeDedupWiring();
|
||||
|
||||
const externalPath = "/tmp/sibling/foo.ts";
|
||||
const ctx = makeCtx();
|
||||
const event = makeExtDirToolEvent("read", externalPath, "tc-1");
|
||||
|
||||
// First access: prompt fires and records session approval.
|
||||
await handler.handleToolCall(event, ctx);
|
||||
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second access: covered by session approval — no re-prompt.
|
||||
await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
|
||||
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Shutdown clears session approvals.
|
||||
session.shutdown();
|
||||
|
||||
// Third access: session rules cleared — must re-prompt.
|
||||
await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
|
||||
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Acceptance test for issue #418.
|
||||
*
|
||||
* Reproduces the reported bug with a real symlink (no `realpathSync` mock):
|
||||
* an `external_directory` allow configured for the path as the user types it
|
||||
* (`<link>/*`) must allow access even though the OS resolves `<link>` to a
|
||||
* different canonical directory. Exercised end-to-end through the real
|
||||
* `PermissionManager` + `PermissionResolver` for both a path-bearing tool and
|
||||
* a bash command, and for an allow keyed on the symlink-resolved form too.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BashProgram } from "#src/access-intent/bash/program";
|
||||
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
|
||||
import {
|
||||
type GateDescriptor,
|
||||
isGateBypass,
|
||||
isGateDescriptor,
|
||||
} from "#src/handlers/gates/descriptor";
|
||||
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
|
||||
import type { ToolCallContext } from "#src/handlers/gates/types";
|
||||
import { pathFlavorForPlatform } from "#src/path/path-flavor";
|
||||
import { PathNormalizer } from "#src/path-normalizer";
|
||||
import { PermissionResolver } from "#src/permission-resolver";
|
||||
import { SessionRules } from "#src/session-rules";
|
||||
import type { ScopeConfig } from "#src/types";
|
||||
|
||||
import { createManager } from "#test/helpers/manager-harness";
|
||||
|
||||
// ── real symlink fixture ─────────────────────────────────────────────────────
|
||||
|
||||
let realDir: string;
|
||||
let linkDir: string;
|
||||
let cwd: string;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
function mkTemp(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
tempRoots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
realDir = mkTemp("ext-real-");
|
||||
const linkParent = mkTemp("ext-link-");
|
||||
linkDir = join(linkParent, "link");
|
||||
symlinkSync(realDir, linkDir);
|
||||
cwd = mkTemp("ext-cwd-");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (tempRoots.length > 0) {
|
||||
const dir = tempRoots.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeResolver(config: ScopeConfig) {
|
||||
const { manager, cleanup } = createManager(config);
|
||||
manager.configureForCwd(cwd);
|
||||
const resolver = new PermissionResolver(manager, new SessionRules());
|
||||
return { resolver, cleanup };
|
||||
}
|
||||
|
||||
function readTcc(): ToolCallContext {
|
||||
return {
|
||||
toolName: "read",
|
||||
agentName: null,
|
||||
input: { path: join(linkDir, "file.ts") },
|
||||
toolCallId: "tc-1",
|
||||
cwd,
|
||||
};
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("external_directory symlink acceptance (#418)", () => {
|
||||
it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => {
|
||||
const { resolver, cleanup } = makeResolver({
|
||||
permission: {
|
||||
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = describeExternalDirectoryGate(
|
||||
readTcc(),
|
||||
[],
|
||||
resolver,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a path-bearing tool when the allow is keyed on the resolved path", () => {
|
||||
// Key the allow on the fully symlink-resolved directory (on macOS the
|
||||
// tmpdir root itself is a symlink, e.g. /var -> /private/var).
|
||||
const resolved = realpathSync(realDir);
|
||||
const { resolver, cleanup } = makeResolver({
|
||||
permission: {
|
||||
external_directory: { "*": "ask", [`${resolved}/*`]: "allow" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = describeExternalDirectoryGate(
|
||||
readTcc(),
|
||||
[],
|
||||
resolver,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("still prompts (ask) when no external_directory allow matches", () => {
|
||||
const { resolver, cleanup } = makeResolver({
|
||||
permission: { external_directory: { "*": "ask" } },
|
||||
});
|
||||
try {
|
||||
const result = describeExternalDirectoryGate(
|
||||
readTcc(),
|
||||
[],
|
||||
resolver,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a bash command referencing the typed (symlinked) path", async () => {
|
||||
const { resolver, cleanup } = makeResolver({
|
||||
permission: {
|
||||
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const command = `cat ${join(linkDir, "file.ts")}`;
|
||||
const tcc: ToolCallContext = {
|
||||
toolName: "bash",
|
||||
agentName: null,
|
||||
input: { command },
|
||||
toolCallId: "tc-2",
|
||||
cwd,
|
||||
};
|
||||
const program = await BashProgram.parse(
|
||||
command,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
|
||||
);
|
||||
const result = describeBashExternalDirectoryGate(tcc, program, resolver);
|
||||
// All external paths are covered by the allow → bypass, no prompt.
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AccessIntent } from "#src/access-intent/access-intent";
|
||||
import { BashProgram } from "#src/access-intent/bash/program";
|
||||
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
|
||||
import type {
|
||||
GateBypass,
|
||||
GateDescriptor,
|
||||
GateResult,
|
||||
} from "#src/handlers/gates/descriptor";
|
||||
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
||||
import type { ToolCallContext } from "#src/handlers/gates/types";
|
||||
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
|
||||
import { PathNormalizer } from "#src/path-normalizer";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import {
|
||||
allEvidence,
|
||||
findEvidence,
|
||||
type PromptPayload,
|
||||
} from "#src/presentation/prompt-payload";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
||||
|
||||
import { makeResolver } from "#test/helpers/gate-fixtures";
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every escaping path the payload lists, in payload order. */
|
||||
function externalPaths(payload: PromptPayload): string[] {
|
||||
return allEvidence(payload, "external path").map((entry) => entry.text);
|
||||
}
|
||||
|
||||
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
||||
return {
|
||||
toolName: "bash",
|
||||
agentName: null,
|
||||
input: { command: "cat /outside/project/file.ts" },
|
||||
toolCallId: "tc-1",
|
||||
cwd: "/test/project",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCheckResult(
|
||||
state: "allow" | "deny" | "ask",
|
||||
overrides: Partial<PermissionCheckResult> = {},
|
||||
): PermissionCheckResult {
|
||||
return {
|
||||
state,
|
||||
toolName: "external_directory",
|
||||
source: "special",
|
||||
origin: "builtin",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract the policy match values a resolve(intent) call carries. */
|
||||
function intentValues(intent: AccessIntent): readonly string[] {
|
||||
if (intent.kind === "access-path") return intent.path.matchValues();
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the handler's parse-once derivation: parse the bash command into a
|
||||
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
|
||||
* does, so the gate is exercised through the production wiring.
|
||||
*/
|
||||
async function describeGate(
|
||||
tcc: ToolCallContext,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): Promise<GateResult> {
|
||||
const command = getNonEmptyString(toRecord(tcc.input).command);
|
||||
const bashProgram =
|
||||
tcc.toolName === "bash" && command
|
||||
? await BashProgram.parse(
|
||||
command,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
|
||||
)
|
||||
: null;
|
||||
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
|
||||
}
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("describeBashExternalDirectoryGate", () => {
|
||||
it("returns null when tool is not bash", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ toolName: "read" }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when command has no external paths", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "ls -la" } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("resolved shell expansions (#694)", () => {
|
||||
it("prompts for a $HOME write target that does not exist yet", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
const result = await describeGate(
|
||||
makeTcc({
|
||||
input: {
|
||||
command: 'touch "$HOME/pi-permission-system-repro-new"',
|
||||
},
|
||||
}),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
|
||||
join(homedir(), "pi-permission-system-repro-new"),
|
||||
]);
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
|
||||
it("prompts for a braced ${HOME} reference", async () => {
|
||||
const result = await describeGate(
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
|
||||
makeTcc({ input: { command: 'ls "${HOME}/somewhere"' } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
|
||||
join(homedir(), "somewhere"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not prompt for a variable it cannot resolve", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: 'CURRENT="$HOME"; ls "$CURRENT"' } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves each external path on the external_directory surface via an access-path intent (#418)", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
const intent = resolver.resolve.mock.calls[0][0];
|
||||
expect(intent).toMatchObject({
|
||||
kind: "access-path",
|
||||
surface: "external_directory",
|
||||
agentName: undefined,
|
||||
});
|
||||
expect(intentValues(intent)).toEqual(["/outside/a.ts"]);
|
||||
});
|
||||
|
||||
it("carries the deciding path's access facts on promptDetails (bash external_directory surface)", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
const result = (await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
||||
resolver,
|
||||
)) as GateDescriptor;
|
||||
const intent = resolver.resolve.mock.calls[0][0];
|
||||
const path = intent.kind === "access-path" ? intent.path : undefined;
|
||||
expect(path).toBeDefined();
|
||||
expect(result.promptDetails.accessIntent).toEqual({
|
||||
surface: "external_directory",
|
||||
matchValues: path?.matchValues(),
|
||||
boundaryValue: path?.boundaryValue(),
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a bash_external_directory payload listing every escaping path", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
const result = (await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
||||
resolver,
|
||||
)) as GateDescriptor;
|
||||
|
||||
expect(result.payload.kind).toBe("bash_external_directory");
|
||||
// The command is the decision value; the paths it reaches are evidence.
|
||||
expect(result.payload.request.value).toBe("cat /outside/a.ts");
|
||||
expect(result.payload.evidence).toContainEqual({
|
||||
label: "external path",
|
||||
text: "/outside/a.ts",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns GateBypass when all external paths are session-covered", async () => {
|
||||
const resolver = makeResolver(
|
||||
makeCheckResult("allow", { source: "session" }),
|
||||
);
|
||||
const result = await describeGate(makeTcc(), resolver);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
const bypass = result as GateBypass;
|
||||
expect(bypass.action).toBe("allow");
|
||||
expect(bypass.log).toMatchObject({
|
||||
event: "permission_request.session_approved",
|
||||
details: expect.objectContaining({ resolution: "session_approved" }),
|
||||
});
|
||||
expect(bypass.decidedBy).toEqual({
|
||||
kind: "session_approval",
|
||||
surface: "external_directory",
|
||||
pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns GateBypass when all external paths are config-level allowed", async () => {
|
||||
// Config-level allow (source: "special") should suppress the prompt,
|
||||
// not just session-level allow. This was the bug: source !== "session"
|
||||
// kept config-allowed paths in the uncovered set.
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).length > 0
|
||||
? makeCheckResult("allow", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(makeTcc(), resolver);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
|
||||
// The path-less extCheck used to always return the "*" catch-all (ask),
|
||||
// silently downgrading a config-level deny to ask. After the fix, the
|
||||
// descriptor's preCheck is derived from the actual path check result.
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).length > 0
|
||||
? makeCheckResult("deny", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(makeTcc(), resolver);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.preCheck?.state).toBe("deny");
|
||||
});
|
||||
|
||||
it("descriptor surface is 'external_directory'", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc(),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("descriptor decision surface is 'external_directory'", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc(),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.decision.surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("payload carries the command and the boundary it escaped", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/file.ts" } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const { payload } = result as GateDescriptor;
|
||||
expect(payload.kind).toBe("bash_external_directory");
|
||||
expect(payload.request.value).toBe("cat /outside/file.ts");
|
||||
expect(findEvidence(payload, "working directory")?.text).toBe(
|
||||
"/test/project",
|
||||
);
|
||||
});
|
||||
|
||||
it("promptDetails includes command and tool_call source", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.promptDetails).toMatchObject({
|
||||
source: "tool_call",
|
||||
agentName: "agent-1",
|
||||
toolCallId: "tc-5",
|
||||
toolName: "bash",
|
||||
command: "cat /outside/project/file.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
|
||||
// One path config-allowed, one config-ask → descriptor with only the ask path.
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).includes("/outside/a.ts")
|
||||
? makeCheckResult("allow", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBe(1);
|
||||
expect(desc.preCheck?.state).toBe("ask");
|
||||
});
|
||||
|
||||
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
|
||||
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).includes("/outside/a.ts")
|
||||
? makeCheckResult("deny", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.preCheck?.state).toBe("deny");
|
||||
// Both paths are uncovered (neither is allow), so both patterns are included.
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBe(2);
|
||||
});
|
||||
|
||||
it("only includes uncovered paths when some are session-covered", async () => {
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).includes("/outside/a.ts")
|
||||
? makeCheckResult("allow", { source: "session" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
// Should have patterns only for the uncovered path
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeBashExternalDirectoryGate — Git Bash semantics (win32)", () => {
|
||||
async function describeGateWin32(
|
||||
tcc: ToolCallContext,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): Promise<GateResult> {
|
||||
const command = getNonEmptyString(toRecord(tcc.input).command);
|
||||
const bashProgram =
|
||||
tcc.toolName === "bash" && command
|
||||
? await BashProgram.parse(
|
||||
command,
|
||||
new PathNormalizer(win32PathFlavor, tcc.cwd),
|
||||
)
|
||||
: null;
|
||||
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
|
||||
}
|
||||
|
||||
const winTcc = (command: string): ToolCallContext =>
|
||||
makeTcc({ cwd: "C:/projects/app", input: { command } });
|
||||
|
||||
it("does not prompt for a /dev/null redirect target", async () => {
|
||||
const result = await describeGateWin32(
|
||||
winTcc("echo hi > /dev/null"),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("prompts for a /tmp path displayed as typed, not as C:\\tmp", async () => {
|
||||
const result = await describeGateWin32(
|
||||
winTcc("ls /tmp"),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect(externalPaths((result as GateDescriptor).payload)).toEqual(["/tmp"]);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AccessPath } from "#src/access-intent/access-path";
|
||||
import {
|
||||
resolveExternalDirectoryPolicy,
|
||||
selectUncoveredExternalPaths,
|
||||
} from "#src/handlers/gates/external-directory-policy";
|
||||
import { posixPathFlavor } from "#src/path/path-flavor";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
|
||||
import { makeResolver } from "#test/helpers/gate-fixtures";
|
||||
|
||||
const cwd = "/test/project";
|
||||
|
||||
function makeCheckResult(
|
||||
state: "allow" | "deny" | "ask",
|
||||
overrides: Partial<PermissionCheckResult> = {},
|
||||
): PermissionCheckResult {
|
||||
return {
|
||||
state,
|
||||
toolName: "external_directory",
|
||||
source: "special",
|
||||
origin: "builtin",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveExternalDirectoryPolicy", () => {
|
||||
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
|
||||
const path = AccessPath.forPath("/outside/a.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
});
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
|
||||
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledWith({
|
||||
kind: "access-path",
|
||||
surface: "external_directory",
|
||||
path,
|
||||
agentName: undefined,
|
||||
});
|
||||
expect(result).toEqual(makeCheckResult("ask"));
|
||||
});
|
||||
|
||||
it("threads the agent name through to the resolver", () => {
|
||||
const path = AccessPath.forPath("/outside/a.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
});
|
||||
const resolver = makeResolver(makeCheckResult("allow"));
|
||||
|
||||
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledWith({
|
||||
kind: "access-path",
|
||||
surface: "external_directory",
|
||||
path,
|
||||
agentName: "reviewer",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectUncoveredExternalPaths", () => {
|
||||
it("returns no uncovered paths when every path resolves to allow", () => {
|
||||
const paths = [
|
||||
AccessPath.forPath("/outside/a.ts", { cwd, flavor: posixPathFlavor }),
|
||||
AccessPath.forPath("/outside/b.ts", { cwd, flavor: posixPathFlavor }),
|
||||
];
|
||||
const resolver = makeResolver(makeCheckResult("allow"));
|
||||
|
||||
const { uncovered, worstCheck } = selectUncoveredExternalPaths(
|
||||
paths,
|
||||
resolver,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(uncovered).toEqual([]);
|
||||
expect(worstCheck).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects only paths whose resolved state is not allow", () => {
|
||||
const allowed = AccessPath.forPath("/outside/ok.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
});
|
||||
const asked = AccessPath.forPath("/outside/ask.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
});
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) => {
|
||||
const values =
|
||||
intent.kind === "access-path" ? intent.path.matchValues() : [];
|
||||
return values.includes("/outside/ok.ts")
|
||||
? makeCheckResult("allow")
|
||||
: makeCheckResult("ask");
|
||||
});
|
||||
|
||||
const { uncovered } = selectUncoveredExternalPaths(
|
||||
[allowed, asked],
|
||||
resolver,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(uncovered.map(({ path }) => path.value())).toEqual([asked.value()]);
|
||||
});
|
||||
|
||||
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
|
||||
const asked = AccessPath.forPath("/outside/ask.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
});
|
||||
const denied = AccessPath.forPath("/outside/deny.ts", {
|
||||
cwd,
|
||||
flavor: posixPathFlavor,
|
||||
});
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) => {
|
||||
const values =
|
||||
intent.kind === "access-path" ? intent.path.matchValues() : [];
|
||||
return values.includes("/outside/deny.ts")
|
||||
? makeCheckResult("deny")
|
||||
: makeCheckResult("ask");
|
||||
});
|
||||
|
||||
const { worstCheck } = selectUncoveredExternalPaths(
|
||||
[asked, denied],
|
||||
resolver,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(worstCheck?.state).toBe("deny");
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
GateBypass,
|
||||
GateDescriptor,
|
||||
} from "#src/handlers/gates/descriptor";
|
||||
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
||||
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
|
||||
import type { ToolCallContext } from "#src/handlers/gates/types";
|
||||
import { pathFlavorForPlatform } from "#src/path/path-flavor";
|
||||
import { PathNormalizer } from "#src/path-normalizer";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
||||
import { makeResolver } from "#test/helpers/gate-fixtures";
|
||||
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
||||
|
||||
// ── helpers ───────────────────────────��────────────────────────────��───────
|
||||
|
||||
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
||||
return {
|
||||
toolName: "read",
|
||||
agentName: null,
|
||||
input: { path: "/outside/project/file.ts" },
|
||||
toolCallId: "tc-1",
|
||||
cwd: "/test/project",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Default resolver for descriptor-shape tests that do not assert the resolved
|
||||
// state: returns `ask` for the external_directory surface so a descriptor is
|
||||
// produced. Tests that assert the typed+resolved matching pass an explicit
|
||||
// resolver to `describeExternalDirectoryGate` directly.
|
||||
function gateUnderTest(
|
||||
tcc: ToolCallContext,
|
||||
infraDirs: string[],
|
||||
extractors?: ToolAccessExtractorLookup,
|
||||
resolver: ScopedPermissionResolver = makeResolver(
|
||||
makeCheckResult({ state: "ask", toolName: "external_directory" }),
|
||||
),
|
||||
) {
|
||||
return describeExternalDirectoryGate(
|
||||
tcc,
|
||||
infraDirs,
|
||||
resolver,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
|
||||
extractors,
|
||||
);
|
||||
}
|
||||
|
||||
// ── tests ────────────────────��────────────────────────────────────��────────
|
||||
|
||||
describe("describeExternalDirectoryGate", () => {
|
||||
it("returns null when tool is not path-bearing", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ toolName: "bash", input: { command: "ls" } }),
|
||||
["/test/agent"],
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when path is inside CWD", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
|
||||
["/test/agent"],
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ── Pi infrastructure read bypass ─────────────────���────────────────────
|
||||
|
||||
it("returns GateBypass for read targeting an infra dir", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({
|
||||
toolName: "read",
|
||||
input: { path: "/test/agent/git/some-package/SKILL.md" },
|
||||
}),
|
||||
["/test/agent", "/test/agent/git"],
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
const bypass = result as GateBypass;
|
||||
expect(bypass.action).toBe("allow");
|
||||
expect(bypass.decision).toMatchObject({
|
||||
resolution: "infrastructure_auto_allowed",
|
||||
result: "allow",
|
||||
});
|
||||
expect(bypass.log).toMatchObject({
|
||||
event: "permission_request.infrastructure_auto_allowed",
|
||||
});
|
||||
// Containment allowed this, not a rule the operator wrote.
|
||||
expect(bypass.decidedBy).toEqual({ kind: "infrastructure_read" });
|
||||
});
|
||||
|
||||
it("returns GateBypass respecting custom infraDirs", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({
|
||||
toolName: "read",
|
||||
input: { path: "/custom/infra/SKILL.md" },
|
||||
}),
|
||||
["/custom/infra"],
|
||||
);
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT bypass for write tools targeting infra dirs", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({
|
||||
toolName: "write",
|
||||
input: { path: "/test/agent/git/some-file.ts", content: "x" },
|
||||
}),
|
||||
["/test/agent", "/test/agent/git"],
|
||||
);
|
||||
// Should be a GateDescriptor (needs permission check), not a bypass
|
||||
expect(result).not.toBeNull();
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
});
|
||||
|
||||
// ── GateDescriptor for external paths ─────────────────────────────────��
|
||||
|
||||
it("returns GateDescriptor with surface 'external_directory'", () => {
|
||||
const result = gateUnderTest(makeTcc(), ["/test/agent"]);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("decision value is the external path", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
||||
["/test/agent"],
|
||||
) as GateDescriptor;
|
||||
expect(result.decision.value).toBe("/outside/project/file.ts");
|
||||
expect(result.decision.surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("carries the child-fixed access facts on promptDetails (external_directory surface)", () => {
|
||||
const path = "/outside/project/file.ts";
|
||||
const result = gateUnderTest(makeTcc({ input: { path } }), [
|
||||
"/test/agent",
|
||||
]) as GateDescriptor;
|
||||
const accessPath = new PathNormalizer(
|
||||
pathFlavorForPlatform(process.platform),
|
||||
"/test/project",
|
||||
).forPath(path);
|
||||
expect(result.promptDetails.accessIntent).toEqual({
|
||||
surface: "external_directory",
|
||||
matchValues: accessPath.matchValues(),
|
||||
boundaryValue: accessPath.boundaryValue(),
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an external_directory payload carrying the escaped boundary", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
||||
["/test/agent"],
|
||||
) as GateDescriptor;
|
||||
|
||||
expect(result.payload.kind).toBe("external_directory");
|
||||
expect(result.payload.request.value).toBe("/outside/project/file.ts");
|
||||
expect(result.payload.evidence).toContainEqual({
|
||||
label: "working directory",
|
||||
text: "/test/project",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
||||
["/test/agent"],
|
||||
) as GateDescriptor;
|
||||
expect(result.input).toEqual({});
|
||||
expect(result.preCheck).toBeDefined();
|
||||
expect(result.preCheck?.state).toBe("ask");
|
||||
});
|
||||
|
||||
it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => {
|
||||
const resolver = makeResolver(
|
||||
makeCheckResult({ state: "ask", toolName: "external_directory" }),
|
||||
);
|
||||
gateUnderTest(
|
||||
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
||||
["/test/agent"],
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
expect(resolver.resolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "access-path",
|
||||
surface: "external_directory",
|
||||
agentName: undefined,
|
||||
}),
|
||||
);
|
||||
const intent = resolver.resolve.mock.calls[0][0];
|
||||
expect(intent.kind).toBe("access-path");
|
||||
if (intent.kind === "access-path") {
|
||||
expect(intent.path.matchValues()).toEqual(["/outside/project/file.ts"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("sessionApproval uses deriveApprovalPattern", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
||||
["/test/agent"],
|
||||
) as GateDescriptor;
|
||||
expect(result.sessionApproval).toBeDefined();
|
||||
expect(result.sessionApproval?.surface).toBe("external_directory");
|
||||
expect(result.sessionApproval?.representativePattern).toBeDefined();
|
||||
});
|
||||
|
||||
it("payload contains the external path and the boundary it escaped", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ input: { path: "/outside/project/file.ts" } }),
|
||||
["/test/agent"],
|
||||
) as GateDescriptor;
|
||||
expect(result.payload.kind).toBe("external_directory");
|
||||
expect(result.payload.request.toolName).toBe("read");
|
||||
expect(result.payload.request.value).toBe("/outside/project/file.ts");
|
||||
expect(result.payload.evidence).toContainEqual({
|
||||
label: "working directory",
|
||||
text: "/test/project",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("promptDetails includes path and tool_call source", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }),
|
||||
["/test/agent"],
|
||||
) as GateDescriptor;
|
||||
expect(result.promptDetails).toMatchObject({
|
||||
source: "tool_call",
|
||||
agentName: "agent-1",
|
||||
toolCallId: "tc-5",
|
||||
toolName: "read",
|
||||
path: "/outside/project/file.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("logContext includes the path, and no prompt wording", () => {
|
||||
const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor;
|
||||
expect(result.logContext).toMatchObject({
|
||||
source: "tool_call",
|
||||
path: "/outside/project/file.ts",
|
||||
});
|
||||
// The payload's request facts are stamped by the runner, not the gate.
|
||||
expect(result.logContext).not.toHaveProperty("message");
|
||||
});
|
||||
});
|
||||
|
||||
// Extension and MCP tools are now external-directory gated (#352) ───────────
|
||||
|
||||
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
|
||||
it("gates an extension tool with an external input.path", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({
|
||||
toolName: "my-ext",
|
||||
input: { path: "/outside/project/file.ts" },
|
||||
}),
|
||||
["/test/agent"],
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect((result as GateDescriptor).surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("gates an MCP tool with an external arguments.path", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({
|
||||
toolName: "mcp",
|
||||
input: { arguments: { path: "/outside/project/file.ts" } },
|
||||
}),
|
||||
["/test/agent"],
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a registered extractor's external path for a custom-shaped tool", () => {
|
||||
const extractors = {
|
||||
get: (name: string) =>
|
||||
name === "ffgrep"
|
||||
? (input: Record<string, unknown>) =>
|
||||
typeof input.target === "string" ? input.target : undefined
|
||||
: undefined,
|
||||
};
|
||||
const result = gateUnderTest(
|
||||
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
|
||||
["/test/agent"],
|
||||
extractors,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for an extension tool whose path is inside cwd", () => {
|
||||
const result = gateUnderTest(
|
||||
makeTcc({
|
||||
toolName: "my-ext",
|
||||
input: { path: "/test/project/src/x.ts" },
|
||||
}),
|
||||
["/test/agent"],
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -847,29 +847,6 @@ describe("GateRunner — request identity", () => {
|
||||
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
|
||||
});
|
||||
|
||||
it("stamps a bypass's log entry and decision event with one id", async () => {
|
||||
const { runner, reviewWrites, decisions } = makeRecordingRunner();
|
||||
const bypass: GateBypass = {
|
||||
action: "allow",
|
||||
decidedBy: { kind: "infrastructure_read" },
|
||||
log: {
|
||||
event: "permission_request.infrastructure_auto_allowed",
|
||||
details: { path: "/x" },
|
||||
},
|
||||
decision: {
|
||||
surface: "read",
|
||||
value: "/x",
|
||||
result: "allow",
|
||||
resolution: "infrastructure_auto_allowed",
|
||||
origin: null,
|
||||
agentName: null,
|
||||
matchedPattern: null,
|
||||
},
|
||||
};
|
||||
await runner.run(bypass, null);
|
||||
expect(decisions[0].requestId).toMatch(/^perm-/);
|
||||
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
|
||||
});
|
||||
|
||||
it("stamps an allow decision event even when nothing is written to the log", async () => {
|
||||
const { runner, decisions } = makeRecordingRunner();
|
||||
|
||||
@@ -118,17 +118,6 @@ describe("ToolCallGatePipeline", () => {
|
||||
expect(getToolPreviewLimits).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls getInfrastructureReadDirs() during evaluate", async () => {
|
||||
const getInfrastructureReadDirs = vi.fn<() => string[]>(() => []);
|
||||
const resolver = makeResolver(makeCheckResult());
|
||||
const inputs = makeGateInputs({ getInfrastructureReadDirs });
|
||||
const { runner } = makeGateRunner();
|
||||
const pipeline = new ToolCallGatePipeline(resolver, inputs);
|
||||
|
||||
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
|
||||
|
||||
expect(getInfrastructureReadDirs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls getActiveSkillEntries() during evaluate", async () => {
|
||||
const getActiveSkillEntries = vi.fn<() => []>(() => []);
|
||||
|
||||
@@ -154,35 +154,6 @@ describe("shell-tool alias gating (#574)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("gates an aliased tool's workdir and its relative tokens via external_directory", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
shellTools: execShellTools,
|
||||
tools: ["exec_command"],
|
||||
session: {
|
||||
checkPermission: makeSurfaceCheck(
|
||||
{ external_directory: { state: "deny", matchedPattern: "*" } },
|
||||
{ state: "allow" },
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// workdir /etc is outside the cwd; the relative token resolves against it.
|
||||
await handler.handleToolCall(
|
||||
makeToolCallEvent("exec_command", {
|
||||
input: { cmd: "cat ../secret.txt", workdir: "/etc" },
|
||||
}),
|
||||
makeCtx(),
|
||||
);
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
expect(decisions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
surface: "external_directory",
|
||||
result: "deny",
|
||||
resolution: "policy_deny",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("floors an indirection wrapper (sudo) in an aliased command to ask (#490)", async () => {
|
||||
const { handler, events } = makeHandler({
|
||||
|
||||
@@ -223,34 +223,6 @@ describe("handleToolCall decision events — confirmation_unavailable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── infrastructure_auto_allowed path ──────────────────────────────────────
|
||||
|
||||
describe("handleToolCall decision events — infrastructure_auto_allowed", () => {
|
||||
it("emits allow with infrastructure_auto_allowed for Pi infra reads", async () => {
|
||||
const infraDir = "/test/agent";
|
||||
const { handler, events } = makeHandler({
|
||||
session: {
|
||||
checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
|
||||
getInfrastructureReadDirs: vi.fn().mockReturnValue([infraDir]),
|
||||
},
|
||||
});
|
||||
|
||||
const event = makeToolCallEvent("read", {
|
||||
input: { path: `${infraDir}/some-file.json` },
|
||||
});
|
||||
await handler.handleToolCall(event, makeCtx());
|
||||
|
||||
const decisions = getDecisionEvents(events);
|
||||
const infraEvents = decisions.filter(
|
||||
(e) => e.resolution === "infrastructure_auto_allowed",
|
||||
);
|
||||
expect(infraEvents).toHaveLength(1);
|
||||
expect(infraEvents[0]).toMatchObject({
|
||||
result: "allow",
|
||||
resolution: "infrastructure_auto_allowed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── auto_approved path (yolo mode) ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -292,86 +292,6 @@ describe("handleToolCall — bash command chain gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Moved from permission-system.test.ts catch-all (#342)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleToolCall — bash external-directory policy states", () => {
|
||||
it("allows bash command with only internal paths when external_directory is denied", async () => {
|
||||
const { handler } = makeHandler({ tools: ["bash"] });
|
||||
const event = makeToolCallEvent("bash", {
|
||||
input: { command: "cat src/index.ts" },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
|
||||
it("blocks bash command with external path when external_directory is ask and no UI", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSurfaceCheck({
|
||||
external_directory: { state: "ask", source: "special" },
|
||||
}),
|
||||
},
|
||||
tools: ["bash"],
|
||||
prompter: {
|
||||
escalate: vi.fn().mockResolvedValue({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const event = makeToolCallEvent("bash", {
|
||||
input: { command: "cat /etc/hosts" },
|
||||
});
|
||||
const result = await handler.handleToolCall(
|
||||
event,
|
||||
makeCtx({ hasUI: false }),
|
||||
);
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
expect(String((result as { reason?: unknown }).reason)).toMatch(
|
||||
/no interactive UI/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows bash command with external path when external_directory is allow", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSurfaceCheck({
|
||||
external_directory: { state: "allow", source: "special" },
|
||||
}),
|
||||
},
|
||||
tools: ["bash"],
|
||||
});
|
||||
const event = makeToolCallEvent("bash", {
|
||||
input: { command: "cat /etc/hosts" },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toEqual({ action: "allow" });
|
||||
});
|
||||
|
||||
it("applies bash pattern deny after external_directory allow", async () => {
|
||||
const { handler } = makeHandler({
|
||||
session: {
|
||||
checkPermission: makeSurfaceCheck(
|
||||
{
|
||||
external_directory: { state: "allow", source: "special" },
|
||||
bash: { state: "deny", source: "bash" },
|
||||
},
|
||||
{ state: "allow" },
|
||||
),
|
||||
},
|
||||
tools: ["bash"],
|
||||
});
|
||||
const event = makeToolCallEvent("bash", {
|
||||
input: { command: "cat /etc/hosts" },
|
||||
});
|
||||
const result = await handler.handleToolCall(event, makeCtx());
|
||||
expect(result).toMatchObject({ action: "block" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleToolCall — generic ask prompt content", () => {
|
||||
it("ask prompt includes serialized tool input for informed approval", async () => {
|
||||
const { handler, prompter } = makeHandler({
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("HardGuardPolicyLoader", () => {
|
||||
"~/.ssh/**": "deny",
|
||||
"~/.ssh/*.example": "allow",
|
||||
});
|
||||
expect(loader.loadProjectConfig()).toEqual({});
|
||||
expect(loader.getConfigIssues()).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* Shared fixtures for the external-directory handler-pipeline tests.
|
||||
*
|
||||
* Targets the collapsed external-directory gate (Phase 6 Step 5, #477).
|
||||
* Consumed by external-directory-integration.test.ts and
|
||||
* external-directory-session-dedup.test.ts.
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
||||
import { GateDecisionReporter } from "#src/decision-reporter";
|
||||
import { GateRunner } from "#src/handlers/gates/runner";
|
||||
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
||||
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
||||
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
||||
import type { ScopedPermissionManager } from "#src/permission-manager";
|
||||
import type { SessionLogger } from "#src/session-logger";
|
||||
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
||||
import { wildcardMatch } from "#src/wildcard-matcher";
|
||||
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
|
||||
|
||||
import {
|
||||
getDecisionEvents,
|
||||
makeEvents,
|
||||
makeSurfaceCheck,
|
||||
makeToolRegistry,
|
||||
} from "#test/helpers/handler-fixtures";
|
||||
import {
|
||||
makeRealResolver,
|
||||
makeRealSession,
|
||||
} from "#test/helpers/session-fixtures";
|
||||
|
||||
// ── Shared constants ───────────────────────────────────────────────────────
|
||||
|
||||
/** Working-directory used by the external-directory handler-pipeline tests. */
|
||||
export const EXT_DIR_CWD = "/test/project";
|
||||
|
||||
/** An external path (outside {@link EXT_DIR_CWD}) used across the test suite. */
|
||||
export const EXTERNAL_PATH = "/outside/project/file.ts";
|
||||
|
||||
/** All path-bearing tools subject to the external-directory gate. */
|
||||
export const ALL_PATH_BEARING_TOOLS = [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"find",
|
||||
"grep",
|
||||
"ls",
|
||||
];
|
||||
|
||||
/** Path-bearing tools where the path is optional (no input → gate is skipped). */
|
||||
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
|
||||
|
||||
/** Full tool set used as the default registry in external-directory tests. */
|
||||
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
|
||||
|
||||
// ── Setup builders ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds a `checkPermission` mock for external-directory tests.
|
||||
*
|
||||
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
|
||||
* with `source: "special"` (so the cross-cutting path gate is transparent),
|
||||
* and every other surface to `toolState` (default: allow).
|
||||
*/
|
||||
export function makeExtDirCheck(
|
||||
externalDirectoryState: PermissionState,
|
||||
toolState: PermissionState = "allow",
|
||||
) {
|
||||
return makeSurfaceCheck(
|
||||
{
|
||||
external_directory: { state: externalDirectoryState },
|
||||
path: { state: "allow", source: "special" },
|
||||
},
|
||||
{ state: toolState },
|
||||
);
|
||||
}
|
||||
|
||||
/** AskEscalator stub that approves with `state: "approved"`. */
|
||||
export function makeApprovingPrompter(): AskEscalator {
|
||||
return {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AskEscalator stub that denies.
|
||||
*
|
||||
* Pass `denialReason` to simulate a user who explains the refusal.
|
||||
*/
|
||||
export function makeDenyingPrompter(denialReason?: string): AskEscalator {
|
||||
return {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue(
|
||||
denialReason !== undefined
|
||||
? {
|
||||
approved: false,
|
||||
state: "denied",
|
||||
denialReason,
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}
|
||||
: { approved: false, state: "denied", decidedBy: DECIDED_BY_HUMAN },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AskEscalator stub for a session with no reachable authority: the
|
||||
* DenyingAuthorizer denies with the `confirmationUnavailable` marker.
|
||||
*/
|
||||
export function makeUnavailablePrompter(): AskEscalator {
|
||||
return {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: false,
|
||||
state: "denied",
|
||||
confirmationUnavailable: true,
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Query helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Find the `external_directory` decision event from the events mock. */
|
||||
export function findExtDirDecision(events: ReturnType<typeof makeEvents>) {
|
||||
return getDecisionEvents(events).find(
|
||||
(d) => d.surface === "external_directory",
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the `permission_request.blocked` review-log entries from the logger mock. */
|
||||
export function blockReviewEntries(logger: SessionLogger) {
|
||||
return (logger.review as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([eventName]: string[]) => eventName === "permission_request.blocked",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Session-dedup wiring ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Installs the session-aware `check(intent)` mock on the permission manager.
|
||||
*
|
||||
* Returns `ask` for `external_directory` on first access; re-checks recorded
|
||||
* session rules on subsequent calls and returns `allow` (source: "session")
|
||||
* when a `wildcardMatch` covers the path.
|
||||
*/
|
||||
export function makeExtDirDedupCheck(
|
||||
permissionManager: ScopedPermissionManager,
|
||||
): void {
|
||||
vi.mocked(permissionManager.check).mockImplementation(
|
||||
(intent, rules): PermissionCheckResult => {
|
||||
const { surface } = intent;
|
||||
const pathValue =
|
||||
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
|
||||
|
||||
if (surface === "external_directory") {
|
||||
if (pathValue && rules && rules.length > 0) {
|
||||
const match = rules.findLast(
|
||||
(r) =>
|
||||
r.surface === "external_directory" &&
|
||||
wildcardMatch(r.pattern, pathValue),
|
||||
);
|
||||
if (match) {
|
||||
return {
|
||||
state: "allow",
|
||||
toolName: surface,
|
||||
source: "session",
|
||||
origin: "session",
|
||||
matchedPattern: match.pattern,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: "ask",
|
||||
toolName: surface,
|
||||
source: "special",
|
||||
origin: "global",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: "allow",
|
||||
toolName: surface,
|
||||
source: "tool",
|
||||
origin: "builtin",
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** AskEscalator stub that approves for the session (`state: "approved_for_session"`). */
|
||||
function makeSessionApprovingPrompter(): AskEscalator {
|
||||
return {
|
||||
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
||||
approved: true,
|
||||
state: "approved_for_session",
|
||||
decidedBy: DECIDED_BY_HUMAN,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the fully-wired session-dedup handler with real collaborators.
|
||||
*
|
||||
* Unlike `makeHandler`, this wires `makeRealSession` + `makeRealResolver`
|
||||
* manually so the caller can access the raw `session` for shutdown tests.
|
||||
*
|
||||
* Returns `{ handler, prompter, session }`.
|
||||
*/
|
||||
export function makeDedupWiring(prompter?: AskEscalator) {
|
||||
const { session, permissionManager, sessionRules, logger } =
|
||||
makeRealSession();
|
||||
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
||||
makeExtDirDedupCheck(permissionManager);
|
||||
const events = makeEvents();
|
||||
const reporter = new GateDecisionReporter(logger, events);
|
||||
const resolvedPrompter: AskEscalator =
|
||||
prompter ?? makeSessionApprovingPrompter();
|
||||
const runner = new GateRunner(
|
||||
resolver,
|
||||
sessionRules,
|
||||
resolvedPrompter,
|
||||
reporter,
|
||||
() => false,
|
||||
);
|
||||
const handler = new PermissionGateHandler(
|
||||
session,
|
||||
makeToolRegistry({
|
||||
getAll: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: "read" },
|
||||
{ name: "write" },
|
||||
{ name: "edit" },
|
||||
{ name: "bash" },
|
||||
]),
|
||||
}),
|
||||
new ToolCallGatePipeline(resolver, session),
|
||||
new SkillInputGatePipeline(resolver),
|
||||
runner,
|
||||
);
|
||||
return { handler, prompter: resolvedPrompter, session };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the session-dedup handler without exposing the raw session.
|
||||
*
|
||||
* Wraps `makeDedupWiring`; returns `{ handler, prompter }`.
|
||||
* Use `makeDedupWiring` when the test also needs `session.shutdown()`.
|
||||
*/
|
||||
export function makeDeduplicatingHandler(prompter?: AskEscalator) {
|
||||
const { handler, prompter: resolvedPrompter } = makeDedupWiring(prompter);
|
||||
return { handler, prompter: resolvedPrompter };
|
||||
}
|
||||
|
||||
// ── Event builders ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds a tool-call event in the shape that external-directory-session-dedup
|
||||
* tests use — `toolName` field (not `name`); both are accepted by
|
||||
* `getToolNameFromValue`.
|
||||
*/
|
||||
export function makeExtDirToolEvent(
|
||||
toolName: string,
|
||||
path: string,
|
||||
toolCallId = "tc-1",
|
||||
) {
|
||||
return { type: "tool_call" as const, toolCallId, toolName, input: { path } };
|
||||
}
|
||||
|
||||
/** Builds a bash tool-call event for external-directory session-dedup tests. */
|
||||
export function makeExtDirBashEvent(command: string, toolCallId = "tc-1") {
|
||||
return {
|
||||
type: "tool_call" as const,
|
||||
toolCallId,
|
||||
toolName: "bash",
|
||||
input: { command },
|
||||
};
|
||||
}
|
||||
@@ -224,7 +224,6 @@ export function makeGateCheckResult(
|
||||
export function makeGateInputs(
|
||||
overrides: {
|
||||
getActiveSkillEntries?: () => SkillPromptEntry[];
|
||||
getInfrastructureReadDirs?: () => string[];
|
||||
getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
|
||||
getPathNormalizer?: () => PathNormalizer;
|
||||
getShellToolAliases?: () => ShellToolsConfig | undefined;
|
||||
@@ -234,8 +233,6 @@ export function makeGateInputs(
|
||||
getActiveSkillEntries:
|
||||
overrides.getActiveSkillEntries ??
|
||||
vi.fn<() => SkillPromptEntry[]>(() => []),
|
||||
getInfrastructureReadDirs:
|
||||
overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
|
||||
getToolPreviewLimits:
|
||||
overrides.getToolPreviewLimits ??
|
||||
vi.fn<() => ToolPreviewFormatterOptions>(() => ({
|
||||
|
||||
@@ -128,8 +128,6 @@ export function makeToolRegistry(
|
||||
): ToolRegistry {
|
||||
return {
|
||||
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
||||
getActive: vi.fn().mockReturnValue(["read", "bash"]),
|
||||
setActive: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -212,7 +210,7 @@ export function makeBashCommandCheck(opts: {
|
||||
*
|
||||
* The `session` override bag maps to the real collaborators:
|
||||
* - `checkPermission` → applied to `permissionManager.checkPermission`
|
||||
* - `getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`
|
||||
* - `getActiveSkillEntries`, `getToolPreviewLimits`
|
||||
* → applied as vi.spyOn overrides on the real session
|
||||
* - `resolveAgentName` → applied as a vi.spyOn override on the real session
|
||||
*
|
||||
@@ -283,11 +281,6 @@ export function makeHandler(overrides?: {
|
||||
so.getActiveSkillEntries,
|
||||
);
|
||||
}
|
||||
if (so?.getInfrastructureReadDirs) {
|
||||
vi.spyOn(session, "getInfrastructureReadDirs").mockImplementation(
|
||||
so.getInfrastructureReadDirs,
|
||||
);
|
||||
}
|
||||
if (so?.getToolPreviewLimits) {
|
||||
vi.spyOn(session, "getToolPreviewLimits").mockImplementation(
|
||||
so.getToolPreviewLimits,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths";
|
||||
import { getGlobalConfigPath } from "#src/config-paths";
|
||||
import { PermissionManager, type PolicyLoader } from "#src/permission-manager";
|
||||
import type { ResolvedPolicyPaths } from "#src/policy-loader";
|
||||
import type { Rule } from "#src/rule";
|
||||
@@ -21,7 +21,6 @@ import type { PermissionState, ScopeConfig } from "#src/types";
|
||||
export function createInMemoryPolicyLoader(
|
||||
scopes: {
|
||||
global?: ScopeConfig;
|
||||
project?: ScopeConfig;
|
||||
agent?: Record<string, ScopeConfig>;
|
||||
projectAgent?: Record<string, ScopeConfig>;
|
||||
} = {},
|
||||
@@ -30,7 +29,6 @@ export function createInMemoryPolicyLoader(
|
||||
const issues: string[] = [];
|
||||
return {
|
||||
loadGlobalConfig: () => scopes.global ?? ({} as const),
|
||||
loadProjectConfig: () => scopes.project ?? ({} as const),
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
|
||||
loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
|
||||
loadProjectAgentConfig: (name?: string) =>
|
||||
@@ -42,8 +40,6 @@ export function createInMemoryPolicyLoader(
|
||||
getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
|
||||
globalConfigPath: "/in-memory/config.json",
|
||||
globalConfigExists: true,
|
||||
projectConfigPath: null,
|
||||
projectConfigExists: false,
|
||||
agentsDir: "/in-memory/agents",
|
||||
agentsDirExists: false,
|
||||
projectAgentsDir: null,
|
||||
@@ -185,7 +181,6 @@ export function createManagerWithProject(
|
||||
const manager = new PermissionManager({
|
||||
globalConfigPath,
|
||||
agentsDir,
|
||||
projectGlobalConfigPath,
|
||||
projectAgentsDir,
|
||||
mcpServerNames: options.mcpServerNames,
|
||||
});
|
||||
@@ -245,7 +240,13 @@ export function createAgentDirHarness(opts: {
|
||||
JSON.stringify({ permission: opts.globalPermission }, null, 2),
|
||||
);
|
||||
|
||||
const projectConfigPath = getProjectConfigPath(cwd);
|
||||
const projectConfigPath = join(
|
||||
cwd,
|
||||
".pi",
|
||||
"extensions",
|
||||
"pi-permission-system",
|
||||
"policy.jsonc",
|
||||
);
|
||||
mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@ export function makePaths(
|
||||
subagentSessionsDir: "/test/agent/subagent-sessions",
|
||||
forwardingDir: "/test/agent/sessions/permission-forwarding",
|
||||
globalLogsDir: "/test/agent/logs",
|
||||
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,45 +84,6 @@ describe("PathNormalizer", () => {
|
||||
expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false);
|
||||
});
|
||||
|
||||
test("isOutsideWorkingDirectory tests against the baked cwd", () => {
|
||||
expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
|
||||
});
|
||||
|
||||
test("isOutsideWorkingDirectory expands a home-relative token", () => {
|
||||
expect(normalizer.isOutsideWorkingDirectory("~/secrets")).toBe(true);
|
||||
});
|
||||
|
||||
test("isOutsideWorkingDirectory resolves a relative token inside cwd", () => {
|
||||
expect(normalizer.isOutsideWorkingDirectory("src/index.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("isOutsideWorkingDirectory follows an in-cwd symlink to an external target", () => {
|
||||
// ./link -> /etc: realpathSync resolves the full token in one call.
|
||||
realpathSync.mockImplementation((p: string) => {
|
||||
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
|
||||
return p;
|
||||
});
|
||||
expect(normalizer.isOutsideWorkingDirectory("./link/hosts")).toBe(true);
|
||||
});
|
||||
|
||||
test("isOutsideWorkingDirectory keeps a path inside a symlinked cwd", () => {
|
||||
// /tmp -> /private/tmp on macOS; cwd reported as the resolved /private/tmp.
|
||||
realpathSync.mockImplementation((p: string) => {
|
||||
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
|
||||
if (p === "/tmp") return "/private/tmp";
|
||||
return p;
|
||||
});
|
||||
const symlinkNormalizer = new PathNormalizer(
|
||||
posixPathFlavor,
|
||||
"/private/tmp",
|
||||
);
|
||||
expect(
|
||||
symlinkNormalizer.isOutsideWorkingDirectory("/tmp/workspace/file.ts"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("comparableValue returns the lexical absolute form (no FS)", () => {
|
||||
expect(normalizer.comparableValue("src/foo.ts")).toBe(
|
||||
@@ -136,20 +97,6 @@ describe("PathNormalizer", () => {
|
||||
expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
|
||||
});
|
||||
|
||||
test("isBoundaryOutsideWorkingDirectory tests a canonical path against cwd", () => {
|
||||
expect(
|
||||
normalizer.isBoundaryOutsideWorkingDirectory("/projects/my-app/src"),
|
||||
).toBe(false);
|
||||
expect(normalizer.isBoundaryOutsideWorkingDirectory("/etc/hosts")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isBoundaryOutsideWorkingDirectory excludes a safe device path", () => {
|
||||
expect(normalizer.isBoundaryOutsideWorkingDirectory("/dev/null")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("interpretBashCdTarget maps an absolute target to absolute (posix)", () => {
|
||||
expect(normalizer.interpretBashCdTarget("/etc")).toEqual({
|
||||
@@ -199,12 +146,6 @@ describe("PathNormalizer", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("isOutsideWorkingDirectory case-folds against the baked cwd", () => {
|
||||
expect(
|
||||
normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"),
|
||||
).toBe(false);
|
||||
expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
|
||||
});
|
||||
|
||||
test("comparableValue case-folds the lexical absolute form", () => {
|
||||
expect(normalizer.comparableValue("src\\foo.ts")).toBe(
|
||||
@@ -216,9 +157,6 @@ describe("PathNormalizer", () => {
|
||||
const ap = normalizer.forBashToken("/dev/null");
|
||||
expect(ap.value()).toBe("/dev/null");
|
||||
expect(ap.boundaryValue()).toBe("/dev/null");
|
||||
expect(
|
||||
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("forBashToken preserves all four safe device paths", () => {
|
||||
@@ -235,17 +173,11 @@ describe("PathNormalizer", () => {
|
||||
test("forBashToken translates an MSYS drive mount to a Windows path", () => {
|
||||
const ap = normalizer.forBashToken("/c/Other/x");
|
||||
expect(ap.value()).toBe("c:\\other\\x");
|
||||
expect(
|
||||
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("forBashToken translates an in-cwd drive mount (not external)", () => {
|
||||
test("forBashToken translates an in-cwd drive mount", () => {
|
||||
const ap = normalizer.forBashToken("/c/projects/app/inside.txt");
|
||||
expect(ap.value()).toBe("c:\\projects\\app\\inside.txt");
|
||||
expect(
|
||||
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("forBashToken keeps a non-mount POSIX absolute as a literal", () => {
|
||||
@@ -283,45 +215,8 @@ describe("PathNormalizer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("isBoundaryOutsideWorkingDirectory case-folds against the baked cwd", () => {
|
||||
expect(
|
||||
normalizer.isBoundaryOutsideWorkingDirectory("c:\\projects\\app\\src"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
normalizer.isBoundaryOutsideWorkingDirectory("c:\\other\\dir"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInfrastructureRead", () => {
|
||||
const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app");
|
||||
|
||||
test("allows a read-only tool targeting a configured infra dir", () => {
|
||||
const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
|
||||
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not allow a write tool targeting an infra dir", () => {
|
||||
const ap = normalizer.forPath("/infra/git/pkg/file.ts");
|
||||
expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not allow a read-only tool outside any infra dir", () => {
|
||||
const ap = normalizer.forPath("/elsewhere/file.ts");
|
||||
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
|
||||
const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
|
||||
expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entryExists", () => {
|
||||
// Real filesystem: the probe's whole purpose is to consult fs state, so
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Hoisted stub so the vi.mock factory can reference it.
|
||||
const { mockSpawnSync } = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:child_process so tests that exercise the subprocess fallback path
|
||||
// don't actually invoke npm. Default: subprocess fails (non-zero exit), so
|
||||
// tests focused on the walk-up strategy continue to expect null.
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawnSync: mockSpawnSync,
|
||||
default: { spawnSync: mockSpawnSync },
|
||||
}));
|
||||
|
||||
import { discoverGlobalNodeModulesRoot } from "#src/node-modules-discovery";
|
||||
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
|
||||
import { isPiInfrastructureRead } from "#src/path/pi-infrastructure-read";
|
||||
|
||||
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
||||
|
||||
describe("discoverGlobalNodeModulesRoot", () => {
|
||||
beforeEach(() => {
|
||||
// Default: subprocess fails, so walk-up-focused tests see null for URLs
|
||||
// with no node_modules ancestor.
|
||||
mockSpawnSync.mockReset();
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
});
|
||||
|
||||
test("returns the node_modules dir when the file is inside one", () => {
|
||||
const url =
|
||||
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/opt/homebrew/lib/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns node_modules for a deeply nested file", () => {
|
||||
const url =
|
||||
"file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/home/user/.nvm/versions/node/v20/lib/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns node_modules for a bun global install path", () => {
|
||||
const url =
|
||||
"file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/home/user/.bun/install/global/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the innermost (closest-to-file) node_modules ancestor", () => {
|
||||
// The walk-up algorithm stops at the first node_modules dir it encounters,
|
||||
// which is the innermost one when the file is inside a nested install.
|
||||
// In practice this never happens for a real global install — the extension
|
||||
// is always directly at <global_root>/node_modules/pi-permission-system/…
|
||||
const url =
|
||||
"file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBe(
|
||||
"/opt/lib/node_modules/some-pkg/node_modules",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when the file is not inside any node_modules directory", () => {
|
||||
const url =
|
||||
"file:///home/user/development/pi-permission-system/dist/external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a root-level file", () => {
|
||||
const url = "file:///external-directory.js";
|
||||
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for an invalid URL", () => {
|
||||
expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
test("works with the real import.meta.url of this extension (smoke test)", () => {
|
||||
// The extension IS installed inside a node_modules tree when running in CI
|
||||
// or global install. In a local dev checkout the result may be null — that's
|
||||
// the documented graceful-degradation path.
|
||||
const result = discoverGlobalNodeModulesRoot();
|
||||
expect(result === null || result.endsWith("node_modules")).toBe(true);
|
||||
});
|
||||
|
||||
test("the discovered path includes the pi-permission-system package directory", () => {
|
||||
const url =
|
||||
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|
||||
const root = discoverGlobalNodeModulesRoot(url);
|
||||
expect(root).not.toBeNull();
|
||||
expect(join(root!, "pi-permission-system")).toBe(
|
||||
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPiInfrastructureRead ─────────────────────────────────────────────────
|
||||
|
||||
const INFRA_DIRS = [
|
||||
"/home/user/.pi/agent",
|
||||
"/home/user/.pi/agent/git",
|
||||
"/opt/homebrew/lib/node_modules",
|
||||
];
|
||||
const CWD = "/home/user/project";
|
||||
|
||||
describe("isPiInfrastructureRead", () => {
|
||||
// ── read tools allowed for infra paths ──────────────────────────────────
|
||||
|
||||
test("allows 'read' tool for a file inside agentDir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'find' tool for a path inside node_modules infra dir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"find",
|
||||
"/opt/homebrew/lib/node_modules/pi-ask-user/skills",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'grep' tool for a path inside agentDir/git", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"grep",
|
||||
"/home/user/.pi/agent/git/some-package/README.md",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'ls' tool for a path inside node_modules infra dir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"ls",
|
||||
"/opt/homebrew/lib/node_modules/pi-permission-system",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── write tools never allowed even for infra paths ───────────────────────
|
||||
|
||||
test("blocks 'write' tool for a file inside agentDir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"write",
|
||||
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("blocks 'edit' tool for a file inside node_modules", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"edit",
|
||||
"/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("blocks 'bash' tool regardless of path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"bash",
|
||||
"/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── non-infra paths not allowed ──────────────────────────────────────────
|
||||
|
||||
test("does not allow 'read' for a path outside all infra dirs", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/etc/passwd",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
|
||||
// /home/user/.pi/agent-other should not match /home/user/.pi/agent
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/home/user/.pi/agent-other/config.json",
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
|
||||
|
||||
test("allows 'read' for a path inside project-local .pi/npm/", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("allows 'read' for a path inside project-local .pi/git/", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("blocks 'write' for a path inside project-local .pi/npm/", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"write",
|
||||
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
|
||||
INFRA_DIRS,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ── empty / edge cases ───────────────────────────────────────────────────
|
||||
|
||||
test("returns false when infrastructureDirs is empty and path is not project-local", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead("read", "/etc/passwd", [], CWD, posixPathFlavor),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
|
||||
// Project-local paths are checked separately from the dirs array.
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${CWD}/.pi/npm/node_modules/x/SKILL.md`,
|
||||
[],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPiInfrastructureRead — glob patterns ─────────────────────────────────
|
||||
|
||||
describe("isPiInfrastructureRead with glob patterns", () => {
|
||||
test("glob entry matches a versioned nested path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("** behaves the same as * (matches across path separators)", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("glob entry does not match an unrelated path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/etc/passwd",
|
||||
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("? matches exactly one character", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/X/file.md",
|
||||
["/opt/homebrew/?/file.md"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("? does not match multiple characters", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/abc/file.md",
|
||||
["/opt/homebrew/?/file.md"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("mixed array of plain dirs and glob patterns — both branches work", () => {
|
||||
const dirs = [
|
||||
"/home/user/.pi/agent",
|
||||
"/opt/homebrew/*/@earendil-works/pi-coding-agent/*",
|
||||
];
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/home/user/.pi/agent/config.json",
|
||||
dirs,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
dirs,
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("plain entry with ~ prefix matches after home expansion", () => {
|
||||
const home = homedir();
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
`${home}/.pi/agent/config.json`,
|
||||
["~/.pi/agent"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("write tool with a glob-matching path is still rejected", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"write",
|
||||
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
|
||||
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
|
||||
CWD,
|
||||
posixPathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isPiInfrastructureRead — win32 case-insensitive matching ───────────────
|
||||
|
||||
describe("isPiInfrastructureRead on win32", () => {
|
||||
test("plain infra dir matches a case-different path", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"c:\\users\\foo\\.pi\\agent\\config.json",
|
||||
["C:\\Users\\Foo\\.pi\\agent"],
|
||||
"C:\\proj",
|
||||
win32PathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("glob infra dir matches case-insensitively", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
|
||||
["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
|
||||
"C:\\proj",
|
||||
win32PathFlavor,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a path outside every infra dir", () => {
|
||||
expect(
|
||||
isPiInfrastructureRead(
|
||||
"read",
|
||||
"c:\\windows\\system32\\drivers\\etc\\hosts",
|
||||
["C:\\Users\\Foo\\.pi\\agent"],
|
||||
"C:\\proj",
|
||||
win32PathFlavor,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -132,18 +132,6 @@ describe("suggestSessionPattern", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("external_directory surface", () => {
|
||||
it("returns parent-directory glob from deriveApprovalPattern", () => {
|
||||
const result = suggestSessionPattern(
|
||||
"external_directory",
|
||||
"/tmp/foo.txt",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
surface: "external_directory",
|
||||
pattern: "/tmp/*",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("path surface", () => {
|
||||
it("returns directory-scoped pattern for a file path", () => {
|
||||
@@ -225,15 +213,6 @@ describe("suggestSessionPattern", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("external_directory label includes surface prefix", () => {
|
||||
const result = suggestSessionPattern(
|
||||
"external_directory",
|
||||
"/tmp/foo.txt",
|
||||
);
|
||||
expect(result.label).toBe(
|
||||
'Yes, allow access to external directory "/tmp/*" for this session',
|
||||
);
|
||||
});
|
||||
|
||||
it("path-bearing tool label includes path pattern", () => {
|
||||
const result = suggestSessionPattern("edit", "src/file.ts");
|
||||
|
||||
@@ -167,7 +167,6 @@ describe("emitDecisionEvent", () => {
|
||||
"policy_allow",
|
||||
"policy_deny",
|
||||
"session_approved",
|
||||
"infrastructure_auto_allowed",
|
||||
"user_approved",
|
||||
"user_approved_for_session",
|
||||
"user_denied",
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PermissionManager } from "#src/permission-manager";
|
||||
import type { ScopeConfig } from "#src/types";
|
||||
import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness";
|
||||
|
||||
/**
|
||||
* Fail-closed clamp (#646): when a non-global config scope (project / agent /
|
||||
* project-agent) is invalid, the composed policy is floored so nothing resolves
|
||||
* more permissively than `ask` (`allow` → `ask`, tagged `origin: "fail-closed"`).
|
||||
* An invalid global scope never triggers the clamp; hard `deny` survives.
|
||||
*/
|
||||
function makeManager(scopes: {
|
||||
global?: ScopeConfig;
|
||||
project?: ScopeConfig;
|
||||
agent?: Record<string, ScopeConfig>;
|
||||
projectAgent?: Record<string, ScopeConfig>;
|
||||
}): PermissionManager {
|
||||
return new PermissionManager({
|
||||
policyLoader: createInMemoryPolicyLoader(scopes),
|
||||
});
|
||||
}
|
||||
|
||||
const bashCheck = {
|
||||
kind: "tool" as const,
|
||||
surface: "bash",
|
||||
input: { command: "echo hi" },
|
||||
};
|
||||
|
||||
describe("PermissionManager fail-closed clamp on invalid non-global scope", () => {
|
||||
it("floors a lower-scope allow to ask when a project scope is invalid", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" } },
|
||||
project: { invalid: true },
|
||||
});
|
||||
const result = manager.check(bashCheck);
|
||||
expect(result.state).toBe("ask");
|
||||
expect(result.origin).toBe("fail-closed");
|
||||
});
|
||||
|
||||
it("preserves a lower-scope deny when a project scope is invalid", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "deny" } },
|
||||
project: { invalid: true },
|
||||
});
|
||||
const result = manager.check(bashCheck);
|
||||
expect(result.state).toBe("deny");
|
||||
expect(result.origin).not.toBe("fail-closed");
|
||||
});
|
||||
|
||||
it("floors when an agent scope is invalid", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" } },
|
||||
agent: { coder: { invalid: true } },
|
||||
});
|
||||
const result = manager.check({ ...bashCheck, agentName: "coder" });
|
||||
expect(result.state).toBe("ask");
|
||||
expect(result.origin).toBe("fail-closed");
|
||||
});
|
||||
|
||||
it("does not floor when the invalid flag is on the global scope", () => {
|
||||
// Global is the lowest precedence — nothing more permissive is inherited,
|
||||
// so its invalid flag is ignored by the clamp.
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" }, invalid: true },
|
||||
});
|
||||
const result = manager.check(bashCheck);
|
||||
expect(result.state).toBe("allow");
|
||||
expect(result.origin).not.toBe("fail-closed");
|
||||
});
|
||||
|
||||
it("does not floor when all scopes are valid", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" } },
|
||||
project: { permission: { read: "allow" } },
|
||||
});
|
||||
const result = manager.check(bashCheck);
|
||||
expect(result.state).toBe("allow");
|
||||
expect(result.origin).not.toBe("fail-closed");
|
||||
});
|
||||
|
||||
it("floors getToolPermission too (display parity)", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" } },
|
||||
project: { invalid: true },
|
||||
});
|
||||
expect(manager.getToolPermission("bash")).toBe("ask");
|
||||
});
|
||||
|
||||
it("appends a fail-closed notice to config issues naming the invalid scope", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" } },
|
||||
project: { invalid: true },
|
||||
});
|
||||
expect(manager.getConfigIssues()).toEqual([
|
||||
"Invalid project configuration detected — failing closed: 'allow' rules " +
|
||||
"are clamped to 'ask' for this session until the configuration is corrected.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits no fail-closed notice when all scopes are valid", () => {
|
||||
const manager = makeManager({
|
||||
global: { permission: { bash: "allow" } },
|
||||
project: { permission: { read: "allow" } },
|
||||
});
|
||||
expect(manager.getConfigIssues()).toEqual([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,10 @@ import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness";
|
||||
function makeManager(
|
||||
global: Record<string, unknown>,
|
||||
isYoloEnabled: () => boolean,
|
||||
project?: ScopeConfig,
|
||||
): PermissionManager {
|
||||
return new PermissionManager({
|
||||
policyLoader: createInMemoryPolicyLoader({
|
||||
global: { permission: global } as ScopeConfig,
|
||||
project,
|
||||
}),
|
||||
isYoloEnabled,
|
||||
});
|
||||
@@ -95,34 +93,3 @@ describe("PermissionManager yolo rewrite", () => {
|
||||
expect(manager.getToolPermission("bash")).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionManager fail-closed clamp under yolo (#646)", () => {
|
||||
it("yolo re-permits a fail-closed floored allow (allow→ask→allow)", () => {
|
||||
// Invalid project scope floors global's `allow` to `ask` at composition;
|
||||
// yolo then rewrites the `ask` back to `allow`. yolo is an explicit
|
||||
// full-permissive opt-in, so yolo users are unaffected by the clamp.
|
||||
const manager = makeManager({ bash: "allow" }, () => true, {
|
||||
invalid: true,
|
||||
});
|
||||
const result = manager.check({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command: "echo hi" },
|
||||
});
|
||||
expect(result.state).toBe("allow");
|
||||
expect(result.origin).toBe("yolo");
|
||||
});
|
||||
|
||||
it("preserves a hard deny under yolo even with an invalid scope", () => {
|
||||
const manager = makeManager({ bash: "deny" }, () => true, {
|
||||
invalid: true,
|
||||
});
|
||||
const result = manager.check({
|
||||
kind: "tool",
|
||||
surface: "bash",
|
||||
input: { command: "rm -rf /" },
|
||||
});
|
||||
expect(result.state).toBe("deny");
|
||||
expect(result.origin).not.toBe("yolo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,29 +271,6 @@ describe("PermissionSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("infrastructure paths", () => {
|
||||
it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
|
||||
const configStore = makeConfigStore({
|
||||
current: vi.fn().mockReturnValue({
|
||||
piInfrastructureReadPaths: ["/extra/path"],
|
||||
}),
|
||||
});
|
||||
const { session } = createSession({ configStore });
|
||||
expect(session.getInfrastructureReadDirs()).toEqual([
|
||||
"/test/agent",
|
||||
"/test/agent/git",
|
||||
"/extra/path",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
|
||||
const { session } = createSession();
|
||||
expect(session.getInfrastructureReadDirs()).toEqual([
|
||||
"/test/agent",
|
||||
"/test/agent/git",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config delegation", () => {
|
||||
it("refreshConfig delegates to configStore.refresh with the trust flag", () => {
|
||||
|
||||
@@ -160,20 +160,6 @@ describe("checkPermission", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an external_directory path query through an access-path intent matching the canonical alias", () => {
|
||||
realpathSync.mockImplementation((p: string) =>
|
||||
p === "/test/project/link" ? "/test/project/real" : p,
|
||||
);
|
||||
const { service, resolver } = makeService();
|
||||
service.checkPermission("external_directory", "link");
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(1);
|
||||
const intent = resolver.resolve.mock.calls[0][0];
|
||||
expect(intent.kind).toBe("access-path");
|
||||
if (intent.kind === "access-path") {
|
||||
expect(intent.surface).toBe("external_directory");
|
||||
expect(intent.path.matchValues()).toContain("/test/project/real");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a path-bearing tool query (read) through an access-path intent", () => {
|
||||
const { service, resolver } = makeService();
|
||||
|
||||
@@ -78,70 +78,6 @@ describe("FilePolicyLoader.loadGlobalConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadProjectConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FilePolicyLoader.loadProjectConfig", () => {
|
||||
it("returns empty ScopeConfig when no project path is configured", () => {
|
||||
const loader = new FilePolicyLoader({
|
||||
globalConfigPath: "/nonexistent/config.json",
|
||||
agentsDir: "/nonexistent/agents",
|
||||
});
|
||||
const config = loader.loadProjectConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it("returns ScopeConfig from a project config file", () => {
|
||||
const baseDir = makeTempDir();
|
||||
try {
|
||||
const projectConfigPath = join(baseDir, "project-config.json");
|
||||
writeFileSync(
|
||||
projectConfigPath,
|
||||
JSON.stringify({ permission: { bash: "allow" } }),
|
||||
);
|
||||
const loader = new FilePolicyLoader({
|
||||
globalConfigPath: "/nonexistent/config.json",
|
||||
agentsDir: "/nonexistent/agents",
|
||||
projectGlobalConfigPath: projectConfigPath,
|
||||
});
|
||||
const config = loader.loadProjectConfig();
|
||||
expect(config.permission).toEqual({ bash: "allow" });
|
||||
expect(config.invalid).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the scope invalid when the project config file is rejected", () => {
|
||||
const baseDir = makeTempDir();
|
||||
try {
|
||||
const projectConfigPath = join(baseDir, "project-config.json");
|
||||
// Unrecognized key — schema rejection, the issue's "typo" scenario.
|
||||
writeFileSync(projectConfigPath, JSON.stringify({ debugLo: true }));
|
||||
const loader = new FilePolicyLoader({
|
||||
globalConfigPath: "/nonexistent/config.json",
|
||||
agentsDir: "/nonexistent/agents",
|
||||
projectGlobalConfigPath: projectConfigPath,
|
||||
});
|
||||
const config = loader.loadProjectConfig();
|
||||
expect(config.invalid).toBe(true);
|
||||
} finally {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves invalid unset when no project file is present", () => {
|
||||
const loader = new FilePolicyLoader({
|
||||
globalConfigPath: "/nonexistent/config.json",
|
||||
agentsDir: "/nonexistent/agents",
|
||||
projectGlobalConfigPath: "/nonexistent/project-config.json",
|
||||
});
|
||||
const config = loader.loadProjectConfig();
|
||||
expect(config.invalid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadAgentConfig / loadProjectAgentConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -270,8 +206,6 @@ describe("FilePolicyLoader.getResolvedPolicyPaths", () => {
|
||||
expect(paths.globalConfigExists).toBe(true);
|
||||
expect(paths.agentsDir).toBe(agentsDir);
|
||||
expect(paths.agentsDirExists).toBe(true);
|
||||
expect(paths.projectConfigPath).toBeNull();
|
||||
expect(paths.projectConfigExists).toBe(false);
|
||||
expect(paths.projectAgentsDir).toBeNull();
|
||||
expect(paths.projectAgentsDirExists).toBe(false);
|
||||
} finally {
|
||||
|
||||
@@ -136,76 +136,6 @@ describe("renderPolicyDenial", () => {
|
||||
).toBe("[pi-permission-system] Denied by policy: 'bash'.");
|
||||
});
|
||||
|
||||
it("names the escaped boundary for a tool external-directory deny", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "write",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[{ label: "working directory", text: "/repo", detail: null }],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'write' for path '/etc/hosts' (rule '*'): outside working directory '/repo'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the canonical target when a path resolves elsewhere", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "read",
|
||||
value: "link",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[
|
||||
{ label: "resolves to", text: "/etc/shadow", detail: null },
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'read' for path 'link' (resolves to '/etc/shadow') (rule '*'): outside working directory '/repo'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names every escaping path but not the command for a bash external-directory deny", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"bash_external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "bash",
|
||||
value: "diff /etc/hosts ~/.ssh/config",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/etc/hosts", detail: null },
|
||||
{
|
||||
label: "external path",
|
||||
text: "~/.ssh/config",
|
||||
detail: "/home/me/.ssh/config",
|
||||
},
|
||||
],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'bash' for paths '/etc/hosts', '~/.ssh/config' (resolves to '/home/me/.ssh/config') (rule '*'): outside working directory '/repo'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the MCP target", () => {
|
||||
expect(
|
||||
@@ -370,25 +300,6 @@ describe("renderUnavailableDenial", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the escaped boundary, which no retry shape would change", () => {
|
||||
expect(
|
||||
renderUnavailableDenial(
|
||||
payload(
|
||||
"external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "write",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[{ label: "working directory", text: "/repo", detail: null }],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] This 'external_directory' call for tool 'write' for path '/etc/hosts' (rule '*') requires approval, but no interactive UI is available.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the flagged-element field cap", () => {
|
||||
|
||||
@@ -48,24 +48,6 @@ describe("renderPromptDialog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the gate surface when it differs from the tool", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "external_directory",
|
||||
request: requestFacts({
|
||||
surface: "external_directory",
|
||||
toolName: "write",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : write",
|
||||
"surface : external_directory",
|
||||
"rule : *",
|
||||
"path : /etc/hosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits an agent line for an unnamed local requester, and a surface the value label already names", () => {
|
||||
expect(
|
||||
@@ -214,36 +196,6 @@ describe("renderPromptDialog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an entry's detail on the entry's own line", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash_external_directory",
|
||||
request: requestFacts({
|
||||
surface: "external_directory",
|
||||
toolName: "bash",
|
||||
value: "cat /etc/hosts /tmp/x",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
evidence: [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{
|
||||
label: "external path",
|
||||
text: "/etc/hosts",
|
||||
detail: "/private/etc/hosts",
|
||||
},
|
||||
{ label: "external path", text: "/tmp/x", detail: null },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"surface : external_directory",
|
||||
"rule : *",
|
||||
"command : cat /etc/hosts /tmp/x",
|
||||
"working directory : /repo",
|
||||
"external path : /etc/hosts → /private/etc/hosts",
|
||||
"external path : /tmp/x",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a skill read's path", () => {
|
||||
expect(
|
||||
@@ -558,35 +510,6 @@ describe("renderPromptDialog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("paints each escaping path rather than the command that referenced them", () => {
|
||||
expect(
|
||||
renderPromptDialog(
|
||||
makePromptPayload({
|
||||
kind: "bash_external_directory",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
surface: "external_directory",
|
||||
toolName: "bash",
|
||||
value: "cat /etc/hosts /etc/hostsbackup",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence: [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/etc/hosts", detail: null },
|
||||
],
|
||||
}),
|
||||
completeViewBudget(200),
|
||||
mark,
|
||||
).lines,
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"surface : external_directory",
|
||||
"rule : *",
|
||||
"command : cat [/etc/hosts] /etc/hostsbackup",
|
||||
"working directory : /repo",
|
||||
"external path : [/etc/hosts]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the text untouched when no paint is supplied", () => {
|
||||
expect(
|
||||
|
||||
@@ -26,10 +26,6 @@ function payloadOf(
|
||||
};
|
||||
}
|
||||
|
||||
/** An `external path` evidence entry, as the bash external-directory gate emits it. */
|
||||
function externalPath(path: string, resolved: string | null): PromptEvidence {
|
||||
return { label: "external path", text: path, detail: resolved };
|
||||
}
|
||||
|
||||
describe("flaggedElements", () => {
|
||||
it("flags the decision-relevant value for a single-value ask", () => {
|
||||
@@ -44,17 +40,6 @@ describe("flaggedElements", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags the escaping paths rather than the command for a bash external-directory ask", () => {
|
||||
expect(
|
||||
flaggedElements(
|
||||
payloadOf("bash_external_directory", "diff /etc/hosts ~/.ssh/config", [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
externalPath("/etc/hosts", null),
|
||||
externalPath("~/.ssh/config", "/home/me/.ssh/config"),
|
||||
]),
|
||||
),
|
||||
).toEqual(["/etc/hosts", "~/.ssh/config"]);
|
||||
});
|
||||
|
||||
it("flags nothing when the value is empty", () => {
|
||||
expect(flaggedElements(payloadOf("tool", ""))).toEqual([]);
|
||||
@@ -64,11 +49,9 @@ describe("flaggedElements", () => {
|
||||
describe("valueLabel", () => {
|
||||
it.each([
|
||||
["bash", "command"],
|
||||
["bash_external_directory", "command"],
|
||||
["mcp", "target"],
|
||||
["tool", "tool"],
|
||||
["path", "path"],
|
||||
["external_directory", "path"],
|
||||
["skill", "skill"],
|
||||
["skill_read", "skill"],
|
||||
] as const)("labels a %s ask's value %s", (kind, label) => {
|
||||
@@ -88,11 +71,6 @@ describe("valueLabel", () => {
|
||||
});
|
||||
|
||||
describe("flaggedElementLabel", () => {
|
||||
it("labels the escaping paths a bash external-directory ask flags", () => {
|
||||
expect(
|
||||
flaggedElementLabel(payloadOf("bash_external_directory", "cmd")),
|
||||
).toBe("path");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"bash",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildBashExternalDirectoryAskPayload,
|
||||
buildExternalDirectoryAskPayload,
|
||||
buildPathAskPayload,
|
||||
} from "#src/presentation/path-ask-payload";
|
||||
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
|
||||
|
||||
describe("buildPathAskPayload", () => {
|
||||
test("carries the typed path as the decision value and the rule that fired", () => {
|
||||
@@ -38,89 +34,3 @@ describe("buildPathAskPayload", () => {
|
||||
).toEqual({ agentName: null, forwarded: false, sessionId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExternalDirectoryAskPayload", () => {
|
||||
test("carries the typed path, the boundary, and the requester", () => {
|
||||
const payload = buildExternalDirectoryAskPayload({
|
||||
toolName: "write",
|
||||
pathValue: "/tmp/out.txt",
|
||||
cwd: "/projects/my-app",
|
||||
agentName: "my-agent",
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("external_directory");
|
||||
expect(payload.request.toolName).toBe("write");
|
||||
expect(payload.request.value).toBe("/tmp/out.txt");
|
||||
expect(payload.request.requester.agentName).toBe("my-agent");
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discloses the resolved path as its own entry when it differs", () => {
|
||||
expect(
|
||||
buildExternalDirectoryAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "demo-symlink-passwd",
|
||||
resolvedPath: "/etc/passwd",
|
||||
cwd: "/projects/my-app",
|
||||
agentName: null,
|
||||
}).evidence,
|
||||
).toEqual([
|
||||
{ label: "resolves to", text: "/etc/passwd", detail: null },
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits the disclosure when resolvedPath is undefined", () => {
|
||||
const payload = buildExternalDirectoryAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "/etc/passwd",
|
||||
cwd: "/projects/my-app",
|
||||
agentName: null,
|
||||
});
|
||||
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBashExternalDirectoryAskPayload", () => {
|
||||
test("makes the command the value and the paths it reached evidence", () => {
|
||||
const payload = buildBashExternalDirectoryAskPayload({
|
||||
command: "cat /etc/passwd",
|
||||
externalPaths: [{ path: "/etc/passwd" }],
|
||||
cwd: "/projects/my-app",
|
||||
agentName: "my-agent",
|
||||
toolName: "bash",
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("bash_external_directory");
|
||||
expect(payload.request.value).toBe("cat /etc/passwd");
|
||||
expect(payload.request.requester.agentName).toBe("my-agent");
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
{ label: "external path", text: "/etc/passwd", detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("binds each path's canonical alias to its own entry", () => {
|
||||
const payload = buildBashExternalDirectoryAskPayload({
|
||||
command: "cat a b",
|
||||
externalPaths: [
|
||||
{ path: "/a", resolvedPath: "/private/a" },
|
||||
{ path: "/b" },
|
||||
],
|
||||
cwd: "/repo",
|
||||
agentName: null,
|
||||
toolName: "bash",
|
||||
});
|
||||
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/a", detail: "/private/a" },
|
||||
{ label: "external path", text: "/b", detail: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,8 +118,8 @@ describe("evaluate", () => {
|
||||
).toBe("ask");
|
||||
});
|
||||
|
||||
test("last-match-wins: later conflicting rule overrides earlier", () => {
|
||||
const ruleset: Ruleset = [allowBashGit, denyBashGitPush];
|
||||
test("specific pattern wins even when declared before a broad match", () => {
|
||||
const ruleset: Ruleset = [denyBashGitPush, allowBashGit];
|
||||
const result = evaluate(
|
||||
"bash",
|
||||
"git push origin main",
|
||||
@@ -129,7 +129,7 @@ describe("evaluate", () => {
|
||||
expect(result).toEqual(denyBashGitPush);
|
||||
});
|
||||
|
||||
test("last-match-wins: broad deny followed by specific allow", () => {
|
||||
test("specific allow wins over a broad deny", () => {
|
||||
const denyAll: Rule = {
|
||||
surface: "bash",
|
||||
pattern: "*",
|
||||
@@ -236,7 +236,7 @@ describe("evaluate", () => {
|
||||
expect(
|
||||
evaluate("bash", "git status", [withoutLayer], posixPathFlavor).action,
|
||||
).toBe("allow");
|
||||
// Layer metadata does not affect last-match-wins ordering.
|
||||
// Layer metadata does not affect specificity ordering.
|
||||
const ruleset: Rule[] = [withDefault, withLayer];
|
||||
expect(evaluate("bash", "git status", ruleset, posixPathFlavor)).toEqual(
|
||||
withLayer,
|
||||
@@ -245,7 +245,7 @@ describe("evaluate", () => {
|
||||
const reversedRuleset: Rule[] = [withLayer, withDefault];
|
||||
expect(
|
||||
evaluate("bash", "git status", reversedRuleset, posixPathFlavor),
|
||||
).toEqual(withDefault);
|
||||
).toEqual(withLayer);
|
||||
});
|
||||
|
||||
test("evaluate() preserves origin on a matched rule", () => {
|
||||
@@ -280,7 +280,7 @@ describe("evaluate", () => {
|
||||
expect(result.reason).toBe("Use pnpm instead");
|
||||
});
|
||||
|
||||
test("evaluate() carries reason through last-match-wins when deny wins", () => {
|
||||
test("evaluate() carries the reason from the most-specific deny", () => {
|
||||
const allowAll: Rule = {
|
||||
surface: "bash",
|
||||
pattern: "*",
|
||||
@@ -361,26 +361,26 @@ describe("evaluate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Windows: path-surface patterns fold case (last-match-wins) ──────────
|
||||
// ── Windows: path-surface patterns fold case ────────────────────────────
|
||||
|
||||
const denyExternalAll: Rule = {
|
||||
surface: "external_directory",
|
||||
surface: "path",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
layer: "config",
|
||||
origin: "global",
|
||||
};
|
||||
const allowExternalPi: Rule = {
|
||||
surface: "external_directory",
|
||||
surface: "path",
|
||||
pattern: "C:\\Users\\Foo\\pi\\*",
|
||||
action: "allow",
|
||||
layer: "config",
|
||||
origin: "global",
|
||||
};
|
||||
|
||||
test("win32: external_directory allow override matches a lowercased path over a preceding deny", () => {
|
||||
test("win32: a specific path allow matches a lowercased value", () => {
|
||||
const result = evaluate(
|
||||
"external_directory",
|
||||
"path",
|
||||
"c:\\users\\foo\\pi\\docs\\readme.md",
|
||||
[denyExternalAll, allowExternalPi],
|
||||
win32PathFlavor,
|
||||
@@ -390,7 +390,7 @@ describe("evaluate", () => {
|
||||
|
||||
test("posix: the same mixed-case override stays case-sensitive (falls through to deny)", () => {
|
||||
const result = evaluate(
|
||||
"external_directory",
|
||||
"path",
|
||||
"c:\\users\\foo\\pi\\docs\\readme.md",
|
||||
[denyExternalAll, allowExternalPi],
|
||||
posixPathFlavor,
|
||||
@@ -398,16 +398,16 @@ describe("evaluate", () => {
|
||||
expect(result.action).toBe("deny");
|
||||
});
|
||||
|
||||
test("win32: a forward-slash external_directory pattern matches a backslash value", () => {
|
||||
test("win32: a forward-slash path pattern matches a backslash value", () => {
|
||||
const allowForwardSlash: Rule = {
|
||||
surface: "external_directory",
|
||||
surface: "path",
|
||||
pattern: "C:/Users/Foo/pi/*",
|
||||
action: "allow",
|
||||
layer: "config",
|
||||
origin: "global",
|
||||
};
|
||||
const result = evaluate(
|
||||
"external_directory",
|
||||
"path",
|
||||
"c:\\users\\foo\\pi\\docs\\readme.md",
|
||||
[denyExternalAll, allowForwardSlash],
|
||||
win32PathFlavor,
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Helpers for building prompt sections.
|
||||
function availableToolsSection(tools: string[]): string {
|
||||
return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n");
|
||||
}
|
||||
|
||||
function guidelinesSection(guidelines: string[]): string {
|
||||
return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n");
|
||||
}
|
||||
|
||||
function prompt(...sections: string[]): string {
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
||||
test("keeps allowed tool lines and the header, drops denied ones", () => {
|
||||
const input = prompt(
|
||||
availableToolsSection(["bash", "read"]),
|
||||
"Other content",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).toContain("Available tools:");
|
||||
expect(result.prompt).toContain("- read");
|
||||
expect(result.prompt).not.toContain("- bash");
|
||||
});
|
||||
|
||||
test("leaves the section untouched when every tool is allowed", () => {
|
||||
const input = prompt(
|
||||
availableToolsSection(["bash", "read"]),
|
||||
"Other content",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toBe(input);
|
||||
});
|
||||
|
||||
// Bug #33: findSection extends to lines.length when no subsequent recognised
|
||||
// header follows, so content after the last section is silently deleted.
|
||||
test("preserves content that follows the Available tools section (bug #33)", () => {
|
||||
const input = prompt(
|
||||
availableToolsSection(["bash", "read"]),
|
||||
"Other content",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
||||
expect(result.prompt).toContain("Other content");
|
||||
});
|
||||
|
||||
test("removes the whole section when no tool is allowed", () => {
|
||||
const input = prompt(
|
||||
availableToolsSection(["bash", "read"]),
|
||||
"Other content",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("Available tools:");
|
||||
expect(result.prompt).toContain("Other content");
|
||||
});
|
||||
|
||||
test("removed flag is false when no Available tools section is present", () => {
|
||||
const input = "Just some instructions.\n\nNo tools section.";
|
||||
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toBe(input);
|
||||
});
|
||||
|
||||
test("keeps non-tool boilerplate prose near the section", () => {
|
||||
const input = [
|
||||
"Available tools:",
|
||||
"- read: Read file contents",
|
||||
"- bash: Run shell commands",
|
||||
"",
|
||||
"In addition to the tools above, you may have access to other custom tools depending on the project.",
|
||||
].join("\n");
|
||||
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
||||
expect(result.prompt).toContain("- read: Read file contents");
|
||||
expect(result.prompt).not.toContain("- bash: Run shell commands");
|
||||
expect(result.prompt).toContain("In addition to the tools above");
|
||||
});
|
||||
|
||||
test("returns original prompt reference unchanged when nothing is removed", () => {
|
||||
const input = "No tools section here.";
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.prompt).toBe(input);
|
||||
});
|
||||
|
||||
test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => {
|
||||
const allowed = ["read", "edit", "write"];
|
||||
const fullProse = [
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"Available tools:",
|
||||
"- bash: Run shell commands",
|
||||
"- read: Read file contents",
|
||||
"- edit: Edit a file",
|
||||
"- write: Write a file",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- use bash for file operations like ls, rg, find",
|
||||
"- use read to examine files instead of cat or sed.",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
const narrowedProse = [
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"Available tools:",
|
||||
"- read: Read file contents",
|
||||
"- edit: Edit a file",
|
||||
"- write: Write a file",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- use read to examine files instead of cat or sed.",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
|
||||
const fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt;
|
||||
const fromNarrowed = sanitizeAvailableToolsSection(
|
||||
narrowedProse,
|
||||
allowed,
|
||||
).prompt;
|
||||
|
||||
// Idempotent on the already-narrowed input Pi feeds back on later turns.
|
||||
expect(fromNarrowed).toBe(narrowedProse);
|
||||
// Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes.
|
||||
expect(fromFull).toBe(fromNarrowed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAvailableToolsSection — Guidelines section", () => {
|
||||
test("removes bash guideline when bash is not in allowed tools", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("use bash for file operations");
|
||||
});
|
||||
|
||||
test("keeps bash guideline when bash is in allowed tools", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toContain("use bash for file operations");
|
||||
});
|
||||
|
||||
test("removes read guideline when read is not allowed", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("use read to examine files");
|
||||
});
|
||||
|
||||
test("keeps read guideline when read is allowed", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toContain("use read to examine files");
|
||||
});
|
||||
|
||||
test("removes edit guideline when edit is not allowed", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection([
|
||||
"use edit for precise changes (old text must match exactly)",
|
||||
]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("use edit for precise changes");
|
||||
});
|
||||
|
||||
test("removes write guideline when write is not allowed", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use write only for new files or complete rewrites"]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("use write only for new files");
|
||||
});
|
||||
|
||||
test("removes entire Guidelines section when all bullets are filtered out", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection([
|
||||
"use bash for file operations like ls, rg, find",
|
||||
"use write only for new files or complete rewrites",
|
||||
]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("Guidelines:");
|
||||
});
|
||||
|
||||
test("preserves unrecognised guidelines regardless of allowed tools", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["some custom guideline not in the rules"]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toContain("some custom guideline not in the rules");
|
||||
});
|
||||
|
||||
test("handles both sections together: removes tools section and filters guidelines", () => {
|
||||
const input = prompt(
|
||||
availableToolsSection(["bash"]),
|
||||
guidelinesSection([
|
||||
"use bash for file operations like ls, rg, find",
|
||||
"use write only for new files or complete rewrites",
|
||||
"some custom guideline not in the rules",
|
||||
]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("Available tools:");
|
||||
expect(result.prompt).not.toContain("use bash for file operations");
|
||||
expect(result.prompt).not.toContain("use write only for new files");
|
||||
expect(result.prompt).toContain("some custom guideline not in the rules");
|
||||
});
|
||||
|
||||
test("trims whitespace from allowed tool names", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, [" bash "]);
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toContain("use bash for file operations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAvailableToolsSection — multi-section prompt", () => {
|
||||
test("collapses extra blank lines after removal", () => {
|
||||
const input = prompt(
|
||||
"Intro",
|
||||
availableToolsSection(["bash"]),
|
||||
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
||||
"Closing",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
// No run of 3+ consecutive newlines
|
||||
expect(result.prompt).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeAvailableToolsSection — findSection boundary edge cases", () => {
|
||||
test("preserves content after Guidelines when Guidelines is the last recognised section", () => {
|
||||
const input = prompt(
|
||||
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
||||
"Trailing custom instructions",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.prompt).toContain("Trailing custom instructions");
|
||||
});
|
||||
|
||||
test("preserves trailing prose when both sections are removed", () => {
|
||||
const input = prompt(
|
||||
availableToolsSection(["bash"]),
|
||||
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
||||
"Important user note",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("Available tools:");
|
||||
expect(result.prompt).not.toContain("Guidelines:");
|
||||
expect(result.prompt).toContain("Important user note");
|
||||
});
|
||||
|
||||
test("section at EOF is removed entirely when no tool is allowed", () => {
|
||||
const input = availableToolsSection(["bash", "read"]);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).toBe("");
|
||||
});
|
||||
|
||||
test("section followed by blank lines then prose — prose survives removal", () => {
|
||||
const input = ["Available tools:", "- bash", "", "", "Custom note"].join(
|
||||
"\n",
|
||||
);
|
||||
const result = sanitizeAvailableToolsSection(input, []);
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).toContain("Custom note");
|
||||
expect(result.prompt).not.toContain("Available tools:");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Moved from permission-system.test.ts catch-all (#342)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("System prompt sanitizer keeps the active tools in the Available tools section", () => {
|
||||
const prompt = [
|
||||
"Available tools:",
|
||||
"- read: Read file contents",
|
||||
"- mcp: Discover, inspect, and call MCP tools across configured servers",
|
||||
"",
|
||||
"In addition to the tools above, you may have access to other custom tools depending on the project.",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
|
||||
const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]);
|
||||
|
||||
expect(result.removed).toBe(false);
|
||||
expect(result.prompt).toContain("Available tools:");
|
||||
expect(result.prompt).toContain("- read: Read file contents");
|
||||
expect(result.prompt).toContain("- mcp: Discover");
|
||||
expect(result.prompt).toContain("In addition to the tools above");
|
||||
expect(result.prompt).toMatch(/Guidelines:/);
|
||||
});
|
||||
|
||||
test("System prompt sanitizer drops a denied tool's line but keeps the section", () => {
|
||||
const prompt = [
|
||||
"Available tools:",
|
||||
"- read: Read file contents",
|
||||
"- mcp: Discover, inspect, and call MCP tools across configured servers",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
|
||||
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
|
||||
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).toContain("Available tools:");
|
||||
expect(result.prompt).toContain("- read: Read file contents");
|
||||
expect(result.prompt).not.toContain("- mcp: Discover");
|
||||
});
|
||||
|
||||
test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => {
|
||||
const prompt = [
|
||||
"Guidelines:",
|
||||
"- Use task when work SHOULD be delegated to one or more specialized agents instead of handled entirely in the current session.",
|
||||
"- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
|
||||
"- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
|
||||
"- Be concise in your responses",
|
||||
"- Show file paths clearly when working with files",
|
||||
].join("\n");
|
||||
|
||||
const result = sanitizeAvailableToolsSection(prompt, ["bash", "grep", "mcp"]);
|
||||
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain("Use task when work SHOULD");
|
||||
expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i);
|
||||
expect(result.prompt).toMatch(/Prefer grep\/find\/ls tools over bash/i);
|
||||
expect(result.prompt).toMatch(/Be concise in your responses/);
|
||||
expect(result.prompt).toMatch(
|
||||
/Show file paths clearly when working with files/,
|
||||
);
|
||||
});
|
||||
|
||||
test("System prompt sanitizer removes inactive built-in write guidance", () => {
|
||||
const prompt = [
|
||||
"Guidelines:",
|
||||
"- Use write only for new files or complete rewrites",
|
||||
"- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
|
||||
"- Be concise in your responses",
|
||||
].join("\n");
|
||||
|
||||
const result = sanitizeAvailableToolsSection(prompt, ["read"]);
|
||||
|
||||
expect(result.removed).toBe(true);
|
||||
expect(result.prompt).not.toContain(
|
||||
"Use write only for new files or complete rewrites",
|
||||
);
|
||||
expect(result.prompt).not.toContain(
|
||||
"do NOT use cat or bash to display what you did",
|
||||
);
|
||||
expect(result.prompt).toMatch(/Be concise in your responses/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const permissionSources = [
|
||||
"src/index.ts",
|
||||
"src/tool-registry.ts",
|
||||
"src/handlers/before-agent-start.ts",
|
||||
];
|
||||
|
||||
describe("Tool Search ownership boundary", () => {
|
||||
it("never reads or changes Pi's active tool list", () => {
|
||||
const source = permissionSources
|
||||
.map((path) => readFileSync(join(process.cwd(), path), "utf8"))
|
||||
.join("\n");
|
||||
|
||||
expect(source).not.toContain("getActiveTools");
|
||||
expect(source).not.toContain("setActiveTools");
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe("findCompiledWildcardMatch", () => {
|
||||
expect(findCompiledWildcardMatch(patterns, "npm install")).toBeNull();
|
||||
});
|
||||
|
||||
test("last-match-wins precedence: later pattern overrides earlier", () => {
|
||||
test("the low-level compiled matcher returns the last matching entry", () => {
|
||||
const patterns = compileWildcardPatternEntries([
|
||||
["git *", "allow"],
|
||||
["git push *", "deny"],
|
||||
@@ -95,7 +95,7 @@ describe("findCompiledWildcardMatch", () => {
|
||||
expect(result?.matchedPattern).toBe("git push *");
|
||||
});
|
||||
|
||||
test("last-match-wins: specific deny before broad allow matches the later one", () => {
|
||||
test("the low-level matcher does not apply permission-rule specificity", () => {
|
||||
const patterns = compileWildcardPatternEntries([
|
||||
["*", "deny"],
|
||||
["git status", "allow"],
|
||||
|
||||
Reference in New Issue
Block a user