mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user