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,115 @@
/**
* Shared fixtures for building an `AuthorizerSelection` and the `selectAuthorizer`
* dependency bag.
*
* Extracted from `test/authority/authorizer-selection.test.ts` so more than one
* test file can drive a **real** `AuthorizerSelection` — notably the
* forwarded-request server tests, which wire it in as the serving node's
* `AskEscalator` to exercise the chain end to end.
*/
import { type Mock, vi } from "vitest";
import type {
AuthorizerVerdict,
AuthorizerSelectionDeps as SelectionCtorDeps,
} from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
import { ForwardingLivenessJudge } from "#src/authority/forwarding-liveness";
import type { PermissionPrompterApi } from "#src/authority/permission-prompter";
import { ServingSessionRegistry } from "#src/authority/serving-registry";
import type { SubagentDetector } from "#src/authority/subagent-detection";
import type { PermissionQuery } from "#src/service";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makePromptPreferences } from "#test/helpers/prompt-view-fixtures";
/** The full constructor bag `AuthorizerSelection` takes (the ctor intersection). */
export type AuthorizerSelectionTestDeps = SelectionCtorDeps & {
prompter: PermissionPrompterApi;
getPermissionQuery: () => PermissionQuery;
authorizerRegistry: AuthorizerRegistry;
getAuthorizerChain: () => string[];
};
/** A `SubagentDetector` answering a fixed verdict. */
export function makeDetection(isSubagent = false): SubagentDetector {
return { isSubagent: vi.fn(() => isSubagent) };
}
/** A prompter that records the call and resolves to a default approval. */
export function makePrompterApi(): PermissionPrompterApi & {
prompt: Mock<PermissionPrompterApi["prompt"]>;
} {
return {
prompt: vi.fn<PermissionPrompterApi["prompt"]>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* A prompter that actually runs the passed authorizer, so a test can observe
* the composed chain's decision (the real `PermissionPrompter` brackets log
* entries around `authorizer.authorize(details)`).
*/
export function makeInvokingPrompter(): PermissionPrompterApi & {
prompt: Mock<PermissionPrompterApi["prompt"]>;
} {
return {
prompt: vi.fn<PermissionPrompterApi["prompt"]>((authorizer, details) =>
authorizer.authorize(details),
),
};
}
/** Register a link returning a fixed verdict. */
export function registerLink(
registry: AuthorizerRegistry,
name: string,
verdict: AuthorizerVerdict,
): void {
registry.register(name, () => Promise.resolve(verdict));
}
function makeQuery(): PermissionQuery {
return { checkPermission: vi.fn(), getToolPermission: vi.fn() };
}
/** The `AuthorizerSelection` constructor bag, override-driven. */
export function makeAuthorizerSelectionDeps(
overrides: Partial<AuthorizerSelectionTestDeps> = {},
): AuthorizerSelectionTestDeps {
return {
detection: overrides.detection ?? makeDetection(),
events: overrides.events ?? {
emit: vi.fn(),
on: vi.fn().mockReturnValue(() => undefined),
},
getPromptPreferences:
overrides.getPromptPreferences ?? (() => makePromptPreferences()),
requestPermissionDecision:
overrides.requestPermissionDecision ??
vi.fn().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
forwardingDir: overrides.forwardingDir ?? "/tmp/forwarding",
registry: overrides.registry,
serving:
overrides.serving ??
new ForwardingLivenessJudge({
registry: new ServingSessionRegistry(),
heartbeats: { read: () => "absent", servingIds: () => [] },
}),
getForwardingTimeoutMs: overrides.getForwardingTimeoutMs ?? (() => 1000),
logger: overrides.logger ?? makeAuthorizerLog(),
prompter: overrides.prompter ?? makePrompterApi(),
getPermissionQuery: overrides.getPermissionQuery ?? (() => makeQuery()),
authorizerRegistry:
overrides.authorizerRegistry ?? new AuthorizerRegistry(),
getAuthorizerChain: overrides.getAuthorizerChain ?? (() => []),
};
}
@@ -0,0 +1,13 @@
import { vi } from "vitest";
/**
* A fake `AuthorizerLog` / `DebugReviewLogger`: `review` and `debug` as
* `vi.fn()` stubs.
*
* The return type is intentionally unannotated so callers keep full `Mock`
* access (`toHaveBeenCalledWith`, `mock.calls`); the shape structurally
* satisfies both the narrow authorizer-log seam and the session logger.
*/
export function makeAuthorizerLog() {
return { review: vi.fn(), debug: vi.fn() };
}
@@ -0,0 +1,13 @@
import type { DecisionSource } from "#src/authority/decision-source";
/**
* The decider a test stands in for when the decision's *provenance* is not its
* subject: a human answering the inline dialog, which is what a real
* `LocalUserAuthorizer` produces.
*
* A shared constant rather than a decision builder, so each fixture's literal
* still shows its own `approved`/`state` — in most of these tests that pair is
* the subject, and hiding it behind a factory would cost more than the
* duplication saves.
*/
export const DECIDED_BY_HUMAN: DecisionSource = { kind: "user", via: "dialog" };
@@ -0,0 +1,281 @@
/**
* Shared fixtures for the external-directory handler-pipeline tests.
*
* Targets the collapsed external-directory gate (Phase 6 Step 5, #477).
* Consumed by external-directory-integration.test.ts and
* external-directory-session-dedup.test.ts.
*/
import { vi } from "vitest";
import type { AskEscalator } from "#src/authority/authorizer-selection";
import { GateDecisionReporter } from "#src/decision-reporter";
import { GateRunner } from "#src/handlers/gates/runner";
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
import type { ScopedPermissionManager } from "#src/permission-manager";
import type { SessionLogger } from "#src/session-logger";
import type { PermissionCheckResult, PermissionState } from "#src/types";
import { wildcardMatch } from "#src/wildcard-matcher";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import {
getDecisionEvents,
makeEvents,
makeSurfaceCheck,
makeToolRegistry,
} from "#test/helpers/handler-fixtures";
import {
makeRealResolver,
makeRealSession,
} from "#test/helpers/session-fixtures";
// ── Shared constants ───────────────────────────────────────────────────────
/** Working-directory used by the external-directory handler-pipeline tests. */
export const EXT_DIR_CWD = "/test/project";
/** An external path (outside {@link EXT_DIR_CWD}) used across the test suite. */
export const EXTERNAL_PATH = "/outside/project/file.ts";
/** All path-bearing tools subject to the external-directory gate. */
export const ALL_PATH_BEARING_TOOLS = [
"read",
"write",
"edit",
"find",
"grep",
"ls",
];
/** Path-bearing tools where the path is optional (no input → gate is skipped). */
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
/** Full tool set used as the default registry in external-directory tests. */
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
// ── Setup builders ─────────────────────────────────────────────────────────
/**
* Builds a `checkPermission` mock for external-directory tests.
*
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
* with `source: "special"` (so the cross-cutting path gate is transparent),
* and every other surface to `toolState` (default: allow).
*/
export function makeExtDirCheck(
externalDirectoryState: PermissionState,
toolState: PermissionState = "allow",
) {
return makeSurfaceCheck(
{
external_directory: { state: externalDirectoryState },
path: { state: "allow", source: "special" },
},
{ state: toolState },
);
}
/** AskEscalator stub that approves with `state: "approved"`. */
export function makeApprovingPrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* AskEscalator stub that denies.
*
* Pass `denialReason` to simulate a user who explains the refusal.
*/
export function makeDenyingPrompter(denialReason?: string): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue(
denialReason !== undefined
? {
approved: false,
state: "denied",
denialReason,
decidedBy: DECIDED_BY_HUMAN,
}
: { approved: false, state: "denied", decidedBy: DECIDED_BY_HUMAN },
),
};
}
/**
* AskEscalator stub for a session with no reachable authority: the
* DenyingAuthorizer denies with the `confirmationUnavailable` marker.
*/
export function makeUnavailablePrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
// ── Query helpers ──────────────────────────────────────────────────────────
/** Find the `external_directory` decision event from the events mock. */
export function findExtDirDecision(events: ReturnType<typeof makeEvents>) {
return getDecisionEvents(events).find(
(d) => d.surface === "external_directory",
);
}
/** Return the `permission_request.blocked` review-log entries from the logger mock. */
export function blockReviewEntries(logger: SessionLogger) {
return (logger.review as ReturnType<typeof vi.fn>).mock.calls.filter(
([eventName]: string[]) => eventName === "permission_request.blocked",
);
}
// ── Session-dedup wiring ──────────────────────────────────────────────────
/**
* Installs the session-aware `check(intent)` mock on the permission manager.
*
* Returns `ask` for `external_directory` on first access; re-checks recorded
* session rules on subsequent calls and returns `allow` (source: "session")
* when a `wildcardMatch` covers the path.
*/
export function makeExtDirDedupCheck(
permissionManager: ScopedPermissionManager,
): void {
vi.mocked(permissionManager.check).mockImplementation(
(intent, rules): PermissionCheckResult => {
const { surface } = intent;
const pathValue =
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
if (surface === "external_directory") {
if (pathValue && rules && rules.length > 0) {
const match = rules.findLast(
(r) =>
r.surface === "external_directory" &&
wildcardMatch(r.pattern, pathValue),
);
if (match) {
return {
state: "allow",
toolName: surface,
source: "session",
origin: "session",
matchedPattern: match.pattern,
};
}
}
return {
state: "ask",
toolName: surface,
source: "special",
origin: "global",
};
}
return {
state: "allow",
toolName: surface,
source: "tool",
origin: "builtin",
};
},
);
}
/** AskEscalator stub that approves for the session (`state: "approved_for_session"`). */
function makeSessionApprovingPrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* Builds the fully-wired session-dedup handler with real collaborators.
*
* Unlike `makeHandler`, this wires `makeRealSession` + `makeRealResolver`
* manually so the caller can access the raw `session` for shutdown tests.
*
* Returns `{ handler, prompter, session }`.
*/
export function makeDedupWiring(prompter?: AskEscalator) {
const { session, permissionManager, sessionRules, logger } =
makeRealSession();
const { resolver } = makeRealResolver(permissionManager, sessionRules);
makeExtDirDedupCheck(permissionManager);
const events = makeEvents();
const reporter = new GateDecisionReporter(logger, events);
const resolvedPrompter: AskEscalator =
prompter ?? makeSessionApprovingPrompter();
const runner = new GateRunner(
resolver,
sessionRules,
resolvedPrompter,
reporter,
() => false,
);
const handler = new PermissionGateHandler(
session,
makeToolRegistry({
getAll: vi
.fn()
.mockReturnValue([
{ name: "read" },
{ name: "write" },
{ name: "edit" },
{ name: "bash" },
]),
}),
new ToolCallGatePipeline(resolver, session),
new SkillInputGatePipeline(resolver),
runner,
);
return { handler, prompter: resolvedPrompter, session };
}
/**
* Builds the session-dedup handler without exposing the raw session.
*
* Wraps `makeDedupWiring`; returns `{ handler, prompter }`.
* Use `makeDedupWiring` when the test also needs `session.shutdown()`.
*/
export function makeDeduplicatingHandler(prompter?: AskEscalator) {
const { handler, prompter: resolvedPrompter } = makeDedupWiring(prompter);
return { handler, prompter: resolvedPrompter };
}
// ── Event builders ─────────────────────────────────────────────────────────
/**
* Builds a tool-call event in the shape that external-directory-session-dedup
* tests use — `toolName` field (not `name`); both are accepted by
* `getToolNameFromValue`.
*/
export function makeExtDirToolEvent(
toolName: string,
path: string,
toolCallId = "tc-1",
) {
return { type: "tool_call" as const, toolCallId, toolName, input: { path } };
}
/** Builds a bash tool-call event for external-directory session-dedup tests. */
export function makeExtDirBashEvent(command: string, toolCallId = "tc-1") {
return {
type: "tool_call" as const,
toolCallId,
toolName: "bash",
input: { command },
};
}
@@ -0,0 +1,28 @@
import type { TSNode } from "#src/access-intent/bash/parser";
/**
* Build a fake {@link TSNode} for testing the pure AST helpers without paying
* for a real tree-sitter parse.
*
* Fills only the fields those helpers read; `children` drives both `childCount`
* and `child(i)`, so a node's structural shape (delimiters plus a
* `variable_name`, a quoted string's inner content) can be expressed directly.
*
* Prefer a real parse (`getParser()`) when the test's subject is the AST shape
* tree-sitter actually produces; use this when the subject is the helper's
* behavior given a shape.
*/
export function makeTSNode(
type: string,
text: string,
children: TSNode[] = [],
): TSNode {
return {
type,
text,
startIndex: 0,
childCount: children.length,
isNamed: true,
child: (i) => children[i] ?? null,
};
}
@@ -0,0 +1,261 @@
/**
* Shared fixtures for the forwarding subsystem's test files.
*
* Collapses the temp forwarding-directory scaffolding, the forwarded-request
* writer, and the `ParentAuthorizerDeps` / `ForwardedRequestServerDeps` /
* `ForwarderContext` / UI-decision builders that the split-out per-class test
* files repeated per test.
*
* Consumed by test/authority/approval-escalator.test.ts (the escalation-up
* role, ParentAuthorizer since #555) and test/authority/forwarded-request-server.test.ts
* (the serving-down role) — both extracted from `PermissionForwarder` by Phase 8
* Step 6 (#530).
* The `{ emit, on }` events mock is not duplicated here — reuse `makeEvents`
* from `#test/helpers/handler-fixtures`.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
import type { ParentAuthorizerDeps } from "#src/authority/approval-escalator";
import type { ForwardedRequestServerDeps } from "#src/authority/forwarded-request-server";
import type { ForwarderContext } from "#src/authority/forwarder-context";
import {
ForwardingLivenessJudge,
ServingHeartbeatStore,
type TargetServingLookup,
} from "#src/authority/forwarding-liveness";
import {
createPermissionForwardingLocation,
type ForwardedAccessIntent,
type ForwardedPermissionRequest,
PERMISSION_FORWARDING_TIMEOUT_MS,
type PermissionForwardingLocation,
} from "#src/authority/permission-forwarding";
import {
type ServingLookup,
ServingSessionRegistry,
} from "#src/authority/serving-registry";
import {
type SubagentSessionInfo,
SubagentSessionRegistry,
} from "#src/authority/subagent-registry";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
/** Handle over a temp forwarding directory; register `cleanup` in `afterEach`. */
export interface ForwardingTempDir {
/** Absolute path passed as `forwardingDir` to `ParentAuthorizerDeps` / `ForwardedRequestServerDeps`. */
forwardingDir: string;
/** The session's request/response location under `forwardingDir`. */
location: PermissionForwardingLocation;
/** Writes a `ForwardedPermissionRequest` JSON into `location.requestsDir`. */
writeRequest(
overrides?: Partial<ForwardedPermissionRequest>,
): ForwardedPermissionRequest;
/** `rmSync(root, { recursive, force })`. */
cleanup(): void;
}
/**
* Creates a temp forwarding directory for `sessionId`.
*
* Always creates `requests/`; pass `{ createResponsesDir: false }` to omit
* `responses/` (the missing-`responses/` race test relies on this).
*/
export function createForwardingTempDir(
sessionId: string,
options: { createResponsesDir?: boolean } = {},
): ForwardingTempDir {
const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
const forwardingDir = join(root, "forwarding");
const location = createPermissionForwardingLocation(forwardingDir, sessionId);
mkdirSync(location.requestsDir, { recursive: true });
if (options.createResponsesDir ?? true) {
mkdirSync(location.responsesDir, { recursive: true });
}
return {
forwardingDir,
location,
writeRequest(overrides = {}) {
const request: ForwardedPermissionRequest = {
id: "req-forwarded",
createdAt: Date.now(),
requesterSessionId: "child-session",
targetSessionId: sessionId,
requesterAgentName: "Explore",
payload: makePromptPayload(),
...overrides,
};
writeFileSync(
join(location.requestsDir, `${request.id}.json`),
JSON.stringify(request),
"utf-8",
);
return request;
},
cleanup() {
rmSync(root, { recursive: true, force: true });
},
};
}
/**
* Builds `ForwardedRequestServerDeps` with a policy that defers to escalation
* (`ask`) and an approving escalator.
*
* Override `policy` / `escalator` with captured `vi.fn()` mocks to assert the
* resolve-then-escalate flow (e.g. `policy: { resolve }` returning
* `makeCheckResult({ state: "allow" })`, `escalator: { escalate }`).
*/
export function makeServerDeps(
overrides: Partial<ForwardedRequestServerDeps> = {},
): ForwardedRequestServerDeps {
return {
forwardingDir: "/tmp/forwarding",
logger: { review: vi.fn(), debug: vi.fn() },
policy: { resolve: vi.fn(() => makeCheckResult({ state: "ask" })) },
escalator: {
escalate: vi
.fn()
.mockResolvedValue({ approved: true, state: "approved" }),
},
recorder: { recordSessionApproval: vi.fn() },
...overrides,
};
}
/**
* Builds `ParentAuthorizerDeps` with a silent logger.
*
* `forwardingDir` and `registry` are the two a test almost always supplies
* (from `createForwardingTempDir` and `makeSubagentRegistry`); everything else
* defaults so a new dep lands here once rather than at every construction site.
*
* `serving` defaults to a lookup that reports every target as serving, so a
* test exercising the ordinary round trip is not accidentally fast-failed; a
* test targeting the unserved path passes {@link makeLivenessJudge}.
* `getTimeoutMs` defaults to the production value — override it with a small
* number to exercise the timeout without waiting it out.
*/
export function makeParentAuthorizerDeps(
overrides: Partial<ParentAuthorizerDeps> = {},
): ParentAuthorizerDeps {
return {
forwardingDir: "/tmp/forwarding",
logger: { review: vi.fn(), debug: vi.fn() },
serving: alwaysServing,
getTimeoutMs: () => PERMISSION_FORWARDING_TIMEOUT_MS,
...overrides,
};
}
/** A `TargetServingLookup` answering "yes" for any target (the non-fast-fail default). */
const alwaysServing: TargetServingLookup = {
isServing: () => true,
describe: () => ({ channel: "none", state: null, servingIds: [] }),
};
/**
* Builds the real judge over an in-process registry and the heartbeat records
* under `forwardingDir`.
*
* The production collaborator rather than a fake, because what these tests are
* about is which channel answers for which target — a hand-written double would
* be free to disagree with the routing under test.
*/
export function makeLivenessJudge(options: {
forwardingDir: string;
registry?: ServingLookup;
isProcessAlive?: (pid: number) => boolean;
}): ForwardingLivenessJudge {
return new ForwardingLivenessJudge({
registry: options.registry ?? new ServingSessionRegistry(),
heartbeats: new ServingHeartbeatStore({
forwardingDir: options.forwardingDir,
logger: { review: vi.fn(), debug: vi.fn() },
...(options.isProcessAlive
? { isProcessAlive: options.isProcessAlive }
: {}),
}),
});
}
/** Publishes a serving heartbeat for `sessionId`, as a live parent would. */
export function publishServingHeartbeat(
forwardingDir: string,
sessionId: string,
pid?: number,
): void {
new ServingHeartbeatStore({
forwardingDir,
logger: { review: vi.fn(), debug: vi.fn() },
...(pid === undefined ? {} : { pid }),
}).markServing(sessionId);
}
/**
* Builds a well-formed `ForwardedAccessIntent` (ADR 0008 §2) for request /
* policy fixtures. Defaults model a child in a worktree: a cwd-relative alias
* alongside the absolute one, so a relative parent rule stays relevant across
* cwds.
*/
export function makeForwardedAccessIntent(
overrides: Partial<ForwardedAccessIntent> = {},
): ForwardedAccessIntent {
return {
surface: "bash",
matchValues: ["git status"],
boundaryValue: null,
requesterCwd: "/worktree/issue-42",
principal: { sessionId: "child-session", agentName: "Explore" },
...overrides,
};
}
/**
* Builds a `ForwarderContext`.
*
* The `sessionId` shortcut populates `getSessionId`; an explicit
* `sessionManager` override merges last for tests stubbing other readers.
*/
export function makeForwarderContext(
overrides: {
hasUI?: boolean;
ui?: ForwarderContext["ui"];
cwd?: string;
sessionId?: string;
sessionManager?: Partial<ForwarderContext["sessionManager"]>;
} = {},
): ForwarderContext {
return {
hasUI: overrides.hasUI ?? false,
ui: overrides.ui ?? { select: vi.fn(), input: vi.fn() },
cwd: overrides.cwd ?? "/repo",
sessionManager: {
getSessionId: vi.fn(() => overrides.sessionId ?? ""),
getSessionDir: vi.fn(() => ""),
getEntries: vi.fn(() => []),
...overrides.sessionManager,
},
};
}
/**
* Builds a `SubagentSessionRegistry`, optionally pre-registering `childSessionId`.
*
* Omit `entry` for an empty registry (the "session not in registry" case);
* pass `{}` to register `childSessionId` with no `parentSessionId`.
*/
export function makeSubagentRegistry(
childSessionId: string,
entry?: SubagentSessionInfo,
): SubagentSessionRegistry {
const registry = new SubagentSessionRegistry();
if (entry) {
registry.register(childSessionId, entry);
}
return registry;
}
@@ -0,0 +1,289 @@
/**
* Shared gate-level test fixtures for gate descriptor and runner tests.
*/
import { vi } from "vitest";
import type { AskEscalator } from "#src/authority/authorizer-selection";
import type { ShellToolsConfig } from "#src/config-schema";
import type { DecisionReporter } from "#src/decision-reporter";
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
import { GateRunner } from "#src/handlers/gates/runner";
import type { SkillInputGateInputs } from "#src/handlers/gates/skill-input-gate-pipeline";
import type { ToolCallGateInputs } from "#src/handlers/gates/tool-call-gate-pipeline";
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 { SessionApprovalRecorder } from "#src/session-approval-recorder";
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
import {
makeGatePromptDetails,
makePromptPayload,
} from "#test/helpers/prompt-details-fixtures";
/**
* Permission resolver mock with an optional default check result.
*
* Returns a plain object whose `resolve` is a `vi.fn` so callers retain full
* mock access (`mockReturnValue`, `mockImplementation`, `mock.calls`).
*/
export function makeResolver(defaultCheck?: PermissionCheckResult) {
const resolve = vi.fn<ScopedPermissionResolver["resolve"]>();
if (defaultCheck) {
resolve.mockReturnValue(defaultCheck);
}
return { resolve };
}
/**
* Gate descriptor factory with runner-test defaults.
*
* Carries the payload every render over this descriptor reads, so a test that
* verifies a block path gets rendered denial text without overriding it.
*/
export function makeDescriptor(
overrides: Partial<GateDescriptor> = {},
): GateDescriptor {
return {
surface: "read",
input: {},
payload: makePromptPayload({
request: {
...makePromptPayload().request,
matchedPattern: "*",
},
}),
promptDetails: makeGatePromptDetails({
toolCallId: "tc-1",
toolName: "read",
}),
logContext: {
source: "tool_call",
toolCallId: "tc-1",
toolName: "read",
},
decision: {
surface: "read",
value: "read",
},
...overrides,
};
}
/**
* Reporter mock with independently inspectable vi.fn() stubs.
*/
export function makeReporter(
overrides: Partial<DecisionReporter> = {},
): DecisionReporter {
return {
writeReviewLog: vi.fn(),
emitDecision: vi.fn(),
...overrides,
};
}
/**
* Gate runner factory for `GateRunner` unit tests.
*
* Builds one `GateRunner` from four role mocks and returns `{ runner, deps }`
* so tests can both invoke `runner.run(...)` and assert on the individual
* mock call records (`deps.reporter.*`, `deps.resolve`, etc.).
*/
export function makeGateRunner(
overrides: {
resolveResult?: PermissionCheckResult;
resolve?: ScopedPermissionResolver["resolve"];
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
escalate?: AskEscalator["escalate"];
reporter?: Partial<DecisionReporter>;
/** Standing yolo setting for the runner's residual-ask grant. */
yolo?: boolean;
/** Live yolo reader, for tests that toggle the setting between runs. */
isYoloEnabled?: () => boolean;
} = {},
) {
const reporter = makeReporter(overrides.reporter);
const resolve =
overrides.resolve ??
vi
.fn<ScopedPermissionResolver["resolve"]>()
.mockReturnValue(
overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
);
const recordSessionApproval =
overrides.recordSessionApproval ??
(vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
const escalate =
overrides.escalate ??
vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
});
const isYoloEnabled =
overrides.isYoloEnabled ?? ((): boolean => overrides.yolo ?? false);
const runner = new GateRunner(
{ resolve },
{ recordSessionApproval },
{ escalate },
reporter,
isYoloEnabled,
);
return {
runner,
deps: {
resolve,
recordSessionApproval,
escalate,
reporter,
},
};
}
/**
* Tool-call context factory with bash defaults.
*
* path.test.ts uses different defaults (toolName "read", path input) and
* keeps a local wrapper; bash-path.test.ts uses this factory directly.
*/
export function makeTcc(
overrides: Partial<ToolCallContext> = {},
): ToolCallContext {
return {
toolName: "bash",
agentName: null,
input: { command: "cat .env" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
/**
* Resolver whose `resolve` dispatches on `input.path`, falling back to a
* default result for any path not in the map.
*
* Use when a test needs different results for different path tokens without
* writing a full `mockImplementation` block.
*
* Return type is intentionally unannotated so callers retain full `vi.fn()`
* mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.).
*/
export function makePathDispatchResolver(
byPath: Record<string, PermissionCheckResult>,
defaultResult: PermissionCheckResult,
) {
const resolve = vi.fn<ScopedPermissionResolver["resolve"]>();
resolve.mockImplementation((intent) => {
if (intent.kind === "tool") {
const path = (intent.input as Record<string, unknown>).path;
if (typeof path === "string" && path in byPath) {
return byPath[path];
}
return defaultResult;
}
const values = intent.path.matchValues();
for (const value of values) {
if (value in byPath) return byPath[value];
}
return defaultResult;
});
return { resolve };
}
/**
* Path-surface check result factory.
*
* Shared between bash-path.test.ts and path.test.ts; both use
* toolName "path", source "special", origin "global" as defaults.
*/
export function makeGateCheckResult(
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
toolName: "path",
state: "allow",
source: "special",
origin: "global",
...overrides,
};
}
/**
* Mock of `ToolCallGateInputs` for `ToolCallGatePipeline` unit tests.
*
* Each method is a `vi.fn()` stub so callers retain full mock access
* (`mock.calls`, `mockReturnValue`, etc.) on the returned object.
* Pass `overrides` to replace individual stubs without rebuilding the whole
* mock from scratch.
*/
export function makeGateInputs(
overrides: {
getActiveSkillEntries?: () => SkillPromptEntry[];
getInfrastructureReadDirs?: () => string[];
getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
getPathNormalizer?: () => PathNormalizer;
getShellToolAliases?: () => ShellToolsConfig | undefined;
} = {},
): ToolCallGateInputs {
return {
getActiveSkillEntries:
overrides.getActiveSkillEntries ??
vi.fn<() => SkillPromptEntry[]>(() => []),
getInfrastructureReadDirs:
overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
getToolPreviewLimits:
overrides.getToolPreviewLimits ??
vi.fn<() => ToolPreviewFormatterOptions>(() => ({
toolInputPreviewMaxLength: 500,
toolTextSummaryMaxLength: 100,
})),
getPathNormalizer:
overrides.getPathNormalizer ??
vi.fn<() => PathNormalizer>(
() =>
new PathNormalizer(
pathFlavorForPlatform(process.platform),
"/test/cwd",
),
),
getShellToolAliases:
overrides.getShellToolAliases ??
vi.fn<() => ShellToolsConfig | undefined>(() => undefined),
};
}
/**
* Mock of `SkillInputGateInputs` for `SkillInputGatePipeline` unit tests.
*
* Returns a plain object with a `checkPermission` `vi.fn()` stub so callers
* retain full mock access (`mockReturnValue`, `mock.calls`, etc.).
*/
export function makeSkillInputInputs(
overrides: { checkPermission?: SkillInputGateInputs["checkPermission"] } = {},
): SkillInputGateInputs {
return {
checkPermission:
overrides.checkPermission ??
vi
.fn<SkillInputGateInputs["checkPermission"]>()
.mockReturnValue(makeCheckResult()),
};
}
/**
* Mock `GateNotifier` for `SkillInputGatePipeline` unit tests.
*
* Return type is intentionally unannotated so callers retain full `vi.fn()`
* mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.) — annotating with
* `GateNotifier` would erase `Mock<...>` methods from the inferred type.
*/
export function makeNotifier() {
return {
warn: vi.fn<(message: string) => void>(),
};
}
@@ -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);
}
@@ -0,0 +1,100 @@
/**
* `makeFakePi()` — a composition-root test harness.
*
* Lets a test run the real `piPermissionSystemExtension(pi)` factory and then
* introspect and drive the result. Unlike the per-handler unit fixtures in
* `handler-fixtures.ts` (which inject collaborators), this harness exercises the
* factory itself — the wiring layer where registration completeness, shared-
* instance contracts, teardown, and event ordering live.
*
* It provides:
* - `events` — a real `createEventBus()` so cross-extension pub/sub and RPC
* behave as in production (tests can inject a shared bus to model parent/child
* instances).
* - `handlers` — every `pi.on(event, handler)` registration, keyed by event
* name, so a test can assert completeness and fire handlers.
* - `commands` — every `pi.registerCommand(name, …)` registration.
* - `fire(event, input, ctx)` — drive a registered handler; resolves to its
* (possibly async) result.
*
* The harness object is cast to `ExtensionAPI` at the call to the factory; the
* `FakePi` interface itself stays narrow (ISP — only what the factory touches).
*/
import { createEventBus, type EventBus } from "@earendil-works/pi-coding-agent";
import { vi } from "vitest";
/** A handler recorded by `pi.on(...)`, kept generic over event/result shapes. */
export type RecordedHandler = (event: unknown, ctx: unknown) => unknown;
export interface FakePi {
/** Real event bus so cross-extension pub/sub and RPC behave as in production. */
events: EventBus;
/** Every `pi.on(event, handler)` registration, keyed by event name. */
handlers: Map<string, RecordedHandler>;
/** Every `pi.registerCommand(name, …)` registration, keyed by command name. */
commands: Map<string, unknown>;
/**
* Drive a registered handler; resolves to its (possibly async) result.
*
* Throws if no handler is registered for `event` so a typo in a test surfaces
* loudly instead of silently resolving to `undefined`.
*/
fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
/** Minimal tool registry — returns the configured tool names. */
getAllTools(): { name: string }[];
/** Active tool names (`pi.getActiveTools()` shape — bare strings). */
getActiveTools(): string[];
setActiveTools(names: string[]): void;
}
export interface MakeFakePiOptions {
/** Inject a shared bus to model parent/child instances; defaults to a fresh bus. */
events?: EventBus;
/** Tool names returned by `getAllTools()`; defaults to a small set. */
toolNames?: readonly string[];
}
const DEFAULT_TOOL_NAMES = ["read", "write", "edit", "bash", "ls", "grep"];
/**
* Build a fake `ExtensionAPI` for composition-root tests.
*
* The returned object is structurally a `FakePi`; pass it to the factory as
* `piPermissionSystemExtension(pi as unknown as ExtensionAPI)`.
*/
export function makeFakePi(options: MakeFakePiOptions = {}): FakePi {
const events = options.events ?? createEventBus();
const toolNames = options.toolNames ?? DEFAULT_TOOL_NAMES;
const handlers = new Map<string, RecordedHandler>();
const commands = new Map<string, unknown>();
return {
events,
handlers,
commands,
fire(event, input, ctx): Promise<unknown> {
const handler = handlers.get(event);
if (!handler) {
throw new Error(`No handler registered for event "${event}"`);
}
return Promise.resolve(handler(input, ctx));
},
getAllTools(): { name: string }[] {
return toolNames.map((name) => ({ name }));
},
getActiveTools(): string[] {
return [...toolNames];
},
setActiveTools: vi.fn(),
// ── ExtensionAPI methods the factory touches (recorded) ────────────────
on(event: string, handler: RecordedHandler): void {
handlers.set(event, handler);
},
registerCommand(name: string, optionsArg: unknown): void {
commands.set(name, optionsArg);
},
// ── ExtensionAPI methods present for the cast but unused by the factory ─
registerProvider: vi.fn(),
exec: vi.fn(),
} as FakePi & Record<string, unknown>;
}
@@ -0,0 +1,266 @@
/**
* Filesystem-backed PermissionManager harness for integration tests.
*
* Writes a real config file and agents directory to a temp directory so
* PermissionManager can load them without mocking the file system.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths";
import { PermissionManager, type PolicyLoader } from "#src/permission-manager";
import type { ResolvedPolicyPaths } from "#src/policy-loader";
import type { Rule } from "#src/rule";
import type { PermissionState, ScopeConfig } from "#src/types";
/**
* Minimal in-memory PolicyLoader for testing merge + evaluation logic
* without touching the filesystem.
*/
export function createInMemoryPolicyLoader(
scopes: {
global?: ScopeConfig;
project?: ScopeConfig;
agent?: Record<string, ScopeConfig>;
projectAgent?: Record<string, ScopeConfig>;
} = {},
mcpServerNames: readonly string[] = [],
): PolicyLoader {
const issues: string[] = [];
return {
loadGlobalConfig: () => scopes.global ?? ({} as const),
loadProjectConfig: () => scopes.project ?? ({} as const),
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
loadProjectAgentConfig: (name?: string) =>
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
(name && scopes.projectAgent?.[name]) || {},
getConfiguredMcpServerNames: () => mcpServerNames,
getCacheStamp: () => "in-memory",
getConfigIssues: () => issues,
getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
globalConfigPath: "/in-memory/config.json",
globalConfigExists: true,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/in-memory/agents",
agentsDirExists: false,
projectAgentsDir: null,
projectAgentsDirExists: false,
}),
};
}
/** Manager backed by an in-memory PolicyLoader — no filesystem required. */
export function createInMemoryManager(
scopes: Parameters<typeof createInMemoryPolicyLoader>[0] = {},
mcpServerNames: readonly string[] = [],
): PermissionManager {
return new PermissionManager({
policyLoader: createInMemoryPolicyLoader(scopes, mcpServerNames),
});
}
/** Manager backed by nonexistent config paths — universal default is "ask". */
export function createMissingConfigManager(
mcpServerNames: readonly string[] = [],
): PermissionManager {
return new PermissionManager({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
mcpServerNames: [...mcpServerNames],
});
}
/** Build a session-layer rule (default action "allow"). */
export function sessionRule(
surface: string,
pattern: string,
action: PermissionState = "allow",
): Rule {
return { surface, pattern, action, layer: "session", origin: "session" };
}
export type CreateManagerOptions = {
mcpServerNames?: readonly string[];
};
export type CreateManagerWithProjectOptions = CreateManagerOptions & {
projectConfig?: ScopeConfig;
projectAgentFiles?: Record<string, string>;
};
export function createManager(
config: ScopeConfig,
agentFiles: Record<string, string> = {},
options: CreateManagerOptions = {},
) {
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
const agentsDir = join(baseDir, "agents");
mkdirSync(agentsDir, { recursive: true });
writeFileSync(
globalConfigPath,
`${JSON.stringify(config, null, 2)}\n`,
"utf8",
);
for (const [name, content] of Object.entries(agentFiles)) {
writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
}
const manager = new PermissionManager({
globalConfigPath,
agentsDir,
mcpServerNames: options.mcpServerNames,
});
return {
manager,
globalConfigPath,
cleanup: (): void => {
rmSync(baseDir, { recursive: true, force: true });
},
};
}
/**
* Manager backed by a temp config holding only a permission map.
* Delegates to createManager; returns the manager and its cleanup.
*/
export function createManagerWithConfig(
permission: Record<string, unknown>,
mcpServerNames: readonly string[] = [],
): { manager: PermissionManager; cleanup: () => void } {
const { manager, cleanup } = createManager(
{ permission } as ScopeConfig,
{},
{ mcpServerNames },
);
return { manager, cleanup };
}
export function createManagerWithProject(
config: ScopeConfig,
agentFiles: Record<string, string> = {},
options: CreateManagerWithProjectOptions = {},
) {
const baseDir = mkdtempSync(
join(tmpdir(), "pi-permission-system-proj-test-"),
);
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
const agentsDir = join(baseDir, "agents");
const projectRoot = join(baseDir, "project");
const projectGlobalConfigPath = join(projectRoot, "pi-permissions.jsonc");
const projectAgentsDir = join(projectRoot, "agents");
mkdirSync(agentsDir, { recursive: true });
mkdirSync(projectAgentsDir, { recursive: true });
writeFileSync(
globalConfigPath,
`${JSON.stringify(config, null, 2)}\n`,
"utf8",
);
if (options.projectConfig) {
writeFileSync(
projectGlobalConfigPath,
`${JSON.stringify(options.projectConfig, null, 2)}\n`,
"utf8",
);
}
for (const [name, content] of Object.entries(agentFiles)) {
writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
}
for (const [name, content] of Object.entries(
options.projectAgentFiles ?? {},
)) {
writeFileSync(join(projectAgentsDir, `${name}.md`), content, "utf8");
}
const manager = new PermissionManager({
globalConfigPath,
agentsDir,
projectGlobalConfigPath,
projectAgentsDir,
mcpServerNames: options.mcpServerNames,
});
return {
manager,
cleanup: (): void => {
rmSync(baseDir, { recursive: true, force: true });
},
};
}
/**
* Manager backed by a global permission map plus an optional project map.
* Delegates to createManagerWithProject; returns the manager and its cleanup.
*/
export function createManagerWithScopes(
globalPermission: Record<string, unknown>,
projectPermission?: Record<string, unknown>,
): { manager: PermissionManager; cleanup: () => void } {
return createManagerWithProject(
{ permission: globalPermission } as ScopeConfig,
{},
{
projectConfig:
projectPermission === undefined
? undefined
: ({ permission: projectPermission } as ScopeConfig),
},
);
}
/**
* Build a temp agentDir with a global config and an optional cwd with a
* project config. Returns the paths and a cleanup function.
*/
export function createAgentDirHarness(opts: {
globalPermission: Record<string, unknown>;
projectPermission?: Record<string, unknown>;
}): {
agentDir: string;
cwd: string;
globalConfigPath: string;
projectConfigPath: string;
cleanup: () => void;
} {
const baseDir = mkdtempSync(join(tmpdir(), "pm-agent-dir-test-"));
const agentDir = join(baseDir, "agent");
const cwd = join(baseDir, "project");
const globalConfigPath = getGlobalConfigPath(agentDir);
mkdirSync(join(agentDir, "extensions", "pi-permission-system"), {
recursive: true,
});
writeFileSync(
globalConfigPath,
JSON.stringify({ permission: opts.globalPermission }, null, 2),
);
const projectConfigPath = getProjectConfigPath(cwd);
mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
recursive: true,
});
if (opts.projectPermission) {
writeFileSync(
projectConfigPath,
JSON.stringify({ permission: opts.projectPermission }, null, 2),
);
}
return {
agentDir,
cwd,
globalConfigPath,
projectConfigPath,
cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
};
}
@@ -0,0 +1,51 @@
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
import {
TOOL_INPUT_PREVIEW_MAX_LENGTH,
TOOL_TEXT_SUMMARY_MAX_LENGTH,
} from "#src/tool-input-preview";
import {
ToolPreviewFormatter,
type ToolPreviewFormatterOptions,
} from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types";
/**
* Build a `PermissionCheckResult` for a presentation unit test.
*
* Defaults to the package's least-privilege `ask`. The presentation modules
* (the payload builders, `permission-prompts`, `tool-preview-formatter`) never
* read `state`, so a file whose subject is denials or allows wraps this with
* its own default rather than the caller repeating the whole literal.
*/
export function makePermissionCheckResult(
toolName: string,
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
toolName,
state: "ask",
source: "tool",
origin: "builtin",
...overrides,
};
}
/**
* Build a `ToolPreviewFormatter` at the built-in preview limits.
*
* Pass `options` to exercise a configured limit, and `customFormatters` to
* exercise the registry seam ahead of the built-in switch.
*/
export function makeToolPreviewFormatter(
options: Partial<ToolPreviewFormatterOptions> = {},
customFormatters?: ToolInputFormatterLookup,
): ToolPreviewFormatter {
return new ToolPreviewFormatter(
{
toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
...options,
},
customFormatters,
);
}
@@ -0,0 +1,57 @@
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { PromptPayload } from "#src/presentation/prompt-payload";
/**
* Build a minimal `PromptPermissionDetails` for a prompter/authorizer unit test.
*
* Owns the *structural* contract — every required field, and nothing else — so a
* new required field is defaulted here once instead of at every construction
* site. A test file that asserts on a particular value keeps its own semantic
* defaults by wrapping this factory.
*/
export function makePromptDetails(
overrides?: Partial<PromptPermissionDetails>,
): PromptPermissionDetails {
return { requestId: "req-1", ...makeGatePromptDetails(), ...overrides };
}
/**
* The gate-descriptor projection of {@link makePromptDetails}.
*
* A gate supplies every prompt detail except the request id, which the runner
* mints. Declared beside the full factory so a new required field is defaulted
* once for both, rather than diverging between the prompter tests and the gate
* tests.
*/
export function makeGatePromptDetails(
overrides?: Partial<Omit<PromptPermissionDetails, "requestId">>,
): Omit<PromptPermissionDetails, "requestId"> {
return {
source: "tool_call",
agentName: null,
payload: makePromptPayload(),
...overrides,
};
}
/** A minimal complete {@link PromptPayload} for a test that does not render it. */
export function makePromptPayload(
overrides?: Partial<PromptPayload>,
): PromptPayload {
return {
kind: "tool",
request: {
requester: { agentName: null, forwarded: false, sessionId: null },
surface: "read",
toolName: "read",
invokedToolName: null,
value: "read",
matchedPattern: null,
commandContext: null,
executedUnit: null,
},
evidence: [],
annotations: [],
...overrides,
};
}
@@ -0,0 +1,23 @@
/**
* Shared fixtures for the prompt-presentation surfaces a test constructs.
*
* `PromptPreferences` is read live at prompt time and threaded through
* `LocalUserAuthorizer` into the dialog, so several files build one. Building
* it here means a new preference is added in one place and the compiler finds
* every consumer, rather than each inline literal silently keeping the old
* shape.
*/
import type { PromptPreferences } from "#src/authority/permission-prompt-component";
import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer";
/** The live prompt preferences, override-driven. */
export function makePromptPreferences(
overrides: Partial<PromptPreferences> = {},
): PromptPreferences {
return {
doublePressToConfirm: true,
budget: DEFAULT_RENDER_BUDGET,
...overrides,
};
}
@@ -0,0 +1,198 @@
/**
* Shared real-instance test fixtures for PermissionSession and
* PermissionResolver.
*
* Use these instead of hand-rolling per-file mock intersection types.
* Build a real PermissionSession from small per-collaborator fakes so tests
* assert against actual behavior rather than mock contracts.
*
* Note: tests that exercise `resolveAgentName` must mock `active-agent` in
* their own file (the vi.hoisted / vi.mock pattern from permission-session.test.ts)
* since that mock is module-scoped.
*/
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { vi } from "vitest";
import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
import type { AuthorizerSelectionLifecycle } from "#src/authority/authorizer-selection";
import type { ForwardingController } from "#src/authority/forwarding-manager";
import type { SessionConfigStore } from "#src/config-store";
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
import type { ExtensionPaths } from "#src/extension-paths";
import { type PathFlavor, pathFlavorForPlatform } from "#src/path/path-flavor";
import type { ScopedPermissionManager } from "#src/permission-manager";
import { PermissionResolver } from "#src/permission-resolver";
import { PermissionSession } from "#src/permission-session";
import type { Ruleset } from "#src/rule";
import type { SessionLogger } from "#src/session-logger";
import { SessionRules } from "#src/session-rules";
import type { PermissionCheckResult, PermissionState } from "#src/types";
// ── Per-collaborator fake factories ────────────────────────────────────────
export function makePaths(
overrides: Partial<ExtensionPaths> = {},
): ExtensionPaths {
return {
agentDir: "/test/agent",
sessionsDir: "/test/agent/sessions",
subagentSessionsDir: "/test/agent/subagent-sessions",
forwardingDir: "/test/agent/sessions/permission-forwarding",
globalLogsDir: "/test/agent/logs",
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
...overrides,
};
}
export function makeLogger(): SessionLogger {
return {
debug: vi.fn(),
review: vi.fn(),
warn: vi.fn(),
};
}
export function makeConfigStore(
overrides: Partial<SessionConfigStore> = {},
): SessionConfigStore {
return {
current:
overrides.current ??
vi
.fn<() => typeof DEFAULT_EXTENSION_CONFIG>()
.mockReturnValue({ ...DEFAULT_EXTENSION_CONFIG }),
refresh:
overrides.refresh ??
vi.fn<
(ctx: ExtensionContext | undefined, projectTrusted: boolean) => void
>(),
logResolvedPaths: overrides.logResolvedPaths ?? vi.fn<() => void>(),
};
}
export function makeAuthorizerSelection(): AuthorizerSelectionLifecycle {
return {
activate: vi.fn<AuthorizerSelectionLifecycle["activate"]>(),
deactivate: vi.fn<AuthorizerSelectionLifecycle["deactivate"]>(),
};
}
export function makeForwarding(): ForwardingController {
return {
start: vi.fn(),
stop: vi.fn(),
};
}
/**
* Fake `ScopedPermissionManager` with vi.fn() stubs.
*
* Return type is intentionally unannotated so callers retain full `vi.fn()`
* mock access (`mock.calls`, `toHaveBeenCalledWith`, `mockReturnValue`, etc.).
*/
export function makeFakePermissionManager() {
return {
configureForCwd: vi.fn<(cwd: string | undefined | null) => void>(),
check: vi
.fn<
(
intent: ResolvedAccessIntent,
sessionRules?: Ruleset,
) => PermissionCheckResult
>()
.mockReturnValue({
state: "allow",
toolName: "read",
source: "tool",
origin: "builtin",
}),
getToolPermission: vi
.fn<(toolName: string, agentName?: string) => PermissionState>()
.mockReturnValue("allow"),
getConfigIssues: vi.fn((): string[] => []),
};
}
// ── Real-instance factories ────────────────────────────────────────────────
/**
* Build a real PermissionSession from per-collaborator fakes.
*
* Returns the session and every collaborator so callers can destructure only
* what they need and assert against collaborator spies directly.
* The `permissionManager` is a `makeFakePermissionManager()` result unless
* the caller passes an explicit `ScopedPermissionManager`.
*/
export function makeRealSession(overrides?: {
paths?: Partial<ExtensionPaths>;
logger?: SessionLogger;
forwarding?: ForwardingController;
permissionManager?: ScopedPermissionManager;
sessionRules?: SessionRules;
configStore?: SessionConfigStore;
authorizerSelection?: AuthorizerSelectionLifecycle;
flavor?: PathFlavor;
}): {
session: PermissionSession;
paths: ExtensionPaths;
logger: SessionLogger;
forwarding: ForwardingController;
permissionManager: ReturnType<typeof makeFakePermissionManager>;
sessionRules: SessionRules;
configStore: SessionConfigStore;
authorizerSelection: AuthorizerSelectionLifecycle;
} {
const paths = makePaths(overrides?.paths);
const logger = overrides?.logger ?? makeLogger();
const forwarding = overrides?.forwarding ?? makeForwarding();
const permissionManager =
(overrides?.permissionManager as
| ReturnType<typeof makeFakePermissionManager>
| undefined) ?? makeFakePermissionManager();
const sessionRules = overrides?.sessionRules ?? new SessionRules();
const configStore = overrides?.configStore ?? makeConfigStore();
const authorizerSelection =
overrides?.authorizerSelection ?? makeAuthorizerSelection();
const flavor = overrides?.flavor ?? pathFlavorForPlatform(process.platform);
const session = new PermissionSession(
paths,
forwarding,
permissionManager,
sessionRules,
configStore,
authorizerSelection,
flavor,
);
return {
session,
paths,
logger,
forwarding,
permissionManager,
sessionRules,
configStore,
authorizerSelection,
};
}
/**
* Build a real PermissionResolver from a fake manager and a SessionRules
* instance.
*
* When called with no arguments, creates a fresh fake manager and fresh
* SessionRules. Pass shared instances to connect the resolver to the same
* manager/rules used by a real session.
*/
export function makeRealResolver(
manager?: ReturnType<typeof makeFakePermissionManager>,
sessionRules?: SessionRules,
): {
resolver: PermissionResolver;
manager: ReturnType<typeof makeFakePermissionManager>;
sessionRules: SessionRules;
} {
const resolvedManager = manager ?? makeFakePermissionManager();
const resolvedRules = sessionRules ?? new SessionRules();
const resolver = new PermissionResolver(resolvedManager, resolvedRules);
return { resolver, manager: resolvedManager, sessionRules: resolvedRules };
}
@@ -0,0 +1,76 @@
import {
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
/**
* Real-filesystem fixtures for tests that must exercise filesystem *state*
* rather than lexical path logic — existence probes (`PathNormalizer.entryExists`)
* and symlink resolution (`canonicalizePath`).
*
* Every directory created is registered for cleanup; call {@link TmpFixture.cleanup}
* from an `afterEach`. Mirrors the `{ …, cleanup }` shape of `manager-harness.ts`.
*/
export interface TmpFixture {
/** Create a registered temp directory and return its absolute path. */
dir(prefix?: string): string;
/**
* Write a file under `parent` (creating intermediate directories) and return
* its absolute path. `name` may contain separators (`sub/file.txt`).
*/
file(parent: string, name: string, content?: string): string;
/** Create a directory under `parent` and return its absolute path. */
subdir(parent: string, name: string): string;
/**
* Create a symlink at `parent/name` pointing at `target`, and return the link's
* absolute path. `target` need not exist — pass a nonexistent path to build a
* dangling symlink.
*/
symlink(parent: string, name: string, target: string): string;
/** Remove every directory this fixture created. */
cleanup(): void;
}
export function createTmpFixture(): TmpFixture {
const roots: string[] = [];
return {
dir(prefix = "pi-perm-") {
const created = mkdtempSync(join(tmpdir(), prefix));
roots.push(created);
return created;
},
file(parent, name, content = "") {
const target = join(parent, name);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content);
return target;
},
subdir(parent, name) {
const target = join(parent, name);
mkdirSync(target, { recursive: true });
return target;
},
symlink(parent, name, target) {
const link = join(parent, name);
mkdirSync(dirname(link), { recursive: true });
symlinkSync(target, link);
return link;
},
cleanup() {
while (roots.length > 0) {
const root = roots.pop();
if (root) rmSync(root, { recursive: true, force: true });
}
},
};
}