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
+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;
});
}