mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
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:
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user