From 2014e7f793b5bda31b13dcfd236425c3d7d27a70 Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Tue, 11 Aug 2026 21:50:23 +0800 Subject: [PATCH] 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 --- packages/pi-permission-ai-judge/package.json | 9 ++- packages/pi-permission-ai-judge/src/index.ts | 60 +++++++++++++++---- packages/pi-permission-inner-cmd/package.json | 6 +- .../pi-permission-inner-cmd/src/authorizer.ts | 5 +- packages/pi-permission-shared/package.json | 28 +++++++++ packages/pi-permission-shared/src/index.ts | 1 + .../src/recovery.ts | 17 +++--- .../test/recovery.test.ts | 0 packages/pi-permission-shared/tsconfig.json | 10 ++++ pnpm-lock.yaml | 25 ++++++++ 10 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 packages/pi-permission-shared/package.json create mode 100644 packages/pi-permission-shared/src/index.ts rename packages/{pi-permission-inner-cmd => pi-permission-shared}/src/recovery.ts (82%) rename packages/{pi-permission-inner-cmd => pi-permission-shared}/test/recovery.test.ts (100%) create mode 100644 packages/pi-permission-shared/tsconfig.json diff --git a/packages/pi-permission-ai-judge/package.json b/packages/pi-permission-ai-judge/package.json index d5c0011..98e079f 100644 --- a/packages/pi-permission-ai-judge/package.json +++ b/packages/pi-permission-ai-judge/package.json @@ -16,15 +16,22 @@ "@earendil-works/pi-coding-agent": "*", "@gotgenes/pi-permission-system": ">=20.10.0" }, + "dependencies": { + "@sikongjueluo/pi-permission-shared": "workspace:*" + }, + "bundledDependencies": [ + "@sikongjueluo/pi-permission-shared" + ], "devDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", "@gotgenes/pi-permission-system": ">=20.10.0", + "@types/node": "^26.0.0", "typescript": "^5", "vitest": "^3" }, "scripts": { "check": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run --passWithNoTests" } } diff --git a/packages/pi-permission-ai-judge/src/index.ts b/packages/pi-permission-ai-judge/src/index.ts index 655cb3c..3aa5f2d 100644 --- a/packages/pi-permission-ai-judge/src/index.ts +++ b/packages/pi-permission-ai-judge/src/index.ts @@ -1,13 +1,25 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { + ExtensionAPI, + SessionEntry, +} from "@earendil-works/pi-coding-agent"; import { getPermissionsService, PERMISSIONS_READY_CHANNEL, } from "@gotgenes/pi-permission-system"; +import { + NATIVE_BASH_TOOL_NAME, + recoverNativeBashCommand, +} from "@sikongjueluo/pi-permission-shared"; const LINK_NAME = "ai-bash-judge"; +/** 捕获的 UI-root 会话读取入口,用于还原完整命令。 */ +interface CapturedSession { + getEntries(): ReadonlyArray; +} + export default function permissionAiJudge(pi: ExtensionAPI): void { - let sessionStarted = false; + let session: CapturedSession | undefined; let disposeAuthorizer: (() => void) | undefined; /** @@ -21,7 +33,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { * 谁后满足条件,谁完成注册。 */ function tryRegister(): void { - if (!sessionStarted || disposeAuthorizer) { + if (!session || disposeAuthorizer) { return; } @@ -34,6 +46,9 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { return; } + // 捕获此刻的会话引用:回调触发时读取最新 entries。 + const captured = session; + disposeAuthorizer = service.registerAuthorizer( LINK_NAME, @@ -43,11 +58,29 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { details.surface ?? undefined; + /** + * 还原完整的 bash 命令。 + * + * details.command 可能只是聚合 ask 里的某个命令单元(见 + * ADR 0001),AI 判定需要完整输入。只有原生 bash 工具调用 + * 才能从会话里还原;否则回退到 details.command。 + */ + const command = + details.toolName === NATIVE_BASH_TOOL_NAME && + details.toolCallId !== undefined + ? recoverNativeBashCommand( + captured.getEntries(), + details.toolCallId, + ) + : undefined; + + const effectiveCommand = command ?? details.command; + console.error(`[${LINK_NAME}] permission ask received`, { requestId: details.requestId, surface, toolName: details.toolName, - command: details.command, + command: effectiveCommand ?? null, path: details.path, value: details.value, agentName: details.agentName, @@ -60,10 +93,10 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { * 它只是询问 pi-permission-system 的确定性规则: * “如果检查这个 bash command,规则本身会怎么判?” */ - if (surface === "bash" && details.command) { + if (surface === "bash" && effectiveCommand) { const result = query.checkPermission( "bash", - details.command, + effectiveCommand, details.agentName ?? undefined, ); @@ -81,7 +114,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { log.review("ai_bash_judge.test", { requestId: details.requestId, surface, - command: details.command ?? null, + command: effectiveCommand ?? null, verdict: "defer", }); @@ -103,8 +136,15 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { console.error(`[${LINK_NAME}] registered`); } - pi.on("session_start", () => { - sessionStarted = true; + pi.on("session_start", (_event, ctx) => { + // 仅从 proven UI-present root 注册:headless / 进程内 subagent child + // 能解析到父进程的 service,但不能用 child 捕获的上下文注册, + // 否则还原出的命令会来自错误的会话。 + if (!ctx.hasUI) { + return; + } + + session = ctx.sessionManager; tryRegister(); }); @@ -116,7 +156,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void { disposeAuthorizer?.(); disposeAuthorizer = undefined; - sessionStarted = false; + session = undefined; console.error(`[${LINK_NAME}] unregistered`); }); diff --git a/packages/pi-permission-inner-cmd/package.json b/packages/pi-permission-inner-cmd/package.json index 87117d3..bd7b8a2 100644 --- a/packages/pi-permission-inner-cmd/package.json +++ b/packages/pi-permission-inner-cmd/package.json @@ -12,10 +12,12 @@ ] }, "dependencies": { - "@gotgenes/pi-permission-system": ">=24.0.0" + "@gotgenes/pi-permission-system": ">=24.0.0", + "@sikongjueluo/pi-permission-shared": "workspace:*" }, "bundledDependencies": [ - "@gotgenes/pi-permission-system" + "@gotgenes/pi-permission-system", + "@sikongjueluo/pi-permission-shared" ], "peerDependencies": { "@earendil-works/pi-ai": "*", diff --git a/packages/pi-permission-inner-cmd/src/authorizer.ts b/packages/pi-permission-inner-cmd/src/authorizer.ts index 5f35bed..ff470a2 100644 --- a/packages/pi-permission-inner-cmd/src/authorizer.ts +++ b/packages/pi-permission-inner-cmd/src/authorizer.ts @@ -6,7 +6,10 @@ import type { PromptPermissionDetails, } from "@gotgenes/pi-permission-system"; import { classifyWrapper, isRecognizedWrapper } from "./recognizer"; -import { NATIVE_BASH_TOOL_NAME, recoverNativeBashCommand } from "./recovery"; +import { + NATIVE_BASH_TOOL_NAME, + recoverNativeBashCommand, +} from "@sikongjueluo/pi-permission-shared"; /** Bash permission surface queried when re-evaluating the inner command. */ const BASH_SURFACE = "bash"; diff --git a/packages/pi-permission-shared/package.json b/packages/pi-permission-shared/package.json new file mode 100644 index 0000000..a20f525 --- /dev/null +++ b/packages/pi-permission-shared/package.json @@ -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" + } +} diff --git a/packages/pi-permission-shared/src/index.ts b/packages/pi-permission-shared/src/index.ts new file mode 100644 index 0000000..0d36a24 --- /dev/null +++ b/packages/pi-permission-shared/src/index.ts @@ -0,0 +1 @@ +export * from "./recovery"; diff --git a/packages/pi-permission-inner-cmd/src/recovery.ts b/packages/pi-permission-shared/src/recovery.ts similarity index 82% rename from packages/pi-permission-inner-cmd/src/recovery.ts rename to packages/pi-permission-shared/src/recovery.ts index 13d26a0..5be48af 100644 --- a/packages/pi-permission-inner-cmd/src/recovery.ts +++ b/packages/pi-permission-shared/src/recovery.ts @@ -37,17 +37,18 @@ function extractBashCommand(block: ToolCallBlock): string | undefined { * walked in reverse and the search stops at the first (latest) assistant * message that contains a `toolCall` block whose `id` equals `toolCallId`. * - * 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 + * 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` so the - * caller defers fail-closed. + * 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`. */ diff --git a/packages/pi-permission-inner-cmd/test/recovery.test.ts b/packages/pi-permission-shared/test/recovery.test.ts similarity index 100% rename from packages/pi-permission-inner-cmd/test/recovery.test.ts rename to packages/pi-permission-shared/test/recovery.test.ts diff --git a/packages/pi-permission-shared/tsconfig.json b/packages/pi-permission-shared/tsconfig.json new file mode 100644 index 0000000..258fe90 --- /dev/null +++ b/packages/pi-permission-shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": [ + "src", + "test" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8504689..fc14205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,10 @@ importers: version: 24.0.0(@earendil-works/pi-coding-agent@0.84.1)(@earendil-works/pi-tui@0.84.1) packages/pi-permission-ai-judge: + dependencies: + '@sikongjueluo/pi-permission-shared': + specifier: workspace:* + version: link:../pi-permission-shared devDependencies: '@earendil-works/pi-ai': specifier: '*' @@ -222,6 +226,9 @@ importers: '@gotgenes/pi-permission-system': specifier: '>=20.10.0' version: 24.0.0(@earendil-works/pi-coding-agent@0.84.1)(@earendil-works/pi-tui@0.84.1) + '@types/node': + specifier: ^26.0.0 + version: 26.1.2 typescript: specifier: ^5 version: 5.9.3 @@ -234,6 +241,9 @@ importers: '@gotgenes/pi-permission-system': specifier: '>=24.0.0' version: 24.0.0(@earendil-works/pi-coding-agent@0.84.1)(@earendil-works/pi-tui@0.84.1) + '@sikongjueluo/pi-permission-shared': + specifier: workspace:* + version: link:../pi-permission-shared devDependencies: '@earendil-works/pi-ai': specifier: '*' @@ -251,6 +261,21 @@ importers: specifier: ^3 version: 3.2.7(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + packages/pi-permission-shared: + devDependencies: + '@earendil-works/pi-coding-agent': + specifier: '*' + version: 0.84.1(ws@8.21.2)(zod@4.4.3) + '@types/node': + specifier: ^26.0.0 + version: 26.1.2 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.7(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + packages: '@anthropic-ai/sdk@0.91.1':