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:
2026-08-11 21:53:46 +08:00
parent 06b21a28a6
commit 2014e7f793
10 changed files with 139 additions and 22 deletions
+8 -1
View File
@@ -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"
}
}
+50 -10
View File
@@ -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<SessionEntry>;
}
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`);
});
@@ -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": "*",
@@ -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";
@@ -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";
@@ -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`.
*/
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": [
"src",
"test"
]
}
+25
View File
@@ -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':