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
+63
View File
@@ -0,0 +1,63 @@
---
status: accepted
---
# The bare `time` wrapper unwraps like `timeout` (ADR 0009)
`pi-permission-inner-cmd` recognized only `timeout <duration> <command>`
(ADR 0001). `time <command>` poses the same authorization question — a
wrapper around an inner command — and appears routinely in agent Bash calls
(`time pnpm build`). A wrapper qualifies for unwrapping only when its
modifier args are transparent (CONTEXT.md, Wrappers).
## Decision
Recognize exactly the bare reserved-word form:
```regex
^time[ \t]+(?![-\s])(.+)$
```
It is transparent: `time` runs the inner command unchanged and only adds
timing, so the `timeout` unwrap pipeline applies verbatim — strip the wrapper
from the FULL command (scaffold included) and re-evaluate the whole de-wrapped
compound, so sibling commands stay under judgment. An inner `allow` allows,
anything else defers fail-closed.
Everything else that begins with `time` defers with
`inner_cmd.unsupported_wrapper_syntax`:
- **any dash-leading modifier** (`time -p ls`, `time -- ls`, `time -o FILE ls`).
The command string cannot distinguish the Bash reserved word (which accepts
only `-p`) from `/usr/bin/time` (whose `-o FILE` writes a file and `-v`
dumps environment-bearing stats), and the lookahead also blocks regex
backtracking from smuggling a leading space past a wide separator. A
modifier arg is therefore treated as potentially non-transparent until
individually proven otherwise.
- **a bare `time`** — it times the shell itself; there is no inner command to
re-evaluate.
`/usr/bin/time cmd` (full path) is not claimed by any handler and defers
silently like any unrecognized command.
## Generalizations that ride along
- `isRecognizedWrapper` now covers timeout ∪ time, so `time timeout 10 cmd`,
`timeout 10 time cmd`, and `time time cmd` all defer at most-one-level
nested wrappers (previously the check was timeout-only).
- The defer events generalize from `inner_cmd.nested_timeout` /
`inner_cmd.unsupported_timeout_syntax` to `inner_cmd.nested_wrapper` /
`inner_cmd.unsupported_wrapper_syntax`, each carrying a `wrapper:
"timeout" | "time"` field, so future wrapper handlers reuse the same event
names. Downstream analysis joins only on the decisive
`inner_cmd.allow|deny` markers and is unaffected.
## Consequences
- `time pnpm test` no longer reaches the human dialog / AI judge when the
inner command is already allowed.
- Handler precedence stays irrelevant: `timeout` and `time` prefixes are
disjoint, and both are tried before the claiming non-transparent handlers
(`env`, `xargs`).
- If `time -p` transparency is ever wanted, it is a one-line grammar change
plus tests — recorded here as deliberately out of scope for v0.1.
@@ -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 * Revalidates root ownership, reads the complete native Bash command from the
* structured prompt payload, then hands it to the first registered handler that * structured prompt payload, then hands it to the first registered handler that
* claims it. Each handler owns its own recognition and verdict logic: the * 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 * timeout and time handlers unwrap one level and re-evaluate the inner
* handler defers as non-transparent. * command; the env and xargs handlers defer as non-transparent.
* *
* Every uncertain path — forwarded requests, a session-identity mismatch, * Every uncertain path — forwarded requests, a session-identity mismatch,
* non-Bash tools, malformed payload evidence, an unrecognized command, or any * non-Bash tools, malformed payload evidence, an unrecognized command, or any
@@ -1,4 +1,5 @@
import { envHandler } from "./env"; import { envHandler } from "./env";
import { timeHandler } from "./time";
import { timeoutHandler } from "./timeout"; import { timeoutHandler } from "./timeout";
import { xargsHandler } from "./xargs"; import { xargsHandler } from "./xargs";
import type { CommandHandler } from "./types"; import type { CommandHandler } from "./types";
@@ -10,6 +11,7 @@ import type { CommandHandler } from "./types";
*/ */
export const handlers: readonly CommandHandler[] = [ export const handlers: readonly CommandHandler[] = [
timeoutHandler, timeoutHandler,
timeHandler,
envHandler, envHandler,
xargsHandler, 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, parseTimeoutWrapper,
TIMEOUT_PREFIX, TIMEOUT_PREFIX,
} from "../recognizer"; } from "../recognizer";
import { stripWrapperUnit } from "./strip";
import type { CommandHandler } from "./types"; import type { CommandHandler } from "./types";
/** 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";
/**
* 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). * The simple-timeout wrapper handler (ADR 0001).
* *
@@ -43,9 +19,9 @@ function stripWrapperUnit(
* compound is re-evaluated, so sibling commands (including dangerous ones) are * compound is re-evaluated, so sibling commands (including dangerous ones) are
* still judged and cannot hide behind the wrapper's allow. * still judged and cannot hide behind the wrapper's allow.
* *
* Unsupported timeout syntax, a nested wrapper, a unit that cannot be located * Unsupported timeout syntax, a nested recognized wrapper (`timeout` or
* exactly once in the full command, and any non-allowing re-evaluation all * `time`), a unit that cannot be located exactly once in the full command,
* defer fail-closed. * and any non-allowing re-evaluation all defer fail-closed.
*/ */
export const timeoutHandler: CommandHandler = { export const timeoutHandler: CommandHandler = {
id: "timeout", id: "timeout",
@@ -64,8 +40,9 @@ export const timeoutHandler: CommandHandler = {
// Not the recognized form. If it still names `timeout`, surface it // Not the recognized form. If it still names `timeout`, surface it
// as unsupported; otherwise this unit is not ours. // as unsupported; otherwise this unit is not ours.
if (TIMEOUT_PREFIX.test(unit)) { if (TIMEOUT_PREFIX.test(unit)) {
log.debug("inner_cmd.unsupported_timeout_syntax", { log.debug("inner_cmd.unsupported_wrapper_syntax", {
command: fullCommand, command: fullCommand,
wrapper: "timeout",
}); });
return { kind: "defer" }; return { kind: "defer" };
} }
@@ -75,11 +52,12 @@ export const timeoutHandler: CommandHandler = {
const innerCommand = unitMatch.innerCommand; const innerCommand = unitMatch.innerCommand;
evidence.innerCommand = innerCommand; evidence.innerCommand = innerCommand;
// Never unwrap into another wrapper. // Never unwrap into another recognized wrapper (timeout or time).
if (isRecognizedWrapper(innerCommand)) { if (isRecognizedWrapper(innerCommand)) {
log.debug("inner_cmd.nested_timeout", { log.debug("inner_cmd.nested_wrapper", {
command: fullCommand, command: fullCommand,
innerCommand, innerCommand,
wrapper: "timeout",
}); });
return { kind: "defer" }; 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 * Two transparent wrappers are recognized in their strict bare forms:
* `timeout <duration> <command>`; every other `timeout` invocation is left to * `timeout <duration> <command>` (ADR 0001) and `time <command>` (ADR 0009,
* the next authority. * 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 = const TIMEOUT_WRAPPER_PATTERN =
/^timeout[ \t]+([1-9][0-9]*(?:\.[0-9]+)?[smhd]?)[ \t]+(.+)$/; /^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. */ /** A command that begins with the bare `timeout` wrapper program. */
export const TIMEOUT_PREFIX = /^timeout(?:[ \t]|$)/; 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 { export interface TimeoutWrapperMatch {
readonly duration: string; readonly duration: string;
readonly innerCommand: 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 * Parse a command as the bare `time` wrapper (ADR 0009).
* wrappers so v0.1 unwraps at most one level. *
* @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 { 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. * The `wrapper` discriminator tells consumers which `match` shape applies
* - `unsupportedTimeout`: the command invokes `timeout` but is not the * 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 * recognized strict form (flags, `-k`, missing command, ...). These are
* logged at debug so an operator can see why a wrapper was skipped. * logged at debug so an operator can see why a wrapper was skipped.
* - `nonTimeout`: an ordinary command this authorizer does not handle. These * - `other`: an ordinary command this authorizer does not handle. These defer
* defer silently. * silently.
*/ */
export function classifyWrapper(command: string): WrapperClassification { export function classifyWrapper(command: string): WrapperClassification {
const match = parseTimeoutWrapper(command); const timeoutMatch = parseTimeoutWrapper(command);
if (match !== undefined) { if (timeoutMatch !== undefined) {
return { kind: "recognized", match }; 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)) { 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" };
} }
@@ -326,8 +326,11 @@ describe("authorizeInnerCommand — fail-closed deferrals", () => {
expect(log).toEqual([ expect(log).toEqual([
{ {
level: "debug", level: "debug",
event: "inner_cmd.unsupported_timeout_syntax", event: "inner_cmd.unsupported_wrapper_syntax",
details: { command: "timeout -k 5s 30s pnpm test" }, details: {
command: "timeout -k 5s 30s pnpm test",
wrapper: "timeout",
},
}, },
]); ]);
}); });
@@ -342,10 +345,11 @@ describe("authorizeInnerCommand — fail-closed deferrals", () => {
expect(log).toEqual([ expect(log).toEqual([
{ {
level: "debug", level: "debug",
event: "inner_cmd.nested_timeout", event: "inner_cmd.nested_wrapper",
details: { details: {
command: "timeout 30s timeout 10s pnpm test", command: "timeout 30s timeout 10s pnpm test",
innerCommand: "timeout 10s pnpm test", innerCommand: "timeout 10s pnpm test",
wrapper: "timeout",
}, },
}, },
]); ]);
@@ -602,6 +606,176 @@ describe("authorizeInnerCommand — fail-closed deferrals", () => {
}); });
}); });
describe("authorizeInnerCommand — time wrapper", () => {
it("maps an inner allow to allow and records a review", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "time pnpm test",
states: { "pnpm test": "allow" },
});
expect(verdict.kind).toBe("allow");
expect(log).toEqual([
{
level: "review",
event: "inner_cmd.allow",
details: {
requestId: "req-1",
command: "time pnpm test",
innerCommand: "pnpm test",
},
},
]);
expect(check).toEqual([
{ surface: "bash", value: "pnpm test", agentName: undefined },
]);
});
it("maps an inner deny to deny and an inner ask to defer", async () => {
const denied = await run({
recoveredCommand: "time rm -rf /",
states: { "rm -rf /": "deny" },
});
expect(denied.verdict.kind).toBe("deny");
expect(denied.log[0]?.event).toBe("inner_cmd.deny");
const asked = await run({
recoveredCommand: "time git push",
states: { "git push": "ask" },
});
expect(asked.verdict.kind).toBe("defer");
expect(asked.log[0]?.event).toBe("inner_cmd.inner_ask");
});
it("re-checks the complete inner program for compound input", async () => {
const { verdict, check } = await run({
recoveredCommand: "time pnpm test && git push",
states: { "pnpm test && git push": "ask" },
});
expect(verdict.kind).toBe("defer");
expect(check).toEqual([
{
surface: "bash",
value: "pnpm test && git push",
agentName: undefined,
},
]);
});
it("unwraps a time buried in a scaffold", async () => {
const full = "cd /repo && time pnpm install 2>&1 | tail -5";
const deWrapped = "cd /repo && pnpm install 2>&1 | tail -5";
const { verdict, log, check } = await run({
recoveredCommand: full,
unitCommand: "time pnpm install",
states: { [deWrapped]: "allow" },
});
expect(verdict.kind).toBe("allow");
expect(check).toEqual([
{ surface: "bash", value: deWrapped, agentName: undefined },
]);
expect(log).toEqual([
{
level: "review",
event: "inner_cmd.allow",
details: {
requestId: "req-1",
command: full,
innerCommand: "pnpm install",
},
},
]);
});
it("defers on unsupported time syntax with a debug log", async () => {
// `-p` (and any dash-leading modifier) makes the form unsupported:
// `/usr/bin/time` flags can write files (`-o`), and the command
// string cannot distinguish the reserved word from the binary.
const { verdict, log, check } = await run({
recoveredCommand: "time -p ls",
states: { ls: "allow" },
});
expect(verdict.kind).toBe("defer");
expect(check).toEqual([]);
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.unsupported_wrapper_syntax",
details: { command: "time -p ls", wrapper: "time" },
},
]);
});
it("defers on a bare time with no inner command", async () => {
const { verdict, log } = await run({
recoveredCommand: "time",
states: {},
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.unsupported_wrapper_syntax",
details: { command: "time", wrapper: "time" },
},
]);
});
it("defers on a nested wrapper (time wrapping time or timeout)", async () => {
const nested = await run({
recoveredCommand: "time time pnpm test",
states: { "pnpm test": "allow" },
});
expect(nested.verdict.kind).toBe("defer");
expect(nested.check).toEqual([]);
expect(nested.log).toEqual([
{
level: "debug",
event: "inner_cmd.nested_wrapper",
details: {
command: "time time pnpm test",
innerCommand: "time pnpm test",
wrapper: "time",
},
},
]);
const nestedTimeout = await run({
recoveredCommand: "time timeout 10s pnpm test",
states: { "timeout 10s pnpm test": "allow" },
});
expect(nestedTimeout.verdict.kind).toBe("defer");
expect(nestedTimeout.log[0]?.event).toBe("inner_cmd.nested_wrapper");
});
it("defers on timeout wrapping time (nested the other way)", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "timeout 30s time pnpm test",
states: { "time pnpm test": "allow" },
});
expect(verdict.kind).toBe("defer");
expect(check).toEqual([]);
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.nested_wrapper",
details: {
command: "timeout 30s time pnpm test",
innerCommand: "time pnpm test",
wrapper: "timeout",
},
},
]);
});
it("defers silently on /usr/bin/time (full path is not claimed)", async () => {
const { verdict, log } = await run({
recoveredCommand: "/usr/bin/time ls",
states: { ls: "allow" },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
});
});
describe("authorizeInnerCommand — env wrapper", () => { describe("authorizeInnerCommand — env wrapper", () => {
it("defers on an env wrapper with a debug log (non-transparent)", async () => { it("defers on an env wrapper with a debug log (non-transparent)", async () => {
const { verdict, log, check } = await run({ const { verdict, log, check } = await run({
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
classifyWrapper, classifyWrapper,
isRecognizedWrapper, isRecognizedWrapper,
parseTimeWrapper,
parseTimeoutWrapper, parseTimeoutWrapper,
} from "../src/recognizer"; } from "../src/recognizer";
@@ -86,34 +87,101 @@ describe("parseTimeoutWrapper", () => {
}); });
}); });
describe("parseTimeWrapper", () => {
it("matches the bare reserved-word form", () => {
expect(parseTimeWrapper("time pnpm test")).toEqual({
innerCommand: "pnpm test",
});
expect(parseTimeWrapper("time\techo hi")).toEqual({
innerCommand: "echo hi",
});
expect(parseTimeWrapper("time build")).toEqual({
innerCommand: "build",
});
});
it("preserves compound inner programs as the inner command", () => {
expect(parseTimeWrapper("time pnpm test && git push")).toEqual({
innerCommand: "pnpm test && git push",
});
expect(parseTimeWrapper("time bash -c something")).toEqual({
innerCommand: "bash -c something",
});
});
it("rejects modifier args regardless of separator width", () => {
// A dash right after the separator means flags: not transparent.
expect(parseTimeWrapper("time -p ls")).toBeUndefined();
// Backtracking must not smuggle a leading space into the inner.
expect(parseTimeWrapper("time -p ls")).toBeUndefined();
expect(parseTimeWrapper("time\t-- ls")).toBeUndefined();
expect(parseTimeWrapper("time -o out.txt ls")).toBeUndefined();
});
it("rejects a bare time and non-time commands", () => {
expect(parseTimeWrapper("time")).toBeUndefined();
expect(parseTimeWrapper("timeout 10s ls")).toBeUndefined();
expect(parseTimeWrapper("my-time ls")).toBeUndefined();
expect(parseTimeWrapper("/usr/bin/time ls")).toBeUndefined();
});
});
describe("isRecognizedWrapper", () => { describe("isRecognizedWrapper", () => {
it("is true for the strict form and false otherwise", () => { it("is true for the strict forms and false otherwise", () => {
expect(isRecognizedWrapper("timeout 10s pnpm test")).toBe(true); expect(isRecognizedWrapper("timeout 10s pnpm test")).toBe(true);
expect(isRecognizedWrapper("timeout 10s timeout 5s pnpm test")).toBe(true); expect(isRecognizedWrapper("timeout 10s timeout 5s pnpm test")).toBe(
true,
);
expect(isRecognizedWrapper("time pnpm test")).toBe(true);
expect(isRecognizedWrapper("time timeout 5s pnpm test")).toBe(true);
expect(isRecognizedWrapper("timeout 10s time pnpm test")).toBe(true);
expect(isRecognizedWrapper("pnpm test")).toBe(false); expect(isRecognizedWrapper("pnpm test")).toBe(false);
expect(isRecognizedWrapper("timeout -k 5s 30s pnpm test")).toBe(false); expect(isRecognizedWrapper("timeout -k 5s 30s pnpm test")).toBe(false);
expect(isRecognizedWrapper("time -p pnpm test")).toBe(false);
}); });
}); });
describe("classifyWrapper", () => { describe("classifyWrapper", () => {
it("classifies the recognized wrapper", () => { it("classifies recognized wrappers with their name", () => {
expect(classifyWrapper("timeout 30s pnpm test")).toEqual({ expect(classifyWrapper("timeout 30s pnpm test")).toEqual({
kind: "recognized", kind: "recognized",
wrapper: "timeout",
match: { duration: "30s", innerCommand: "pnpm test" }, match: { duration: "30s", innerCommand: "pnpm test" },
}); });
expect(classifyWrapper("time pnpm test")).toEqual({
kind: "recognized",
wrapper: "time",
match: { innerCommand: "pnpm test" },
});
}); });
it("classifies unsupported timeout syntax", () => { it("classifies unsupported wrapper syntax with its name", () => {
expect(classifyWrapper("timeout -k 5s 30s pnpm test").kind).toBe( expect(classifyWrapper("timeout -k 5s 30s pnpm test")).toEqual({
"unsupportedTimeout", kind: "unsupported",
); wrapper: "timeout",
expect(classifyWrapper("timeout 30s").kind).toBe("unsupportedTimeout"); });
expect(classifyWrapper("timeout --help").kind).toBe("unsupportedTimeout"); expect(classifyWrapper("timeout 30s")).toEqual({
kind: "unsupported",
wrapper: "timeout",
});
expect(classifyWrapper("timeout --help")).toEqual({
kind: "unsupported",
wrapper: "timeout",
});
expect(classifyWrapper("time -p ls")).toEqual({
kind: "unsupported",
wrapper: "time",
});
expect(classifyWrapper("time")).toEqual({
kind: "unsupported",
wrapper: "time",
});
}); });
it("classifies ordinary commands as non-timeout", () => { it("classifies ordinary commands as other", () => {
expect(classifyWrapper("pnpm test").kind).toBe("nonTimeout"); expect(classifyWrapper("pnpm test").kind).toBe("other");
expect(classifyWrapper("rm -rf /").kind).toBe("nonTimeout"); expect(classifyWrapper("rm -rf /").kind).toBe("other");
expect(classifyWrapper("git push").kind).toBe("nonTimeout"); expect(classifyWrapper("git push").kind).toBe("other");
expect(classifyWrapper("/usr/bin/time ls").kind).toBe("other");
}); });
}); });