feat(pi-permission-inner-cmd): authorize inner commands behind timeout wrappers

- recover the full bash command from the session by tool-call id
- add recognizer for the strict timeout wrapper grammar
- add authorizer mapping inner allow/ask/deny and forwarding agent name
- defer fail-closed on session mismatch, nested wrappers, and errors
- add unit tests for recovery, recognizer, authorizer, and lifecycle
- document the decision in ADR 0001
This commit is contained in:
2026-08-11 16:33:08 +08:00
parent c4d76ad284
commit 24153412c9
11 changed files with 1438 additions and 7 deletions
@@ -0,0 +1,449 @@
import { describe, expect, it } from "vitest";
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import type {
AuthorizerLog,
PermissionCheckResult,
PermissionQuery,
PermissionState,
PromptPermissionDetails,
} from "@gotgenes/pi-permission-system";
import { authorizeInnerCommand, type SessionProbe } from "../src/authorizer";
/** Default captured root-session identity used by the harness. */
const ROOT_SESSION_ID = "session-root";
type LogCall = {
level: "review" | "debug";
event: string;
details?: Record<string, unknown>;
};
function makeLog(): { log: AuthorizerLog; calls: LogCall[] } {
const calls: LogCall[] = [];
const log: AuthorizerLog = {
review: (event, details) => calls.push({ level: "review", event, details }),
debug: (event, details) => calls.push({ level: "debug", event, details }),
};
return { log, calls };
}
type CheckCall = {
surface: string;
value: string | undefined;
agentName: string | undefined;
};
function makeQuery(
states: Record<string, PermissionState>,
opts: { throwOn?: string } = {},
): { query: PermissionQuery; calls: CheckCall[] } {
const calls: CheckCall[] = [];
const query: PermissionQuery = {
checkPermission: (surface, value, agentName) => {
calls.push({ surface, value, agentName });
if (opts.throwOn !== undefined && value === opts.throwOn) {
throw new Error("policy boom");
}
const state: PermissionState = states[value ?? ""] ?? "ask";
const result: PermissionCheckResult = {
toolName: "bash",
state,
source: "bash",
origin: "builtin",
};
return result;
},
getToolPermission: () => "ask",
};
return { query, calls };
}
function assistantEntry(content: unknown[]): SessionEntry {
return {
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-08-08T00:00:00.000Z",
message: { role: "assistant", content },
} as unknown as SessionEntry;
}
function bashToolCall(id: string, command: unknown): Record<string, unknown> {
return { type: "toolCall", id, name: "bash", arguments: { command } };
}
function entriesRecovering(command: string, toolCallId = "call_1"): SessionEntry[] {
return [assistantEntry([bashToolCall(toolCallId, command)])];
}
function bashDetails(
toolCallId = "call_1",
agentName: string | null = null,
): PromptPermissionDetails {
return {
requestId: "req-1",
source: "tool_call",
agentName,
message: "May I run bash?",
toolCallId,
toolName: "bash",
// details.command is intentionally the winning unit, not the full input.
command: "ignored-winning-unit",
};
}
function makeSessionProbe(args: {
recoveredCommand: string;
toolCallId: string;
getEntriesThrows?: boolean;
/** Live session id reported at authorize time. */
sessionId?: string;
getSessionIdThrows?: boolean;
}): SessionProbe {
return {
getEntries: args.getEntriesThrows
? (): SessionEntry[] => {
throw new Error("session boom");
}
: (): SessionEntry[] =>
entriesRecovering(args.recoveredCommand, args.toolCallId),
getSessionId: args.getSessionIdThrows
? (): string => {
throw new Error("session id boom");
}
: (): string => args.sessionId ?? ROOT_SESSION_ID,
};
}
async function run(args: {
recoveredCommand: string;
states?: Record<string, PermissionState>;
details?: Partial<PromptPermissionDetails>;
getEntriesThrows?: boolean;
queryThrowsOn?: string;
/** Live session id diverges from the captured provenance. */
sessionMismatch?: boolean;
getSessionIdThrows?: boolean;
}): Promise<{
verdict: { kind: string };
log: LogCall[];
check: CheckCall[];
}> {
const { log, calls } = makeLog();
const toolCallId = args.details?.toolCallId ?? "call_1";
const { query, calls: check } = makeQuery(args.states ?? {}, {
throwOn: args.queryThrowsOn,
});
const session = makeSessionProbe({
recoveredCommand: args.recoveredCommand,
toolCallId,
getEntriesThrows: args.getEntriesThrows,
getSessionIdThrows: args.getSessionIdThrows,
sessionId: args.sessionMismatch ? "session-changed" : ROOT_SESSION_ID,
});
const verdict = await authorizeInnerCommand({
details: { ...bashDetails(toolCallId), ...args.details } as PromptPermissionDetails,
query,
log,
session,
expectedSessionId: ROOT_SESSION_ID,
});
return { verdict: { kind: verdict.kind }, log: calls, check };
}
describe("authorizeInnerCommand — recognized wrapper verdicts", () => {
it("maps an inner allow to allow and records a review", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
});
expect(verdict.kind).toBe("allow");
expect(log).toEqual([
{
level: "review",
event: "inner_cmd.allow",
details: {
command: "timeout 30s pnpm test",
innerCommand: "pnpm test",
},
},
]);
expect(check).toEqual([
{ surface: "bash", value: "pnpm test", agentName: undefined },
]);
});
it("maps an inner ask to defer and records a debug", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s git push",
states: { "git push": "ask" },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.inner_ask",
details: {
command: "timeout 30s git push",
innerCommand: "git push",
},
},
]);
});
it("maps an inner deny to deny and records a review", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s rm -rf /",
states: { "rm -rf /": "deny" },
});
expect(verdict.kind).toBe("deny");
expect(log).toEqual([
{
level: "review",
event: "inner_cmd.deny",
details: {
command: "timeout 30s rm -rf /",
innerCommand: "rm -rf /",
},
},
]);
});
it("re-checks the complete inner program for compound input", async () => {
// timeout 60s pnpm test && git push -> inner "pnpm test && git push".
// The whole program must be re-evaluated; git push asking defers it.
const { verdict, log, check } = await run({
recoveredCommand: "timeout 60s pnpm test && git push",
states: { "pnpm test && git push": "ask" },
});
expect(verdict.kind).toBe("defer");
expect(check).toEqual([
{
surface: "bash",
value: "pnpm test && git push",
agentName: undefined,
},
]);
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.inner_ask",
details: {
command: "timeout 60s pnpm test && git push",
innerCommand: "pnpm test && git push",
},
},
]);
});
it("unwraps timeout around bash -c and re-evaluates the inner program", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "timeout 30s bash -c something",
states: { "bash -c something": "ask" },
});
expect(verdict.kind).toBe("defer");
expect(check).toEqual([
{ surface: "bash", value: "bash -c something", agentName: undefined },
]);
expect(log[0]?.event).toBe("inner_cmd.inner_ask");
});
it("forwards details.agentName ?? undefined into the inner query", async () => {
const { check } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
details: { agentName: "release-worker" },
});
expect(check).toEqual([
{ surface: "bash", value: "pnpm test", agentName: "release-worker" },
]);
});
it("passes undefined when details.agentName is null", async () => {
const { check } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
details: { agentName: null },
});
expect(check[0]?.agentName).toBeUndefined();
});
});
describe("authorizeInnerCommand — root-ownership revalidation", () => {
it("defers fail-closed when the live session id no longer matches", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
sessionMismatch: true,
});
expect(verdict.kind).toBe("defer");
// Never reaches recovery or the decisive deterministic query.
expect(check).toEqual([]);
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.session_mismatch",
details: {
expectedSessionId: ROOT_SESSION_ID,
currentSessionId: "session-changed",
},
},
]);
});
it("still defers a forwarded ask before revalidating ownership", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
sessionMismatch: true,
details: {
forwarding: { requesterAgentName: "child", requesterSessionId: "s1" },
},
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]); // forwarded defers silently, before any session read
});
});
describe("authorizeInnerCommand — fail-closed deferrals", () => {
it("defers on unsupported timeout syntax with a debug log", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout -k 5s 30s pnpm test",
states: { "pnpm test": "allow" },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.unsupported_timeout_syntax",
details: { command: "timeout -k 5s 30s pnpm test" },
},
]);
});
it("defers on a nested wrapper with a debug log", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "timeout 30s timeout 10s pnpm test",
states: { "timeout 10s pnpm test": "allow" },
});
expect(verdict.kind).toBe("defer");
expect(check).toEqual([]); // inner program is never re-evaluated
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.nested_timeout",
details: {
command: "timeout 30s timeout 10s pnpm test",
innerCommand: "timeout 10s pnpm test",
},
},
]);
});
it("defers silently on an ordinary non-timeout command", async () => {
const { verdict, log } = await run({
recoveredCommand: "pnpm test",
states: { "pnpm test": "allow" },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
});
it("defers silently on a forwarded request", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
details: {
forwarding: { requesterAgentName: "child", requesterSessionId: "s1" },
},
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
expect(check).toEqual([]); // never reaches the deterministic query
});
it("defers silently for a non-Bash tool", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
details: { toolName: "read" },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
});
it("defers silently when toolCallId is absent", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
details: { toolCallId: undefined },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
});
it("defers silently when the tool call is not in the session", async () => {
// Recover a command under a different id so recovery misses.
const { log, calls } = makeLog();
const { query, calls: check } = makeQuery({ "pnpm test": "allow" });
const verdict = await authorizeInnerCommand({
details: bashDetails("call_missing"),
query,
log,
session: makeSessionProbe({
recoveredCommand: "timeout 30s pnpm test",
toolCallId: "call_1",
}),
expectedSessionId: ROOT_SESSION_ID,
});
expect(verdict.kind).toBe("defer");
expect(calls).toEqual([]);
expect(check).toEqual([]);
});
});
describe("authorizeInnerCommand — exceptions defer with a debug log", () => {
it("defers when reading the session id throws (logs only safe data)", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
getSessionIdThrows: true,
});
expect(verdict.kind).toBe("defer");
expect(log).toHaveLength(1);
expect(log[0]?.event).toBe("inner_cmd.exception");
// Exception before recognition: no command/innerCommand available.
expect(log[0]?.details).toEqual({ error: "session id boom" });
});
it("defers when reading the session throws (logs only safe data)", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
getEntriesThrows: true,
});
expect(verdict.kind).toBe("defer");
expect(log).toHaveLength(1);
expect(log[0]?.level).toBe("debug");
expect(log[0]?.event).toBe("inner_cmd.exception");
// Exception before recognition: only the error is available.
expect(log[0]?.details).toEqual({ error: "session boom" });
});
it("retains command and innerCommand when the query throws after recognition", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
queryThrowsOn: "pnpm test",
});
expect(verdict.kind).toBe("defer");
expect(log).toHaveLength(1);
expect(log[0]?.event).toBe("inner_cmd.exception");
// Exception after recognition: command + innerCommand retained.
expect(log[0]?.details).toEqual({
error: "policy boom",
command: "timeout 30s pnpm test",
innerCommand: "pnpm test",
});
});
});
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
ExtensionAPI,
ExtensionContext,
SessionStartEvent,
SessionShutdownEvent,
} from "@earendil-works/pi-coding-agent";
import extension from "../src/index";
import {
PERMISSIONS_READY_CHANNEL,
publishPermissionsService,
unpublishPermissionsService,
type Authorizer,
type PermissionsService,
} from "@gotgenes/pi-permission-system";
/**
* Minimal fake ExtensionAPI that records the lifecycle handlers the extension
* uses. Cast to ExtensionAPI because the factory only touches a small surface.
*/
function createFakePi(): {
pi: ExtensionAPI;
fireSessionStart: (
sessionManager: ExtensionContext["sessionManager"],
hasUI?: boolean,
) => void;
fireSessionShutdown: () => void;
readyHandlers: Array<() => unknown>;
} {
const sessionStartHandlers: Array<
(event: SessionStartEvent, ctx: ExtensionContext) => unknown
> = [];
const shutdownHandlers: Array<(event: SessionShutdownEvent) => unknown> = [];
const readyHandlers: Array<() => unknown> = [];
const pi = {
on(event: string, handler: (...args: never[]) => unknown): void {
if (event === "session_start") sessionStartHandlers.push(handler as never);
else if (event === "session_shutdown")
shutdownHandlers.push(handler as never);
},
events: {
on(channel: string, handler: (...args: never[]) => unknown): void {
if (channel === PERMISSIONS_READY_CHANNEL)
readyHandlers.push(handler as never);
},
},
} as unknown as ExtensionAPI;
return {
pi,
fireSessionStart: (sessionManager, hasUI = true) => {
const ctx = { sessionManager, hasUI } as unknown as ExtensionContext;
const event = { type: "session_start", reason: "startup" } as SessionStartEvent;
for (const handler of sessionStartHandlers) handler(event, ctx);
},
fireSessionShutdown: () => {
const event = { type: "session_shutdown" } as SessionShutdownEvent;
for (const handler of shutdownHandlers) handler(event);
},
readyHandlers,
};
}
/**
* A fake session manager whose entries and identity can be inspected for
* assertions. Defaults to a UI-root-shaped non-empty session id.
*/
function createFakeSessionManager(
entries: unknown[] = [],
sessionId = "session-root",
) {
return {
getEntries: () => entries,
getSessionId: () => sessionId,
} as unknown as ExtensionContext["sessionManager"];
}
describe("permissions:ready -> registerAuthorizer lifecycle", () => {
let registerAuthorizer: ReturnType<typeof vi.fn>;
let disposer: ReturnType<typeof vi.fn>;
let service: PermissionsService;
let authorize: Authorizer["authorize"] | undefined;
let published: boolean;
beforeEach(() => {
disposer = vi.fn();
authorize = undefined;
registerAuthorizer = vi.fn((name, callback) => {
authorize = callback;
return disposer;
});
service = {
registerAuthorizer,
checkPermission: () => ({
toolName: "bash",
state: "ask",
source: "bash",
origin: "builtin",
}),
getToolPermission: () => "ask",
} as unknown as PermissionsService;
published = false;
});
afterEach(() => {
if (published) unpublishPermissionsService(service);
});
/**
* Model the permission system becoming ready: it publishes its service and
* then emits the `permissions:ready` channel. Before this, no service is
* available, so an early `session_start` cannot register yet.
*/
function becomeReady(): void {
publishPermissionsService(service);
published = true;
}
it("waits for the service: session_start alone does not register", () => {
const { pi, fireSessionStart } = createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager());
expect(registerAuthorizer).not.toHaveBeenCalled();
});
it("registers once the service becomes ready after session_start", () => {
const { pi, fireSessionStart, readyHandlers } = createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager());
expect(registerAuthorizer).not.toHaveBeenCalled();
becomeReady();
for (const handler of readyHandlers) handler();
expect(registerAuthorizer).toHaveBeenCalledTimes(1);
expect(registerAuthorizer).toHaveBeenCalledWith(
"inner-cmd",
expect.any(Function),
);
});
it("registers immediately at session_start when the service is already ready", () => {
const { pi, fireSessionStart } = createFakePi();
extension(pi);
becomeReady();
fireSessionStart(createFakeSessionManager());
expect(registerAuthorizer).toHaveBeenCalledTimes(1);
});
it("needs a session: ready without session_start does not register", () => {
const { pi, readyHandlers } = createFakePi();
extension(pi);
becomeReady();
for (const handler of readyHandlers) handler();
expect(registerAuthorizer).not.toHaveBeenCalled();
});
it("does not register from a headless (hasUI=false) session_start", () => {
// An in-process/headless child can resolve the published parent service
// but must never register with child-captured context.
const { pi, fireSessionStart, readyHandlers } = createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager(), false);
becomeReady();
for (const handler of readyHandlers) handler();
expect(registerAuthorizer).not.toHaveBeenCalled();
});
it("does not register when the captured session id is empty", () => {
// Without a non-empty identity snapshot there is no provenance to
// revalidate at authorize time, so registration is refused.
const { pi, fireSessionStart, readyHandlers } = createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager([], ""));
becomeReady();
for (const handler of readyHandlers) handler();
expect(registerAuthorizer).not.toHaveBeenCalled();
});
it("does not re-register on a second readiness signal", () => {
const { pi, fireSessionStart, readyHandlers } = createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager());
becomeReady();
for (const handler of readyHandlers) handler();
for (const handler of readyHandlers) handler();
expect(registerAuthorizer).toHaveBeenCalledTimes(1);
});
it("disposes the authorizer on session_shutdown and re-registers after", () => {
const { pi, fireSessionStart, fireSessionShutdown, readyHandlers } =
createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager());
becomeReady();
for (const handler of readyHandlers) handler();
expect(disposer).not.toHaveBeenCalled();
fireSessionShutdown();
expect(disposer).toHaveBeenCalledTimes(1);
// A fresh session cycle registers again.
fireSessionStart(createFakeSessionManager());
for (const handler of readyHandlers) handler();
expect(registerAuthorizer).toHaveBeenCalledTimes(2);
});
it("registers a callback that defers forwarded asks fail-closed", async () => {
const { pi, fireSessionStart, readyHandlers } = createFakePi();
extension(pi);
fireSessionStart(createFakeSessionManager());
becomeReady();
for (const handler of readyHandlers) handler();
expect(authorize).toBeDefined();
const verdict = await authorize!(
{
requestId: "req-1",
source: "tool_call",
agentName: "child",
message: "forwarded ask",
toolCallId: "call_1",
toolName: "bash",
forwarding: {
requesterAgentName: "child",
requesterSessionId: "s1",
},
},
{
checkPermission: () => ({
toolName: "bash",
state: "allow",
source: "bash",
origin: "builtin",
}),
getToolPermission: () => "allow",
},
{ review: () => {}, debug: () => {} },
);
expect(verdict).toEqual({ kind: "defer" });
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
classifyWrapper,
isRecognizedWrapper,
parseTimeoutWrapper,
} from "../src/recognizer";
describe("parseTimeoutWrapper", () => {
it("matches the strict simple-timeout form", () => {
expect(parseTimeoutWrapper("timeout 30s pnpm test")).toEqual({
duration: "30s",
innerCommand: "pnpm test",
});
expect(parseTimeoutWrapper("timeout 1m echo hi")).toEqual({
duration: "1m",
innerCommand: "echo hi",
});
expect(parseTimeoutWrapper("timeout 5h deploy")).toEqual({
duration: "5h",
innerCommand: "deploy",
});
expect(parseTimeoutWrapper("timeout 2d longjob")).toEqual({
duration: "2d",
innerCommand: "longjob",
});
});
it("preserves compound inner programs as the inner command", () => {
expect(parseTimeoutWrapper("timeout 60s pnpm test && git push")).toEqual({
duration: "60s",
innerCommand: "pnpm test && git push",
});
expect(parseTimeoutWrapper("timeout 30s bash -c something")).toEqual({
duration: "30s",
innerCommand: "bash -c something",
});
});
it("accepts tab-separated and multi-space arguments", () => {
expect(parseTimeoutWrapper("timeout\t30s\tpnpm test")).toEqual({
duration: "30s",
innerCommand: "pnpm test",
});
expect(parseTimeoutWrapper("timeout 10s build")).toEqual({
duration: "10s",
innerCommand: "build",
});
});
it("rejects leading-zero and multi-letter durations", () => {
expect(parseTimeoutWrapper("timeout 0s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 030s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 30ms pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 30sec pnpm test")).toBeUndefined();
});
it("rejects unsupported timeout syntax", () => {
expect(parseTimeoutWrapper("timeout -k 5s 30s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout --preserve-status 30s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout -- 30s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 30s")).toBeUndefined();
expect(parseTimeoutWrapper("timeout")).toBeUndefined();
});
it("does not match commands that merely contain timeout", () => {
expect(parseTimeoutWrapper("pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout30s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("my-timeout 30s pnpm test")).toBeUndefined();
});
});
describe("isRecognizedWrapper", () => {
it("is true for the strict form and false otherwise", () => {
expect(isRecognizedWrapper("timeout 10s pnpm test")).toBe(true);
expect(isRecognizedWrapper("timeout 10s timeout 5s pnpm test")).toBe(true);
expect(isRecognizedWrapper("pnpm test")).toBe(false);
expect(isRecognizedWrapper("timeout -k 5s 30s pnpm test")).toBe(false);
});
});
describe("classifyWrapper", () => {
it("classifies the recognized wrapper", () => {
expect(classifyWrapper("timeout 30s pnpm test")).toEqual({
kind: "recognized",
match: { duration: "30s", innerCommand: "pnpm test" },
});
});
it("classifies unsupported timeout syntax", () => {
expect(classifyWrapper("timeout -k 5s 30s pnpm test").kind).toBe(
"unsupportedTimeout",
);
expect(classifyWrapper("timeout 30s").kind).toBe("unsupportedTimeout");
expect(classifyWrapper("timeout --help").kind).toBe("unsupportedTimeout");
});
it("classifies ordinary commands as non-timeout", () => {
expect(classifyWrapper("pnpm test").kind).toBe("nonTimeout");
expect(classifyWrapper("rm -rf /").kind).toBe("nonTimeout");
expect(classifyWrapper("git push").kind).toBe("nonTimeout");
});
});
@@ -0,0 +1,172 @@
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[]): SessionEntry {
return {
type: "message",
id: "entry-1",
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("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",
);
});
});