diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index bae2d738a..5eb9f92d5 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -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 diff --git a/packages/coding-agent/src/core/tools/project-runtime-guard.test.ts b/packages/coding-agent/src/core/tools/project-runtime-guard.test.ts new file mode 100644 index 000000000..610939246 --- /dev/null +++ b/packages/coding-agent/src/core/tools/project-runtime-guard.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/coding-agent/src/core/tools/project-runtime-guard.ts b/packages/coding-agent/src/core/tools/project-runtime-guard.ts new file mode 100644 index 000000000..00360eac0 --- /dev/null +++ b/packages/coding-agent/src/core/tools/project-runtime-guard.ts @@ -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), + ); +}