Files
penguin-harness/packages/cli/test/approval.test.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

163 lines
5.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { Readable, Writable } from "node:stream";
import { toolCall } from "@prismshadow/penguin-core";
import type { OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core";
import { makeApprove, promptApproval, resolveApprovalMode } from "../src/approval.js";
import { getMessages } from "../src/i18n.js";
const t = getMessages("en");
/** An in-memory writable stream that collects everything written to output. */
function collector(): { stream: Writable; text: () => string } {
let buf = "";
const stream = new Writable({
write(chunk, _enc, cb) {
buf += chunk.toString();
cb();
},
});
return { stream, text: () => buf };
}
// mock_read_only_tool is only used for approval-mode tests, not a real tool; its permission is read-only ("r").
const readTool = (): OmniMessage<ToolCallPayload> =>
toolCall({ name: "mock_read_only_tool", arguments: '{"path":"a"}', toolCallId: "r1" });
const writeTool = (): OmniMessage<ToolCallPayload> =>
toolCall({ name: "exec_command", arguments: '{"cmd":"rm x"}', toolCallId: "w1" });
const perms: Record<string, "r" | "rw"> = {
mock_read_only_tool: "r",
exec_command: "rw",
};
const toolPermission = (name: string): "r" | "rw" | undefined => perms[name];
describe("promptApproval", () => {
it('returns "allow" when the user types "y"', async () => {
const { stream, text } = collector();
const decision = await promptApproval({
input: Readable.from(["y\n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
// Output is exactly the approval prompt itself — no input echo, no repeated tool-call rendering.
expect(text()).toBe("? Approve this tool call? [Y/n] ");
});
it('returns "allow" on empty input (Enter) — tool approval defaults to yes', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from(["\n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
});
it('returns "allow" for "yes" (case-insensitive, trimmed)', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from([" YES \n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
});
it('returns "deny" when the user types "n"', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from(["n\n"]),
output: stream,
t,
});
expect(decision).toBe("deny");
});
it('returns "allow" for unrelated input (tool approval defaults to yes)', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from(["maybe\n"]),
output: stream,
t,
});
expect(decision).toBe("allow");
});
it('returns "deny" when the input stream ends (EOF) instead of hanging', async () => {
const { stream } = collector();
const decision = await promptApproval({
input: Readable.from([]),
output: stream,
t,
});
expect(decision).toBe("deny");
});
});
describe("resolveApprovalMode", () => {
it("maps --approve values; defaults to allow-all", () => {
expect(resolveApprovalMode("allow-all", t)).toBe("allow-all");
expect(resolveApprovalMode("read-only", t)).toBe("read-only");
expect(resolveApprovalMode("deny-all", t)).toBe("deny-all");
expect(resolveApprovalMode("always-ask", t)).toBe("always-ask");
expect(resolveApprovalMode("READ-ONLY", t)).toBe("read-only");
expect(resolveApprovalMode(undefined, t)).toBe("allow-all");
});
});
describe("makeApprove permission modes", () => {
it("allow-all → allows everything", async () => {
const approve = makeApprove({
mode: "allow-all",
toolPermission,
interactivePrompt: async () => "deny",
});
expect(await approve(readTool())).toBe("allow");
expect(await approve(writeTool())).toBe("allow");
});
it("deny-all → rejects everything", async () => {
const approve = makeApprove({
mode: "deny-all",
toolPermission,
interactivePrompt: async () => "allow",
});
expect(await approve(readTool())).toBe("deny");
expect(await approve(writeTool())).toBe("deny");
});
it("read-only → auto-allows read-only tools, prompts for the rest", async () => {
let prompted = 0;
const approve = makeApprove({
mode: "read-only",
toolPermission,
interactivePrompt: async () => {
prompted += 1;
return "deny";
},
});
// Read-only tools are auto-allowed without prompting.
expect(await approve(readTool())).toBe("allow");
expect(prompted).toBe(0);
// Read-write tools are handed off to the interactive prompt (denied here).
expect(await approve(writeTool())).toBe("deny");
expect(prompted).toBe(1);
});
it("always-ask → always delegates to the interactive prompt", async () => {
let prompted = 0;
const approve = makeApprove({
mode: "always-ask",
toolPermission,
interactivePrompt: async () => {
prompted += 1;
return "allow";
},
});
expect(await approve(readTool())).toBe("allow");
expect(await approve(writeTool())).toBe("allow");
expect(prompted).toBe(2);
});
});