fix(pi-workflow): allow offline verification command

This commit is contained in:
jb
2026-08-25 20:27:54 -04:00
parent 8086247f26
commit 3c5168aa3c
3 changed files with 85 additions and 15 deletions
+1 -1
View File
@@ -401,7 +401,7 @@ Mutating Assignments run in the Workflow Run's worktree.
- Every Assignment has declared file ownership.
- All mutating Assignments are serialized in the first version. Concurrency remains available for read-only Assignments.
- Mutating child sessions do not receive `bash` or other process-execution tools. Assignment-scoped file operations reject symlink traversal and require canonical containment beneath both the worktree root and the Assignment's owned path roots.
- Plan-approved verification and generation commands run separately through a host executor using fixed executable/argument arrays, never agent-authored shell strings.
- Plan-approved verification and generation commands run separately through a host executor using fixed executable/argument arrays, never agent-authored shell strings. The only accepted wrapper form is `env PI_OFFLINE=1 <executable> ...`; the executor unwraps it, sets that single variable, and applies the normal executable safety checks.
- The host executor sets `PYTHONDONTWRITEBYTECODE=1` so Python-backed checks do not create out-of-scope `__pycache__` artifacts.
- The engine records branch, HEAD, worktree identity, and Git diff state before and after every mutating Assignment and host command.
- Out-of-scope changes, branch movement, commits, or worktree metadata changes invalidate the Assignment and open a Recovery Gate before more mutation starts.
+61
View File
@@ -278,6 +278,11 @@ test("Git probe and approved host commands enforce identity and ownership", asyn
"import bytecode_module\nassert bytecode_module.VALUE == 1\n",
"utf8",
);
await writeFile(
path.join(directory, "check-offline.cjs"),
'process.exit(process.env.PI_OFFLINE === "1" ? 0 : 1)',
"utf8",
);
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
const probe = new GitStateProbe();
@@ -303,6 +308,62 @@ test("Git probe and approved host commands enforce identity and ownership", asyn
access(path.join(directory, "src", "__pycache__")),
/ENOENT/,
);
const offline = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["PI_OFFLINE=1", process.execPath, "check-offline.cjs"],
});
assert.equal(offline.result.code, 0);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["OTHER=value", process.execPath, "check-offline.cjs"],
}),
/cannot invoke a command wrapper or shell/,
);
const blockedOfflineCommands: Array<{
args: string[];
expected: RegExp;
}> = [
{
args: ["PI_OFFLINE=1", "OTHER=value", process.execPath],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "sh", "-c", "touch src/bypass"],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "git", "clean", "-fd"],
expected: /Git host commands are not allowed/,
},
{
args: ["PI_OFFLINE=1", process.execPath, "-e", "process.exit(0)"],
expected: /cannot execute inline interpreter code/,
},
{
args: ["PI_OFFLINE=1", "env", "PI_OFFLINE=1", process.execPath],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "npm", "exec", "git"],
expected: /cannot invoke package-manager executable wrappers/,
},
];
for (const blocked of blockedOfflineCommands) {
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: blocked.args,
}),
blocked.expected,
);
}
await assert.rejects(
executor.run({
worktreeRoot: directory,
+23 -14
View File
@@ -489,6 +489,7 @@ const SHELL_EXECUTABLES = new Set([
]);
const COMMAND_WRAPPERS = new Set(["env", "find", "npx", "xargs"]);
const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
const INLINE_EVALUATION_FLAGS = new Map<string, Set<string>>([
["node", new Set(["-e", "--eval", "-p", "--print"])],
["python", new Set(["-c"])],
@@ -524,7 +525,23 @@ export class ApprovedHostCommandExecutor {
if (!options.executable || options.args.some((argument) => argument.includes("\0"))) {
throw new Error("Host commands require a fixed executable and argument array");
}
const executableName = path.basename(options.executable).toLowerCase();
let executable = options.executable;
let args = [...options.args];
const commandEnv: NodeJS.ProcessEnv = {
...process.env,
PYTHONDONTWRITEBYTECODE: "1",
};
if (
path.basename(executable).toLowerCase() === "env" &&
args[0] === "PI_OFFLINE=1" &&
args[1] &&
!ENVIRONMENT_ASSIGNMENT.test(args[1])
) {
commandEnv.PI_OFFLINE = "1";
executable = args[1];
args = args.slice(2);
}
const executableName = path.basename(executable).toLowerCase();
if (
SHELL_EXECUTABLES.has(executableName) ||
COMMAND_WRAPPERS.has(executableName)
@@ -532,11 +549,11 @@ export class ApprovedHostCommandExecutor {
throw new Error("Host commands cannot invoke a command wrapper or shell");
}
const evaluationFlags = INLINE_EVALUATION_FLAGS.get(executableName);
if (evaluationFlags && options.args.some((argument) => evaluationFlags.has(argument))) {
if (evaluationFlags && args.some((argument) => evaluationFlags.has(argument))) {
throw new Error("Host commands cannot execute inline interpreter code");
}
const packageExecCommands = PACKAGE_EXEC_SUBCOMMANDS.get(executableName);
if (packageExecCommands?.has(options.args[0] ?? "")) {
if (packageExecCommands?.has(args[0] ?? "")) {
throw new Error("Host commands cannot invoke package-manager executable wrappers");
}
if (executableName === "git") {
@@ -551,17 +568,9 @@ export class ApprovedHostCommandExecutor {
: undefined;
const beforeRepository = await this.probe.captureRepository(root);
const before = beforeRepository.get(root) ?? (await this.probe.capture(root));
const result = await this.runner.run(
root,
options.executable,
[...options.args],
{
env: {
...process.env,
PYTHONDONTWRITEBYTECODE: "1",
},
},
);
const result = await this.runner.run(root, executable, args, {
env: commandEnv,
});
try {
const afterRepository = await this.probe.captureRepository(root);
const after = afterRepository.get(root) ?? (await this.probe.capture(root));