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.
|
- Every Assignment has declared file ownership.
|
||||||
- All mutating Assignments are serialized in the first version. Concurrency remains available for read-only Assignments.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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",
|
"import bytecode_module\nassert bytecode_module.VALUE == 1\n",
|
||||||
"utf8",
|
"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, "add", "."]);
|
||||||
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
|
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
|
||||||
const probe = new GitStateProbe();
|
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__")),
|
access(path.join(directory, "src", "__pycache__")),
|
||||||
/ENOENT/,
|
/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(
|
await assert.rejects(
|
||||||
executor.run({
|
executor.run({
|
||||||
worktreeRoot: directory,
|
worktreeRoot: directory,
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ const SHELL_EXECUTABLES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const COMMAND_WRAPPERS = new Set(["env", "find", "npx", "xargs"]);
|
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>>([
|
const INLINE_EVALUATION_FLAGS = new Map<string, Set<string>>([
|
||||||
["node", new Set(["-e", "--eval", "-p", "--print"])],
|
["node", new Set(["-e", "--eval", "-p", "--print"])],
|
||||||
["python", new Set(["-c"])],
|
["python", new Set(["-c"])],
|
||||||
@@ -524,7 +525,23 @@ export class ApprovedHostCommandExecutor {
|
|||||||
if (!options.executable || options.args.some((argument) => argument.includes("\0"))) {
|
if (!options.executable || options.args.some((argument) => argument.includes("\0"))) {
|
||||||
throw new Error("Host commands require a fixed executable and argument array");
|
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 (
|
if (
|
||||||
SHELL_EXECUTABLES.has(executableName) ||
|
SHELL_EXECUTABLES.has(executableName) ||
|
||||||
COMMAND_WRAPPERS.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");
|
throw new Error("Host commands cannot invoke a command wrapper or shell");
|
||||||
}
|
}
|
||||||
const evaluationFlags = INLINE_EVALUATION_FLAGS.get(executableName);
|
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");
|
throw new Error("Host commands cannot execute inline interpreter code");
|
||||||
}
|
}
|
||||||
const packageExecCommands = PACKAGE_EXEC_SUBCOMMANDS.get(executableName);
|
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");
|
throw new Error("Host commands cannot invoke package-manager executable wrappers");
|
||||||
}
|
}
|
||||||
if (executableName === "git") {
|
if (executableName === "git") {
|
||||||
@@ -551,17 +568,9 @@ export class ApprovedHostCommandExecutor {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const beforeRepository = await this.probe.captureRepository(root);
|
const beforeRepository = await this.probe.captureRepository(root);
|
||||||
const before = beforeRepository.get(root) ?? (await this.probe.capture(root));
|
const before = beforeRepository.get(root) ?? (await this.probe.capture(root));
|
||||||
const result = await this.runner.run(
|
const result = await this.runner.run(root, executable, args, {
|
||||||
root,
|
env: commandEnv,
|
||||||
options.executable,
|
});
|
||||||
[...options.args],
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PYTHONDONTWRITEBYTECODE: "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const afterRepository = await this.probe.captureRepository(root);
|
const afterRepository = await this.probe.captureRepository(root);
|
||||||
const after = afterRepository.get(root) ?? (await this.probe.capture(root));
|
const after = afterRepository.get(root) ?? (await this.probe.capture(root));
|
||||||
|
|||||||
Reference in New Issue
Block a user