From 3c5168aa3c72ff1a51cb73a00c3c410ff18beaa1 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 25 Aug 2026 20:27:54 -0400 Subject: [PATCH] fix(pi-workflow): allow offline verification command --- docs/pi-workflow/design.md | 2 +- extensions/pi-workflow/workspace.test.ts | 61 ++++++++++++++++++++++++ extensions/pi-workflow/workspace.ts | 37 ++++++++------ 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/docs/pi-workflow/design.md b/docs/pi-workflow/design.md index 19ed55c..0b5bfbe 100644 --- a/docs/pi-workflow/design.md +++ b/docs/pi-workflow/design.md @@ -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 ...`; 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. diff --git a/extensions/pi-workflow/workspace.test.ts b/extensions/pi-workflow/workspace.test.ts index 226df57..4878407 100644 --- a/extensions/pi-workflow/workspace.test.ts +++ b/extensions/pi-workflow/workspace.test.ts @@ -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, diff --git a/extensions/pi-workflow/workspace.ts b/extensions/pi-workflow/workspace.ts index 468c167..5ce6eff 100644 --- a/extensions/pi-workflow/workspace.ts +++ b/extensions/pi-workflow/workspace.ts @@ -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>([ ["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));