fix(pi-workflow): allow offline verification command
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user