fix(pi-permission-inner-cmd): unwrap timeout in real-world command forms

- accept GNU timeout durations without a unit suffix and with decimals (timeout 240 …)
- detect the wrapper on details.command and strip it from the full command so scaffolded inputs (cd … && timeout … | tail) unwrap
- re-evaluate the full de-wrapped compound so sibling commands cannot hide behind the wrapper allow
- defer fail-closed when the unit is not a unique substring of the full command
- amend ADR 0001 with the relaxed grammar and the scaffolded-command handling
This commit is contained in:
2026-08-12 11:20:46 +08:00
parent 43ae2db90b
commit 6008c9e817
5 changed files with 240 additions and 56 deletions
@@ -79,6 +79,7 @@ function entriesRecovering(command: string, toolCallId = "call_1"): SessionEntry
function bashDetails(
toolCallId = "call_1",
agentName: string | null = null,
command?: string,
): PromptPermissionDetails {
return {
requestId: "req-1",
@@ -87,8 +88,8 @@ function bashDetails(
message: "May I run bash?",
toolCallId,
toolName: "bash",
// details.command is intentionally the winning unit, not the full input.
command: "ignored-winning-unit",
// details.command is the winning unit the permission system isolated.
command,
};
}
@@ -117,6 +118,8 @@ function makeSessionProbe(args: {
async function run(args: {
recoveredCommand: string;
/** details.command — the ask-triggering unit; defaults to recoveredCommand. */
unitCommand?: string;
states?: Record<string, PermissionState>;
details?: Partial<PromptPermissionDetails>;
getEntriesThrows?: boolean;
@@ -141,8 +144,12 @@ async function run(args: {
getSessionIdThrows: args.getSessionIdThrows,
sessionId: args.sessionMismatch ? "session-changed" : ROOT_SESSION_ID,
});
const unitCommand = args.unitCommand ?? args.recoveredCommand;
const verdict = await authorizeInnerCommand({
details: { ...bashDetails(toolCallId), ...args.details } as PromptPermissionDetails,
details: {
...bashDetails(toolCallId, null, unitCommand),
...args.details,
} as PromptPermissionDetails,
query,
log,
session,
@@ -462,6 +469,66 @@ describe("authorizeInnerCommand — xargs wrapper", () => {
});
});
describe("authorizeInnerCommand — scaffolded commands", () => {
it("unwraps a timeout buried in a cd/echo/|/tail scaffold", async () => {
const full =
"cd /repo && echo go && timeout 240 pnpm install --frozen-lockfile 2>&1 | tail -30; echo EXIT";
const deWrapped =
"cd /repo && echo go && pnpm install --frozen-lockfile 2>&1 | tail -30; echo EXIT";
const { verdict, log, check } = await run({
recoveredCommand: full,
unitCommand: "timeout 240 pnpm install --frozen-lockfile",
states: { [deWrapped]: "allow" },
});
expect(verdict.kind).toBe("allow");
// re-evaluated the full de-wrapped compound, not just the unit's inner
expect(check).toEqual([
{ surface: "bash", value: deWrapped, agentName: undefined },
]);
expect(log).toEqual([
{
level: "review",
event: "inner_cmd.allow",
details: {
command: full,
innerCommand: "pnpm install --frozen-lockfile",
},
},
]);
});
it("defers when the de-wrapped compound is not fully allowing (sibling)", async () => {
const full = "cd /repo && timeout 30s pnpm test && git push origin";
const deWrapped = "cd /repo && pnpm test && git push origin";
const { verdict, check } = await run({
recoveredCommand: full,
unitCommand: "timeout 30s pnpm test",
states: {},
});
expect(verdict.kind).toBe("defer");
// the de-wrapped compound still contains the git push sibling
expect(check).toEqual([
{ surface: "bash", value: deWrapped, agentName: undefined },
]);
});
it("defers fail-closed when the unit is not a unique substring", async () => {
const { verdict, log } = await run({
recoveredCommand: "timeout 30s pnpm test",
unitCommand: "timeout 30s pnpm test",
states: { "pnpm test": "allow" },
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.wrapper_not_located",
details: { command: "timeout 30s pnpm test" },
},
]);
});
});
describe("authorizeInnerCommand — exceptions defer with a debug log", () => {
it("defers when reading the session id throws (logs only safe data)", async () => {
const { verdict, log } = await run({
@@ -6,7 +6,7 @@ import {
} from "../src/recognizer";
describe("parseTimeoutWrapper", () => {
it("matches the strict simple-timeout form", () => {
it("matches the simple-timeout form", () => {
expect(parseTimeoutWrapper("timeout 30s pnpm test")).toEqual({
duration: "30s",
innerCommand: "pnpm test",
@@ -25,6 +25,21 @@ describe("parseTimeoutWrapper", () => {
});
});
it("accepts GNU durations: bare integer (seconds) and decimals", () => {
expect(parseTimeoutWrapper("timeout 240 pnpm test")).toEqual({
duration: "240",
innerCommand: "pnpm test",
});
expect(parseTimeoutWrapper("timeout 1.5h deploy")).toEqual({
duration: "1.5h",
innerCommand: "deploy",
});
expect(parseTimeoutWrapper("timeout 2.5s build")).toEqual({
duration: "2.5s",
innerCommand: "build",
});
});
it("preserves compound inner programs as the inner command", () => {
expect(parseTimeoutWrapper("timeout 60s pnpm test && git push")).toEqual({
duration: "60s",
@@ -47,8 +62,10 @@ describe("parseTimeoutWrapper", () => {
});
});
it("rejects leading-zero and multi-letter durations", () => {
it("rejects zero, leading-zero, ms, and multi-letter durations", () => {
expect(parseTimeoutWrapper("timeout 0 pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 0s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 0.5s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 030s pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 30ms pnpm test")).toBeUndefined();
expect(parseTimeoutWrapper("timeout 30sec pnpm test")).toBeUndefined();