diff --git a/docs/adr/0009-time-bare-form-unwrap.md b/docs/adr/0009-time-bare-form-unwrap.md new file mode 100644 index 0000000..1be904b --- /dev/null +++ b/docs/adr/0009-time-bare-form-unwrap.md @@ -0,0 +1,63 @@ +--- +status: accepted +--- + +# The bare `time` wrapper unwraps like `timeout` (ADR 0009) + +`pi-permission-inner-cmd` recognized only `timeout ` +(ADR 0001). `time ` 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. diff --git a/packages/pi-permission-inner-cmd/src/authorizer.ts b/packages/pi-permission-inner-cmd/src/authorizer.ts index 0093b78..b577f3e 100644 --- a/packages/pi-permission-inner-cmd/src/authorizer.ts +++ b/packages/pi-permission-inner-cmd/src/authorizer.ts @@ -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 diff --git a/packages/pi-permission-inner-cmd/src/handlers/index.ts b/packages/pi-permission-inner-cmd/src/handlers/index.ts index 677ecac..83c0c87 100644 --- a/packages/pi-permission-inner-cmd/src/handlers/index.ts +++ b/packages/pi-permission-inner-cmd/src/handlers/index.ts @@ -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, ]; diff --git a/packages/pi-permission-inner-cmd/src/handlers/strip.ts b/packages/pi-permission-inner-cmd/src/handlers/strip.ts new file mode 100644 index 0000000..b91131d --- /dev/null +++ b/packages/pi-permission-inner-cmd/src/handlers/strip.ts @@ -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) + ); +} diff --git a/packages/pi-permission-inner-cmd/src/handlers/time.ts b/packages/pi-permission-inner-cmd/src/handlers/time.ts new file mode 100644 index 0000000..2578bf5 --- /dev/null +++ b/packages/pi-permission-inner-cmd/src/handlers/time.ts @@ -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 ` — 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" }; + } + }, +}; diff --git a/packages/pi-permission-inner-cmd/src/handlers/timeout.ts b/packages/pi-permission-inner-cmd/src/handlers/timeout.ts index f3cbdcd..e802932 100644 --- a/packages/pi-permission-inner-cmd/src/handlers/timeout.ts +++ b/packages/pi-permission-inner-cmd/src/handlers/timeout.ts @@ -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" }; } diff --git a/packages/pi-permission-inner-cmd/src/recognizer.ts b/packages/pi-permission-inner-cmd/src/recognizer.ts index 3b7e88a..f1f3bbd 100644 --- a/packages/pi-permission-inner-cmd/src/recognizer.ts +++ b/packages/pi-permission-inner-cmd/src/recognizer.ts @@ -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 `; every other `timeout` invocation is left to - * the next authority. + * Two transparent wrappers are recognized in their strict bare forms: + * `timeout ` (ADR 0001) and `time ` (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 ` — 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 ` + * 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" }; } diff --git a/packages/pi-permission-inner-cmd/test/authorizer.test.ts b/packages/pi-permission-inner-cmd/test/authorizer.test.ts index ee2e079..c3d3665 100644 --- a/packages/pi-permission-inner-cmd/test/authorizer.test.ts +++ b/packages/pi-permission-inner-cmd/test/authorizer.test.ts @@ -326,8 +326,11 @@ describe("authorizeInnerCommand — fail-closed deferrals", () => { expect(log).toEqual([ { level: "debug", - event: "inner_cmd.unsupported_timeout_syntax", - details: { command: "timeout -k 5s 30s pnpm test" }, + event: "inner_cmd.unsupported_wrapper_syntax", + details: { + command: "timeout -k 5s 30s pnpm test", + wrapper: "timeout", + }, }, ]); }); @@ -342,10 +345,11 @@ describe("authorizeInnerCommand — fail-closed deferrals", () => { expect(log).toEqual([ { level: "debug", - event: "inner_cmd.nested_timeout", + event: "inner_cmd.nested_wrapper", details: { command: "timeout 30s 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", () => { it("defers on an env wrapper with a debug log (non-transparent)", async () => { const { verdict, log, check } = await run({ diff --git a/packages/pi-permission-inner-cmd/test/recognizer.test.ts b/packages/pi-permission-inner-cmd/test/recognizer.test.ts index eae42e1..1d83483 100644 --- a/packages/pi-permission-inner-cmd/test/recognizer.test.ts +++ b/packages/pi-permission-inner-cmd/test/recognizer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { classifyWrapper, isRecognizedWrapper, + parseTimeWrapper, parseTimeoutWrapper, } 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", () => { - 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 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("timeout -k 5s 30s pnpm test")).toBe(false); + expect(isRecognizedWrapper("time -p pnpm test")).toBe(false); }); }); describe("classifyWrapper", () => { - it("classifies the recognized wrapper", () => { + it("classifies recognized wrappers with their name", () => { expect(classifyWrapper("timeout 30s pnpm test")).toEqual({ kind: "recognized", + wrapper: "timeout", 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", () => { - expect(classifyWrapper("timeout -k 5s 30s pnpm test").kind).toBe( - "unsupportedTimeout", - ); - expect(classifyWrapper("timeout 30s").kind).toBe("unsupportedTimeout"); - expect(classifyWrapper("timeout --help").kind).toBe("unsupportedTimeout"); + it("classifies unsupported wrapper syntax with its name", () => { + expect(classifyWrapper("timeout -k 5s 30s pnpm test")).toEqual({ + kind: "unsupported", + wrapper: "timeout", + }); + 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", () => { - expect(classifyWrapper("pnpm test").kind).toBe("nonTimeout"); - expect(classifyWrapper("rm -rf /").kind).toBe("nonTimeout"); - expect(classifyWrapper("git push").kind).toBe("nonTimeout"); + it("classifies ordinary commands as other", () => { + expect(classifyWrapper("pnpm test").kind).toBe("other"); + expect(classifyWrapper("rm -rf /").kind).toBe("other"); + expect(classifyWrapper("git push").kind).toBe("other"); + expect(classifyWrapper("/usr/bin/time ls").kind).toBe("other"); }); });