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,179 @@
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import type {
AuthorizerLog,
AuthorizerVerdict,
PermissionQuery,
PromptPermissionDetails,
} from "@gotgenes/pi-permission-system";
import { classifyWrapper, isRecognizedWrapper } from "./recognizer";
import { NATIVE_BASH_TOOL_NAME, recoverNativeBashCommand } from "./recovery";
/** Bash permission surface queried when re-evaluating the inner command. */
const BASH_SURFACE = "bash";
/** Convert a thrown value into a short, log-safe string. */
function toErrorString(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/**
* Live read access to the captured session at authorize time.
*
* The real `ReadonlySessionManager` satisfies this structurally; tests inject a
* stub so the decision logic stays pure and deterministic.
*/
export interface SessionProbe {
getEntries(): ReadonlyArray<SessionEntry>;
getSessionId(): string;
}
/** Dependencies injected into the pure authorizer decision. */
export interface InnerCommandAuthorizerDeps {
readonly details: PromptPermissionDetails;
readonly query: PermissionQuery;
readonly log: AuthorizerLog;
/** Live reader for the captured UI-root session. */
readonly session: SessionProbe;
/**
* Session identity captured at registration as root-ownership provenance.
* Revalidated against {@link SessionProbe.getSessionId} before any decisive
* verdict so a stale or replaced session can never be judged.
*/
readonly expectedSessionId: string;
}
/**
* V0.1 inner-command Authorizer decision (ADR 0001).
*
* Recovers the complete native Bash command for `details.toolCallId` from the
* captured session, unwraps one strict `timeout` level, and re-evaluates the
* inner command through the deterministic permission policy. Every uncertain
* path — forwarded requests, a session-identity mismatch, non-Bash tools,
* missing/duplicate/malformed session evidence, unsupported or nested wrapper
* syntax, parse failures, and exceptions — defers to the next authority.
*
* Logging contract:
* - `review` only for a recognized wrapper whose inner command resolves to a
* decisive `allow`/`deny`.
* - `debug` for a recognized inner `ask`, unsupported timeout syntax, nested
* wrappers, a session-identity mismatch, and exceptions.
* - ordinary non-timeout commands defer silently.
* - recognized logs carry both `command` and `innerCommand`; an exception after
* recognition retains both alongside `error`, while an earlier exception logs
* only the safe data available at that point.
*/
export async function authorizeInnerCommand(
deps: InnerCommandAuthorizerDeps,
): Promise<AuthorizerVerdict> {
const { details, query, log, session, expectedSessionId } = deps;
// Track recovered evidence so an exception after recognition can retain it.
let command: string | undefined;
let innerCommand: string | undefined;
try {
// Forwarded subagent asks are out of scope for v0.1: the captured
// session is the serving root's conversation, not the requester's.
if (details.forwarding) {
return { kind: "defer" };
}
// Revalidate root ownership: the live session must still be the one we
// registered for. A mismatch (or a session id that cannot be read)
// means the captured conversation can no longer be attributed safely.
const currentSessionId = session.getSessionId();
if (currentSessionId !== expectedSessionId) {
log.debug("inner_cmd.session_mismatch", {
expectedSessionId,
currentSessionId,
});
return { kind: "defer" };
}
// Only the native Bash tool is unwrappable, and only when the ask is
// tied to a specific tool call.
if (details.toolName !== NATIVE_BASH_TOOL_NAME) {
return { kind: "defer" };
}
const toolCallId = details.toolCallId;
if (toolCallId === undefined) {
return { kind: "defer" };
}
// Recover the complete Bash input from the session, never from
// details.command or details.message.
command = recoverNativeBashCommand(session.getEntries(), toolCallId);
if (command === undefined) {
return { kind: "defer" };
}
const classification = classifyWrapper(command);
switch (classification.kind) {
case "nonTimeout":
// An ordinary Bash command this authorizer does not unwrap.
return { kind: "defer" };
case "unsupportedTimeout":
log.debug("inner_cmd.unsupported_timeout_syntax", { command });
return { kind: "defer" };
case "recognized": {
innerCommand = classification.match.innerCommand;
// Never unwrap more than one level in v0.1.
if (isRecognizedWrapper(innerCommand)) {
log.debug("inner_cmd.nested_timeout", {
command,
innerCommand,
});
return { kind: "defer" };
}
const result = query.checkPermission(
BASH_SURFACE,
innerCommand,
details.agentName ?? undefined,
);
switch (result.state) {
case "allow":
log.review("inner_cmd.allow", {
command,
innerCommand,
});
return { kind: "allow" };
case "deny":
log.review("inner_cmd.deny", {
command,
innerCommand,
});
return { kind: "deny" };
case "ask":
default:
// Treat any unexpected state as a safe defer.
log.debug("inner_cmd.inner_ask", {
command,
innerCommand,
});
return { kind: "defer" };
}
}
}
} catch (error) {
const exceptionDetails: Record<string, unknown> = {
error: toErrorString(error),
};
if (command !== undefined) {
exceptionDetails.command = command;
}
if (innerCommand !== undefined) {
exceptionDetails.innerCommand = innerCommand;
}
log.debug("inner_cmd.exception", exceptionDetails);
return { kind: "defer" };
}
}
+65 -4
View File
@@ -3,20 +3,81 @@ import {
getPermissionsService,
PERMISSIONS_READY_CHANNEL,
} from "@gotgenes/pi-permission-system";
import { authorizeInnerCommand, type SessionProbe } from "./authorizer";
const LINK_NAME = "inner-cmd";
export default function permissionAiJudge(pi: ExtensionAPI): void {
let sessionStarted = false;
/** Captured UI-root session: the live probe plus its identity provenance. */
interface CapturedRootSession {
readonly session: SessionProbe;
readonly sessionId: string;
}
export default function permissionInnerCmd(pi: ExtensionAPI): void {
let rootSession: CapturedRootSession | undefined;
let disposeAuthorizer: (() => void) | undefined;
pi.on("session_start", () => {
sessionStarted = true;
/**
* Register the inner-command Authorizer once a proven UI-root session is
* captured and the permission service is ready.
*
* `rootSession` is set only from a UI-present `session_start` with a
* non-empty captured session id, so a headless or in-process subagent child
* that can still resolve the published parent service never registers.
* Either the extension or the permission system may start first; whichever
* satisfies the second condition completes registration.
*/
function tryRegister(): void {
if (disposeAuthorizer || !rootSession) {
return;
}
const service = getPermissionsService();
if (!service) {
return;
}
const { session, sessionId } = rootSession;
disposeAuthorizer = service.registerAuthorizer(
LINK_NAME,
async (details, query, log) =>
authorizeInnerCommand({
details,
query,
log,
session,
expectedSessionId: sessionId,
}),
);
}
pi.on("session_start", (_event, ctx) => {
// Root-ownership gate: register only from the proven UI-present root.
// Headless/in-process children resolve the parent's process-global
// service but must not register with child-captured context.
if (!ctx.hasUI) {
return;
}
// Snapshot the session identity as registration provenance. A non-empty
// id is required so authorization can revalidate ownership later;
// without it, do not register.
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) {
return;
}
rootSession = { session: ctx.sessionManager, sessionId };
tryRegister();
});
pi.events.on(PERMISSIONS_READY_CHANNEL, () => {
tryRegister();
});
pi.on("session_shutdown", () => {
disposeAuthorizer?.();
disposeAuthorizer = undefined;
rootSession = undefined;
});
}
@@ -0,0 +1,80 @@
/**
* V0.1 wrapper recognizer.
*
* The strict simple-timeout grammar from ADR 0001. V0.1 unwraps exactly
* `timeout <duration> <command>`; every other `timeout` invocation is left to
* the next authority.
*/
/**
* Matches `timeout <duration> <command>` where `<duration>` is a positive
* integer (no leading zero) followed by a single unit `s`/`m`/`h`/`d`.
*
* Flags (`-k`, `--preserve-status`, GNU `--`), compound durations, and the
* bare form are intentionally excluded so v0.1 never unwraps a wrapper it
* cannot re-evaluate safely.
*/
const TIMEOUT_WRAPPER_PATTERN = /^timeout[ \t]+([1-9][0-9]*[smhd])[ \t]+(.+)$/;
/** A command that begins with the bare `timeout` wrapper program. */
const TIMEOUT_PREFIX = /^timeout(?:[ \t]|$)/;
export interface TimeoutWrapperMatch {
readonly duration: string;
readonly innerCommand: string;
}
/**
* Parse a command as the strict simple-timeout wrapper.
*
* @returns the duration token and the full inner command (including any
* `&&`/`;`/`|` siblings), or `undefined` when the command is not the
* recognized `timeout <duration> <command>` form.
*/
export function parseTimeoutWrapper(
command: string,
): TimeoutWrapperMatch | undefined {
const match = TIMEOUT_WRAPPER_PATTERN.exec(command);
if (match === null) {
return undefined;
}
return {
duration: match[1],
innerCommand: match[2],
};
}
/**
* Whether a command is itself a recognized wrapper. Used to reject nested
* wrappers so v0.1 unwraps at most one level.
*/
export function isRecognizedWrapper(command: string): boolean {
return parseTimeoutWrapper(command) !== undefined;
}
/** How a recovered Bash command relates to the v0.1 recognizer. */
export type WrapperClassification =
| { readonly kind: "recognized"; readonly match: TimeoutWrapperMatch }
| { readonly kind: "unsupportedTimeout" }
| { readonly kind: "nonTimeout" };
/**
* Classify a recovered Bash command against the v0.1 recognizer.
*
* - `recognized`: the strict simple-timeout wrapper.
* - `unsupportedTimeout`: the command invokes `timeout` but is not the
* recognized strict form (flags, `-k`, missing command, ...). These are
* logged at debug so an operator can see why a wrapper was skipped.
* - `nonTimeout`: an ordinary command this authorizer does not handle. These
* defer silently.
*/
export function classifyWrapper(command: string): WrapperClassification {
const match = parseTimeoutWrapper(command);
if (match !== undefined) {
return { kind: "recognized", match };
}
if (TIMEOUT_PREFIX.test(command)) {
return { kind: "unsupportedTimeout" };
}
return { kind: "nonTimeout" };
}
@@ -0,0 +1,83 @@
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.
*
* Walks session entries, finds the assistant `toolCall` block whose `id` equals
* `toolCallId`, and reads its structured `arguments.command`. Proceeds only
* when, per ADR 0001:
*
* - exactly one block matches the id (no duplicate),
* - that block names the native Bash tool,
* - `arguments.command` is a string.
*
* 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.
*
* @returns the complete Bash command, or `undefined`.
*/
export function recoverNativeBashCommand(
entries: ReadonlyArray<SessionEntry>,
toolCallId: string,
): string | undefined {
let matches = 0;
let command: string | undefined;
for (const entry of entries) {
if (entry.type !== "message") {
continue;
}
const message = entry.message as { role?: unknown; content?: unknown };
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);
}
}
}
return matches === 1 ? command : undefined;
}