mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
/**
|
|
* Shared handler-level test fixtures for PermissionGateHandler tests.
|
|
*
|
|
* `makeHandler` builds a real PermissionSession + PermissionResolver and wires
|
|
* them into the handler and pipelines exactly as `index.ts` does.
|
|
* Call-site overrides for permission results flow through
|
|
* `permissionManager.check`; session state overrides are applied
|
|
* via vi.spyOn on the real session instance.
|
|
*/
|
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import { vi } from "vitest";
|
|
import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
|
|
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
|
import type { ShellToolsConfig } from "#src/config-schema";
|
|
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
import { GateRunner } from "#src/handlers/gates/runner";
|
|
import {
|
|
type SkillInputGateInputs,
|
|
SkillInputGatePipeline,
|
|
} from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
import {
|
|
type ToolCallGateInputs,
|
|
ToolCallGatePipeline,
|
|
} from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
import type { Rule } from "#src/rule";
|
|
import { SessionRules } from "#src/session-rules";
|
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
|
|
import {
|
|
makeConfigStore,
|
|
makeRealResolver,
|
|
makeRealSession,
|
|
} from "#test/helpers/session-fixtures";
|
|
|
|
// ── MockGateHandlerSession ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Mock type for gate-pipeline inputs (ToolCallGateInputs + SkillInputGateInputs).
|
|
*
|
|
* Used by `makeSurfaceCheck`, `makeBashCommandCheck`, and the `session`
|
|
* override bag in `makeHandler`. The `GateHandlerSession` role (activate +
|
|
* resolveAgentName) is now satisfied by the real `PermissionSession`; this
|
|
* type covers only the pipeline input surface.
|
|
*
|
|
* The 4-arg `checkPermission` is a superset of `SkillInputGateInputs` —
|
|
* it routes through `permissionManager.checkPermission` in production.
|
|
*/
|
|
export type MockGateHandlerSession = ToolCallGateInputs &
|
|
SkillInputGateInputs & {
|
|
/** 4-arg form so surface-check mocks can receive optional rules. */
|
|
checkPermission(
|
|
surface: string,
|
|
input: unknown,
|
|
agentName?: string,
|
|
rules?: Rule[],
|
|
): PermissionCheckResult;
|
|
};
|
|
|
|
// ── Small utility factories ───────────────────────────────────────────────
|
|
|
|
export function makeEvents() {
|
|
return {
|
|
emit: vi.fn(),
|
|
on: vi.fn().mockReturnValue(() => undefined),
|
|
};
|
|
}
|
|
|
|
export function makeCtx(
|
|
overrides: Partial<ExtensionContext> = {},
|
|
): ExtensionContext {
|
|
return {
|
|
cwd: "/test/project",
|
|
hasUI: true,
|
|
isProjectTrusted: vi.fn<() => boolean>().mockReturnValue(true),
|
|
ui: {
|
|
setStatus: vi.fn(),
|
|
notify: vi.fn(),
|
|
select: vi.fn(),
|
|
input: vi.fn(),
|
|
},
|
|
sessionManager: {
|
|
getEntries: vi.fn().mockReturnValue([]),
|
|
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
addEntry: vi.fn(),
|
|
},
|
|
...overrides,
|
|
} as unknown as ExtensionContext;
|
|
}
|
|
|
|
export function makeToolCallEvent(
|
|
toolName: string,
|
|
extraFields: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
type: "tool_call",
|
|
toolCallId: "tc-1",
|
|
name: toolName,
|
|
input: {},
|
|
...extraFields,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Neutral-default check-result builder.
|
|
*
|
|
* Pass exactly the fields the original fixture hard-coded so divergent
|
|
* defaults across test files are preserved at their call sites.
|
|
*/
|
|
export function makeCheckResult(
|
|
overrides: Partial<PermissionCheckResult> = {},
|
|
): PermissionCheckResult {
|
|
return {
|
|
state: "allow",
|
|
toolName: "read",
|
|
source: "tool",
|
|
origin: "builtin",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export function makeToolRegistry(
|
|
overrides: Partial<ToolRegistry> = {},
|
|
): ToolRegistry {
|
|
return {
|
|
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
|
getActive: vi.fn().mockReturnValue(["read", "bash"]),
|
|
setActive: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── Surface-check factories ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Surface-dispatching `checkPermission` mock.
|
|
*
|
|
* Returns the matching per-surface result or `defaultResult`.
|
|
* Pass the returned function as `session.checkPermission` in a `makeHandler`
|
|
* override bag — it is applied to `permissionManager.checkPermission`.
|
|
*
|
|
* Return type is intentionally unannotated so callers retain full `vi.fn()`
|
|
* mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.).
|
|
*/
|
|
export function makeSurfaceCheck(
|
|
bySurface: Record<
|
|
string,
|
|
Partial<PermissionCheckResult> & { state: PermissionState }
|
|
>,
|
|
defaultResult: Partial<PermissionCheckResult> & { state: PermissionState } = {
|
|
state: "allow",
|
|
},
|
|
) {
|
|
return vi
|
|
.fn<MockGateHandlerSession["checkPermission"]>()
|
|
.mockImplementation((surface): PermissionCheckResult => {
|
|
const base = bySurface[surface] ?? defaultResult;
|
|
return {
|
|
toolName: surface,
|
|
source: "tool",
|
|
origin: "builtin",
|
|
...base,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Bash-surface `checkPermission` mock that dispatches on a command regex.
|
|
*
|
|
* Pass the returned function as `session.checkPermission` in a `makeHandler`
|
|
* override bag — it is applied to `permissionManager.checkPermission`.
|
|
*
|
|
* Return type is intentionally unannotated so callers retain full `vi.fn()`
|
|
* mock access.
|
|
*/
|
|
export function makeBashCommandCheck(opts: {
|
|
deny: RegExp;
|
|
denyMatched: string;
|
|
allowMatched?: string;
|
|
}) {
|
|
return vi
|
|
.fn<MockGateHandlerSession["checkPermission"]>()
|
|
.mockImplementation((surface, input): PermissionCheckResult => {
|
|
if (surface === "bash") {
|
|
const command = (input as { command?: string }).command ?? "";
|
|
return opts.deny.test(command)
|
|
? makeCheckResult({
|
|
state: "deny",
|
|
source: "bash",
|
|
command,
|
|
matchedPattern: opts.denyMatched,
|
|
})
|
|
: makeCheckResult({
|
|
state: "allow",
|
|
source: "bash",
|
|
command,
|
|
matchedPattern: opts.allowMatched,
|
|
});
|
|
}
|
|
return makeCheckResult({ state: "allow" });
|
|
});
|
|
}
|
|
|
|
// ── makeHandler ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Constructs a PermissionGateHandler wired with real collaborators.
|
|
*
|
|
* The `session` override bag maps to the real collaborators:
|
|
* - `checkPermission` → applied to `permissionManager.checkPermission`
|
|
* - `getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`
|
|
* → applied as vi.spyOn overrides on the real session
|
|
* - `resolveAgentName` → applied as a vi.spyOn override on the real session
|
|
*
|
|
* Returns `{ handler, events, session, toolRegistry, prompter, recorder,
|
|
* permissionManager, forwarding }` so each test file can destructure only
|
|
* what it needs.
|
|
* `session.activate` is not a mock — use `forwarding.start` to assert it
|
|
* was called.
|
|
*/
|
|
export function makeHandler(overrides?: {
|
|
session?: Partial<MockGateHandlerSession> & {
|
|
resolveAgentName?: (
|
|
ctx: ExtensionContext,
|
|
systemPrompt?: string,
|
|
) => string | null;
|
|
};
|
|
/** Override the AskEscalator passed to GateRunner. Defaults to an allow-all stub. */
|
|
prompter?: AskEscalator;
|
|
toolRegistry?: Partial<ToolRegistry>;
|
|
/** Sugar: builds the `getAll` mock from a list of tool names. */
|
|
tools?: string[];
|
|
/** Inject `shellTools` aliases into the session config (#574). */
|
|
shellTools?: ShellToolsConfig;
|
|
/** Standing yolo setting for the runner's residual-ask grant (#712). */
|
|
yolo?: boolean;
|
|
}) {
|
|
const configStore =
|
|
overrides?.shellTools !== undefined
|
|
? makeConfigStore({
|
|
current: vi.fn().mockReturnValue({
|
|
...DEFAULT_EXTENSION_CONFIG,
|
|
shellTools: overrides.shellTools,
|
|
}),
|
|
})
|
|
: undefined;
|
|
const { session, permissionManager, sessionRules, forwarding, logger } =
|
|
makeRealSession(configStore ? { configStore } : undefined);
|
|
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
|
|
// Apply session override bag to the real collaborators.
|
|
const so = overrides?.session;
|
|
const surfaceCheck = so?.checkPermission;
|
|
if (surfaceCheck) {
|
|
// Route the unified check(intent) through the surface dispatcher so
|
|
// makeSurfaceCheck / makeBashCommandCheck overrides apply to all gate
|
|
// paths via the single manager entry point (#478).
|
|
vi.mocked(permissionManager.check).mockImplementation(
|
|
(intent: ResolvedAccessIntent, sessionRules) => {
|
|
if (intent.kind === "path-values") {
|
|
return surfaceCheck(
|
|
intent.surface,
|
|
{ path: intent.values[0] ?? "*" },
|
|
intent.agentName,
|
|
sessionRules,
|
|
);
|
|
}
|
|
return surfaceCheck(
|
|
intent.surface,
|
|
intent.input,
|
|
intent.agentName,
|
|
sessionRules,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
if (so?.getActiveSkillEntries) {
|
|
vi.spyOn(session, "getActiveSkillEntries").mockImplementation(
|
|
so.getActiveSkillEntries,
|
|
);
|
|
}
|
|
if (so?.getInfrastructureReadDirs) {
|
|
vi.spyOn(session, "getInfrastructureReadDirs").mockImplementation(
|
|
so.getInfrastructureReadDirs,
|
|
);
|
|
}
|
|
if (so?.getToolPreviewLimits) {
|
|
vi.spyOn(session, "getToolPreviewLimits").mockImplementation(
|
|
so.getToolPreviewLimits,
|
|
);
|
|
}
|
|
if (so?.resolveAgentName) {
|
|
vi.spyOn(session, "resolveAgentName").mockImplementation(
|
|
so.resolveAgentName,
|
|
);
|
|
}
|
|
|
|
const events = makeEvents();
|
|
const toolRegistry =
|
|
overrides?.tools !== undefined
|
|
? makeToolRegistry({
|
|
getAll: vi
|
|
.fn()
|
|
.mockReturnValue(overrides.tools.map((name) => ({ name }))),
|
|
})
|
|
: makeToolRegistry(overrides?.toolRegistry);
|
|
|
|
const recorder = new SessionRules();
|
|
const pipeline = new ToolCallGatePipeline(resolver, session);
|
|
const skillInputPipeline = new SkillInputGatePipeline(resolver);
|
|
const reporter = new GateDecisionReporter(logger, events);
|
|
const prompter: AskEscalator = overrides?.prompter ?? {
|
|
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
|
|
approved: true,
|
|
state: "approved",
|
|
decidedBy: DECIDED_BY_HUMAN,
|
|
}),
|
|
};
|
|
const runner = new GateRunner(
|
|
resolver,
|
|
recorder,
|
|
prompter,
|
|
reporter,
|
|
() => overrides?.yolo ?? false,
|
|
);
|
|
const handler = new PermissionGateHandler(
|
|
session,
|
|
toolRegistry,
|
|
pipeline,
|
|
skillInputPipeline,
|
|
runner,
|
|
);
|
|
return {
|
|
handler,
|
|
events,
|
|
session,
|
|
logger,
|
|
toolRegistry,
|
|
prompter,
|
|
recorder,
|
|
permissionManager,
|
|
forwarding,
|
|
};
|
|
}
|
|
|
|
// ── Decision-event helper ─────────────────────────────────────────────────
|
|
|
|
/** Extract all permissions:decision payloads from the events.emit mock. */
|
|
export function getDecisionEvents(
|
|
events: ReturnType<typeof makeEvents>,
|
|
): PermissionDecisionEvent[] {
|
|
return events.emit.mock.calls
|
|
.filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
|
|
.map(([, payload]) => payload as PermissionDecisionEvent);
|
|
}
|