fix(bash): block wrong project python runtime
This commit is contained in:
parent
6652462a9d
commit
46d9d45279
3 changed files with 243 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ import {
|
|||
compileInterceptor,
|
||||
DEFAULT_BASH_INTERCEPTOR_RULES,
|
||||
} from "./bash-interceptor.js";
|
||||
import { checkProjectRuntimeForCommand } from "./project-runtime-guard.js";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
|
|
@ -428,6 +429,14 @@ export function createBashTool(
|
|||
: effectiveCommand,
|
||||
);
|
||||
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
|
||||
const runtimeGuard = checkProjectRuntimeForCommand(
|
||||
spawnContext.command,
|
||||
spawnContext.cwd,
|
||||
spawnContext.env,
|
||||
);
|
||||
if (runtimeGuard?.blocked) {
|
||||
throw new Error(runtimeGuard.message);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// We'll stream to a file if output gets large
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
evaluatePythonRuntimeGuard,
|
||||
invokesBarePython,
|
||||
readPythonRuntimeRequirement,
|
||||
} from "./project-runtime-guard.js";
|
||||
|
||||
function makeProject(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-project-runtime-"));
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const path = join(dir, relativePath);
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content, "utf-8");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("project runtime guard", () => {
|
||||
it("detects bare python commands with environment assignments", () => {
|
||||
assert.equal(
|
||||
invokesBarePython("TEST_DB_ALLOWED=1 python -m pytest tests"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
invokesBarePython("mise exec -- python -m pytest tests"),
|
||||
false,
|
||||
);
|
||||
assert.equal(invokesBarePython("uv run python -m pytest tests"), false);
|
||||
});
|
||||
|
||||
it("uses project python declarations over host interpreter assumptions", () => {
|
||||
const dir = makeProject({
|
||||
"pyproject.toml": '[project]\nrequires-python = ">=3.14"\n',
|
||||
"mise.toml": '[tools]\npython = "3.14"\n',
|
||||
});
|
||||
|
||||
const requirement = readPythonRuntimeRequirement(dir);
|
||||
assert.deepEqual(requirement, {
|
||||
version: "3.14",
|
||||
source: "pyproject.toml",
|
||||
});
|
||||
|
||||
const result = evaluatePythonRuntimeGuard(
|
||||
"TEST_DB_ALLOWED=1 python -m pytest python/tests/integration",
|
||||
dir,
|
||||
{
|
||||
executable:
|
||||
"/home/example/.local/share/mise/installs/python/3.11/bin/python",
|
||||
version: "3.11.12",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.blocked, true);
|
||||
assert.match(result?.message ?? "", /Project declares Python >=3\.14/);
|
||||
assert.match(result?.message ?? "", /Do not modify project code/);
|
||||
});
|
||||
|
||||
it("allows bare python when the active interpreter satisfies the project", () => {
|
||||
const dir = makeProject({
|
||||
"pyproject.toml": '[project]\nrequires-python = ">=3.14"\n',
|
||||
});
|
||||
|
||||
const result = evaluatePythonRuntimeGuard("python -m pytest", dir, {
|
||||
executable: "/opt/python/3.14/bin/python",
|
||||
version: "3.14.4",
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
});
|
||||
160
packages/coding-agent/src/core/tools/project-runtime-guard.ts
Normal file
160
packages/coding-agent/src/core/tools/project-runtime-guard.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface PythonRuntimeRequirement {
|
||||
version: string;
|
||||
source: "pyproject.toml" | "mise.toml";
|
||||
}
|
||||
|
||||
export interface PythonRuntimeInfo {
|
||||
executable: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ProjectRuntimeGuardResult {
|
||||
blocked: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function parseVersion(value: string): [number, number, number] | null {
|
||||
const match = value.match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||
if (!match) return null;
|
||||
return [
|
||||
Number.parseInt(match[1]!, 10),
|
||||
Number.parseInt(match[2]!, 10),
|
||||
Number.parseInt(match[3] ?? "0", 10),
|
||||
];
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const left = parseVersion(a);
|
||||
const right = parseVersion(b);
|
||||
if (!left || !right) return 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = left[i]! - right[i]!;
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a shell command invokes the ambient Python interpreter.
|
||||
*
|
||||
* Purpose: identify commands where SF must validate the project-declared
|
||||
* Python runtime before execution, preventing agents from treating syntax
|
||||
* failures from the wrong host Python as source-code defects.
|
||||
*
|
||||
* Consumer: bash tool project-runtime guard.
|
||||
*/
|
||||
export function invokesBarePython(command: string): boolean {
|
||||
const stripped = command
|
||||
.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "")
|
||||
.trim();
|
||||
return /^(python|python3)(?:\s|$)/.test(stripped);
|
||||
}
|
||||
|
||||
function readPyprojectRequirement(
|
||||
cwd: string,
|
||||
): PythonRuntimeRequirement | null {
|
||||
const path = join(cwd, "pyproject.toml");
|
||||
if (!existsSync(path)) return null;
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const match = raw.match(/^\s*requires-python\s*=\s*["']([^"']+)["']/m);
|
||||
if (!match) return null;
|
||||
const min = match[1]!.match(/>=\s*([0-9]+(?:\.[0-9]+){1,2})/);
|
||||
if (!min) return null;
|
||||
return { version: min[1]!, source: "pyproject.toml" };
|
||||
}
|
||||
|
||||
function readMisePythonRequirement(
|
||||
cwd: string,
|
||||
): PythonRuntimeRequirement | null {
|
||||
const path = join(cwd, "mise.toml");
|
||||
if (!existsSync(path)) return null;
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const match = raw.match(/^\s*python\s*=\s*["']([^"']+)["']/m);
|
||||
if (!match) return null;
|
||||
const version = match[1]!.match(/([0-9]+(?:\.[0-9]+){1,2})/);
|
||||
if (!version) return null;
|
||||
return { version: version[1]!, source: "mise.toml" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the strongest project-declared Python minimum from local config.
|
||||
*
|
||||
* Purpose: make the project, not the host PATH, authoritative for Python test
|
||||
* and tooling commands.
|
||||
*
|
||||
* Consumer: bash tool project-runtime guard.
|
||||
*/
|
||||
export function readPythonRuntimeRequirement(
|
||||
cwd: string,
|
||||
): PythonRuntimeRequirement | null {
|
||||
const requirements = [
|
||||
readPyprojectRequirement(cwd),
|
||||
readMisePythonRequirement(cwd),
|
||||
].filter((item): item is PythonRuntimeRequirement => item !== null);
|
||||
if (requirements.length === 0) return null;
|
||||
return requirements.sort((a, b) => compareVersions(b.version, a.version))[0]!;
|
||||
}
|
||||
|
||||
function resolveActivePython(
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): PythonRuntimeInfo | null {
|
||||
const script =
|
||||
"import sys; print(sys.executable); print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')";
|
||||
const result = spawnSync("python", ["-c", script], {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
});
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
const [executable, version] = result.stdout.trim().split(/\r?\n/);
|
||||
if (!executable || !version) return null;
|
||||
return { executable, version };
|
||||
}
|
||||
|
||||
export function evaluatePythonRuntimeGuard(
|
||||
command: string,
|
||||
cwd: string,
|
||||
activePython: PythonRuntimeInfo | null,
|
||||
): ProjectRuntimeGuardResult | null {
|
||||
if (!invokesBarePython(command)) return null;
|
||||
const requirement = readPythonRuntimeRequirement(cwd);
|
||||
if (!requirement) return null;
|
||||
if (!activePython) {
|
||||
return {
|
||||
blocked: true,
|
||||
message:
|
||||
`BLOCKED: project Python runtime could not be resolved before running \`${command}\`.\n\n` +
|
||||
`Project declares Python >=${requirement.version} in ${requirement.source}. ` +
|
||||
"Do not edit source syntax to satisfy an unknown host interpreter; activate the project runtime and rerun the check.",
|
||||
};
|
||||
}
|
||||
if (compareVersions(activePython.version, requirement.version) >= 0)
|
||||
return null;
|
||||
return {
|
||||
blocked: true,
|
||||
message:
|
||||
`BLOCKED: project Python runtime mismatch before running \`${command}\`.\n\n` +
|
||||
`Project declares Python >=${requirement.version} in ${requirement.source}, ` +
|
||||
`but bare \`python\` resolves to ${activePython.version} at ${activePython.executable}.\n\n` +
|
||||
"Do not modify project code to satisfy the wrong interpreter. Activate the project runtime or run the repo-declared Python command, then retry verification.",
|
||||
};
|
||||
}
|
||||
|
||||
export function checkProjectRuntimeForCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): ProjectRuntimeGuardResult | null {
|
||||
if (!invokesBarePython(command)) return null;
|
||||
return evaluatePythonRuntimeGuard(
|
||||
command,
|
||||
cwd,
|
||||
resolveActivePython(cwd, env),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue