feat(pi-permission-inner-cmd): recognize bare time wrapper as transparent

- add time handler that unwraps the bare reserved-word form time <command> and re-evaluates the full de-wrapped compound like timeout (ADR 0009)
- defer fail-closed on dash-leading modifiers, bare time, and nested wrappers in both directions
- generalize isRecognizedWrapper to timeout and time, and classifyWrapper to recognized/unsupported/other with a wrapper name
- rename defer events to inner_cmd.nested_wrapper and inner_cmd.unsupported_wrapper_syntax with a wrapper field
- extract stripWrapperUnit into handlers/strip.ts for shared use
This commit is contained in:
2026-08-23 16:48:31 +08:00
parent 99e953664d
commit e2719f9f11
9 changed files with 562 additions and 73 deletions
@@ -91,13 +91,13 @@ export interface InnerCommandAuthorizerDeps {
}
/**
* Inner-command Authorizer decision (ADRs 0001 and 0004).
* Inner-command Authorizer decision (ADRs 0001, 0004, and 0009).
*
* Revalidates root ownership, reads the complete native Bash command from the
* structured prompt payload, 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.
* timeout and time handlers unwrap one level and re-evaluate the inner
* command; the env and xargs handlers defer as non-transparent.
*
* Every uncertain path — forwarded requests, a session-identity mismatch,
* non-Bash tools, malformed payload evidence, an unrecognized command, or any
@@ -1,4 +1,5 @@
import { envHandler } from "./env";
import { timeHandler } from "./time";
import { timeoutHandler } from "./timeout";
import { xargsHandler } from "./xargs";
import type { CommandHandler } from "./types";
@@ -10,6 +11,7 @@ import type { CommandHandler } from "./types";
*/
export const handlers: readonly CommandHandler[] = [
timeoutHandler,
timeHandler,
envHandler,
xargsHandler,
];
@@ -0,0 +1,28 @@
/**
* Shared helper for the transparent unwrap handlers (`timeout`, `time`).
*/
/**
* Replace the wrapper unit with its unwrapped inner inside the full command,
* exactly once. Returns `undefined` when the unit is not a unique substring
* (absent, or appears more than once), so the caller defers fail-closed rather
* than guess where to strip.
*/
export function stripWrapperUnit(
fullCommand: string,
unit: string,
inner: string,
): string | undefined {
const first = fullCommand.indexOf(unit);
if (first === -1) {
return undefined;
}
if (fullCommand.indexOf(unit, first + unit.length) !== -1) {
return undefined;
}
return (
fullCommand.slice(0, first) +
inner +
fullCommand.slice(first + unit.length)
);
}
@@ -0,0 +1,115 @@
import {
isRecognizedWrapper,
parseTimeWrapper,
TIME_PREFIX,
} from "../recognizer";
import { stripWrapperUnit } from "./strip";
import type { CommandHandler } from "./types";
/** Bash permission surface queried when re-evaluating the inner command. */
const BASH_SURFACE = "bash";
/**
* The bare `time` wrapper handler (ADR 0009).
*
* `time <command>` — the Bash reserved-word timing form with no modifier
* args — is transparent: it runs the inner command unchanged and only adds
* timing. The wrapper is stripped from the FULL command and the whole
* de-wrapped compound is re-evaluated, exactly like `timeout`, so sibling
* commands (including dangerous ones) are still judged and cannot hide behind
* the wrapper's allow.
*
* Unsupported time syntax (`time -p`, `time -- ls`, bare `time`), a nested
* recognized wrapper (`time time cmd`, `time timeout 10 cmd`), a unit that
* cannot be located exactly once in the full command, and any non-allowing
* re-evaluation all defer fail-closed. `/usr/bin/time` by full path is not
* claimed at all.
*/
export const timeHandler: CommandHandler = {
id: "time",
decide(ctx) {
const {
command: fullCommand,
unit,
details,
query,
log,
evidence,
} = ctx;
const unitMatch = parseTimeWrapper(unit);
if (unitMatch === undefined) {
// Not the recognized form. If it still names `time`, surface it
// as unsupported; otherwise this unit is not ours.
if (TIME_PREFIX.test(unit)) {
log.debug("inner_cmd.unsupported_wrapper_syntax", {
command: fullCommand,
wrapper: "time",
});
return { kind: "defer" };
}
return undefined;
}
const innerCommand = unitMatch.innerCommand;
evidence.innerCommand = innerCommand;
// Never unwrap into another recognized wrapper.
if (isRecognizedWrapper(innerCommand)) {
log.debug("inner_cmd.nested_wrapper", {
command: fullCommand,
innerCommand,
wrapper: "time",
});
return { kind: "defer" };
}
// Strip the wrapper from the full command (handles scaffolds). Defer
// fail-closed if the unit is not a unique substring.
const unwrappedFull = stripWrapperUnit(
fullCommand,
unit,
innerCommand,
);
if (unwrappedFull === undefined) {
log.debug("inner_cmd.wrapper_not_located", {
command: fullCommand,
});
return { kind: "defer" };
}
// Authoritative: re-evaluate the full de-wrapped compound. The
// permission system decomposes it into units and keeps the most
// restrictive, so any non-allowing sibling defers here.
const result = query.checkPermission(
BASH_SURFACE,
unwrappedFull,
details.agentName ?? undefined,
);
switch (result.state) {
case "allow":
// `requestId` joins this link decision to the gate's
// permission_request.* entries for offline analysis.
log.review("inner_cmd.allow", {
requestId: details.requestId,
command: fullCommand,
innerCommand,
});
return { kind: "allow" };
case "deny":
log.review("inner_cmd.deny", {
requestId: details.requestId,
command: fullCommand,
innerCommand,
});
return { kind: "deny" };
case "ask":
default:
log.debug("inner_cmd.inner_ask", {
command: fullCommand,
innerCommand,
});
return { kind: "defer" };
}
},
};
@@ -3,36 +3,12 @@ import {
parseTimeoutWrapper,
TIMEOUT_PREFIX,
} from "../recognizer";
import { stripWrapperUnit } from "./strip";
import type { CommandHandler } from "./types";
/** Bash permission surface queried when re-evaluating the inner command. */
const BASH_SURFACE = "bash";
/**
* Replace the wrapper unit with its unwrapped inner inside the full command,
* exactly once. Returns `undefined` when the unit is not a unique substring
* (absent, or appears more than once), so the caller defers fail-closed rather
* than guess where to strip.
*/
function stripWrapperUnit(
fullCommand: string,
unit: string,
inner: string,
): string | undefined {
const first = fullCommand.indexOf(unit);
if (first === -1) {
return undefined;
}
if (fullCommand.indexOf(unit, first + unit.length) !== -1) {
return undefined;
}
return (
fullCommand.slice(0, first) +
inner +
fullCommand.slice(first + unit.length)
);
}
/**
* The simple-timeout wrapper handler (ADR 0001).
*
@@ -43,9 +19,9 @@ function stripWrapperUnit(
* compound is re-evaluated, so sibling commands (including dangerous ones) are
* still judged and cannot hide behind the wrapper's allow.
*
* Unsupported timeout syntax, a nested wrapper, a unit that cannot be located
* exactly once in the full command, and any non-allowing re-evaluation all
* defer fail-closed.
* Unsupported timeout syntax, a nested recognized wrapper (`timeout` or
* `time`), a unit that cannot be located exactly once in the full command,
* and any non-allowing re-evaluation all defer fail-closed.
*/
export const timeoutHandler: CommandHandler = {
id: "timeout",
@@ -64,8 +40,9 @@ export const timeoutHandler: CommandHandler = {
// Not the recognized form. If it still names `timeout`, surface it
// as unsupported; otherwise this unit is not ours.
if (TIMEOUT_PREFIX.test(unit)) {
log.debug("inner_cmd.unsupported_timeout_syntax", {
log.debug("inner_cmd.unsupported_wrapper_syntax", {
command: fullCommand,
wrapper: "timeout",
});
return { kind: "defer" };
}
@@ -75,11 +52,12 @@ export const timeoutHandler: CommandHandler = {
const innerCommand = unitMatch.innerCommand;
evidence.innerCommand = innerCommand;
// Never unwrap into another wrapper.
// Never unwrap into another recognized wrapper (timeout or time).
if (isRecognizedWrapper(innerCommand)) {
log.debug("inner_cmd.nested_timeout", {
log.debug("inner_cmd.nested_wrapper", {
command: fullCommand,
innerCommand,
wrapper: "timeout",
});
return { kind: "defer" };
}
@@ -1,9 +1,10 @@
/**
* V0.1 wrapper recognizer.
* Wrapper recognizers (ADRs 0001 and 0009).
*
* The simple-timeout grammar from ADR 0001. V0.1 unwraps exactly
* `timeout <duration> <command>`; every other `timeout` invocation is left to
* the next authority.
* Two transparent wrappers are recognized in their strict bare forms:
* `timeout <duration> <command>` (ADR 0001) and `time <command>` (ADR 0009,
* the Bash reserved-word timing form with no modifier args). Every other
* invocation of either program is left to the next authority.
*/
/**
@@ -21,9 +22,27 @@
const TIMEOUT_WRAPPER_PATTERN =
/^timeout[ \t]+([1-9][0-9]*(?:\.[0-9]+)?[smhd]?)[ \t]+(.+)$/;
/**
* Matches `time <command>` — the bare timing wrapper with no modifier args.
* A dash immediately after the separator (any amount of whitespace) means
* modifier args are present (`time -p ls`, `time -- ls`, or a `/usr/bin/time`
* flag such as `-o FILE`, which writes a file). Those can change what the
* wrapper does beyond timing, so the form is not recognized and never
* unwrapped. The lookahead also rejects a whitespace-only remainder, so
* regex backtracking cannot smuggle a leading space into the inner command.
*/
const TIME_WRAPPER_PATTERN = /^time[ \t]+(?![-\s])(.+)$/;
/** A command that begins with the bare `timeout` wrapper program. */
export const TIMEOUT_PREFIX = /^timeout(?:[ \t]|$)/;
/**
* A command that begins with the word `time` — the Bash reserved word or the
* `/usr/bin/time`-style binary invoked by bare name. Full-path invocations
* (`/usr/bin/time cmd`) do not match and are not claimed by any handler.
*/
export const TIME_PREFIX = /^time(?:[ \t]|$)/;
export interface TimeoutWrapperMatch {
readonly duration: string;
readonly innerCommand: string;
@@ -49,37 +68,79 @@ export function parseTimeoutWrapper(
};
}
export interface TimeWrapperMatch {
readonly innerCommand: string;
}
/**
* Whether a command is itself a recognized wrapper. Used to reject nested
* wrappers so v0.1 unwraps at most one level.
* Parse a command as the bare `time` wrapper (ADR 0009).
*
* @returns the full inner command (including any `&&`/`;`/`|` siblings), or
* `undefined` when the command is not the recognized bare `time <command>`
* form.
*/
export function parseTimeWrapper(
command: string,
): TimeWrapperMatch | undefined {
const match = TIME_WRAPPER_PATTERN.exec(command);
if (match === null) {
return undefined;
}
return { innerCommand: match[1] };
}
/**
* Whether a command is itself a recognized wrapper (timeout or time, in their
* strict bare forms). Used to reject nested wrappers so at most one level is
* ever unwrapped.
*/
export function isRecognizedWrapper(command: string): boolean {
return parseTimeoutWrapper(command) !== undefined;
return (
parseTimeoutWrapper(command) !== undefined ||
parseTimeWrapper(command) !== undefined
);
}
/** How a complete Bash command relates to the v0.1 recognizer. */
export type WrapperClassification =
| { readonly kind: "recognized"; readonly match: TimeoutWrapperMatch }
| { readonly kind: "unsupportedTimeout" }
| { readonly kind: "nonTimeout" };
/**
* Classify a complete Bash command against the v0.1 recognizer.
* A recognized wrapper, tagged with which grammar recognized it.
*
* - `recognized`: the strict simple-timeout wrapper.
* - `unsupportedTimeout`: the command invokes `timeout` but is not the
* The `wrapper` discriminator tells consumers which `match` shape applies
* without a union-widening cast.
*/
export type RecognizedWrapper =
| { readonly wrapper: "timeout"; readonly match: TimeoutWrapperMatch }
| { readonly wrapper: "time"; readonly match: TimeWrapperMatch };
/** How a complete Bash command relates to the recognizers. */
export type WrapperClassification =
| ({ readonly kind: "recognized" } & RecognizedWrapper)
| { readonly kind: "unsupported"; readonly wrapper: "timeout" | "time" }
| { readonly kind: "other" };
/**
* Classify a complete Bash command against the recognizers.
*
* - `recognized`: one of the strict bare wrapper forms.
* - `unsupported`: the command invokes `timeout` or `time` but is not the
* recognized strict form (flags, `-k`, missing command, ...). These are
* logged at debug so an operator can see why a wrapper was skipped.
* - `nonTimeout`: an ordinary command this authorizer does not handle. These
* defer silently.
* - `other`: an ordinary command this authorizer does not handle. These defer
* silently.
*/
export function classifyWrapper(command: string): WrapperClassification {
const match = parseTimeoutWrapper(command);
if (match !== undefined) {
return { kind: "recognized", match };
const timeoutMatch = parseTimeoutWrapper(command);
if (timeoutMatch !== undefined) {
return { kind: "recognized", wrapper: "timeout", match: timeoutMatch };
}
const timeMatch = parseTimeWrapper(command);
if (timeMatch !== undefined) {
return { kind: "recognized", wrapper: "time", match: timeMatch };
}
if (TIMEOUT_PREFIX.test(command)) {
return { kind: "unsupportedTimeout" };
return { kind: "unsupported", wrapper: "timeout" };
}
return { kind: "nonTimeout" };
if (TIME_PREFIX.test(command)) {
return { kind: "unsupported", wrapper: "time" };
}
return { kind: "other" };
}