mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor(permission-system): simplify policy and autoreview flow
This commit is contained in:
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
}
|
||||
@@ -224,7 +224,6 @@ export function makeGateCheckResult(
|
||||
export function makeGateInputs(
|
||||
overrides: {
|
||||
getActiveSkillEntries?: () => SkillPromptEntry[];
|
||||
getInfrastructureReadDirs?: () => string[];
|
||||
getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
|
||||
getPathNormalizer?: () => PathNormalizer;
|
||||
getShellToolAliases?: () => ShellToolsConfig | undefined;
|
||||
@@ -234,8 +233,6 @@ export function makeGateInputs(
|
||||
getActiveSkillEntries:
|
||||
overrides.getActiveSkillEntries ??
|
||||
vi.fn<() => SkillPromptEntry[]>(() => []),
|
||||
getInfrastructureReadDirs:
|
||||
overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
|
||||
getToolPreviewLimits:
|
||||
overrides.getToolPreviewLimits ??
|
||||
vi.fn<() => ToolPreviewFormatterOptions>(() => ({
|
||||
|
||||
@@ -128,8 +128,6 @@ export function makeToolRegistry(
|
||||
): ToolRegistry {
|
||||
return {
|
||||
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
||||
getActive: vi.fn().mockReturnValue(["read", "bash"]),
|
||||
setActive: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -212,7 +210,7 @@ export function makeBashCommandCheck(opts: {
|
||||
*
|
||||
* The `session` override bag maps to the real collaborators:
|
||||
* - `checkPermission` → applied to `permissionManager.checkPermission`
|
||||
* - `getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`
|
||||
* - `getActiveSkillEntries`, `getToolPreviewLimits`
|
||||
* → applied as vi.spyOn overrides on the real session
|
||||
* - `resolveAgentName` → applied as a vi.spyOn override on the real session
|
||||
*
|
||||
@@ -283,11 +281,6 @@ export function makeHandler(overrides?: {
|
||||
so.getActiveSkillEntries,
|
||||
);
|
||||
}
|
||||
if (so?.getInfrastructureReadDirs) {
|
||||
vi.spyOn(session, "getInfrastructureReadDirs").mockImplementation(
|
||||
so.getInfrastructureReadDirs,
|
||||
);
|
||||
}
|
||||
if (so?.getToolPreviewLimits) {
|
||||
vi.spyOn(session, "getToolPreviewLimits").mockImplementation(
|
||||
so.getToolPreviewLimits,
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { getGlobalConfigPath } 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";
|
||||
@@ -21,7 +21,6 @@ import type { PermissionState, ScopeConfig } from "#src/types";
|
||||
export function createInMemoryPolicyLoader(
|
||||
scopes: {
|
||||
global?: ScopeConfig;
|
||||
project?: ScopeConfig;
|
||||
agent?: Record<string, ScopeConfig>;
|
||||
projectAgent?: Record<string, ScopeConfig>;
|
||||
} = {},
|
||||
@@ -30,7 +29,6 @@ export function createInMemoryPolicyLoader(
|
||||
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) =>
|
||||
@@ -42,8 +40,6 @@ export function createInMemoryPolicyLoader(
|
||||
getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
|
||||
globalConfigPath: "/in-memory/config.json",
|
||||
globalConfigExists: true,
|
||||
projectConfigPath: null,
|
||||
projectConfigExists: false,
|
||||
agentsDir: "/in-memory/agents",
|
||||
agentsDirExists: false,
|
||||
projectAgentsDir: null,
|
||||
@@ -185,7 +181,6 @@ export function createManagerWithProject(
|
||||
const manager = new PermissionManager({
|
||||
globalConfigPath,
|
||||
agentsDir,
|
||||
projectGlobalConfigPath,
|
||||
projectAgentsDir,
|
||||
mcpServerNames: options.mcpServerNames,
|
||||
});
|
||||
@@ -245,7 +240,13 @@ export function createAgentDirHarness(opts: {
|
||||
JSON.stringify({ permission: opts.globalPermission }, null, 2),
|
||||
);
|
||||
|
||||
const projectConfigPath = getProjectConfigPath(cwd);
|
||||
const projectConfigPath = join(
|
||||
cwd,
|
||||
".pi",
|
||||
"extensions",
|
||||
"pi-permission-system",
|
||||
"policy.jsonc",
|
||||
);
|
||||
mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@ export function makePaths(
|
||||
subagentSessionsDir: "/test/agent/subagent-sessions",
|
||||
forwardingDir: "/test/agent/sessions/permission-forwarding",
|
||||
globalLogsDir: "/test/agent/logs",
|
||||
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user