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": "*", "@earendil-works/pi-coding-agent": "*",
"@gotgenes/pi-permission-system": ">=20.10.0" "@gotgenes/pi-permission-system": ">=20.10.0"
}, },
"dependencies": {
"@sikongjueluo/pi-permission-shared": "workspace:*"
},
"bundledDependencies": [
"@sikongjueluo/pi-permission-shared"
],
"devDependencies": { "devDependencies": {
"@earendil-works/pi-ai": "*", "@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-coding-agent": "*",
"@gotgenes/pi-permission-system": ">=20.10.0", "@gotgenes/pi-permission-system": ">=20.10.0",
"@types/node": "^26.0.0",
"typescript": "^5", "typescript": "^5",
"vitest": "^3" "vitest": "^3"
}, },
"scripts": { "scripts": {
"check": "tsc --noEmit", "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 { import {
getPermissionsService, getPermissionsService,
PERMISSIONS_READY_CHANNEL, PERMISSIONS_READY_CHANNEL,
} from "@gotgenes/pi-permission-system"; } from "@gotgenes/pi-permission-system";
import {
NATIVE_BASH_TOOL_NAME,
recoverNativeBashCommand,
} from "@sikongjueluo/pi-permission-shared";
const LINK_NAME = "ai-bash-judge"; const LINK_NAME = "ai-bash-judge";
/** 捕获的 UI-root 会话读取入口,用于还原完整命令。 */
interface CapturedSession {
getEntries(): ReadonlyArray<SessionEntry>;
}
export default function permissionAiJudge(pi: ExtensionAPI): void { export default function permissionAiJudge(pi: ExtensionAPI): void {
let sessionStarted = false; let session: CapturedSession | undefined;
let disposeAuthorizer: (() => void) | undefined; let disposeAuthorizer: (() => void) | undefined;
/** /**
@@ -21,7 +33,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
* 谁后满足条件,谁完成注册。 * 谁后满足条件,谁完成注册。
*/ */
function tryRegister(): void { function tryRegister(): void {
if (!sessionStarted || disposeAuthorizer) { if (!session || disposeAuthorizer) {
return; return;
} }
@@ -34,6 +46,9 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
return; return;
} }
// 捕获此刻的会话引用:回调触发时读取最新 entries。
const captured = session;
disposeAuthorizer = service.registerAuthorizer( disposeAuthorizer = service.registerAuthorizer(
LINK_NAME, LINK_NAME,
@@ -43,11 +58,29 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
details.surface ?? details.surface ??
undefined; 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`, { console.error(`[${LINK_NAME}] permission ask received`, {
requestId: details.requestId, requestId: details.requestId,
surface, surface,
toolName: details.toolName, toolName: details.toolName,
command: details.command, command: effectiveCommand ?? null,
path: details.path, path: details.path,
value: details.value, value: details.value,
agentName: details.agentName, agentName: details.agentName,
@@ -60,10 +93,10 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
* 它只是询问 pi-permission-system 的确定性规则: * 它只是询问 pi-permission-system 的确定性规则:
* “如果检查这个 bash command,规则本身会怎么判?” * “如果检查这个 bash command,规则本身会怎么判?”
*/ */
if (surface === "bash" && details.command) { if (surface === "bash" && effectiveCommand) {
const result = query.checkPermission( const result = query.checkPermission(
"bash", "bash",
details.command, effectiveCommand,
details.agentName ?? undefined, details.agentName ?? undefined,
); );
@@ -81,7 +114,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
log.review("ai_bash_judge.test", { log.review("ai_bash_judge.test", {
requestId: details.requestId, requestId: details.requestId,
surface, surface,
command: details.command ?? null, command: effectiveCommand ?? null,
verdict: "defer", verdict: "defer",
}); });
@@ -103,8 +136,15 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
console.error(`[${LINK_NAME}] registered`); console.error(`[${LINK_NAME}] registered`);
} }
pi.on("session_start", () => { pi.on("session_start", (_event, ctx) => {
sessionStarted = true; // 仅从 proven UI-present root 注册:headless / 进程内 subagent child
// 能解析到父进程的 service,但不能用 child 捕获的上下文注册,
// 否则还原出的命令会来自错误的会话。
if (!ctx.hasUI) {
return;
}
session = ctx.sessionManager;
tryRegister(); tryRegister();
}); });
@@ -116,7 +156,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
disposeAuthorizer?.(); disposeAuthorizer?.();
disposeAuthorizer = undefined; disposeAuthorizer = undefined;
sessionStarted = false; session = undefined;
console.error(`[${LINK_NAME}] unregistered`); console.error(`[${LINK_NAME}] unregistered`);
}); });
@@ -12,10 +12,12 @@
] ]
}, },
"dependencies": { "dependencies": {
"@gotgenes/pi-permission-system": ">=24.0.0" "@gotgenes/pi-permission-system": ">=24.0.0",
"@sikongjueluo/pi-permission-shared": "workspace:*"
}, },
"bundledDependencies": [ "bundledDependencies": [
"@gotgenes/pi-permission-system" "@gotgenes/pi-permission-system",
"@sikongjueluo/pi-permission-shared"
], ],
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": "*", "@earendil-works/pi-ai": "*",
@@ -6,7 +6,10 @@ import type {
PromptPermissionDetails, PromptPermissionDetails,
} from "@gotgenes/pi-permission-system"; } from "@gotgenes/pi-permission-system";
import { classifyWrapper, isRecognizedWrapper } from "./recognizer"; 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. */ /** Bash permission surface queried when re-evaluating the inner command. */
const BASH_SURFACE = "bash"; 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 * walked in reverse and the search stops at the first (latest) assistant
* message that contains a `toolCall` block whose `id` equals `toolCallId`. * message that contains a `toolCall` block whose `id` equals `toolCallId`.
* *
* Per ADR 0001, the id must match exactly one block *within that single * The id must match exactly one block *within that single message*. An earlier
* message*. An earlier message reusing the same id is a stale, already-resolved * message reusing the same id is a stale, already-resolved call and is
* call and is irrelevant to the current authorization; but two matching blocks * irrelevant to the current authorization; but two matching blocks inside one
* inside one message cannot be disambiguated (we cannot tell which one * message cannot be disambiguated (we cannot tell which one the caller's
* `details.toolCallId` refers to), so that case stays fail-closed. The matched * `toolCallId` refers to), so that case returns `undefined` (fail-closed). The
* block must then name the native Bash tool and carry a string * matched block must then name the native Bash tool and carry a string
* `arguments.command`. * `arguments.command`.
* *
* Any other outcome — no match, a within-message duplicate id, a non-Bash tool * 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 * call, a non-string command, or malformed entries — returns `undefined`.
* caller defers fail-closed. *
* See ADR 0001 for the underlying permission/evidence boundaries.
* *
* @returns the complete Bash command, or `undefined`. * @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) version: 24.0.0(@earendil-works/pi-coding-agent@0.84.1)(@earendil-works/pi-tui@0.84.1)
packages/pi-permission-ai-judge: packages/pi-permission-ai-judge:
dependencies:
'@sikongjueluo/pi-permission-shared':
specifier: workspace:*
version: link:../pi-permission-shared
devDependencies: devDependencies:
'@earendil-works/pi-ai': '@earendil-works/pi-ai':
specifier: '*' specifier: '*'
@@ -222,6 +226,9 @@ importers:
'@gotgenes/pi-permission-system': '@gotgenes/pi-permission-system':
specifier: '>=20.10.0' specifier: '>=20.10.0'
version: 24.0.0(@earendil-works/pi-coding-agent@0.84.1)(@earendil-works/pi-tui@0.84.1) 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: typescript:
specifier: ^5 specifier: ^5
version: 5.9.3 version: 5.9.3
@@ -234,6 +241,9 @@ importers:
'@gotgenes/pi-permission-system': '@gotgenes/pi-permission-system':
specifier: '>=24.0.0' specifier: '>=24.0.0'
version: 24.0.0(@earendil-works/pi-coding-agent@0.84.1)(@earendil-works/pi-tui@0.84.1) 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: devDependencies:
'@earendil-works/pi-ai': '@earendil-works/pi-ai':
specifier: '*' specifier: '*'
@@ -251,6 +261,21 @@ importers:
specifier: ^3 specifier: ^3
version: 3.2.7(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) 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: packages:
'@anthropic-ai/sdk@0.91.1': '@anthropic-ai/sdk@0.91.1':