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