mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的 RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展 直接读取已安装组合包内的 config/,pi install / pi update 即完成部署; 删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留 日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本 reset 回发布版本。AGENTS.md 新增「组合包配置原则」。 脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程 安装器改为先下载到临时文件再执行,下载失败可被检测。 权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装 组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、 yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、 web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
/**
|
|
* Unit tests for AuthorizerSelection.
|
|
*
|
|
* AuthorizerSelection owns the stored ExtensionContext and is the sole
|
|
* implementation of the AskEscalator role. These tests verify the
|
|
* escalate/reject contract across activation state.
|
|
*/
|
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { ParentAuthorizer } from "#src/authority/approval-escalator";
|
|
import type { Authorizer } from "#src/authority/authorizer";
|
|
import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
|
|
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
|
|
import { LocalUserAuthorizer } from "#src/authority/local-user-authorizer";
|
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
|
import {
|
|
makeAuthorizerSelectionDeps as makeDeps,
|
|
makeDetection,
|
|
makeInvokingPrompter,
|
|
makePrompterApi,
|
|
registerLink as register,
|
|
} from "#test/helpers/authorizer-fixtures";
|
|
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
|
|
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
|
|
import { makePromptDetails as makeDetails } from "#test/helpers/prompt-details-fixtures";
|
|
|
|
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
|
|
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
return {
|
|
cwd: "/test/project",
|
|
hasUI: 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"),
|
|
getSessionId: vi.fn().mockReturnValue(null),
|
|
addEntry: vi.fn(),
|
|
},
|
|
...overrides,
|
|
} as unknown as ExtensionContext;
|
|
}
|
|
|
|
/** Details carrying the gate-computed surface supplied to authorizers. */
|
|
function makeDetailsOn(surface: string): PromptPermissionDetails {
|
|
return makeDetails({
|
|
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
|
|
});
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("AuthorizerSelection", () => {
|
|
describe("escalate", () => {
|
|
it("rejects before activate", async () => {
|
|
const selection = new AuthorizerSelection(makeDeps());
|
|
await expect(selection.escalate(makeDetails())).rejects.toThrow(
|
|
"escalate called before the session was activated",
|
|
);
|
|
});
|
|
|
|
it("delegates to deps.prompter.prompt with the selected authorizer", async () => {
|
|
const prompter = makePrompterApi();
|
|
const selection = new AuthorizerSelection(makeDeps({ prompter }));
|
|
const ctx = makeCtx({ hasUI: true });
|
|
selection.activate(ctx);
|
|
const details = makeDetails();
|
|
|
|
const result = await selection.escalate(details);
|
|
|
|
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
expect.any(LocalUserAuthorizer),
|
|
details,
|
|
);
|
|
expect(result).toEqual({
|
|
approved: true,
|
|
state: "approved",
|
|
decidedBy: DECIDED_BY_HUMAN,
|
|
});
|
|
});
|
|
|
|
it("uses the most recently selected authorizer", async () => {
|
|
const prompter = makePrompterApi();
|
|
const selection = new AuthorizerSelection(makeDeps({ prompter }));
|
|
selection.activate(makeCtx({ hasUI: false }));
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(makeDetails());
|
|
|
|
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
expect.any(LocalUserAuthorizer),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("rejects after deactivate", async () => {
|
|
const selection = new AuthorizerSelection(makeDeps());
|
|
selection.activate(makeCtx());
|
|
selection.deactivate();
|
|
await expect(selection.escalate(makeDetails())).rejects.toThrow(
|
|
"escalate called before the session was activated",
|
|
);
|
|
});
|
|
|
|
it("returns the prompter decision", async () => {
|
|
const decision: PermissionPromptDecision = {
|
|
approved: false,
|
|
state: "denied",
|
|
decidedBy: DECIDED_BY_HUMAN,
|
|
denialReason: "user declined",
|
|
};
|
|
const prompter = makePrompterApi();
|
|
prompter.prompt.mockResolvedValue(decision);
|
|
const selection = new AuthorizerSelection(makeDeps({ prompter }));
|
|
selection.activate(makeCtx());
|
|
|
|
const result = await selection.escalate(makeDetails());
|
|
|
|
expect(result).toEqual(decision);
|
|
});
|
|
});
|
|
|
|
describe("lifecycle", () => {
|
|
it("multiple activate calls still escalate successfully", async () => {
|
|
const prompter = makePrompterApi();
|
|
const selection = new AuthorizerSelection(makeDeps({ prompter }));
|
|
selection.activate(makeCtx({ cwd: "/old" }));
|
|
selection.activate(makeCtx({ cwd: "/new" }));
|
|
|
|
await selection.escalate(makeDetails());
|
|
|
|
expect(prompter.prompt).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("chain resolution", () => {
|
|
it("consults a configured link before the terminal", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "judge", { kind: "deny", reason: "typo path" });
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
const decision = await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
// The link decided (deny_with_reason); the LocalUserAuthorizer terminal
|
|
// was never reached (it would have approved by default).
|
|
expect(decision).toEqual({
|
|
approved: false,
|
|
state: "denied_with_reason",
|
|
denialReason: "typo path",
|
|
decidedBy: {
|
|
kind: "authorizer",
|
|
name: "judge",
|
|
verdict: "deny",
|
|
reason: "typo path",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("injects the session review-log seam into each link (ADR 0007 §3)", async () => {
|
|
const logger = makeAuthorizerLog();
|
|
const link = vi
|
|
.fn<Authorizer["authorize"]>()
|
|
.mockResolvedValue({ kind: "defer" });
|
|
const registry = new AuthorizerRegistry();
|
|
registry.register("judge", link);
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
logger,
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
// The link is handed the session logger as its third argument, so it can
|
|
// record a decision trail to the shared review log.
|
|
expect(link).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.anything(),
|
|
logger,
|
|
);
|
|
});
|
|
|
|
it("resolves links in config order (first non-defer wins)", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "a", { kind: "deny", reason: "a-wins" });
|
|
register(registry, "b", { kind: "deny", reason: "b-wins" });
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["a", "b"],
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
const decision = await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
expect(decision).toEqual({
|
|
approved: false,
|
|
state: "denied_with_reason",
|
|
denialReason: "a-wins",
|
|
decidedBy: {
|
|
kind: "authorizer",
|
|
name: "a",
|
|
verdict: "deny",
|
|
reason: "a-wins",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("skips an unregistered configured name with a warning", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "present", {
|
|
kind: "deny",
|
|
reason: "present-decided",
|
|
});
|
|
const logger = makeAuthorizerLog();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["missing", "present"],
|
|
logger,
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
const decision = await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
// The unregistered "missing" link is skipped fail-safe; "present"
|
|
// decides, and is the name credited — the skipped one is not.
|
|
expect(decision).toEqual({
|
|
approved: false,
|
|
state: "denied_with_reason",
|
|
denialReason: "present-decided",
|
|
decidedBy: {
|
|
kind: "authorizer",
|
|
name: "present",
|
|
verdict: "deny",
|
|
reason: "present-decided",
|
|
},
|
|
});
|
|
expect(logger.review).toHaveBeenCalledWith(
|
|
"authorizer_chain_unregistered_link",
|
|
{ requestId: "req-1", name: "missing" },
|
|
);
|
|
});
|
|
|
|
it("records the resolved link names on the ask", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "judge", { kind: "defer" });
|
|
const logger = makeAuthorizerLog();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
logger,
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
// Positive evidence the link was consulted: a link that defers decides
|
|
// nothing and would otherwise leave no trace of having run.
|
|
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_resolved", {
|
|
requestId: "req-1",
|
|
links: ["judge"],
|
|
});
|
|
});
|
|
|
|
it("records only the names it could resolve", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "present", { kind: "defer" });
|
|
const logger = makeAuthorizerLog();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["missing", "present"],
|
|
logger,
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_resolved", {
|
|
requestId: "req-1",
|
|
links: ["present"],
|
|
});
|
|
});
|
|
|
|
it("records no consultation when no configured name resolved", async () => {
|
|
const logger = makeAuthorizerLog();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
getAuthorizerChain: () => ["missing"],
|
|
logger,
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
// Nothing ran, so there is no consultation to record; the per-name
|
|
// warning already reports the skip.
|
|
expect(logger.review).not.toHaveBeenCalledWith(
|
|
"authorizer_chain_resolved",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
|
|
it("lets a configured authorizer approve the path surface", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "judge", { kind: "allow" });
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter: makeInvokingPrompter(),
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: false }));
|
|
|
|
const decision = await selection.escalate(makeDetailsOn("path"));
|
|
|
|
// 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",
|
|
decidedBy: {
|
|
kind: "authorizer",
|
|
name: "judge",
|
|
verdict: "allow",
|
|
reason: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("a registered but un-named link grants no authority (terminal identity)", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "judge", { kind: "allow" });
|
|
const prompter = makePrompterApi();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter,
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => [], // not named → opt-in withheld
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(makeDetails());
|
|
|
|
// Empty chain ⇒ the selected value is the terminal instance itself.
|
|
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
expect.any(LocalUserAuthorizer),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("chain delegation on a relaying node", () => {
|
|
/** A no-UI subagent node: its terminal relays the ask to the serving node. */
|
|
function makeRelayingSelection(
|
|
overrides: Parameters<typeof makeDeps>[0] = {},
|
|
): AuthorizerSelection {
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({ detection: makeDetection(true), ...overrides }),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: false }));
|
|
return selection;
|
|
}
|
|
|
|
it("composes no links, so the ask reaches the relaying terminal unchanged", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "judge", { kind: "deny", reason: "judged locally" });
|
|
const prompter = makePrompterApi();
|
|
const selection = makeRelayingSelection({
|
|
prompter,
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
});
|
|
const details = makeDetailsOn("bash");
|
|
|
|
await selection.escalate(details);
|
|
|
|
// Zero links ⇒ the composed chain *is* the terminal instance, so the
|
|
// registered link never ran: the serving node adjudicates this ask.
|
|
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
expect.any(ParentAuthorizer),
|
|
details,
|
|
);
|
|
});
|
|
|
|
it("records the delegated chain instead of the resolved one", async () => {
|
|
const registry = new AuthorizerRegistry();
|
|
register(registry, "judge", { kind: "deny", reason: "judged locally" });
|
|
const logger = makeAuthorizerLog();
|
|
const selection = makeRelayingSelection({
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
logger,
|
|
});
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
expect(logger.review).toHaveBeenCalledWith("authorizer_chain_delegated", {
|
|
requestId: "req-1",
|
|
links: ["judge"],
|
|
});
|
|
expect(logger.review).not.toHaveBeenCalledWith(
|
|
"authorizer_chain_resolved",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("does not report an unregistrable link as an unregistered one", async () => {
|
|
const logger = makeAuthorizerLog();
|
|
const selection = makeRelayingSelection({
|
|
getAuthorizerChain: () => ["model-judge"],
|
|
logger,
|
|
});
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
// A child cannot host a link at all (#699), so its absence is the design,
|
|
// not the misconfiguration `authorizer_chain_unregistered_link` reports.
|
|
expect(logger.review).not.toHaveBeenCalledWith(
|
|
"authorizer_chain_unregistered_link",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("records nothing when no chain is configured", async () => {
|
|
const logger = makeAuthorizerLog();
|
|
const selection = makeRelayingSelection({ logger });
|
|
|
|
await selection.escalate(makeDetailsOn("bash"));
|
|
|
|
expect(logger.review).not.toHaveBeenCalledWith(
|
|
"authorizer_chain_delegated",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("human-only asks (protected writes)", () => {
|
|
const humanOnlyDetails = (): PromptPermissionDetails =>
|
|
makeDetails({ humanOnly: true });
|
|
|
|
it("goes straight to the local user and never runs configured links", async () => {
|
|
const link = vi
|
|
.fn<Authorizer["authorize"]>()
|
|
.mockResolvedValue({ kind: "allow" });
|
|
const registry = new AuthorizerRegistry();
|
|
registry.register("judge", link);
|
|
const prompter = makePrompterApi();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({
|
|
prompter,
|
|
authorizerRegistry: registry,
|
|
getAuthorizerChain: () => ["judge"],
|
|
}),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: true }));
|
|
|
|
await selection.escalate(humanOnlyDetails());
|
|
|
|
expect(link).not.toHaveBeenCalled();
|
|
expect(prompter.prompt).toHaveBeenCalledWith(
|
|
expect.any(LocalUserAuthorizer),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("denies without prompting when the terminal is DenyingAuthorizer", async () => {
|
|
const prompter = makeInvokingPrompter();
|
|
const selection = new AuthorizerSelection(makeDeps({ prompter }));
|
|
// No UI, not a subagent → DenyingAuthorizer terminal.
|
|
selection.activate(makeCtx({ hasUI: false }));
|
|
|
|
const decision = await selection.escalate(humanOnlyDetails());
|
|
|
|
expect(decision).toEqual({
|
|
approved: false,
|
|
state: "denied_with_reason",
|
|
denialReason:
|
|
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
|
|
confirmationUnavailable: true,
|
|
decidedBy: {
|
|
kind: "unavailable",
|
|
reason: "protected_write_no_local_ui",
|
|
},
|
|
});
|
|
expect(prompter.prompt).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("denies instead of relaying to a parent on a subagent node", async () => {
|
|
const prompter = makeInvokingPrompter();
|
|
const selection = new AuthorizerSelection(
|
|
makeDeps({ detection: makeDetection(true), prompter }),
|
|
);
|
|
selection.activate(makeCtx({ hasUI: false }));
|
|
|
|
const decision = await selection.escalate(humanOnlyDetails());
|
|
|
|
// ParentAuthorizer.forward would have been invoked if relaying were
|
|
// still allowed; instead the human-only denial is returned.
|
|
expect(decision.approved).toBe(false);
|
|
expect(decision.state).toBe("denied_with_reason");
|
|
expect(decision.denialReason).toBe(
|
|
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
|
|
);
|
|
expect(prompter.prompt).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
});
|