refactor(pi-permission): extract shared bash-recovery package

- add @sikongjueluo/pi-permission-shared with recoverNativeBashCommand and its tests
- move the recovery module out of pi-permission-inner-cmd and import it from the shared package
- wire pi-permission-ai-judge to capture the UI-root session and recover the full bash command
- gate pi-permission-ai-judge registration on a UI-present root session
- add @types/node to pi-permission-ai-judge and allow its test script to pass with no tests
This commit is contained in:
2026-08-11 21:53:46 +08:00
parent 06b21a28a6
commit 2014e7f793
10 changed files with 139 additions and 22 deletions
@@ -0,0 +1,28 @@
{
"name": "@sikongjueluo/pi-permission-shared",
"version": "0.0.1",
"description": "Shared session-recovery utilities for the pi-permission extensions.",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"private": true,
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@types/node": "^26.0.0",
"typescript": "^5",
"vitest": "^3"
},
"scripts": {
"check": "tsc --noEmit",
"test": "vitest run"
}
}
@@ -0,0 +1 @@
export * from "./recovery";
@@ -0,0 +1,89 @@
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
/** Name of Pi's native Bash tool, as recorded in a tool-call block. */
export const NATIVE_BASH_TOOL_NAME = "bash";
/** A structurally-validated tool-call content block. */
interface ToolCallBlock {
readonly id: string;
readonly name: unknown;
readonly arguments: unknown;
}
function isToolCallBlock(block: unknown): block is ToolCallBlock {
return (
block !== null &&
typeof block === "object" &&
(block as { type?: unknown }).type === "toolCall" &&
typeof (block as { id?: unknown }).id === "string"
);
}
/** Read the native Bash command off a single validated tool-call block. */
function extractBashCommand(block: ToolCallBlock): string | undefined {
if (block.name !== NATIVE_BASH_TOOL_NAME) {
return undefined;
}
const command = (
block.arguments as { command?: unknown } | null | undefined
)?.command;
return typeof command === "string" ? command : undefined;
}
/**
* Recover the complete native Bash command for one tool call.
*
* The tool call being authorized is always the most recent one, so entries are
* walked in reverse and the search stops at the first (latest) assistant
* message that contains a `toolCall` block whose `id` equals `toolCallId`.
*
* The id must match exactly one block *within that single message*. An earlier
* message reusing the same id is a stale, already-resolved call and is
* irrelevant to the current authorization; but two matching blocks inside one
* message cannot be disambiguated (we cannot tell which one the caller's
* `toolCallId` refers to), so that case returns `undefined` (fail-closed). The
* matched block must then name the native Bash tool and carry a string
* `arguments.command`.
*
* Any other outcome — no match, a within-message duplicate id, a non-Bash tool
* call, a non-string command, or malformed entries — returns `undefined`.
*
* See ADR 0001 for the underlying permission/evidence boundaries.
*
* @returns the complete Bash command, or `undefined`.
*/
export function recoverNativeBashCommand(
entries: ReadonlyArray<SessionEntry>,
toolCallId: string,
): string | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type !== "message") {
continue;
}
const message = entry.message;
if (message.role !== "assistant") {
continue;
}
const matches: ToolCallBlock[] = [];
for (const block of message.content) {
if (isToolCallBlock(block) && block.id === toolCallId) {
matches.push(block);
}
}
if (matches.length === 0) {
continue;
}
// Latest message containing the id. Uniqueness only has to hold within
// this one message (see above); a cross-message reuse resolves to the
// latest, which is the call currently being authorized.
return matches.length === 1
? extractBashCommand(matches[0])
: undefined;
}
return undefined;
}
@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import { recoverNativeBashCommand } from "../src/recovery";
/** Build a minimal assistant message entry carrying the given content blocks. */
function assistantEntry(content: unknown[], id = "entry-1"): SessionEntry {
return {
type: "message",
id,
parentId: null,
timestamp: "2026-08-08T00:00:00.000Z",
message: {
role: "assistant",
content,
},
} as unknown as SessionEntry;
}
/** A user message entry, to confirm non-assistant entries are ignored. */
function userEntry(): SessionEntry {
return {
type: "message",
id: "entry-user",
parentId: null,
timestamp: "2026-08-08T00:00:00.000Z",
message: { role: "user", content: "hello" },
} as unknown as SessionEntry;
}
/** A tool-result message entry, ignored by recovery. */
function toolResultEntry(): SessionEntry {
return {
type: "message",
id: "entry-tool-result",
parentId: null,
timestamp: "2026-08-08T00:00:00.000Z",
message: {
role: "toolResult",
toolCallId: "call_1",
toolName: "bash",
content: [],
isError: false,
timestamp: 0,
},
} as unknown as SessionEntry;
}
/** A non-message entry (compaction), ignored by recovery. */
function compactionEntry(): SessionEntry {
return {
type: "compaction",
id: "entry-compaction",
parentId: null,
timestamp: "2026-08-08T00:00:00.000Z",
summary: "...",
firstKeptEntryId: "x",
tokensBefore: 0,
} as unknown as SessionEntry;
}
function toolCall(
id: string,
name: string,
args: Record<string, unknown>,
): Record<string, unknown> {
return { type: "toolCall", id, name, arguments: args };
}
function bashToolCall(id: string, command: unknown): Record<string, unknown> {
return { type: "toolCall", id, name: "bash", arguments: { command } };
}
describe("recoverNativeBashCommand", () => {
it("returns the command for a single native Bash tool call", () => {
const entries = [
userEntry(),
assistantEntry([
{ type: "text", text: "running tests" },
bashToolCall("call_1", "timeout 30s pnpm test"),
]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBe(
"timeout 30s pnpm test",
);
});
it("finds the matching tool call among several with different ids", () => {
const entries = [
assistantEntry([
bashToolCall("call_a", "pnpm build"),
bashToolCall("call_b", "timeout 30s pnpm test"),
]),
];
expect(recoverNativeBashCommand(entries, "call_b")).toBe(
"timeout 30s pnpm test",
);
});
it("ignores user, tool-result, and non-message entries", () => {
const entries = [
compactionEntry(),
userEntry(),
toolResultEntry(),
assistantEntry([bashToolCall("call_1", "echo hi")]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBe("echo hi");
});
it("returns undefined when no tool call matches the id", () => {
const entries = [assistantEntry([bashToolCall("call_1", "echo hi")])];
expect(recoverNativeBashCommand(entries, "call_missing")).toBeUndefined();
});
it("returns undefined on a duplicate id (cannot prove authority)", () => {
const entries = [
assistantEntry([
bashToolCall("call_1", "timeout 30s pnpm test"),
bashToolCall("call_1", "timeout 30s rm -rf /"),
]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBeUndefined();
});
it("returns undefined when the only match is a non-Bash tool", () => {
const entries = [
assistantEntry([
toolCall("call_1", "read", { path: "/etc/passwd" }),
]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBeUndefined();
});
it("returns undefined when arguments.command is not a string", () => {
const entries = [
assistantEntry([bashToolCall("call_1", 12345)]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBeUndefined();
});
it("returns undefined when arguments.command is missing", () => {
const entries = [
assistantEntry([
toolCall("call_1", "bash", { timeout: 30 }),
]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBeUndefined();
});
it("returns undefined for a duplicate id even when the first match is invalid", () => {
const entries = [
assistantEntry([
toolCall("call_1", "read", { path: "/a" }),
bashToolCall("call_1", "timeout 30s pnpm test"),
]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBeUndefined();
});
it("returns the latest command when the id recurs across messages", () => {
// A cross-message id reuse resolves to the latest block, which is the
// call currently being authorized; the earlier block is already-
// resolved history and must not fail-closed the recovery.
const entries = [
assistantEntry(
[bashToolCall("call_1", "timeout 30s rm -rf /")],
"entry-a",
),
assistantEntry(
[bashToolCall("call_1", "timeout 30s pnpm test")],
"entry-b",
),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBe(
"timeout 30s pnpm test",
);
});
it("tolerates a malformed content block that is not a tool call", () => {
const entries = [
assistantEntry([
{ type: "text", text: "thinking..." },
null,
{ type: "thinking", thinking: "..." },
bashToolCall("call_1", "timeout 30s pnpm test"),
]),
];
expect(recoverNativeBashCommand(entries, "call_1")).toBe(
"timeout 30s pnpm test",
);
});
});
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": [
"src",
"test"
]
}