mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
270 lines
9.3 KiB
TypeScript
270 lines
9.3 KiB
TypeScript
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
|
|
import type { AccessIntent } from "#src/access-intent/access-intent";
|
|
import type { AuthorizerRegistrar } from "#src/authority/authorizer-registry";
|
|
import { posixPathFlavor } from "#src/path/path-flavor";
|
|
import { PathNormalizer } from "#src/path-normalizer";
|
|
import { LocalPermissionsService } from "#src/permissions-service";
|
|
import type { ToolAccessExtractorRegistrar } from "#src/tool-access-extractor-registry";
|
|
import type {
|
|
ToolInputFormatter,
|
|
ToolInputFormatterRegistrar,
|
|
} from "#src/tool-input-formatter-registry";
|
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
|
|
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
|
|
// Mock node:fs so realpathSync (the canonical alias) is controllable.
|
|
const realpathSync = vi.hoisted(() =>
|
|
vi.fn<(path: string) => string>((p) => p),
|
|
);
|
|
vi.mock("node:fs", () => ({
|
|
realpathSync,
|
|
default: { realpathSync },
|
|
}));
|
|
|
|
// Mock the advisory bash resolver so the service test asserts delegation; the
|
|
// decomposition behavior itself is covered in bash-advisory-check.test.ts.
|
|
const resolveBashAdvisoryCheck = vi.hoisted(() =>
|
|
vi.fn<
|
|
(
|
|
command: string,
|
|
agentName: string | undefined,
|
|
resolver: unknown,
|
|
) => PermissionCheckResult
|
|
>(),
|
|
);
|
|
vi.mock("#src/bash-advisory-check", () => ({ resolveBashAdvisoryCheck }));
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
interface FakeResolver {
|
|
resolve: Mock<(intent: AccessIntent) => PermissionCheckResult>;
|
|
getToolPermission: Mock<
|
|
(toolName: string, agentName?: string) => PermissionState
|
|
>;
|
|
}
|
|
|
|
function makeResolver(): FakeResolver {
|
|
return {
|
|
resolve: vi
|
|
.fn<(intent: AccessIntent) => PermissionCheckResult>()
|
|
.mockReturnValue(makeCheckResult()),
|
|
getToolPermission: vi
|
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
.mockReturnValue("ask"),
|
|
};
|
|
}
|
|
|
|
function makeFormatterRegistry(): ToolInputFormatterRegistrar {
|
|
return {
|
|
register: vi
|
|
.fn<ToolInputFormatterRegistrar["register"]>()
|
|
.mockReturnValue(vi.fn()),
|
|
};
|
|
}
|
|
|
|
function makeAccessExtractorRegistry(): ToolAccessExtractorRegistrar {
|
|
return {
|
|
register: vi
|
|
.fn<ToolAccessExtractorRegistrar["register"]>()
|
|
.mockReturnValue(vi.fn()),
|
|
};
|
|
}
|
|
|
|
function makeAuthorizerRegistry(): AuthorizerRegistrar {
|
|
return {
|
|
register: vi.fn<AuthorizerRegistrar["register"]>().mockReturnValue(vi.fn()),
|
|
};
|
|
}
|
|
|
|
function makeService(overrides?: {
|
|
resolver?: FakeResolver;
|
|
formatterRegistry?: ToolInputFormatterRegistrar;
|
|
accessExtractorRegistry?: ToolAccessExtractorRegistrar;
|
|
authorizerRegistry?: AuthorizerRegistrar;
|
|
}) {
|
|
const resolver = overrides?.resolver ?? makeResolver();
|
|
// The published service always answers against the parent session's cwd.
|
|
const session = { getPathNormalizer: () => normalizer };
|
|
const formatterRegistry =
|
|
overrides?.formatterRegistry ?? makeFormatterRegistry();
|
|
const accessExtractorRegistry =
|
|
overrides?.accessExtractorRegistry ?? makeAccessExtractorRegistry();
|
|
const authorizerRegistry =
|
|
overrides?.authorizerRegistry ?? makeAuthorizerRegistry();
|
|
const service = new LocalPermissionsService(
|
|
resolver,
|
|
session,
|
|
formatterRegistry,
|
|
accessExtractorRegistry,
|
|
authorizerRegistry,
|
|
);
|
|
return {
|
|
service,
|
|
resolver,
|
|
formatterRegistry,
|
|
accessExtractorRegistry,
|
|
authorizerRegistry,
|
|
};
|
|
}
|
|
|
|
const normalizer = new PathNormalizer(posixPathFlavor, "/test/project");
|
|
|
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
realpathSync.mockReset();
|
|
realpathSync.mockImplementation((p: string) => p);
|
|
resolveBashAdvisoryCheck.mockReset();
|
|
resolveBashAdvisoryCheck.mockReturnValue(
|
|
makeCheckResult({ toolName: "bash" }),
|
|
);
|
|
});
|
|
|
|
describe("checkPermission", () => {
|
|
it("resolves a non-path surface through a tool intent", () => {
|
|
const { service, resolver } = makeService();
|
|
service.checkPermission("skill", "my-skill", "my-agent");
|
|
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
kind: "tool",
|
|
surface: "skill",
|
|
input: { name: "my-skill" },
|
|
agentName: "my-agent",
|
|
});
|
|
});
|
|
|
|
it("routes a bash query through the advisory decomposition resolver", () => {
|
|
const { service, resolver } = makeService();
|
|
const expected = makeCheckResult({ state: "deny", toolName: "bash" });
|
|
resolveBashAdvisoryCheck.mockReturnValue(expected);
|
|
const result = service.checkPermission(
|
|
"bash",
|
|
"cd /repo && npm install x",
|
|
"my-agent",
|
|
);
|
|
expect(resolveBashAdvisoryCheck).toHaveBeenCalledWith(
|
|
"cd /repo && npm install x",
|
|
"my-agent",
|
|
resolver,
|
|
);
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it("passes an empty string to the advisory resolver for a value-less bash query", () => {
|
|
const { service, resolver } = makeService();
|
|
service.checkPermission("bash");
|
|
expect(resolveBashAdvisoryCheck).toHaveBeenCalledWith(
|
|
"",
|
|
undefined,
|
|
resolver,
|
|
);
|
|
});
|
|
|
|
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();
|
|
service.checkPermission("read", "/test/project/.env");
|
|
const intent = resolver.resolve.mock.calls[0][0];
|
|
expect(intent.kind).toBe("access-path");
|
|
if (intent.kind === "access-path") {
|
|
expect(intent.surface).toBe("read");
|
|
expect(intent.path.value()).toBe("/test/project/.env");
|
|
}
|
|
});
|
|
|
|
it("falls back to a tool intent for a value-less path query", () => {
|
|
const { service, resolver } = makeService();
|
|
service.checkPermission("path");
|
|
const intent = resolver.resolve.mock.calls[0][0];
|
|
expect(intent.kind).toBe("tool");
|
|
});
|
|
|
|
it("returns the result from resolver.resolve", () => {
|
|
const expected = makeCheckResult({ state: "deny", toolName: "skill" });
|
|
const resolver = makeResolver();
|
|
resolver.resolve.mockReturnValue(expected);
|
|
const { service } = makeService({ resolver });
|
|
const result = service.checkPermission("skill", "my-skill");
|
|
expect(result).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("getToolPermission", () => {
|
|
it("delegates to resolver.getToolPermission", () => {
|
|
const resolver = makeResolver();
|
|
resolver.getToolPermission.mockReturnValue("deny");
|
|
const { service } = makeService({ resolver });
|
|
const result = service.getToolPermission("write", "my-agent");
|
|
expect(resolver.getToolPermission).toHaveBeenCalledWith(
|
|
"write",
|
|
"my-agent",
|
|
);
|
|
expect(result).toBe("deny");
|
|
});
|
|
|
|
it("omits agentName when not provided", () => {
|
|
const { service, resolver } = makeService();
|
|
service.getToolPermission("read");
|
|
expect(resolver.getToolPermission).toHaveBeenCalledWith("read", undefined);
|
|
});
|
|
});
|
|
|
|
describe("registerToolInputFormatter", () => {
|
|
it("delegates to formatterRegistry.register and returns the unsubscribe function", () => {
|
|
const unsub = vi.fn();
|
|
const { service, formatterRegistry } = makeService();
|
|
vi.mocked(formatterRegistry.register).mockReturnValue(unsub);
|
|
const formatter: ToolInputFormatter = vi.fn();
|
|
const result = service.registerToolInputFormatter("my-tool", formatter);
|
|
expect(formatterRegistry.register).toHaveBeenCalledWith(
|
|
"my-tool",
|
|
formatter,
|
|
);
|
|
expect(result).toBe(unsub);
|
|
});
|
|
});
|
|
|
|
describe("registerToolAccessExtractor", () => {
|
|
it("delegates to accessExtractorRegistry.register and returns the unsubscribe function", () => {
|
|
const unsub = vi.fn();
|
|
const { service, accessExtractorRegistry } = makeService();
|
|
vi.mocked(accessExtractorRegistry.register).mockReturnValue(unsub);
|
|
const extractor = vi.fn();
|
|
const result = service.registerToolAccessExtractor("ffgrep", extractor);
|
|
expect(accessExtractorRegistry.register).toHaveBeenCalledWith(
|
|
"ffgrep",
|
|
extractor,
|
|
);
|
|
expect(result).toBe(unsub);
|
|
});
|
|
});
|
|
|
|
describe("registerAuthorizer", () => {
|
|
it("delegates to authorizerRegistry.register and returns the unsubscribe function", () => {
|
|
const unsub = vi.fn();
|
|
const { service, authorizerRegistry } = makeService();
|
|
vi.mocked(authorizerRegistry.register).mockReturnValue(unsub);
|
|
const authorize = vi.fn();
|
|
const result = service.registerAuthorizer("model-judge", authorize);
|
|
expect(authorizerRegistry.register).toHaveBeenCalledWith(
|
|
"model-judge",
|
|
authorize,
|
|
);
|
|
expect(result).toBe(unsub);
|
|
});
|
|
});
|