harden sandbox escape vectors for bash disabled/restricted modes (#257)

* harden sandbox escape vectors for bash disabled/restricted modes

block git config injection (-c flag as subcommand), dangerous subcommands
(config, submodule, rebase, bisect), code-executing arg flags (--exec,
--extcmd), .gitattributes/.gitmodules writes, and package lifecycle scripts.
add retry logic to test runner for transient failures. add security unit
tests and adhoc attack tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* only filter subcommands in nobash, remove nobash from ui

* use regex matching

* iterate on tests

* simplify githooks

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
David Blass
2026-02-11 02:02:41 +00:00
committed by pullfrog[bot]
parent f37d02b292
commit bc28c658f2
32 changed files with 2515 additions and 1048 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
import { log } from "../utils/cli.ts";
import { installNodeDependencies } from "./installNodeDependencies.ts";
import { installPythonDependencies } from "./installPythonDependencies.ts";
import type { PrepDefinition, PrepResult } from "./types.ts";
import type { PrepDefinition, PrepOptions, PrepResult } from "./types.ts";
export type { PrepResult } from "./types.ts";
export type { PrepOptions, PrepResult } from "./types.ts";
// register all prep steps here
const prepSteps: PrepDefinition[] = [installNodeDependencies, installPythonDependencies];
@@ -12,7 +12,7 @@ const prepSteps: PrepDefinition[] = [installNodeDependencies, installPythonDepen
* run all prep steps sequentially.
* failures are logged as warnings but don't stop the run.
*/
export async function runPrepPhase(): Promise<PrepResult[]> {
export async function runPrepPhase(options: PrepOptions): Promise<PrepResult[]> {
log.debug("» starting prep phase...");
const startTime = Date.now();
const results: PrepResult[] = [];
@@ -25,7 +25,7 @@ export async function runPrepPhase(): Promise<PrepResult[]> {
}
log.debug(`» running ${step.name}...`);
const result = await step.run();
const result = await step.run(options);
results.push(result);
if (result.dependenciesInstalled) {
+22 -2
View File
@@ -5,7 +5,7 @@ import { detect } from "package-manager-detector";
import { resolveCommand } from "package-manager-detector/commands";
import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts";
import type { NodePackageManager, NodePrepResult, PrepDefinition } from "./types.ts";
import type { NodePackageManager, NodePrepResult, PrepDefinition, PrepOptions } from "./types.ts";
// install command templates for each package manager (version placeholder: {version})
const nodePackageManagers: Record<NodePackageManager, string[]> = {
@@ -88,7 +88,7 @@ export const installNodeDependencies: PrepDefinition = {
return existsSync(packageJsonPath);
},
run: async (): Promise<NodePrepResult> => {
run: async (options: PrepOptions): Promise<NodePrepResult> => {
// check packageManager field in package.json first (takes priority)
const fromPackageJson = getPackageManagerFromPackageJson();
@@ -110,6 +110,19 @@ export const installNodeDependencies: PrepDefinition = {
// check if package manager is available, install if needed
if (!(await isCommandAvailable(packageManager))) {
// SECURITY: when bash is disabled, don't install package managers.
// installPackageManager runs `npm install -g` or `curl | sh` (for deno),
// both of which execute code. the package manager must already be available.
if (options.ignoreScripts) {
return {
language: "node",
packageManager,
dependenciesInstalled: false,
issues: [
`${packageManager} is not available and cannot be installed when bash is disabled (would execute code)`,
],
};
}
log.info(`» ${packageManager} not found, attempting to install...`);
const installError = await installPackageManager(packageManager, installSpec);
if (installError) {
@@ -133,6 +146,13 @@ export const installNodeDependencies: PrepDefinition = {
};
}
// SECURITY: when bash is disabled, suppress lifecycle scripts to prevent
// agents from injecting arbitrary code execution via package.json scripts
if (options.ignoreScripts) {
resolved.args.push("--ignore-scripts");
log.info("» --ignore-scripts enabled (bash disabled)");
}
const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`;
log.info(`» running: ${fullCommand}`);
const result = await spawn({
+31 -2
View File
@@ -2,7 +2,12 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts";
import type { PrepDefinition, PythonPackageManager, PythonPrepResult } from "./types.ts";
import type {
PrepDefinition,
PrepOptions,
PythonPackageManager,
PythonPrepResult,
} from "./types.ts";
interface PythonConfig {
file: string;
@@ -98,7 +103,7 @@ export const installPythonDependencies: PrepDefinition = {
return PYTHON_CONFIGS.some((config) => existsSync(join(cwd, config.file)));
},
run: async (): Promise<PythonPrepResult> => {
run: async (options: PrepOptions): Promise<PythonPrepResult> => {
const cwd = process.cwd();
// find the first matching config
@@ -115,6 +120,30 @@ export const installPythonDependencies: PrepDefinition = {
log.info(`» detected python config: ${config.file} (using ${config.tool})`);
// SECURITY: when bash is disabled, skip ALL python dependency installation.
// every python install path can potentially execute arbitrary code:
// - setup.py / pyproject.toml: directly execute build backends
// - requirements.txt: can contain "-e ." or local path references that
// trigger setup.py execution
// - Pipfile/poetry.lock: can contain path dependencies pointing to local
// directories with malicious setup.py
// - source distributions from PyPI also execute setup.py
// there is no equivalent of npm's --ignore-scripts for pip.
if (options.ignoreScripts) {
log.info(
`» skipping python install (bash disabled, python packages can execute arbitrary code)`
);
return {
language: "python",
packageManager: config.tool,
configFile: config.file,
dependenciesInstalled: false,
issues: [
`skipped: python dependency installation can execute arbitrary code (setup.py, build backends, local path references), which is blocked when bash is disabled`,
],
};
}
// check if the tool is available, install if needed
const isAvailable = await isCommandAvailable(config.tool);
if (!isAvailable) {
+6 -1
View File
@@ -24,8 +24,13 @@ export interface UnknownLanguagePrepResult extends PrepResultBase {
export type PrepResult = NodePrepResult | PythonPrepResult | UnknownLanguagePrepResult;
export type PrepOptions = {
/** when true, lifecycle scripts (postinstall, etc.) are suppressed */
ignoreScripts: boolean;
};
export interface PrepDefinition {
name: string;
shouldRun: () => Promise<boolean> | boolean;
run: () => Promise<PrepResult>;
run: (options: PrepOptions) => Promise<PrepResult>;
}