mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 20:02:55 +08:00
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:
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user