feat(pi-permission-inner-cmd): defer xargs as a non-transparent wrapper

- add handlers/xargs.ts mirroring env: claim xargs-leading commands and defer
- register xargsHandler so leading-xargs commands log and defer instead of falling through silently
- add CONTEXT.md xargs example and ADR 0003 (xargs args come from stdin, so even the AI judge cannot know them)
This commit is contained in:
2026-08-12 00:36:09 +08:00
parent 5134e85d32
commit 43ae2db90b
5 changed files with 107 additions and 5 deletions
@@ -1,5 +1,6 @@
import { envHandler } from "./env";
import { timeoutHandler } from "./timeout";
import { xargsHandler } from "./xargs";
import type { CommandHandler } from "./types";
/**
@@ -7,4 +8,8 @@ import type { CommandHandler } from "./types";
* claims the command; the rest are not consulted. Add a handler here (and a
* new file under `handlers/`) to support a new command type.
*/
export const handlers: readonly CommandHandler[] = [timeoutHandler, envHandler];
export const handlers: readonly CommandHandler[] = [
timeoutHandler,
envHandler,
xargsHandler,
];
@@ -0,0 +1,27 @@
import type { CommandHandler } from "./types";
/** Matches a command whose leading program is `xargs`. */
const XARGS_PREFIX = /^xargs(?:[ \t]|$)/;
/**
* The `xargs` wrapper handler (ADR 0003).
*
* `xargs` is non-transparent in a way distinct from `env`: the inner command's
* name is known, but its arguments are read from stdin (or `-a FILE`) at run
* time, so they are absent from the command string entirely. Re-evaluating the
* inner command is unsound — the verdict would apply to arguments that are not
* even knowable from the input. `xargs` is claimed but always deferred; it
* never unwraps. (`xargs` usually appears mid-pipeline, so inner-cmd's
* leading-program check rarely reaches it; this handler covers the rarer
* `xargs`-as-leading-program case for observability.)
*/
export const xargsHandler: CommandHandler = {
id: "xargs",
decide({ command, log }) {
if (!XARGS_PREFIX.test(command)) {
return undefined;
}
log.debug("inner_cmd.xargs_non_transparent", { command });
return { kind: "defer" };
},
};
@@ -432,6 +432,36 @@ describe("authorizeInnerCommand — env wrapper", () => {
});
});
describe("authorizeInnerCommand — xargs wrapper", () => {
it("defers on a leading xargs with a debug log (non-transparent args)", async () => {
const { verdict, log, check } = await run({
recoveredCommand: "xargs rm",
states: { rm: "allow" },
});
expect(verdict.kind).toBe("defer");
// xargs arguments come from stdin, so the inner command is never
// re-evaluated through the deterministic policy.
expect(check).toEqual([]);
expect(log).toEqual([
{
level: "debug",
event: "inner_cmd.xargs_non_transparent",
details: { command: "xargs rm" },
},
]);
});
it("does not claim a command where xargs appears mid-pipeline", async () => {
// Starts with find, not xargs -> no handler claims it -> silent defer.
const { verdict, log } = await run({
recoveredCommand: "find . -name '*.tmp' | xargs rm",
states: {},
});
expect(verdict.kind).toBe("defer");
expect(log).toEqual([]);
});
});
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({