feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -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);
}