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