mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 20:02:55 +08:00
refactor(pi-permission-inner-cmd): dispatch commands through a handler registry
- replace the hardcoded timeout switch with an engine that iterates registered handlers - extract the timeout logic into handlers/timeout.ts and add handlers/env.ts that defers env as non-transparent - thread a partial-evidence bag so the engine exception log retains handler-derived values like innerCommand - add CONTEXT.md with the transparent vs non-transparent wrapper glossary - record ADR 0002: env always defers to the AI judge and is never unwrapped
This commit is contained in:
@@ -5,14 +5,11 @@ import type {
|
||||
PermissionQuery,
|
||||
PromptPermissionDetails,
|
||||
} from "@gotgenes/pi-permission-system";
|
||||
import { classifyWrapper, isRecognizedWrapper } from "./recognizer";
|
||||
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";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
/** Convert a thrown value into a short, log-safe string. */
|
||||
function toErrorString(error: unknown): string {
|
||||
@@ -49,24 +46,22 @@ export interface InnerCommandAuthorizerDeps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V0.1 inner-command Authorizer decision (ADR 0001).
|
||||
* 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.
|
||||
* Revalidates root ownership, recovers the complete native Bash command for
|
||||
* `details.toolCallId` from the captured session, then hands it to the first
|
||||
* registered handler that claims it. Each handler owns its own recognition and
|
||||
* verdict logic: the timeout handler unwraps one level and re-evaluates the
|
||||
* inner command; the env handler defers as non-transparent.
|
||||
*
|
||||
* 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.
|
||||
* Every uncertain path — forwarded requests, a session-identity mismatch,
|
||||
* non-Bash tools, missing session evidence, an unrecognized command, or any
|
||||
* exception — defers to the next authority (fail-closed).
|
||||
*
|
||||
* Logging: handlers emit their own review/debug events for decisive and
|
||||
* notable-defer outcomes; silent deferrals log nothing. Exceptions are logged
|
||||
* by this engine as `inner_cmd.exception`, retaining the recovered command and
|
||||
* any partial evidence the active handler recorded before throwing.
|
||||
*/
|
||||
export async function authorizeInnerCommand(
|
||||
deps: InnerCommandAuthorizerDeps,
|
||||
@@ -75,18 +70,17 @@ export async function authorizeInnerCommand(
|
||||
|
||||
// Track recovered evidence so an exception after recognition can retain it.
|
||||
let command: string | undefined;
|
||||
let innerCommand: string | undefined;
|
||||
let evidence: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
// Forwarded subagent asks are out of scope for v0.1: the captured
|
||||
// session is the serving root's conversation, not the requester's.
|
||||
// Forwarded subagent asks are out of scope: 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.
|
||||
// registered for.
|
||||
const currentSessionId = session.getSessionId();
|
||||
if (currentSessionId !== expectedSessionId) {
|
||||
log.debug("inner_cmd.session_mismatch", {
|
||||
@@ -113,59 +107,15 @@ export async function authorizeInnerCommand(
|
||||
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" };
|
||||
}
|
||||
// Dispatch to the first registered handler that claims the command.
|
||||
for (const handler of handlers) {
|
||||
evidence = {};
|
||||
const verdict = handler.decide({ command, details, query, log, evidence });
|
||||
if (verdict !== undefined) {
|
||||
return verdict;
|
||||
}
|
||||
}
|
||||
return { kind: "defer" };
|
||||
} catch (error) {
|
||||
const exceptionDetails: Record<string, unknown> = {
|
||||
error: toErrorString(error),
|
||||
@@ -173,9 +123,7 @@ export async function authorizeInnerCommand(
|
||||
if (command !== undefined) {
|
||||
exceptionDetails.command = command;
|
||||
}
|
||||
if (innerCommand !== undefined) {
|
||||
exceptionDetails.innerCommand = innerCommand;
|
||||
}
|
||||
Object.assign(exceptionDetails, evidence);
|
||||
log.debug("inner_cmd.exception", exceptionDetails);
|
||||
return { kind: "defer" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CommandHandler } from "./types";
|
||||
|
||||
/** Matches a command whose leading program is `env`. */
|
||||
const ENV_PREFIX = /^env(?:[ \t]|$)/;
|
||||
|
||||
/**
|
||||
* The `env` wrapper handler.
|
||||
*
|
||||
* `env` is non-transparent: its modifier args (`NAME=VALUE`, `-i`, `-u`) can
|
||||
* change which binary the inner command resolves to (e.g. a `PATH=` override),
|
||||
* so stripping them and re-evaluating the inner command is unsound. `env` is
|
||||
* therefore claimed but always deferred — it never unwraps. Commands not
|
||||
* starting with `env` return `undefined` so the engine can try the next
|
||||
* handler.
|
||||
*/
|
||||
export const envHandler: CommandHandler = {
|
||||
id: "env",
|
||||
decide({ command, log }) {
|
||||
if (!ENV_PREFIX.test(command)) {
|
||||
return undefined;
|
||||
}
|
||||
log.debug("inner_cmd.env_non_transparent", { command });
|
||||
return { kind: "defer" };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { envHandler } from "./env";
|
||||
import { timeoutHandler } from "./timeout";
|
||||
import type { CommandHandler } from "./types";
|
||||
|
||||
/**
|
||||
* Registered command handlers, tried in order. The first to return a verdict
|
||||
* claims the command; the rest are not consulted. Add a handler here (and a
|
||||
* new file under `handlers/`) to support a new command type.
|
||||
*/
|
||||
export const handlers: readonly CommandHandler[] = [timeoutHandler, envHandler];
|
||||
@@ -0,0 +1,56 @@
|
||||
import { classifyWrapper, isRecognizedWrapper } from "../recognizer";
|
||||
import type { CommandHandler } from "./types";
|
||||
|
||||
/** Bash permission surface queried when re-evaluating the inner command. */
|
||||
const BASH_SURFACE = "bash";
|
||||
|
||||
/**
|
||||
* The strict simple-timeout wrapper handler (ADR 0001).
|
||||
*
|
||||
* Unwraps `timeout <duration> <command>`, rejects nested wrappers, and
|
||||
* re-evaluates the complete inner program through the deterministic policy.
|
||||
* Unsupported timeout syntax and nested wrappers defer with a debug log.
|
||||
* Commands that are not timeout at all return `undefined` so the engine can try
|
||||
* the next handler.
|
||||
*/
|
||||
export const timeoutHandler: CommandHandler = {
|
||||
id: "timeout",
|
||||
decide(ctx) {
|
||||
const { command, details, query, log, evidence } = ctx;
|
||||
const classification = classifyWrapper(command);
|
||||
switch (classification.kind) {
|
||||
case "nonTimeout":
|
||||
return undefined;
|
||||
case "unsupportedTimeout":
|
||||
log.debug("inner_cmd.unsupported_timeout_syntax", { command });
|
||||
return { kind: "defer" };
|
||||
case "recognized": {
|
||||
const innerCommand = classification.match.innerCommand;
|
||||
// Record the derived inner command so the engine's exception
|
||||
// log retains it if the re-evaluation below throws.
|
||||
evidence.innerCommand = innerCommand;
|
||||
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:
|
||||
log.debug("inner_cmd.inner_ask", { command, innerCommand });
|
||||
return { kind: "defer" };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
AuthorizerLog,
|
||||
AuthorizerVerdict,
|
||||
PermissionQuery,
|
||||
PromptPermissionDetails,
|
||||
} from "@gotgenes/pi-permission-system";
|
||||
|
||||
/** Context handed to a handler for one recovered command. */
|
||||
export interface HandlerContext {
|
||||
/** The full recovered Bash command. */
|
||||
readonly command: string;
|
||||
readonly details: PromptPermissionDetails;
|
||||
readonly query: PermissionQuery;
|
||||
readonly log: AuthorizerLog;
|
||||
/**
|
||||
* Partial-result bag. A handler that recognizes the command records derived
|
||||
* values here (e.g. `ctx.evidence.innerCommand = innerCommand`) so the
|
||||
* engine's exception log retains them if `decide` later throws. The engine
|
||||
* resets this bag per handler.
|
||||
*/
|
||||
readonly evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A self-contained verdict strategy for one kind of Bash command.
|
||||
*
|
||||
* The engine walks registered handlers in order; the first that returns a
|
||||
* verdict claims the command. Returning `undefined` means "not mine" and lets
|
||||
* the engine try the next handler.
|
||||
*/
|
||||
export interface CommandHandler {
|
||||
/** Stable id for logs, e.g. "timeout". */
|
||||
readonly id: string;
|
||||
/**
|
||||
* Inspect the command: return a verdict to claim it (the engine stops), or
|
||||
* `undefined` to pass to the next handler.
|
||||
*/
|
||||
decide(ctx: HandlerContext): AuthorizerVerdict | undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user