refactor(pi-permission-inner-cmd): narrow bash recovery uniqueness to one message

- walk entries in reverse and stop at the latest assistant message containing the id
- require the id to match exactly one block within that message rather than across the whole session
- resolve a cross-message id reuse to the latest call being authorized
- update ADR 0001 wording for the narrowed scope
- add a regression test for cross-message id reuse
This commit is contained in:
2026-08-11 21:53:46 +08:00
parent f21cf54ff1
commit 06b21a28a6
3 changed files with 58 additions and 33 deletions
@@ -8,12 +8,12 @@ status: accepted
## Decision
For direct calls to Pi's native `bash` tool, capture the session manager at `session_start`. During authorization, use `details.toolCallId` to find exactly one matching assistant `toolCall` block in the current session and read its structured `arguments.command` value.
For direct calls to Pi's native `bash` tool, capture the session manager at `session_start`. During authorization, walk the session in reverse to the latest assistant message containing a `toolCall` block whose `id` equals `details.toolCallId`, require exactly one such block *within that message*, and read its structured `arguments.command` value.
Proceed only when all evidence agrees:
- the request and recovered tool call both identify the native `bash` tool;
- the tool-call ID has exactly one match;
- the tool-call ID has exactly one match within the latest assistant message that contains it (a cross-message reuse resolves to the latest, which is the call being authorized);
- `arguments.command` is a string;
- the complete input matches the strict wrapper grammar;
- the inner command is not another recognized wrapper.
@@ -35,7 +35,8 @@ The implementation must retain regression tests for:
- inner `allow`, `ask`, and `deny` mapping;
- compound input such as `timeout 60s pnpm test && git push`;
- unsupported and nested wrapper syntax;
- missing, duplicate, malformed, and forwarded session evidence;
- missing, within-message duplicate, malformed, and forwarded session evidence;
- a tool-call id reused across messages (resolved to the latest);
- Authorizer registration and disposal.
The end-to-end experiment confirmed that permission-system checks every Bash command unit but invokes the Authorizer once for the aggregated `ask`; an Authorizer `allow` then releases the complete tool call. See [context ownership](../research/ai-bash-context-ownership.md) and [minimal Bash judgment evidence](../research/ai-bash-judge-input-minimality.md) for the underlying permission and evidence boundaries.
@@ -33,17 +33,21 @@ function extractBashCommand(block: ToolCallBlock): string | undefined {
/**
* Recover the complete native Bash command for one tool call.
*
* Walks session entries, finds the assistant `toolCall` block whose `id` equals
* `toolCallId`, and reads its structured `arguments.command`. Proceeds only
* when, per ADR 0001:
* 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`.
*
* - exactly one block matches the id (no duplicate),
* - that block names the native Bash tool,
* - `arguments.command` is a string.
* Per ADR 0001, 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
* `details.toolCallId` refers to), so that case stays fail-closed. The matched
* block must then name the native Bash tool and carry a string
* `arguments.command`.
*
* Any other outcome — no match, duplicate id, a non-Bash tool call, a
* non-string command, or malformed entries — returns `undefined` so the caller
* defers fail-closed.
* Any other outcome — no match, a within-message duplicate id, a non-Bash tool
* call, a non-string command, or malformed entries — returns `undefined` so the
* caller defers fail-closed.
*
* @returns the complete Bash command, or `undefined`.
*/
@@ -51,33 +55,34 @@ export function recoverNativeBashCommand(
entries: ReadonlyArray<SessionEntry>,
toolCallId: string,
): string | undefined {
let matches = 0;
let command: string | undefined;
for (const entry of entries) {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type !== "message") {
continue;
}
const message = entry.message as { role?: unknown; content?: unknown };
const message = entry.message;
if (message.role !== "assistant") {
continue;
}
const content = message.content;
if (!Array.isArray(content)) {
continue;
}
for (const block of content) {
if (!isToolCallBlock(block) || block.id !== toolCallId) {
continue;
}
matches += 1;
// Keep walking the whole session so a duplicate id (two matching
// blocks) is detected even when the first match was unusable.
if (matches === 1) {
command = extractBashCommand(block);
}
const matches: ToolCallBlock[] = [];
for (const block of message.content) {
if (isToolCallBlock(block) && block.id === toolCallId) {
matches.push(block);
}
}
return matches === 1 ? command : undefined;
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;
}
@@ -3,10 +3,10 @@ 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 {
function assistantEntry(content: unknown[], id = "entry-1"): SessionEntry {
return {
type: "message",
id: "entry-1",
id,
parentId: null,
timestamp: "2026-08-08T00:00:00.000Z",
message: {
@@ -156,6 +156,25 @@ describe("recoverNativeBashCommand", () => {
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([