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,395 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AccessIntent } from "#src/access-intent/access-intent";
|
||||
import { BashProgram } from "#src/access-intent/bash/program";
|
||||
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
|
||||
import type {
|
||||
GateBypass,
|
||||
GateDescriptor,
|
||||
GateResult,
|
||||
} from "#src/handlers/gates/descriptor";
|
||||
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
|
||||
import type { ToolCallContext } from "#src/handlers/gates/types";
|
||||
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
|
||||
import { PathNormalizer } from "#src/path-normalizer";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import {
|
||||
allEvidence,
|
||||
findEvidence,
|
||||
type PromptPayload,
|
||||
} from "#src/presentation/prompt-payload";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
||||
|
||||
import { makeResolver } from "#test/helpers/gate-fixtures";
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every escaping path the payload lists, in payload order. */
|
||||
function externalPaths(payload: PromptPayload): string[] {
|
||||
return allEvidence(payload, "external path").map((entry) => entry.text);
|
||||
}
|
||||
|
||||
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
||||
return {
|
||||
toolName: "bash",
|
||||
agentName: null,
|
||||
input: { command: "cat /outside/project/file.ts" },
|
||||
toolCallId: "tc-1",
|
||||
cwd: "/test/project",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCheckResult(
|
||||
state: "allow" | "deny" | "ask",
|
||||
overrides: Partial<PermissionCheckResult> = {},
|
||||
): PermissionCheckResult {
|
||||
return {
|
||||
state,
|
||||
toolName: "external_directory",
|
||||
source: "special",
|
||||
origin: "builtin",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract the policy match values a resolve(intent) call carries. */
|
||||
function intentValues(intent: AccessIntent): readonly string[] {
|
||||
if (intent.kind === "access-path") return intent.path.matchValues();
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the handler's parse-once derivation: parse the bash command into a
|
||||
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
|
||||
* does, so the gate is exercised through the production wiring.
|
||||
*/
|
||||
async function describeGate(
|
||||
tcc: ToolCallContext,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): Promise<GateResult> {
|
||||
const command = getNonEmptyString(toRecord(tcc.input).command);
|
||||
const bashProgram =
|
||||
tcc.toolName === "bash" && command
|
||||
? await BashProgram.parse(
|
||||
command,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
|
||||
)
|
||||
: null;
|
||||
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
|
||||
}
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("describeBashExternalDirectoryGate", () => {
|
||||
it("returns null when tool is not bash", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ toolName: "read" }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when command has no external paths", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "ls -la" } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("resolved shell expansions (#694)", () => {
|
||||
it("prompts for a $HOME write target that does not exist yet", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
const result = await describeGate(
|
||||
makeTcc({
|
||||
input: {
|
||||
command: 'touch "$HOME/pi-permission-system-repro-new"',
|
||||
},
|
||||
}),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
|
||||
join(homedir(), "pi-permission-system-repro-new"),
|
||||
]);
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
|
||||
it("prompts for a braced ${HOME} reference", async () => {
|
||||
const result = await describeGate(
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
|
||||
makeTcc({ input: { command: 'ls "${HOME}/somewhere"' } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
|
||||
join(homedir(), "somewhere"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not prompt for a variable it cannot resolve", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: 'CURRENT="$HOME"; ls "$CURRENT"' } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves each external path on the external_directory surface via an access-path intent (#418)", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
const intent = resolver.resolve.mock.calls[0][0];
|
||||
expect(intent).toMatchObject({
|
||||
kind: "access-path",
|
||||
surface: "external_directory",
|
||||
agentName: undefined,
|
||||
});
|
||||
expect(intentValues(intent)).toEqual(["/outside/a.ts"]);
|
||||
});
|
||||
|
||||
it("carries the deciding path's access facts on promptDetails (bash external_directory surface)", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
const result = (await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
||||
resolver,
|
||||
)) as GateDescriptor;
|
||||
const intent = resolver.resolve.mock.calls[0][0];
|
||||
const path = intent.kind === "access-path" ? intent.path : undefined;
|
||||
expect(path).toBeDefined();
|
||||
expect(result.promptDetails.accessIntent).toEqual({
|
||||
surface: "external_directory",
|
||||
matchValues: path?.matchValues(),
|
||||
boundaryValue: path?.boundaryValue(),
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a bash_external_directory payload listing every escaping path", async () => {
|
||||
const resolver = makeResolver(makeCheckResult("ask"));
|
||||
const result = (await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/a.ts" } }),
|
||||
resolver,
|
||||
)) as GateDescriptor;
|
||||
|
||||
expect(result.payload.kind).toBe("bash_external_directory");
|
||||
// The command is the decision value; the paths it reaches are evidence.
|
||||
expect(result.payload.request.value).toBe("cat /outside/a.ts");
|
||||
expect(result.payload.evidence).toContainEqual({
|
||||
label: "external path",
|
||||
text: "/outside/a.ts",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns GateBypass when all external paths are session-covered", async () => {
|
||||
const resolver = makeResolver(
|
||||
makeCheckResult("allow", { source: "session" }),
|
||||
);
|
||||
const result = await describeGate(makeTcc(), resolver);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
const bypass = result as GateBypass;
|
||||
expect(bypass.action).toBe("allow");
|
||||
expect(bypass.log).toMatchObject({
|
||||
event: "permission_request.session_approved",
|
||||
details: expect.objectContaining({ resolution: "session_approved" }),
|
||||
});
|
||||
expect(bypass.decidedBy).toEqual({
|
||||
kind: "session_approval",
|
||||
surface: "external_directory",
|
||||
pattern: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns GateBypass when all external paths are config-level allowed", async () => {
|
||||
// Config-level allow (source: "special") should suppress the prompt,
|
||||
// not just session-level allow. This was the bug: source !== "session"
|
||||
// kept config-allowed paths in the uncovered set.
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).length > 0
|
||||
? makeCheckResult("allow", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(makeTcc(), resolver);
|
||||
expect(result).not.toBeNull();
|
||||
expect(isGateBypass(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
|
||||
// The path-less extCheck used to always return the "*" catch-all (ask),
|
||||
// silently downgrading a config-level deny to ask. After the fix, the
|
||||
// descriptor's preCheck is derived from the actual path check result.
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).length > 0
|
||||
? makeCheckResult("deny", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(makeTcc(), resolver);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.preCheck?.state).toBe("deny");
|
||||
});
|
||||
|
||||
it("descriptor surface is 'external_directory'", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc(),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("descriptor decision surface is 'external_directory'", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc(),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.decision.surface).toBe("external_directory");
|
||||
});
|
||||
|
||||
it("payload carries the command and the boundary it escaped", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "cat /outside/file.ts" } }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const { payload } = result as GateDescriptor;
|
||||
expect(payload.kind).toBe("bash_external_directory");
|
||||
expect(payload.request.value).toBe("cat /outside/file.ts");
|
||||
expect(findEvidence(payload, "working directory")?.text).toBe(
|
||||
"/test/project",
|
||||
);
|
||||
});
|
||||
|
||||
it("promptDetails includes command and tool_call source", async () => {
|
||||
const result = await describeGate(
|
||||
makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.promptDetails).toMatchObject({
|
||||
source: "tool_call",
|
||||
agentName: "agent-1",
|
||||
toolCallId: "tc-5",
|
||||
toolName: "bash",
|
||||
command: "cat /outside/project/file.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
|
||||
// One path config-allowed, one config-ask → descriptor with only the ask path.
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).includes("/outside/a.ts")
|
||||
? makeCheckResult("allow", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBe(1);
|
||||
expect(desc.preCheck?.state).toBe("ask");
|
||||
});
|
||||
|
||||
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
|
||||
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).includes("/outside/a.ts")
|
||||
? makeCheckResult("deny", { source: "special" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
expect(desc.preCheck?.state).toBe("deny");
|
||||
// Both paths are uncovered (neither is allow), so both patterns are included.
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBe(2);
|
||||
});
|
||||
|
||||
it("only includes uncovered paths when some are session-covered", async () => {
|
||||
const resolver = makeResolver();
|
||||
resolver.resolve.mockImplementation((intent) =>
|
||||
intentValues(intent).includes("/outside/a.ts")
|
||||
? makeCheckResult("allow", { source: "session" })
|
||||
: makeCheckResult("ask"),
|
||||
);
|
||||
const result = await describeGate(
|
||||
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
||||
resolver,
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
const desc = result as GateDescriptor;
|
||||
// Should have patterns only for the uncovered path
|
||||
expect(desc.sessionApproval).toBeDefined();
|
||||
if (!desc.sessionApproval) return;
|
||||
expect(desc.sessionApproval.patterns.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeBashExternalDirectoryGate — Git Bash semantics (win32)", () => {
|
||||
async function describeGateWin32(
|
||||
tcc: ToolCallContext,
|
||||
resolver: ScopedPermissionResolver,
|
||||
): Promise<GateResult> {
|
||||
const command = getNonEmptyString(toRecord(tcc.input).command);
|
||||
const bashProgram =
|
||||
tcc.toolName === "bash" && command
|
||||
? await BashProgram.parse(
|
||||
command,
|
||||
new PathNormalizer(win32PathFlavor, tcc.cwd),
|
||||
)
|
||||
: null;
|
||||
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
|
||||
}
|
||||
|
||||
const winTcc = (command: string): ToolCallContext =>
|
||||
makeTcc({ cwd: "C:/projects/app", input: { command } });
|
||||
|
||||
it("does not prompt for a /dev/null redirect target", async () => {
|
||||
const result = await describeGateWin32(
|
||||
winTcc("echo hi > /dev/null"),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("prompts for a /tmp path displayed as typed, not as C:\\tmp", async () => {
|
||||
const result = await describeGateWin32(
|
||||
winTcc("ls /tmp"),
|
||||
makeResolver(makeCheckResult("ask")),
|
||||
);
|
||||
expect(isGateDescriptor(result)).toBe(true);
|
||||
expect(externalPaths((result as GateDescriptor).payload)).toEqual(["/tmp"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user