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:
2026-08-12 00:12:41 +08:00
parent c00ae33031
commit 5134e85d32
8 changed files with 269 additions and 125 deletions
+21 -46
View File
@@ -1,53 +1,28 @@
# Permission Authorization # pi-extensions
This context describes how ambiguous coding-agent operations are reviewed before they may execute. Personal `pi` coding-agent extensions. This context covers the permission
extensions that inspect and re-evaluate Bash commands before they are allowed.
## Language ## Language
**Deterministic Permission Policy**: ### Wrappers
The rule-based authority that classifies an operation as allowed, denied, or requiring a decision.
_Avoid_: Static judge
**Authorization Judge**: **Wrapper**:
An independent reviewer that proposes a verdict for an operation the Deterministic Permission Policy could not decide. A Bash command of the form `<program> [modifier-args] <inner-command>`, where the
_Avoid_: Bash parser, safety classifier authorization question is "what does the inner command do?". Whether a wrapper
may be unwrapped depends on whether its modifier args are transparent.
_Avoid_: command type, prefix command
**Shadow Mode**: **Transparent wrapper**:
An observation mode in which an Authorization Judge records a verdict without changing whether the operation executes. A wrapper whose modifier args do not change which program the inner command
_Avoid_: Dry run resolves to or its trust boundary (e.g. `timeout`). Stripping the modifiers and
re-evaluating the inner command is sound: the verdict applies to the same
program that actually runs.
_Avoid_: safe wrapper
**Enforce Mode**: **Non-transparent wrapper**:
An authority mode in which an Authorization Judge's allow verdict may approve an operation. In the initial rollout, deny and defer still pass to the next authority. A wrapper whose modifier args change the inner command's resolution or effect
_Avoid_: Production mode (e.g. `env`, whose `PATH=` or `-i` can make the inner name resolve to a
different binary). Stripping the modifiers and re-evaluating the inner command
**Judge Participation**: is unsound: the verdict may apply to a different program than the one that runs.
The presence of an Authorization Judge in the configured authorizer chain. Participation determines whether the Judge is consulted, independently of whether it is in Shadow Mode or Enforce Mode. _Avoid_: unsafe wrapper
_Avoid_: Enabled, installed
**Defer**:
A verdict stating that the available information or the judge itself is insufficient to decide, leaving the decision to the next authority.
_Avoid_: Deny, error
**False Allow**:
A Shadow Mode outcome in which the Authorization Judge proposes approval and the human reviewer rejects the same permission request.
_Avoid_: False positive
**Requesting Session**:
The session in which the operation requiring authorization originated. For a forwarded request, this is the child session.
_Avoid_: Current session
**Serving Session**:
The authority-bearing session that resolves a forwarded request and runs its configured Authorization Judge before the terminal human authority.
_Avoid_: Parent context, current session
**Conversation Owner**:
The session whose conversation entries are supplied to an Authorization Judge. It may differ from the Requesting Session for forwarded requests.
_Avoid_: Requester
**Judgment Evidence**:
The minimal operation, execution, user-intent, and forwarded-provenance facts supplied to an Authorization Judge for one verdict. Adapter validation state and review-log metadata are not Judgment Evidence.
_Avoid_: Judge request, full request context
**Execution Working Directory**:
The working directory of the Requesting Session in which an operation will execute and relative paths are resolved. For a forwarded request, it must not be replaced with the Serving Session's working directory.
_Avoid_: Current cwd, parent cwd
+61
View File
@@ -0,0 +1,61 @@
---
status: accepted
---
# Defer `env` to the AI judge; inner-cmd never unwraps it
`env` is the second command type considered for `pi-permission-inner-cmd`'s
deterministic unwrapping. We decided inner-cmd **always defers** `env` — it
never strips modifiers or re-evaluates an inner command — and lets the AI judge
authority handle `env` commands together with their full environment context.
## Why
`env` is a *non-transparent wrapper*: its modifier args (`NAME=VALUE`, `-i`,
`-u`, `-C`, `-S`) form an unbounded, implicit input channel that the
command-string policy cannot see. Stripping them and re-evaluating the inner
command is unsound, because the modifiers can change what actually runs without
changing the inner command string:
- `PATH=/evil` — resolve the inner name to a different binary;
- `LD_PRELOAD=/x.so`, `LD_LIBRARY_PATH` — inject arbitrary native code into the
inner binary before its `main()`;
- `BASH_ENV`, `PYTHONPATH`, `NODE_OPTIONS`, `PERL5OPT`, … — source scripts or
inject code into the inner command's runtime;
- `-i`, `-u NAME`, `-C DIR` — alter the execution context.
A "detect dangerous variables and defer" denylist is infeasible: the dangerous
set is open-ended (new runtimes keep introducing new `*_OPT` / `*_PATH`
injection variables), so any denylist is permanently incomplete, and one miss is
an RCE. Detecting "does *any* modifier exist" is trivial and complete, but the
only sound unwrap case is bare `env <cmd>` (zero modifiers), which is too rare in
practice to justify the shell parsing it would require (assignment/flag
detection, `-S`, `--`, `/usr/bin/env` basename, quoting).
## Considered options
- **Detect a `PATH=` override, otherwise unwrap** — rejected as false security:
`LD_PRELOAD` and the interpreter-injection variables defeat it without
touching `PATH`.
- **Unwrap only bare `env <cmd>` (zero modifiers)** — sound, but bare `env` is
rarely written and the boundary parsing adds real complexity for near-zero
coverage. Not worth it.
- **Always defer (chosen)** — zero parsing risk, zero soundness surface;
non-transparent commands get the semantic judgement they require from the AI
judge.
## Consequences
This establishes a clean division between the two permission authorities:
- **inner-cmd (deterministic)** unwraps only *transparent* wrappers (`timeout`)
— sound, fast, narrow. Every uncertain or non-transparent command defers
fail-closed.
- **The AI judge authority** receives deferred commands (including `env`) and
reasons about the full command *with* its environment, where non-transparency
can be understood semantically rather than stripped.
This is why the AI judge consumes the full recovered command from the shared
recovery module (ADR 0001) — it needs the complete input, modifiers included, to
judge non-transparent wrappers. See `CONTEXT.md` for the transparent /
non-transparent wrapper distinction.
@@ -5,14 +5,11 @@ import type {
PermissionQuery, PermissionQuery,
PromptPermissionDetails, PromptPermissionDetails,
} from "@gotgenes/pi-permission-system"; } from "@gotgenes/pi-permission-system";
import { classifyWrapper, isRecognizedWrapper } from "./recognizer";
import { import {
NATIVE_BASH_TOOL_NAME, NATIVE_BASH_TOOL_NAME,
recoverNativeBashCommand, recoverNativeBashCommand,
} from "@sikongjueluo/pi-permission-shared"; } from "@sikongjueluo/pi-permission-shared";
import { handlers } from "./handlers";
/** Bash permission surface queried when re-evaluating the inner command. */
const BASH_SURFACE = "bash";
/** Convert a thrown value into a short, log-safe string. */ /** Convert a thrown value into a short, log-safe string. */
function toErrorString(error: unknown): 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 * Revalidates root ownership, recovers the complete native Bash command for
* captured session, unwraps one strict `timeout` level, and re-evaluates the * `details.toolCallId` from the captured session, then hands it to the first
* inner command through the deterministic permission policy. Every uncertain * registered handler that claims it. Each handler owns its own recognition and
* path — forwarded requests, a session-identity mismatch, non-Bash tools, * verdict logic: the timeout handler unwraps one level and re-evaluates the
* missing/duplicate/malformed session evidence, unsupported or nested wrapper * inner command; the env handler defers as non-transparent.
* syntax, parse failures, and exceptions — defers to the next authority.
* *
* Logging contract: * Every uncertain path — forwarded requests, a session-identity mismatch,
* - `review` only for a recognized wrapper whose inner command resolves to a * non-Bash tools, missing session evidence, an unrecognized command, or any
* decisive `allow`/`deny`. * exception — defers to the next authority (fail-closed).
* - `debug` for a recognized inner `ask`, unsupported timeout syntax, nested *
* wrappers, a session-identity mismatch, and exceptions. * Logging: handlers emit their own review/debug events for decisive and
* - ordinary non-timeout commands defer silently. * notable-defer outcomes; silent deferrals log nothing. Exceptions are logged
* - recognized logs carry both `command` and `innerCommand`; an exception after * by this engine as `inner_cmd.exception`, retaining the recovered command and
* recognition retains both alongside `error`, while an earlier exception logs * any partial evidence the active handler recorded before throwing.
* only the safe data available at that point.
*/ */
export async function authorizeInnerCommand( export async function authorizeInnerCommand(
deps: InnerCommandAuthorizerDeps, deps: InnerCommandAuthorizerDeps,
@@ -75,18 +70,17 @@ export async function authorizeInnerCommand(
// Track recovered evidence so an exception after recognition can retain it. // Track recovered evidence so an exception after recognition can retain it.
let command: string | undefined; let command: string | undefined;
let innerCommand: string | undefined; let evidence: Record<string, unknown> = {};
try { try {
// Forwarded subagent asks are out of scope for v0.1: the captured // Forwarded subagent asks are out of scope: the captured session is the
// session is the serving root's conversation, not the requester's. // serving root's conversation, not the requester's.
if (details.forwarding) { if (details.forwarding) {
return { kind: "defer" }; return { kind: "defer" };
} }
// Revalidate root ownership: the live session must still be the one we // Revalidate root ownership: the live session must still be the one we
// registered for. A mismatch (or a session id that cannot be read) // registered for.
// means the captured conversation can no longer be attributed safely.
const currentSessionId = session.getSessionId(); const currentSessionId = session.getSessionId();
if (currentSessionId !== expectedSessionId) { if (currentSessionId !== expectedSessionId) {
log.debug("inner_cmd.session_mismatch", { log.debug("inner_cmd.session_mismatch", {
@@ -113,59 +107,15 @@ export async function authorizeInnerCommand(
return { kind: "defer" }; return { kind: "defer" };
} }
const classification = classifyWrapper(command); // Dispatch to the first registered handler that claims the command.
for (const handler of handlers) {
switch (classification.kind) { evidence = {};
case "nonTimeout": const verdict = handler.decide({ command, details, query, log, evidence });
// An ordinary Bash command this authorizer does not unwrap. if (verdict !== undefined) {
return { kind: "defer" }; return verdict;
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" };
}
} }
} }
return { kind: "defer" };
} catch (error) { } catch (error) {
const exceptionDetails: Record<string, unknown> = { const exceptionDetails: Record<string, unknown> = {
error: toErrorString(error), error: toErrorString(error),
@@ -173,9 +123,7 @@ export async function authorizeInnerCommand(
if (command !== undefined) { if (command !== undefined) {
exceptionDetails.command = command; exceptionDetails.command = command;
} }
if (innerCommand !== undefined) { Object.assign(exceptionDetails, evidence);
exceptionDetails.innerCommand = innerCommand;
}
log.debug("inner_cmd.exception", exceptionDetails); log.debug("inner_cmd.exception", exceptionDetails);
return { kind: "defer" }; 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;
}
@@ -402,6 +402,36 @@ describe("authorizeInnerCommand — fail-closed deferrals", () => {
}); });
}); });
describe("authorizeInnerCommand — env wrapper", () => {
it("defers on an env wrapper with a debug log (non-transparent)", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "env FOO=bar pnpm test",
states: { "pnpm test": "allow" },
});
expect(verdict.kind).toBe("defer");
// env is non-transparent: never unwrapped, so the inner command is not
// re-evaluated through the deterministic policy.
expect(check).toEqual([]);
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.env_non_transparent",
details: { command: "env FOO=bar pnpm test" },
},
]);
});
it("does not claim a command that only contains env later", async () => {
// Starts with printf, not env -> no handler claims it -> silent defer.
const { verdict, log } = await run({
recoveredCommand: "printf hi; env | sort",
states: {},
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
});
});
describe("authorizeInnerCommand — exceptions defer with a debug log", () => { describe("authorizeInnerCommand — exceptions defer with a debug log", () => {
it("defers when reading the session id throws (logs only safe data)", async () => { it("defers when reading the session id throws (logs only safe data)", async () => {
const { verdict, log } = await run({ const { verdict, log } = await run({