fix(bash): block wrong project python runtime

This commit is contained in:
Mikael Hugo 2026-05-15 05:33:28 +02:00
parent 6652462a9d
commit 46d9d45279
3 changed files with 243 additions and 0 deletions

View file

@ -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

View file

@ -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);
});
});

View 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),
);
}