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:
@@ -48,7 +48,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
} as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
/** Details whose gate-computed surface drives the delegation envelope. */
|
||||
/** Details carrying the gate-computed surface supplied to authorizers. */
|
||||
function makeDetailsOn(surface: string): PromptPermissionDetails {
|
||||
return makeDetails({
|
||||
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
|
||||
@@ -340,29 +340,8 @@ describe("AuthorizerSelection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("caps a link's allow on an excluded surface, falling through to the terminal", async () => {
|
||||
const registry = new AuthorizerRegistry();
|
||||
register(registry, "judge", { kind: "allow" });
|
||||
const selection = new AuthorizerSelection(
|
||||
makeDeps({
|
||||
prompter: makeInvokingPrompter(),
|
||||
authorizerRegistry: registry,
|
||||
getAuthorizerChain: () => ["judge"],
|
||||
}),
|
||||
);
|
||||
// No UI, not a subagent → the terminal is DenyingAuthorizer.
|
||||
selection.activate(makeCtx({ hasUI: false }));
|
||||
|
||||
const decision = await selection.escalate(
|
||||
makeDetailsOn("external_directory"),
|
||||
);
|
||||
|
||||
// The envelope downgraded the link's allow to defer, so the terminal
|
||||
// (denying) owns the decision — the allow did not leak through.
|
||||
expect(decision.approved).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a link's allow through on a non-excluded surface", async () => {
|
||||
it("lets a configured authorizer approve the path surface", async () => {
|
||||
const registry = new AuthorizerRegistry();
|
||||
register(registry, "judge", { kind: "allow" });
|
||||
const selection = new AuthorizerSelection(
|
||||
@@ -374,10 +353,10 @@ describe("AuthorizerSelection", () => {
|
||||
);
|
||||
selection.activate(makeCtx({ hasUI: false }));
|
||||
|
||||
const decision = await selection.escalate(makeDetailsOn("bash"));
|
||||
const decision = await selection.escalate(makeDetailsOn("path"));
|
||||
|
||||
// bash is not excluded, so the link's allow stands (a non-persistent
|
||||
// approved grant) — the denying terminal is never reached.
|
||||
// A configured authorizer's allow is final for this ask and does not
|
||||
// create a persistent session grant. The terminal is never reached.
|
||||
expect(decision).toEqual({
|
||||
approved: true,
|
||||
state: "approved",
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Authorizer, AuthorizerVerdict } from "#src/authority/authorizer";
|
||||
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
|
||||
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
||||
import type { PermissionQuery } from "#src/service";
|
||||
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
|
||||
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
|
||||
|
||||
function makeQuery(): PermissionQuery {
|
||||
return { checkPermission: vi.fn(), getToolPermission: vi.fn() };
|
||||
}
|
||||
|
||||
/** Build details whose gate-computed surface is `accessIntentSurface`. */
|
||||
function makeDetails(
|
||||
accessIntentSurface: string | undefined,
|
||||
displaySurface?: string | null,
|
||||
toolName?: string,
|
||||
): PromptPermissionDetails {
|
||||
return makePromptDetails({
|
||||
surface: displaySurface,
|
||||
toolName,
|
||||
accessIntent:
|
||||
accessIntentSurface === undefined
|
||||
? undefined
|
||||
: {
|
||||
surface: accessIntentSurface,
|
||||
matchValues: ["/some/value"],
|
||||
boundaryValue: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A link whose fixed verdict the envelope may cap. */
|
||||
function makeLink(verdict: AuthorizerVerdict): Authorizer["authorize"] {
|
||||
return vi.fn<Authorizer["authorize"]>().mockResolvedValue(verdict);
|
||||
}
|
||||
|
||||
describe("encloseInDelegationEnvelope", () => {
|
||||
const query = makeQuery();
|
||||
const log = makeAuthorizerLog();
|
||||
|
||||
describe("caps an allow verdict on an excluded surface to defer", () => {
|
||||
it("downgrades an allow on external_directory", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory"),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
it.each(["write", "edit", "bash", "custom-tool"] as const)(
|
||||
"downgrades an allow on external_directory for %s",
|
||||
async (toolName) => {
|
||||
const enclosed = encloseInDelegationEnvelope(
|
||||
makeLink({ kind: "allow" }),
|
||||
);
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory", undefined, toolName),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
},
|
||||
);
|
||||
|
||||
it("downgrades an allow on the path surface", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails("path"), query, log);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
it("downgrades an allow when the surface is undetermined (fail-safe)", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails(undefined, null), query, log);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("passes verdicts through unchanged", () => {
|
||||
it("keeps an allow on a non-excluded surface (bash)", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails("bash"), query, log);
|
||||
expect(verdict).toEqual({ kind: "allow" });
|
||||
});
|
||||
|
||||
it("keeps an allow on a per-tool surface (read)", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(makeDetails("read"), query, log);
|
||||
expect(verdict).toEqual({ kind: "allow" });
|
||||
});
|
||||
|
||||
it.each(["read", "find", "grep", "ls"] as const)(
|
||||
"keeps an allow on external_directory for the built-in read-only tool %s",
|
||||
async (toolName) => {
|
||||
const enclosed = encloseInDelegationEnvelope(
|
||||
makeLink({ kind: "allow" }),
|
||||
);
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory", undefined, toolName),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "allow" });
|
||||
},
|
||||
);
|
||||
|
||||
it("never caps a deny, even on an excluded surface", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(
|
||||
makeLink({ kind: "deny", reason: "wrong path" }),
|
||||
);
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory"),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "deny", reason: "wrong path" });
|
||||
});
|
||||
|
||||
it("never caps a defer", async () => {
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "defer" }));
|
||||
const verdict = await enclosed(makeDetails("path"), query, log);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the gate-computed accessIntent surface over the display surface", async () => {
|
||||
// accessIntent.surface (external_directory) is authoritative even when the
|
||||
// display-surface override says otherwise.
|
||||
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
|
||||
const verdict = await enclosed(
|
||||
makeDetails("external_directory", "bash"),
|
||||
query,
|
||||
log,
|
||||
);
|
||||
expect(verdict).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
it("forwards details, the injected query, and the review-log seam to the wrapped link", async () => {
|
||||
const link = makeLink({ kind: "defer" });
|
||||
const enclosed = encloseInDelegationEnvelope(link);
|
||||
const details = makeDetails("bash");
|
||||
await enclosed(details, query, log);
|
||||
expect(link).toHaveBeenCalledWith(details, query, log);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,8 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Authorizer } from "#src/authority/authorizer";
|
||||
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
|
||||
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
|
||||
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
|
||||
import { ForwardedRequestServer } from "#src/authority/forwarded-request-server";
|
||||
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
||||
import type {
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
PermissionPrompter,
|
||||
type PromptPermissionDetails,
|
||||
} from "#src/authority/permission-prompter";
|
||||
import type { PermissionQuery } from "#src/service";
|
||||
import {
|
||||
makeAuthorizerSelectionDeps,
|
||||
registerLink,
|
||||
@@ -57,9 +54,8 @@ function readResponse(
|
||||
* An approving `AskEscalator` that records the details it was handed.
|
||||
*
|
||||
* The reconstructed `PromptPermissionDetails` is itself the subject of the
|
||||
* access-facts and bounded-delegation cases below: they assert its exact shape,
|
||||
* and hand it to the real delegation envelope — a collaborator the server never
|
||||
* touches, but one that reads the details the server builds.
|
||||
* access-facts cases below: they assert the exact evidence passed to the
|
||||
* serving node's configured authorizer chain.
|
||||
*/
|
||||
function makeCapturingEscalator() {
|
||||
const escalated: PromptPermissionDetails[] = [];
|
||||
@@ -550,9 +546,8 @@ describe("processInbox — child-fixed access facts on the escalated ask", () =>
|
||||
value: "git push",
|
||||
});
|
||||
|
||||
// Absence, not an explicit `undefined`: the delegation envelope's
|
||||
// `accessIntent?.surface ?? surface` fallback reads the display surface only
|
||||
// when the key is genuinely absent.
|
||||
// Version-skew requests can omit access intent entirely; preserve that
|
||||
// absence instead of manufacturing evidence.
|
||||
expect(details).not.toHaveProperty("accessIntent");
|
||||
});
|
||||
});
|
||||
@@ -640,49 +635,6 @@ describe("processInbox — the child's payload on the escalated ask", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("processInbox — bounded delegation over forwarded asks", () => {
|
||||
const query: PermissionQuery = {
|
||||
checkPermission: vi.fn(),
|
||||
getToolPermission: vi.fn(),
|
||||
};
|
||||
const log = makeAuthorizerLog();
|
||||
const allowingLink: Authorizer["authorize"] = () =>
|
||||
Promise.resolve({ kind: "allow" });
|
||||
|
||||
test("caps a link's allow on a forwarded path ask to defer", async () => {
|
||||
const details = await escalateForwardedAsk({
|
||||
id: "req-envelope-path",
|
||||
source: "tool_call",
|
||||
surface: "write",
|
||||
value: "/worktree/issue-42/.ssh/config",
|
||||
accessIntent: makeForwardedAccessIntent({
|
||||
surface: "path",
|
||||
matchValues: ["/worktree/issue-42/.ssh/config"],
|
||||
boundaryValue: "/worktree/issue-42/.ssh/config",
|
||||
}),
|
||||
});
|
||||
|
||||
const enclosed = encloseInDelegationEnvelope(allowingLink);
|
||||
|
||||
// The gate surface, not the displayed tool name, decides exclusion — so a
|
||||
// forwarded path ask is capped exactly like the same ask made locally.
|
||||
expect(await enclosed(details, query, log)).toEqual({ kind: "defer" });
|
||||
});
|
||||
|
||||
test("passes a link's allow on a forwarded bash ask through", async () => {
|
||||
const details = await escalateForwardedAsk({
|
||||
id: "req-envelope-bash",
|
||||
source: "tool_call",
|
||||
surface: "bash",
|
||||
value: "npm test",
|
||||
accessIntent: makeForwardedAccessIntent({ matchValues: ["npm test"] }),
|
||||
});
|
||||
|
||||
const enclosed = encloseInDelegationEnvelope(allowingLink);
|
||||
|
||||
expect(await enclosed(details, query, log)).toEqual({ kind: "allow" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("processInbox — the serving node's chain adjudicates a forwarded ask", () => {
|
||||
/** A serving node with UI: its terminal is the human prompt. */
|
||||
|
||||
Reference in New Issue
Block a user