From b3fd163b29136ce45141f7c9b476be6a2de45ffe Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 15:51:18 +0300 Subject: [PATCH 01/12] add fs option and threat model docs Allow injecting a custom FileSystem via ShellOptions.fs, expose SHELL_MAX_OUTPUT env var from execution limits, and add THREAT_MODEL.md documenting the security model. --- AGENTS.md | 1 + README.md | 5 ++- THREAT_MODEL.md | 65 ++++++++++++++++++++++++++++++++++ src/index.ts | 45 ++++++++++++++--------- src/interpreter/interpreter.ts | 3 ++ tests/shell.test.ts | 33 ++++++++++++++++- 6 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 THREAT_MODEL.md diff --git a/AGENTS.md b/AGENTS.md index 6d15c8e..dee7341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,4 +33,5 @@ Design docs for AI agents in `docs/`. Read on-demand, not required. - [`docs/design/security.md`](docs/design/security.md) - Execution limits, regex guardrails, threat model - [`docs/design/jq.md`](docs/design/jq.md) - Generator evaluator, builtins, format strings - [`docs/3rd-party/testing-with-smokepod.md`](docs/3rd-party/testing-with-smokepod.md) - Comparison test workflow +- [`THREAT_MODEL.md`](THREAT_MODEL.md) - Security model, protections, threat analysis, non-goals diff --git a/README.md b/README.md index 12fcff5..3ee2130 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ const shell = new Shell(options?: ShellOptions); | Option | Type | Description | |--------|------|-------------| -| `files` | `Record string \| Promise)>` | Initial filesystem contents. Values can be strings or lazy-loaded functions. | +| `fs` | `FileSystem` | Custom filesystem implementation. When provided, `files` is ignored. | +| `files` | `Record string \| Promise)>` | Initial filesystem contents. Values can be strings or lazy-loaded functions. Ignored when `fs` is provided. | | `env` | `Record` | Environment variables. Merged with defaults (HOME, USER, PATH, SHELL). | | `limits` | `Partial` | Execution limits. Merged with safe defaults. | | `commands` | `Record` | Custom commands to register. | @@ -253,6 +254,8 @@ const shell = new Shell({ ## Security Model +See [THREAT_MODEL.md](THREAT_MODEL.md) for the full security model, threat analysis, and explicit non-goals. + **What we do:** - All user-provided regex goes through pattern complexity checks and input-length caps diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..ecb4447 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,65 @@ +# Security Model + +@mylocalgpt/shell is a virtual bash interpreter designed for AI agents. The primary threat is untrusted or buggy agent-generated scripts causing resource exhaustion, ReDoS, or state corruption. Defense is architectural: no eval, no node: imports, Map-based state, and configurable execution limits. + +## Protections + +### Regex Guardrails + +User-provided regex patterns (in grep, sed, awk, expr, find, jq) are analyzed before execution: + +- **Nested quantifier detection** - identifies patterns like `(a+)+`, `(a*)*`, `(.+)+` that cause catastrophic backtracking +- **Backreference in quantified group** - catches groups followed by quantifiers containing `\1`-`\9` +- **Input caps** - patterns are limited to 1,000 characters, subjects to 100,000 characters + +All detection is hand-written with no dependencies. Properly handles escaped characters and character class internals. + +Validated in: `tests/security.test.ts` + +### Execution Limits + +Seven configurable limits prevent runaway scripts. All are checked at execution points (loop iteration, function call, command dispatch). Exceeding a limit throws a descriptive error, not a silent truncation. + +| Limit | Default | Prevents | +|-------|---------|----------| +| maxLoopIterations | 10,000 | Infinite loops (for, while, until) | +| maxCallDepth | 100 | Stack overflow from recursive functions | +| maxCommandCount | 10,000 | Runaway scripts executing endless commands | +| maxStringLength | 10,000,000 | Memory exhaustion from string concatenation | +| maxArraySize | 100,000 | Memory exhaustion from array growth | +| maxOutputSize | 10,000,000 | Unbounded stdout/stderr accumulation | +| maxPipelineDepth | 100 | Deeply nested pipeline structures | + +Limits are per-exec call. Each `Shell.exec()` call resets counters. + +Validated in: `tests/security.test.ts` + +### Map-based Environment Variables + +Environment variables are stored in a `Map`, not a plain object. This prevents prototype pollution via keys like `__proto__`, `constructor`, or `toString`. + +Validated in: `tests/security.test.ts` + +### No eval or Function + +The codebase contains zero `eval()` or `new Function()` code paths. Shell script execution is done by walking the AST with a recursive descent interpreter. This eliminates code injection vectors entirely. + +### Path Normalization + +All filesystem paths are normalized to absolute paths with `..` segments resolved in-memory. Scripts cannot escape the virtual filesystem root. The virtual filesystem has no connection to the host filesystem. + +### Error Sanitization + +Internal errors are caught and returned as `{ stdout, stderr, exitCode }` results. `Shell.exec()` never throws to the caller. Stack traces and internal state are not leaked in error messages. + +## Explicit Non-Goals + +- **OS-level sandboxing.** The shell executes within your JavaScript runtime's security context. It does not provide process isolation. +- **Network isolation.** Custom commands have full access to the JavaScript environment. Network restrictions are the caller's responsibility. +- **Multi-tenancy.** Each Shell instance is single-tenant. There is no isolation between exec() calls on the same instance. +- **Permission enforcement.** `chmod` stores mode bits but does not enforce them. Read/write access is unrestricted within the virtual filesystem. +- **Comprehensive ReDoS prevention.** Regex guardrails are heuristic. They catch common patterns but cannot detect all possible exponential-time regexes. The input caps provide a hard backstop. + +## Recommendation + +For running untrusted scripts, combine @mylocalgpt/shell with OS-level isolation (containers, V8 isolates, or similar). The shell's built-in limits protect against accidental resource exhaustion but are not a substitute for a security sandbox. diff --git a/src/index.ts b/src/index.ts index edb65af..cca055b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -123,9 +123,16 @@ export type CommandHandler = ( * ``` */ export interface ShellOptions { + /** + * Custom filesystem implementation to use instead of the default InMemoryFs. + * When provided, the `files` option is ignored. + * Enables injecting any FileSystem implementation (e.g. OverlayFs). + */ + fs?: FileSystem; /** * Initial files to populate in the filesystem. * Values can be strings (immediate content) or functions (lazy-loaded on first read). + * Ignored when `fs` is provided. */ files?: Record string | Promise)>; /** Initial environment variables. Merged with defaults (HOME, USER, PATH, etc.). */ @@ -248,26 +255,30 @@ export class Shell { private interpreter: Interpreter | null = null; constructor(options?: ShellOptions) { - // Initialize filesystem - const fsInstance = new InMemoryFs(); - this._fs = fsInstance; - this.initialCwd = '/'; - this._limits = options?.limits ?? {}; - this._onOutput = options?.onOutput; - - // Populate files (supports both string and lazy content) - if (options?.files) { - const paths = Object.keys(options.files); - for (let i = 0; i < paths.length; i++) { - const filePath = paths[i]; - const content = options.files[filePath]; - if (typeof content === 'function') { - fsInstance.addLazyFile(filePath, content as () => string | Promise); - } else { - fsInstance.writeFile(filePath, content); + // Initialize filesystem: use provided fs or create InMemoryFs + if (options?.fs) { + this._fs = options.fs; + } else { + const fsInstance = new InMemoryFs(); + this._fs = fsInstance; + + // Populate files (supports both string and lazy content) + if (options?.files) { + const paths = Object.keys(options.files); + for (let i = 0; i < paths.length; i++) { + const filePath = paths[i]; + const content = options.files[filePath]; + if (typeof content === 'function') { + fsInstance.addLazyFile(filePath, content as () => string | Promise); + } else { + fsInstance.writeFile(filePath, content); + } } } } + this.initialCwd = '/'; + this._limits = options?.limits ?? {}; + this._onOutput = options?.onOutput; // Set up command registry this.registry = new CommandRegistry(); diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index a9c7ae0..69934a7 100644 --- a/src/interpreter/interpreter.ts +++ b/src/interpreter/interpreter.ts @@ -144,6 +144,9 @@ export class Interpreter { this.readonlyVars = new Set(); this.pendingStdin = ''; this.builtins = new Map(); + + // Expose output limit so commands can read it via env + this.env.set('SHELL_MAX_OUTPUT', String(this.limits.maxOutputSize)); } /** Get the current shell state for the expansion engine. */ diff --git a/tests/shell.test.ts b/tests/shell.test.ts index c4bdfbf..86611e8 100644 --- a/tests/shell.test.ts +++ b/tests/shell.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Shell } from '../src/index.js'; +import { InMemoryFs, Shell } from '../src/index.js'; describe('Shell.exec()', () => { describe('acceptance criteria', () => { @@ -190,6 +190,25 @@ describe('Shell.exec()', () => { expect(result.stdout).toBe('agent\n'); }); + it('accepts custom fs implementation', async () => { + const customFs = new InMemoryFs(); + customFs.writeFile('/custom/file.txt', 'from custom fs'); + const shell = new Shell({ fs: customFs }); + const result = await shell.exec('cat /custom/file.txt'); + expect(result.stdout).toBe('from custom fs'); + expect(shell.fs).toBe(customFs); + }); + + it('ignores files option when fs is provided', async () => { + const customFs = new InMemoryFs(); + const shell = new Shell({ + fs: customFs, + files: { '/should-not-exist.txt': 'ignored' }, + }); + const result = await shell.exec('cat /should-not-exist.txt'); + expect(result.exitCode).not.toBe(0); + }); + it('enabledCommands filters available commands', async () => { const shell = new Shell({ enabledCommands: ['echo'] }); const echoResult = await shell.exec('echo hello'); @@ -205,6 +224,18 @@ describe('Shell.exec()', () => { const result = await shell.exec('echo $HOME'); expect(result.stdout).toBe('/root\n'); }); + + it('SHELL_MAX_OUTPUT env var reflects output limit', async () => { + const shell = new Shell(); + const result = await shell.exec('echo $SHELL_MAX_OUTPUT'); + expect(result.stdout.trim()).toBe('10000000'); + }); + + it('SHELL_MAX_OUTPUT reflects custom limit', async () => { + const shell = new Shell({ limits: { maxOutputSize: 5000 } }); + const result = await shell.exec('echo $SHELL_MAX_OUTPUT'); + expect(result.stdout.trim()).toBe('5000'); + }); }); describe('exec options', () => { From 5fb2866bf51a09acd68baf84a0322eef33e5eb04 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:18:26 +0300 Subject: [PATCH 02/12] add yes, xxd, timeout commands; fix wc -m Three new commands (yes with output cap, xxd hex dump, timeout with Promise.race) and fixed wc -m to count Unicode characters via string iterator. --- src/commands/defaults.ts | 12 +++ src/commands/timeout.ts | 41 +++++++++ src/commands/wc.ts | 4 +- src/commands/xxd.ts | 92 +++++++++++++++++++ src/commands/yes.ts | 19 ++++ tests/commands/timeout.test.ts | 40 ++++++++ tests/commands/xxd.test.ts | 45 +++++++++ tests/commands/yes.test.ts | 25 +++++ tests/comparison/commands/timeout.test | 7 ++ tests/comparison/commands/xxd.test | 11 +++ tests/comparison/commands/yes.test | 10 ++ .../fixtures/commands/timeout.fixture.json | 29 ++++++ .../fixtures/commands/xxd.fixture.json | 38 ++++++++ .../fixtures/commands/yes.fixture.json | 29 ++++++ 14 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 src/commands/timeout.ts create mode 100644 src/commands/xxd.ts create mode 100644 src/commands/yes.ts create mode 100644 tests/commands/timeout.test.ts create mode 100644 tests/commands/xxd.test.ts create mode 100644 tests/commands/yes.test.ts create mode 100644 tests/comparison/commands/timeout.test create mode 100644 tests/comparison/commands/xxd.test create mode 100644 tests/comparison/commands/yes.test create mode 100644 tests/comparison/fixtures/commands/timeout.fixture.json create mode 100644 tests/comparison/fixtures/commands/xxd.fixture.json create mode 100644 tests/comparison/fixtures/commands/yes.fixture.json diff --git a/src/commands/defaults.ts b/src/commands/defaults.ts index ec96128..add444d 100644 --- a/src/commands/defaults.ts +++ b/src/commands/defaults.ts @@ -259,6 +259,18 @@ export function registerDefaultCommands(registry: CommandRegistry): void { name: 'sleep', load: () => import('./sleep.js').then((m) => m.sleep), }); + registry.register({ + name: 'yes', + load: () => import('./yes.js').then((m) => m.yes), + }); + registry.register({ + name: 'timeout', + load: () => import('./timeout.js').then((m) => m.timeout), + }); + registry.register({ + name: 'xxd', + load: () => import('./xxd.js').then((m) => m.xxd), + }); // JSON processing registry.register({ diff --git a/src/commands/timeout.ts b/src/commands/timeout.ts new file mode 100644 index 0000000..35578f7 --- /dev/null +++ b/src/commands/timeout.ts @@ -0,0 +1,41 @@ +import type { Command, CommandContext, CommandResult } from './types.js'; + +export const timeout: Command = { + name: 'timeout', + async execute(args: string[], ctx: CommandContext): Promise { + if (args.length < 2) { + return { + exitCode: 1, + stdout: '', + stderr: 'timeout: missing operand\nUsage: timeout DURATION COMMAND [ARG]...\n', + }; + } + + const durationStr = args[0]; + const seconds = Number.parseFloat(durationStr); + if (Number.isNaN(seconds) || seconds < 0) { + return { + exitCode: 1, + stdout: '', + stderr: `timeout: invalid time interval '${durationStr}'\n`, + }; + } + + const cmd = args.slice(1).join(' '); + + // Duration of 0 means no timeout + if (seconds === 0) { + return ctx.exec(cmd); + } + + const ms = seconds * 1000; + const TIMEOUT_RESULT: CommandResult = { exitCode: 124, stdout: '', stderr: '' }; + + const result = await Promise.race([ + ctx.exec(cmd), + new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_RESULT), ms)), + ]); + + return result; + }, +}; diff --git a/src/commands/wc.ts b/src/commands/wc.ts index 17faa48..7b6a87d 100644 --- a/src/commands/wc.ts +++ b/src/commands/wc.ts @@ -31,7 +31,9 @@ function countContent(content: string): { // bytes is content.length for ASCII; for UTF-8 we approximate with string length const bytes = content.length; - const chars = content.length; + // Count Unicode characters via string iterator (handles surrogate pairs) + let chars = 0; + for (const _ of content) chars++; return { lines, words, bytes, chars }; } diff --git a/src/commands/xxd.ts b/src/commands/xxd.ts new file mode 100644 index 0000000..8685f37 --- /dev/null +++ b/src/commands/xxd.ts @@ -0,0 +1,92 @@ +import type { Command, CommandContext, CommandResult } from './types.js'; + +function resolvePath(p: string, cwd: string): string { + if (p.startsWith('/')) return p; + return cwd === '/' ? `/${p}` : `${cwd}/${p}`; +} + +function toHex(n: number, width: number): string { + const h = n.toString(16); + let pad = ''; + for (let i = h.length; i < width; i++) pad += '0'; + return pad + h; +} + +export const xxd: Command = { + name: 'xxd', + async execute(args: string[], ctx: CommandContext): Promise { + let limitBytes = -1; + let offset = 0; + const files: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '-l' && i + 1 < args.length) { + limitBytes = Number.parseInt(args[++i], 10); + } else if (arg === '-s' && i + 1 < args.length) { + offset = Number.parseInt(args[++i], 10); + } else if (!arg.startsWith('-')) { + files.push(arg); + } + } + + let input: string; + if (files.length > 0) { + const path = resolvePath(files[0], ctx.cwd); + try { + const data = ctx.fs.readFile(path); + input = typeof data === 'string' ? data : await data; + } catch { + return { + exitCode: 1, + stdout: '', + stderr: `xxd: ${files[0]}: No such file or directory\n`, + }; + } + } else { + input = ctx.stdin; + } + + // Apply offset + if (offset > 0) { + input = input.slice(offset); + } + + // Apply length limit + if (limitBytes >= 0) { + input = input.slice(0, limitBytes); + } + + if (input.length === 0) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + + let stdout = ''; + const bytesPerLine = 16; + + for (let pos = 0; pos < input.length; pos += bytesPerLine) { + const lineAddr = offset + pos; + let hexPart = ''; + let asciiPart = ''; + const end = pos + bytesPerLine < input.length ? pos + bytesPerLine : input.length; + const count = end - pos; + + for (let j = 0; j < count; j++) { + const code = input.charCodeAt(pos + j) & 0xff; + hexPart += toHex(code, 2); + // Group bytes in pairs: add space after every 2nd byte within pair + if (j % 2 === 1 && j < count - 1) { + hexPart += ' '; + } + asciiPart += code >= 0x20 && code <= 0x7e ? String.fromCharCode(code) : '.'; + } + + // Pad hex part to fixed width: 16 bytes = 8 groups of 4 hex chars + 7 spaces = 39 chars + while (hexPart.length < 39) hexPart += ' '; + + stdout += `${toHex(lineAddr, 8)}: ${hexPart} ${asciiPart}\n`; + } + + return { exitCode: 0, stdout, stderr: '' }; + }, +}; diff --git a/src/commands/yes.ts b/src/commands/yes.ts new file mode 100644 index 0000000..9f2d9ea --- /dev/null +++ b/src/commands/yes.ts @@ -0,0 +1,19 @@ +import type { Command, CommandContext, CommandResult } from './types.js'; + +export const yes: Command = { + name: 'yes', + async execute(args: string[], ctx: CommandContext): Promise { + const line = args.length > 0 ? args.join(' ') : 'y'; + const maxOutputStr = ctx.env.get('SHELL_MAX_OUTPUT'); + const maxOutput = maxOutputStr ? Number.parseInt(maxOutputStr, 10) : 10_000_000; + const lineLen = line.length + 1; // +1 for newline + + const parts: string[] = []; + let len = 0; + while (len < maxOutput) { + parts.push(line); + len += lineLen; + } + return { exitCode: 0, stdout: `${parts.join('\n')}\n`, stderr: '' }; + }, +}; diff --git a/tests/commands/timeout.test.ts b/tests/commands/timeout.test.ts new file mode 100644 index 0000000..8be1821 --- /dev/null +++ b/tests/commands/timeout.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { Shell } from '../../src/index.js'; + +describe('timeout command', () => { + it('runs command within timeout', async () => { + const shell = new Shell(); + const result = await shell.exec('timeout 10 echo hello'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello\n'); + }); + + it('returns exit code 124 when timeout expires', async () => { + // sleep is a no-op in the virtual shell, so use a custom command + // that takes real async time + const shell = new Shell({ + commands: { + 'slow-cmd': async () => { + await new Promise((resolve) => setTimeout(resolve, 5000)); + return { stdout: 'done\n', stderr: '', exitCode: 0 }; + }, + }, + }); + const result = await shell.exec('timeout 0.01 slow-cmd'); + expect(result.exitCode).toBe(124); + }); + + it('returns usage error on missing args', async () => { + const shell = new Shell(); + const result = await shell.exec('timeout'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing operand'); + }); + + it('returns error for invalid duration', async () => { + const shell = new Shell(); + const result = await shell.exec('timeout abc echo hi'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('invalid time interval'); + }); +}); diff --git a/tests/commands/xxd.test.ts b/tests/commands/xxd.test.ts new file mode 100644 index 0000000..a1a056b --- /dev/null +++ b/tests/commands/xxd.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { Shell } from '../../src/index.js'; + +describe('xxd command', () => { + it('formats hex dump from stdin', async () => { + const shell = new Shell(); + const result = await shell.exec('echo -n "abc" | xxd'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('6162 63'); + expect(result.stdout).toContain('abc'); + }); + + it('handles empty input', async () => { + const shell = new Shell(); + const result = await shell.exec('echo -n "" | xxd'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('reads from file', async () => { + const shell = new Shell({ + files: { '/test.bin': 'Hello' }, + }); + const result = await shell.exec('xxd /test.bin'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('4865 6c6c 6f'); + expect(result.stdout).toContain('Hello'); + }); + + it('supports -l length limit', async () => { + const shell = new Shell(); + const result = await shell.exec('echo -n "Hello, World!" | xxd -l 5'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Hello'); + expect(result.stdout).not.toContain('World'); + }); + + it('supports -s offset', async () => { + const shell = new Shell(); + const result = await shell.exec('echo -n "Hello, World!" | xxd -s 7'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('World!'); + expect(result.stdout).toContain('00000007'); + }); +}); diff --git a/tests/commands/yes.test.ts b/tests/commands/yes.test.ts new file mode 100644 index 0000000..e1e1a63 --- /dev/null +++ b/tests/commands/yes.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { Shell } from '../../src/index.js'; + +describe('yes command', () => { + it('outputs y lines piped to head', async () => { + const shell = new Shell(); + const result = await shell.exec('yes | head -3'); + expect(result.stdout).toBe('y\ny\ny\n'); + expect(result.exitCode).toBe(0); + }); + + it('outputs custom string piped to head', async () => { + const shell = new Shell(); + const result = await shell.exec('yes hello | head -2'); + expect(result.stdout).toBe('hello\nhello\n'); + }); + + it('output length is capped at configured limit', async () => { + const shell = new Shell({ limits: { maxOutputSize: 100 } }); + const result = await shell.exec('yes'); + // Output should be around the limit, not unbounded + expect(result.stdout.length).toBeLessThan(500); + expect(result.stdout.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/comparison/commands/timeout.test b/tests/comparison/commands/timeout.test new file mode 100644 index 0000000..057afdf --- /dev/null +++ b/tests/comparison/commands/timeout.test @@ -0,0 +1,7 @@ +## timeout-basic +$ timeout 10 echo hello +hello + +## timeout-printf +$ timeout 10 printf "ok" +ok diff --git a/tests/comparison/commands/xxd.test b/tests/comparison/commands/xxd.test new file mode 100644 index 0000000..e5d29d4 --- /dev/null +++ b/tests/comparison/commands/xxd.test @@ -0,0 +1,11 @@ +## xxd-basic +$ echo -n "Hello" | xxd +00000000: 4865 6c6c 6f Hello + +## xxd-limit +$ echo -n "Hello, World!" | xxd -l 5 +00000000: 4865 6c6c 6f Hello + +## xxd-offset +$ echo -n "Hello, World!" | xxd -s 7 +00000007: 576f 726c 6421 World! diff --git a/tests/comparison/commands/yes.test b/tests/comparison/commands/yes.test new file mode 100644 index 0000000..6b53697 --- /dev/null +++ b/tests/comparison/commands/yes.test @@ -0,0 +1,10 @@ +## yes-head +$ yes | head -3 +y +y +y + +## yes-custom-head +$ yes hello | head -2 +hello +hello diff --git a/tests/comparison/fixtures/commands/timeout.fixture.json b/tests/comparison/fixtures/commands/timeout.fixture.json new file mode 100644 index 0000000..5b07c63 --- /dev/null +++ b/tests/comparison/fixtures/commands/timeout.fixture.json @@ -0,0 +1,29 @@ +{ + "source": "tests/comparison/commands/timeout.test", + "recorded_with": "bash", + "platform": { + "os": "darwin", + "arch": "arm64", + "shell_version": "GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)" + }, + "sections": { + "timeout-basic": [ + { + "line": 2, + "command": "timeout 10 echo hello", + "stdout": "hello\n", + "stderr": "", + "exit_code": 0 + } + ], + "timeout-printf": [ + { + "line": 6, + "command": "timeout 10 printf \"ok\"", + "stdout": "ok", + "stderr": "", + "exit_code": 0 + } + ] + } +} diff --git a/tests/comparison/fixtures/commands/xxd.fixture.json b/tests/comparison/fixtures/commands/xxd.fixture.json new file mode 100644 index 0000000..23b7b7f --- /dev/null +++ b/tests/comparison/fixtures/commands/xxd.fixture.json @@ -0,0 +1,38 @@ +{ + "source": "tests/comparison/commands/xxd.test", + "recorded_with": "bash", + "platform": { + "os": "darwin", + "arch": "arm64", + "shell_version": "GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)" + }, + "sections": { + "xxd-basic": [ + { + "line": 2, + "command": "echo -n \"Hello\" | xxd", + "stdout": "00000000: 4865 6c6c 6f Hello\n", + "stderr": "", + "exit_code": 0 + } + ], + "xxd-limit": [ + { + "line": 6, + "command": "echo -n \"Hello, World!\" | xxd -l 5", + "stdout": "00000000: 4865 6c6c 6f Hello\n", + "stderr": "", + "exit_code": 0 + } + ], + "xxd-offset": [ + { + "line": 10, + "command": "echo -n \"Hello, World!\" | xxd -s 7", + "stdout": "00000007: 576f 726c 6421 World!\n", + "stderr": "", + "exit_code": 0 + } + ] + } +} diff --git a/tests/comparison/fixtures/commands/yes.fixture.json b/tests/comparison/fixtures/commands/yes.fixture.json new file mode 100644 index 0000000..8157328 --- /dev/null +++ b/tests/comparison/fixtures/commands/yes.fixture.json @@ -0,0 +1,29 @@ +{ + "source": "tests/comparison/commands/yes.test", + "recorded_with": "bash", + "platform": { + "os": "darwin", + "arch": "arm64", + "shell_version": "GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)" + }, + "sections": { + "yes-custom-head": [ + { + "line": 8, + "command": "yes hello | head -2", + "stdout": "hello\nhello\n", + "stderr": "", + "exit_code": 0 + } + ], + "yes-head": [ + { + "line": 2, + "command": "yes | head -3", + "stdout": "y\ny\ny\n", + "stderr": "", + "exit_code": 0 + } + ] + } +} From f3c2a0ae829d1e450f7d4a82cc5bfdc38286f524 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:24:28 +0300 Subject: [PATCH 03/12] add per-command hooks for observability onBeforeCommand and onCommandResult hooks fire for every command including pipeline stages, enabling logging, blocking, and output filtering at the command dispatch level. --- src/index.ts | 28 ++++++++++ src/interpreter/interpreter.ts | 27 ++++++++++ tests/hooks.test.ts | 96 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 tests/hooks.test.ts diff --git a/src/index.ts b/src/index.ts index cca055b..998c5ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -178,6 +178,22 @@ export interface ShellOptions { * ``` */ onOutput?: (result: ExecResult) => ExecResult; + /** + * Hook called before each command executes (including each stage of a pipeline). + * Receives the command name and arguments after word expansion. + * Return `false` to block the command (exit code 126, "permission denied"). + * Async-capable for external policy checks. + */ + onBeforeCommand?: ( + cmd: string, + args: string[], + ) => boolean | undefined | Promise; + /** + * Hook called after each command executes (including each stage of a pipeline). + * Receives the command name and result. Return a (possibly modified) result. + * Synchronous to prevent unhandled promise rejections in pipe chains. + */ + onCommandResult?: (cmd: string, result: CommandResult) => CommandResult; /** Hostname for the virtual shell (used by the hostname command). */ hostname?: string; /** Username for the virtual shell (used by the whoami command). */ @@ -252,6 +268,12 @@ export class Shell { private readonly _limits: Partial; private readonly registry: CommandRegistry; private readonly _onOutput: ((result: ExecResult) => ExecResult) | undefined; + private readonly _onBeforeCommand: + | ((cmd: string, args: string[]) => boolean | undefined | Promise) + | undefined; + private readonly _onCommandResult: + | ((cmd: string, result: CommandResult) => CommandResult) + | undefined; private interpreter: Interpreter | null = null; constructor(options?: ShellOptions) { @@ -279,6 +301,8 @@ export class Shell { this.initialCwd = '/'; this._limits = options?.limits ?? {}; this._onOutput = options?.onOutput; + this._onBeforeCommand = options?.onBeforeCommand; + this._onCommandResult = options?.onCommandResult; // Set up command registry this.registry = new CommandRegistry(); @@ -544,6 +568,10 @@ export class Shell { env, this.initialCwd, this._limits, + { + onBeforeCommand: this._onBeforeCommand, + onCommandResult: this._onCommandResult, + }, ); registerBuiltins(this.interpreter); } diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index 69934a7..ff6fad0 100644 --- a/src/interpreter/interpreter.ts +++ b/src/interpreter/interpreter.ts @@ -42,6 +42,15 @@ import { expandWord, } from './expansion.js'; +/** Hooks that fire per-command during interpretation. */ +export interface InterpreterHooks { + onBeforeCommand?: ( + cmd: string, + args: string[], + ) => boolean | undefined | Promise; + onCommandResult?: (cmd: string, result: CommandResult) => CommandResult; +} + /** Shell runtime options (separate from ShellOptions in index.ts). */ export interface ShellRuntimeOptions { errexit: boolean; @@ -116,6 +125,7 @@ export class Interpreter { string, (args: string[], ctx: InterpreterContext) => Promise >; + private readonly hooks: InterpreterHooks; constructor( fs: FileSystem, @@ -123,6 +133,7 @@ export class Interpreter { env?: Map, cwd?: string, limits?: Partial, + hooks?: InterpreterHooks, ) { this.fs = fs; this.registry = registry; @@ -144,6 +155,7 @@ export class Interpreter { this.readonlyVars = new Set(); this.pendingStdin = ''; this.builtins = new Map(); + this.hooks = hooks ?? {}; // Expose output limit so commands can read it via env this.env.set('SHELL_MAX_OUTPUT', String(this.limits.maxOutputSize)); @@ -526,6 +538,16 @@ export class Interpreter { const cmdName = expandedWords[0]; const cmdArgs = expandedWords.slice(1); + // Hook: onBeforeCommand - allows blocking commands before dispatch + if (this.hooks.onBeforeCommand) { + const allowed = await this.hooks.onBeforeCommand(cmdName, cmdArgs); + if (allowed === false) { + const blocked = makeResult(126, '', 'permission denied\n'); + this.exitCode = 126; + return blocked; + } + } + // 3. Pre-expand here-string targets (<<<), then apply redirections for (let i = 0; i < node.redirections.length; i++) { const redir = node.redirections[i]; @@ -576,6 +598,11 @@ export class Interpreter { } } + // Hook: onCommandResult - allows modifying command output before redirections + if (this.hooks.onCommandResult) { + result = this.hooks.onCommandResult(cmdName, result); + } + // Apply output redirections result = this.applyOutputRedirections(result, redirState); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..d32c22b --- /dev/null +++ b/tests/hooks.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { Shell } from '../src/index.js'; + +describe('command hooks', () => { + describe('onBeforeCommand', () => { + it('receives correct cmd name and args', async () => { + const log: Array<{ cmd: string; args: string[] }> = []; + const shell = new Shell({ + onBeforeCommand: (cmd, args) => { + log.push({ cmd, args: [...args] }); + }, + }); + await shell.exec('echo hello world'); + expect(log).toHaveLength(1); + expect(log[0].cmd).toBe('echo'); + expect(log[0].args).toEqual(['hello', 'world']); + }); + + it('blocks command when returning false', async () => { + const shell = new Shell({ + onBeforeCommand: () => false, + }); + const result = await shell.exec('echo should not run'); + expect(result.exitCode).toBe(126); + expect(result.stderr).toContain('permission denied'); + expect(result.stdout).toBe(''); + }); + + it('blocks command with async false', async () => { + const shell = new Shell({ + onBeforeCommand: async () => false, + }); + const result = await shell.exec('echo blocked'); + expect(result.exitCode).toBe(126); + expect(result.stderr).toContain('permission denied'); + }); + + it('fires for each command in a pipeline', async () => { + const commands: string[] = []; + const shell = new Shell({ + onBeforeCommand: (cmd) => { + commands.push(cmd); + }, + }); + await shell.exec('echo hello | cat | wc -c'); + expect(commands).toEqual(['echo', 'cat', 'wc']); + }); + }); + + describe('onCommandResult', () => { + it('can redact stdout content', async () => { + const shell = new Shell({ + onCommandResult: (cmd, result) => ({ + ...result, + stdout: result.stdout.replace('secret', 'REDACTED'), + }), + }); + const result = await shell.exec('echo secret'); + expect(result.stdout).toBe('REDACTED\n'); + }); + + it('fires per command in a pipeline', async () => { + const commands: string[] = []; + const shell = new Shell({ + onCommandResult: (cmd, result) => { + commands.push(cmd); + return result; + }, + }); + await shell.exec('echo hello | cat'); + expect(commands).toEqual(['echo', 'cat']); + }); + + it('downstream pipe sees modified output', async () => { + const shell = new Shell({ + onCommandResult: (cmd, result) => { + if (cmd === 'echo') { + return { ...result, stdout: 'replaced\n' }; + } + return result; + }, + }); + const result = await shell.exec('echo original | cat'); + expect(result.stdout).toBe('replaced\n'); + }); + }); + + describe('no hooks set', () => { + it('executes normally without hooks', async () => { + const shell = new Shell(); + const result = await shell.exec('echo hello'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello\n'); + }); + }); +}); From 4711446847f0857abde3cc97e8e31f5617033d2a Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:30:48 +0300 Subject: [PATCH 04/12] add curl command with network delegation curl delegates HTTP to a consumer-provided handler via ShellOptions.network, with hostname allowlist, redirect following, and file I/O flags. Core stays network-free. --- src/commands/curl.ts | 262 +++++++++++++++++++++++++++++++++ src/commands/defaults.ts | 6 + src/commands/types.ts | 22 +++ src/index.ts | 21 ++- src/interpreter/interpreter.ts | 6 +- tests/commands/curl.test.ts | 142 ++++++++++++++++++ 6 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 src/commands/curl.ts create mode 100644 tests/commands/curl.test.ts diff --git a/src/commands/curl.ts b/src/commands/curl.ts new file mode 100644 index 0000000..430f294 --- /dev/null +++ b/src/commands/curl.ts @@ -0,0 +1,262 @@ +import { globMatch } from '../utils/glob.js'; +import type { Command, CommandContext, CommandResult, NetworkResponse } from './types.js'; + +function resolvePath(p: string, cwd: string): string { + if (p.startsWith('/')) return p; + return cwd === '/' ? `/${p}` : `${cwd}/${p}`; +} + +/** Extract hostname from a URL without URL constructor. */ +function extractHostname(url: string): string { + // 1. Strip scheme + const schemeIdx = url.indexOf('://'); + const remainder = schemeIdx >= 0 ? url.slice(schemeIdx + 3) : url; + // 2. Strip path + const slashIdx = remainder.indexOf('/'); + const authority = slashIdx >= 0 ? remainder.slice(0, slashIdx) : remainder; + // 3. Strip user:pass@ + const atIdx = authority.lastIndexOf('@'); + const hostPort = atIdx >= 0 ? authority.slice(atIdx + 1) : authority; + // 4. Strip :port + const colonIdx = hostPort.lastIndexOf(':'); + return colonIdx >= 0 ? hostPort.slice(0, colonIdx) : hostPort; +} + +/** Extract filename from URL path's last segment. */ +function extractFilename(url: string): string { + const schemeIdx = url.indexOf('://'); + const remainder = schemeIdx >= 0 ? url.slice(schemeIdx + 3) : url; + const slashIdx = remainder.indexOf('/'); + const path = slashIdx >= 0 ? remainder.slice(slashIdx) : '/'; + const queryIdx = path.indexOf('?'); + const cleanPath = queryIdx >= 0 ? path.slice(0, queryIdx) : path; + const lastSlash = cleanPath.lastIndexOf('/'); + const filename = lastSlash >= 0 ? cleanPath.slice(lastSlash + 1) : cleanPath; + return filename || 'index.html'; +} + +export const curl: Command = { + name: 'curl', + async execute(args: string[], ctx: CommandContext): Promise { + let method = ''; + const headers: Record = {}; + let body: string | undefined; + let dataRaw = false; + let outputFile = ''; + let outputFromUrl = false; + let silent = false; + let followRedirects = false; + let failSilently = false; + let writeOutFormat = ''; + let url = ''; + + // Parse flags + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--data-raw' && i + 1 < args.length) { + body = args[++i]; + dataRaw = true; + } else if (arg.startsWith('-') && arg.length > 1 && !arg.startsWith('--')) { + // Handle combined short flags like -sL, -fsSL, etc. + // Flags that take a next argument: X, H, d, o, w + for (let j = 1; j < arg.length; j++) { + const ch = arg[j]; + // Flags that consume the rest of the arg or next arg + if (ch === 'X') { + method = j + 1 < arg.length ? arg.slice(j + 1) : i + 1 < args.length ? args[++i] : ''; + break; + } + if (ch === 'H') { + const hdr = + j + 1 < arg.length ? arg.slice(j + 1) : i + 1 < args.length ? args[++i] : ''; + const colonIdx = hdr.indexOf(':'); + if (colonIdx >= 0) { + headers[hdr.slice(0, colonIdx).trim()] = hdr.slice(colonIdx + 1).trim(); + } + break; + } + if (ch === 'd') { + body = j + 1 < arg.length ? arg.slice(j + 1) : i + 1 < args.length ? args[++i] : ''; + dataRaw = false; + break; + } + if (ch === 'o') { + outputFile = + j + 1 < arg.length ? arg.slice(j + 1) : i + 1 < args.length ? args[++i] : ''; + break; + } + if (ch === 'w') { + writeOutFormat = + j + 1 < arg.length ? arg.slice(j + 1) : i + 1 < args.length ? args[++i] : ''; + break; + } + // Boolean flags + if (ch === 'O') outputFromUrl = true; + else if (ch === 's') silent = true; + else if (ch === 'L') followRedirects = true; + else if (ch === 'f') failSilently = true; + // 'S' is no-op (show errors even when silent) + } + } else if (!arg.startsWith('-')) { + url = arg; + } + } + + if (!url) { + return { + exitCode: 2, + stdout: '', + stderr: 'curl: no URL specified\n', + }; + } + + // Check network handler + if (!ctx.network) { + return { + exitCode: 1, + stdout: '', + stderr: + 'curl: network access not configured. Pass a network handler via ShellOptions.network to enable curl.\n', + }; + } + + // Check allowlist + if (ctx.network.allowlist) { + const hostname = extractHostname(url); + let allowed = false; + for (let i = 0; i < ctx.network.allowlist.length; i++) { + if (globMatch(ctx.network.allowlist[i], hostname, true)) { + allowed = true; + break; + } + } + if (!allowed) { + return { + exitCode: 7, + stdout: '', + stderr: `curl: (7) Failed to connect to ${extractHostname(url)}: host not in allowlist\n`, + }; + } + } + + // Handle -d auto-POST + if (body !== undefined && !method) { + method = 'POST'; + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + } + if (!method) method = 'GET'; + + // Handle -d @file (read body from filesystem) + if (body?.startsWith('@') && !dataRaw) { + const filePath = resolvePath(body.slice(1), ctx.cwd); + try { + const data = ctx.fs.readFile(filePath); + body = typeof data === 'string' ? data : await data; + } catch { + return { + exitCode: 1, + stdout: '', + stderr: `curl: can't read data from file '${body.slice(1)}': No such file or directory\n`, + }; + } + } + + // Execute request with redirect following + let response: NetworkResponse; + let finalUrl = url; + const maxRedirects = 10; + let redirectCount = 0; + let currentMethod = method; + let currentBody = body; + + try { + response = await ctx.network.handler(finalUrl, { + method: currentMethod, + headers, + body: currentBody, + }); + + // Follow redirects + if (followRedirects) { + while ( + redirectCount < maxRedirects && + (response.status === 301 || + response.status === 302 || + response.status === 303 || + response.status === 307 || + response.status === 308) + ) { + // Find location header (case-insensitive) + let location = ''; + const responseHeaders = Object.keys(response.headers); + for (let i = 0; i < responseHeaders.length; i++) { + if (responseHeaders[i].toLowerCase() === 'location') { + location = response.headers[responseHeaders[i]]; + break; + } + } + if (!location) break; + + // 303 changes method to GET + if (response.status === 303) { + currentMethod = 'GET'; + currentBody = undefined; + } + + finalUrl = location; + redirectCount++; + response = await ctx.network.handler(finalUrl, { + method: currentMethod, + headers, + body: currentBody, + }); + } + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + exitCode: 1, + stdout: '', + stderr: `curl: (6) Could not resolve host: ${msg}\n`, + }; + } + + // Handle -f (fail silently on HTTP errors) + if (failSilently && response.status >= 400) { + return { + exitCode: 22, + stdout: '', + stderr: silent ? '' : `curl: (22) The requested URL returned error: ${response.status}\n`, + }; + } + + let stdout = response.body; + + // Handle -o file + if (outputFile) { + const path = resolvePath(outputFile, ctx.cwd); + ctx.fs.writeFile(path, response.body); + stdout = ''; + } + + // Handle -O (derive filename from URL) + if (outputFromUrl) { + const filename = extractFilename(url); + const path = resolvePath(filename, ctx.cwd); + ctx.fs.writeFile(path, response.body); + stdout = ''; + } + + // Handle -w format + if (writeOutFormat) { + let writeOut = writeOutFormat; + writeOut = writeOut.split('%{http_code}').join(String(response.status)); + writeOut = writeOut.split('%{url}').join(finalUrl); + stdout += writeOut; + } + + return { exitCode: 0, stdout, stderr: '' }; + }, +}; diff --git a/src/commands/defaults.ts b/src/commands/defaults.ts index add444d..6d50777 100644 --- a/src/commands/defaults.ts +++ b/src/commands/defaults.ts @@ -272,6 +272,12 @@ export function registerDefaultCommands(registry: CommandRegistry): void { load: () => import('./xxd.js').then((m) => m.xxd), }); + // Network commands + registry.register({ + name: 'curl', + load: () => import('./curl.js').then((m) => m.curl), + }); + // JSON processing registry.register({ name: 'jq', diff --git a/src/commands/types.ts b/src/commands/types.ts index 1bb4a5c..54b58ae 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -1,5 +1,25 @@ import type { FileSystem } from '../fs/types.js'; +/** Options for a network request made by curl. */ +export interface NetworkRequest { + method: string; + headers: Record; + body?: string; +} + +/** Response from a network handler. */ +export interface NetworkResponse { + status: number; + body: string; + headers: Record; +} + +/** Network configuration for commands that need HTTP access. */ +export interface NetworkConfig { + handler: (url: string, options: NetworkRequest) => Promise; + allowlist?: string[]; +} + /** * Result of executing a command. */ @@ -33,6 +53,8 @@ export interface CommandContext { * Enables command implementations to invoke other commands. */ exec: (cmd: string) => Promise; + /** Network configuration for HTTP access. Undefined when no handler is provided. */ + network?: NetworkConfig; } /** diff --git a/src/index.ts b/src/index.ts index 998c5ae..5f975f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,15 @@ export type { FileStat, FileSystem, LazyFileContent } from './fs/types.js'; // Types - commands -export type { Command, CommandContext, CommandResult, LazyCommandDef } from './commands/types.js'; +export type { + Command, + CommandContext, + CommandResult, + LazyCommandDef, + NetworkConfig, + NetworkRequest, + NetworkResponse, +} from './commands/types.js'; // Types - security export type { ExecutionLimits } from './security/limits.js'; @@ -68,7 +76,7 @@ export { registerBuiltins } from './interpreter/builtins.js'; import { registerDefaultCommands } from './commands/defaults.js'; import { CommandRegistry } from './commands/registry.js'; -import type { Command, CommandContext, CommandResult } from './commands/types.js'; +import type { Command, CommandContext, CommandResult, NetworkConfig } from './commands/types.js'; import { InMemoryFs } from './fs/memory.js'; import type { FileSystem, LazyFileContent } from './fs/types.js'; import { registerBuiltins } from './interpreter/builtins.js'; @@ -203,6 +211,12 @@ export interface ShellOptions { * When set, only the listed commands are available; all others are removed from the registry. */ enabledCommands?: string[]; + /** + * Network configuration for commands like curl. + * Provides a handler function for HTTP requests and an optional hostname allowlist. + * The shell never makes real HTTP requests; all network access is delegated to this handler. + */ + network?: NetworkConfig; } /** @@ -274,6 +288,7 @@ export class Shell { private readonly _onCommandResult: | ((cmd: string, result: CommandResult) => CommandResult) | undefined; + private readonly _network: NetworkConfig | undefined; private interpreter: Interpreter | null = null; constructor(options?: ShellOptions) { @@ -303,6 +318,7 @@ export class Shell { this._onOutput = options?.onOutput; this._onBeforeCommand = options?.onBeforeCommand; this._onCommandResult = options?.onCommandResult; + this._network = options?.network; // Set up command registry this.registry = new CommandRegistry(); @@ -572,6 +588,7 @@ export class Shell { onBeforeCommand: this._onBeforeCommand, onCommandResult: this._onCommandResult, }, + this._network, ); registerBuiltins(this.interpreter); } diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index ff6fad0..5d2f6f1 100644 --- a/src/interpreter/interpreter.ts +++ b/src/interpreter/interpreter.ts @@ -1,5 +1,5 @@ import type { CommandRegistry } from '../commands/registry.js'; -import type { Command, CommandContext, CommandResult } from '../commands/types.js'; +import type { Command, CommandContext, CommandResult, NetworkConfig } from '../commands/types.js'; import { findSimilarCommands } from '../errors.js'; import type { FileSystem } from '../fs/types.js'; import type { @@ -126,6 +126,7 @@ export class Interpreter { (args: string[], ctx: InterpreterContext) => Promise >; private readonly hooks: InterpreterHooks; + private readonly network?: NetworkConfig; constructor( fs: FileSystem, @@ -134,6 +135,7 @@ export class Interpreter { cwd?: string, limits?: Partial, hooks?: InterpreterHooks, + network?: NetworkConfig, ) { this.fs = fs; this.registry = registry; @@ -156,6 +158,7 @@ export class Interpreter { this.pendingStdin = ''; this.builtins = new Map(); this.hooks = hooks ?? {}; + this.network = network; // Expose output limit so commands can read it via env this.env.set('SHELL_MAX_OUTPUT', String(this.limits.maxOutputSize)); @@ -650,6 +653,7 @@ export class Interpreter { env: this.env, stdin, exec: (cmd: string) => this.executeString(cmd), + network: this.network, }; return cmd.execute(args, ctx); } diff --git a/tests/commands/curl.test.ts b/tests/commands/curl.test.ts new file mode 100644 index 0000000..850c738 --- /dev/null +++ b/tests/commands/curl.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import type { NetworkRequest } from '../../src/index.js'; +import { Shell } from '../../src/index.js'; + +const mockHandler = async (_url: string, _opts: NetworkRequest) => ({ + status: 200, + body: '{"name":"test"}', + headers: {} as Record, +}); + +function shellWithNetwork(handler = mockHandler, allowlist?: string[]): Shell { + return new Shell({ + network: { handler, allowlist }, + }); +} + +describe('curl command', () => { + it('basic GET returns body', async () => { + const shell = shellWithNetwork(); + const result = await shell.exec('curl -s http://example.com/api'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"test"}'); + }); + + it('POST with -d data', async () => { + let capturedOpts: NetworkRequest | undefined; + const shell = shellWithNetwork(async (_url, opts) => { + capturedOpts = opts; + return { status: 200, body: 'ok', headers: {} }; + }); + await shell.exec('curl -s -X POST -d \'{"key":"val"}\' http://example.com/api'); + expect(capturedOpts?.method).toBe('POST'); + expect(capturedOpts?.body).toBe('{"key":"val"}'); + }); + + it('sends custom headers with -H', async () => { + let capturedOpts: NetworkRequest | undefined; + const shell = shellWithNetwork(async (_url, opts) => { + capturedOpts = opts; + return { status: 200, body: 'ok', headers: {} }; + }); + await shell.exec('curl -s -H "Authorization: Bearer tok" http://example.com'); + expect(capturedOpts?.headers.Authorization).toBe('Bearer tok'); + }); + + it('-d auto-sets POST and content-type', async () => { + let capturedOpts: NetworkRequest | undefined; + const shell = shellWithNetwork(async (_url, opts) => { + capturedOpts = opts; + return { status: 200, body: 'ok', headers: {} }; + }); + await shell.exec('curl -s -d "key=val" http://example.com'); + expect(capturedOpts?.method).toBe('POST'); + expect(capturedOpts?.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + }); + + it('-d @file reads body from filesystem', async () => { + let capturedBody: string | undefined; + const shell = new Shell({ + files: { '/data.json': '{"from":"file"}' }, + network: { + handler: async (_url, opts) => { + capturedBody = opts.body; + return { status: 200, body: 'ok', headers: {} }; + }, + }, + }); + await shell.exec('curl -s -d @/data.json http://example.com'); + expect(capturedBody).toBe('{"from":"file"}'); + }); + + it('-o file writes to filesystem', async () => { + const shell = shellWithNetwork(); + const result = await shell.exec('curl -s -o /output.json http://example.com/api'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + expect(shell.fs.readFile('/output.json')).toBe('{"name":"test"}'); + }); + + it('-f on 404 exits 22 with empty output', async () => { + const shell = shellWithNetwork(async () => ({ + status: 404, + body: 'not found', + headers: {}, + })); + const result = await shell.exec('curl -sf http://example.com/missing'); + expect(result.exitCode).toBe(22); + expect(result.stdout).toBe(''); + }); + + it('-L follows redirects', async () => { + let callCount = 0; + const shell = shellWithNetwork(async (url) => { + callCount++; + if (callCount === 1) { + return { + status: 302, + body: '', + headers: { Location: 'http://example.com/final' }, + }; + } + return { status: 200, body: 'final', headers: {} }; + }); + const result = await shell.exec('curl -sL http://example.com/redirect'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('final'); + expect(callCount).toBe(2); + }); + + it("-w '%{http_code}' outputs status code", async () => { + const shell = shellWithNetwork(); + const result = await shell.exec("curl -s -w '%{http_code}' http://example.com"); + expect(result.stdout).toContain('200'); + }); + + it('returns error when network not configured', async () => { + const shell = new Shell(); + const result = await shell.exec('curl -s http://example.com'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('network access not configured'); + }); + + it('rejects hosts not in allowlist', async () => { + const shell = shellWithNetwork(mockHandler, ['api.allowed.com']); + const result = await shell.exec('curl -s http://evil.com/data'); + expect(result.exitCode).toBe(7); + expect(result.stderr).toContain('not in allowlist'); + }); + + it('allows hosts matching allowlist pattern', async () => { + const shell = shellWithNetwork(mockHandler, ['*.example.com']); + const result = await shell.exec('curl -s http://api.example.com/data'); + expect(result.exitCode).toBe(0); + }); + + it('works in pipes with jq', async () => { + const shell = shellWithNetwork(); + const result = await shell.exec('curl -s http://example.com/api | jq .name'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('"test"\n'); + }); +}); From 9ab237241c3dba056548e0ba1a347e07c269aec2 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:35:15 +0300 Subject: [PATCH 05/12] add overlay filesystem entry point Read-through OverlayFs reads from a host directory, writes to memory, and exposes getChanges() for tracking. Shipped as @mylocalgpt/shell/overlay. --- .github/workflows/ci.yml | 4 +- biome.json | 12 + package.json | 30 ++- pnpm-lock.yaml | 38 ++- src/overlay/index.ts | 482 ++++++++++++++++++++++++++++++++++ src/overlay/types.ts | 20 ++ tests/overlay/overlay.test.ts | 215 +++++++++++++++ tsdown.config.ts | 2 +- 8 files changed, 787 insertions(+), 16 deletions(-) create mode 100644 src/overlay/index.ts create mode 100644 src/overlay/types.ts create mode 100644 tests/overlay/overlay.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee55c7a..6212d12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,8 @@ jobs: - name: Verify no node imports in dist run: | - if grep -rE "(from|require\()\s*['\"]node:" dist/ --include='*.mjs' --include='*.cjs' --include='*.js' 2>/dev/null; then - echo "ERROR: found node: imports in dist/" + if grep -rE "(from|require\()\s*['\"]node:" dist/ --include='*.mjs' --include='*.cjs' --include='*.js' --exclude-dir=overlay 2>/dev/null; then + echo "ERROR: found node: imports in dist/ (excluding overlay)" exit 1 fi diff --git a/biome.json b/biome.json index c5c876d..a1bbfe0 100644 --- a/biome.json +++ b/biome.json @@ -45,6 +45,18 @@ "semicolons": "always" } }, + "overrides": [ + { + "include": ["src/overlay/**", "tests/overlay/**"], + "linter": { + "rules": { + "nursery": { + "noRestrictedImports": "off" + } + } + } + } + ], "files": { "ignore": ["dist/", "node_modules/", "_docs/", "scripts/", "*.tsbuildinfo"] } diff --git a/package.json b/package.json index 6fe4aa0..3dd773f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,16 @@ "version": "0.0.2", "type": "module", "description": "Virtual bash interpreter for AI agents. Pure TypeScript, zero dependencies.", - "keywords": ["bash", "shell", "interpreter", "virtual", "sandbox", "agent", "ai", "jq"], + "keywords": [ + "bash", + "shell", + "interpreter", + "virtual", + "sandbox", + "agent", + "ai", + "jq" + ], "repository": { "type": "git", "url": "git+https://github.com/mylocalgpt/shell.git" @@ -19,9 +28,19 @@ "types": "./dist/jq/index.d.mts", "import": "./dist/jq/index.mjs", "require": "./dist/jq/index.cjs" + }, + "./overlay": { + "types": "./dist/overlay/index.d.mts", + "import": "./dist/overlay/index.mjs", + "require": "./dist/overlay/index.cjs" } }, - "files": ["dist", "README.md", "LICENSE", "AGENTS.md"], + "files": [ + "dist", + "README.md", + "LICENSE", + "AGENTS.md" + ], "packageManager": "pnpm@10.32.1", "scripts": { "build": "tsdown", @@ -38,6 +57,7 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", + "@types/node": "^25.5.0", "smokepod": "^1.1.2", "tsdown": "^0.21.0", "tsx": "^4.19.4", @@ -46,6 +66,10 @@ }, "license": "Apache-2.0", "pnpm": { - "onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "smokepod"] + "onlyBuiltDependencies": [ + "@biomejs/biome", + "esbuild", + "smokepod" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a1d42b..2f325fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@biomejs/biome': specifier: ^1.9.4 version: 1.9.4 + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 smokepod: specifier: ^1.1.2 version: 1.1.2 @@ -25,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.1 - version: 3.2.4(jiti@2.6.1)(tsx@4.21.0) + version: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0) packages: @@ -575,6 +578,9 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -885,6 +891,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unrun@0.2.32: resolution: {integrity: sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg==} engines: {node: '>=20.19.0'} @@ -1311,6 +1320,10 @@ snapshots: '@types/jsesc@2.5.1': {} + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1319,13 +1332,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -1635,17 +1648,19 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@7.18.2: {} + unrun@0.2.32: dependencies: rolldown: 1.0.0-rc.9 - vite-node@3.2.4(jiti@2.6.1)(tsx@4.21.0): + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -1660,7 +1675,7 @@ snapshots: - tsx - yaml - vite@7.3.1(jiti@2.6.1)(tsx@4.21.0): + vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) @@ -1669,15 +1684,16 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.5.0 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - vitest@3.2.4(jiti@2.6.1)(tsx@4.21.0): + vitest@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -1695,9 +1711,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) - vite-node: 3.2.4(jiti@2.6.1)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 transitivePeerDependencies: - jiti - less diff --git a/src/overlay/index.ts b/src/overlay/index.ts new file mode 100644 index 0000000..04c2810 --- /dev/null +++ b/src/overlay/index.ts @@ -0,0 +1,482 @@ +import * as nodeFs from 'node:fs'; +import * as nodePath from 'node:path'; +import { FsError } from '../fs/memory.js'; +import type { FileStat, FileSystem } from '../fs/types.js'; +import { globMatch } from '../utils/glob.js'; +import type { ChangeSet, FileChange, OverlayFsOptions } from './types.js'; + +export type { ChangeSet, FileChange, OverlayFsOptions } from './types.js'; + +/** Normalize a virtual path: resolve `.`, `..`, collapse double slashes. */ +function normalizePath(input: string): string { + if (!input.startsWith('/')) { + throw new FsError('EINVAL', input, `Path must be absolute: ${input}`); + } + const segments: string[] = []; + const parts = input.split('/'); + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === '' || part === '.') continue; + if (part === '..') { + if (segments.length > 0) segments.pop(); + continue; + } + segments.push(part); + } + return segments.length === 0 ? '/' : `/${segments.join('/')}`; +} + +/** Get parent directory of a normalized path. */ +function parentDir(path: string): string { + const lastSlash = path.lastIndexOf('/'); + if (lastSlash <= 0) return '/'; + return path.slice(0, lastSlash); +} + +/** Translate a native fs error to FsError. */ +function translateError(err: unknown, path: string): FsError { + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code: string }).code; + return new FsError(code, path); + } + return new FsError('EIO', path, String(err)); +} + +/** + * Read-through overlay filesystem. + * + * Reads from a real host directory, writes to an in-memory layer. + * The host filesystem is never modified. Use `getChanges()` to + * retrieve a changeset of all modifications. + */ +export class OverlayFs implements FileSystem { + private readonly root: string; + private readonly allowPaths: string[] | undefined; + private readonly denyPaths: string[] | undefined; + private readonly memoryFiles: Map = new Map(); + private readonly memoryDirs: Set = new Set(); + private readonly deletedPaths: Set = new Set(); + private readonly memoryModes: Map = new Map(); + private readonly memoryTimes: Map = new Map(); + private readonly hostExisted: Set = new Set(); + private readonly memorySymlinks: Map = new Map(); + + constructor(root: string, options?: OverlayFsOptions) { + this.root = nodePath.resolve(root); + this.allowPaths = options?.allowPaths; + this.denyPaths = options?.denyPaths; + // Root always exists as a directory in the overlay + this.memoryDirs.add('/'); + } + + /** Map virtual path to host filesystem path. */ + private hostPath(virtualPath: string): string { + return nodePath.join(this.root, virtualPath); + } + + /** Check if a virtual path is allowed by access control rules. */ + private isAllowed(virtualPath: string): boolean { + if (this.denyPaths) { + for (let i = 0; i < this.denyPaths.length; i++) { + if (globMatch(this.denyPaths[i], virtualPath, true)) return false; + } + } + if (this.allowPaths) { + for (let i = 0; i < this.allowPaths.length; i++) { + if (globMatch(this.allowPaths[i], virtualPath, true)) return true; + } + return false; + } + return true; + } + + /** Check if a path exists on the host filesystem. */ + private hostExists(virtualPath: string): boolean { + try { + nodeFs.statSync(this.hostPath(virtualPath)); + return true; + } catch { + return false; + } + } + + /** Check if a path is a directory on the host filesystem. */ + private hostIsDirectory(virtualPath: string): boolean { + try { + return nodeFs.statSync(this.hostPath(virtualPath)).isDirectory(); + } catch { + return false; + } + } + + readFile(path: string): string { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) { + throw new FsError('ENOENT', p); + } + + // Check memory first + const memContent = this.memoryFiles.get(p); + if (memContent !== undefined) return memContent; + + // Check memory symlinks + const symlinkTarget = this.memorySymlinks.get(p); + if (symlinkTarget) { + const resolved = symlinkTarget.startsWith('/') + ? symlinkTarget + : normalizePath(`${parentDir(p)}/${symlinkTarget}`); + return this.readFile(resolved); + } + + // Check access control + if (!this.isAllowed(p)) { + throw new FsError('ENOENT', p); + } + + // Read from host + try { + return nodeFs.readFileSync(this.hostPath(p), 'utf-8'); + } catch (err) { + throw translateError(err, p); + } + } + + writeFile(path: string, content: string): void { + const p = normalizePath(path); + + // Track whether the file existed on host before first write + if (!this.hostExisted.has(p) && !this.memoryFiles.has(p)) { + if (this.hostExists(p)) { + this.hostExisted.add(p); + } + } + + // Ensure parent directories exist + this.ensureParentDirs(p); + + this.memoryFiles.set(p, content); + this.deletedPaths.delete(p); + + const now = new Date(); + this.memoryTimes.set(p, { mtime: now, ctime: now }); + } + + appendFile(path: string, content: string): void { + const p = normalizePath(path); + let existing = ''; + try { + existing = this.readFile(p); + } catch { + // File doesn't exist, start fresh + } + this.writeFile(p, existing + content); + } + + exists(path: string): boolean { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) return false; + if (this.memoryFiles.has(p)) return true; + if (this.memoryDirs.has(p)) return true; + if (this.memorySymlinks.has(p)) return true; + + if (!this.isAllowed(p)) return false; + + return this.hostExists(p); + } + + stat(path: string): FileStat { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) { + throw new FsError('ENOENT', p); + } + + // Memory file + const memContent = this.memoryFiles.get(p); + if (memContent !== undefined) { + const times = this.memoryTimes.get(p) ?? { mtime: new Date(), ctime: new Date() }; + const mode = this.memoryModes.get(p) ?? 0o644; + return { + isFile: () => true, + isDirectory: () => false, + size: memContent.length, + mode, + mtime: times.mtime, + ctime: times.ctime, + }; + } + + // Memory directory + if (this.memoryDirs.has(p)) { + const times = this.memoryTimes.get(p) ?? { mtime: new Date(), ctime: new Date() }; + const mode = this.memoryModes.get(p) ?? 0o755; + return { + isFile: () => false, + isDirectory: () => true, + size: 0, + mode, + mtime: times.mtime, + ctime: times.ctime, + }; + } + + if (!this.isAllowed(p)) { + throw new FsError('ENOENT', p); + } + + // Host filesystem + try { + const hostStat = nodeFs.statSync(this.hostPath(p)); + const mode = this.memoryModes.get(p) ?? hostStat.mode & 0o7777; + return { + isFile: () => hostStat.isFile(), + isDirectory: () => hostStat.isDirectory(), + size: hostStat.size, + mode, + mtime: hostStat.mtime, + ctime: hostStat.ctime, + }; + } catch (err) { + throw translateError(err, p); + } + } + + readdir(path: string): string[] { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) { + throw new FsError('ENOENT', p); + } + + const entries = new Set(); + + // Host entries + if (this.isAllowed(p)) { + try { + const hostEntries = nodeFs.readdirSync(this.hostPath(p)); + for (let i = 0; i < hostEntries.length; i++) { + const childPath = p === '/' ? `/${hostEntries[i]}` : `${p}/${hostEntries[i]}`; + if (!this.deletedPaths.has(childPath)) { + entries.add(hostEntries[i]); + } + } + } catch { + // Host directory may not exist + } + } + + // Memory file entries + for (const [filePath] of this.memoryFiles) { + if (parentDir(filePath) === p) { + const name = filePath.slice(p === '/' ? 1 : p.length + 1); + if (name && !name.includes('/')) { + entries.add(name); + } + } + } + + // Memory directory entries + for (const dirPath of this.memoryDirs) { + if (dirPath !== p && parentDir(dirPath) === p) { + const name = dirPath.slice(p === '/' ? 1 : p.length + 1); + if (name && !name.includes('/')) { + entries.add(name); + } + } + } + + if (entries.size === 0 && !this.memoryDirs.has(p) && !this.hostIsDirectory(p)) { + throw new FsError('ENOENT', p); + } + + const result = Array.from(entries); + result.sort(); + return result; + } + + mkdir(path: string, options?: { recursive?: boolean }): void { + const p = normalizePath(path); + + if (options?.recursive) { + const segments = p.split('/').filter(Boolean); + let current = ''; + for (let i = 0; i < segments.length; i++) { + current += `/${segments[i]}`; + this.memoryDirs.add(current); + this.deletedPaths.delete(current); + } + } else { + const parent = parentDir(p); + if (!this.exists(parent)) { + throw new FsError('ENOENT', p); + } + if (this.memoryDirs.has(p) || this.hostIsDirectory(p)) { + throw new FsError('EEXIST', p); + } + this.memoryDirs.add(p); + this.deletedPaths.delete(p); + } + + const now = new Date(); + this.memoryTimes.set(p, { mtime: now, ctime: now }); + } + + rmdir(path: string, options?: { recursive?: boolean }): void { + const p = normalizePath(path); + + if (!this.exists(p)) { + throw new FsError('ENOENT', p); + } + + if (options?.recursive) { + // Delete all children + for (const [filePath] of this.memoryFiles) { + if (filePath.startsWith(`${p}/`)) { + this.memoryFiles.delete(filePath); + this.deletedPaths.add(filePath); + } + } + for (const dirPath of this.memoryDirs) { + if (dirPath.startsWith(`${p}/`)) { + this.memoryDirs.delete(dirPath); + this.deletedPaths.add(dirPath); + } + } + this.memoryDirs.delete(p); + this.deletedPaths.add(p); + } else { + // Check if empty + const entries = this.readdir(p); + if (entries.length > 0) { + throw new FsError('ENOTEMPTY', p); + } + this.memoryDirs.delete(p); + this.deletedPaths.add(p); + } + } + + unlink(path: string): void { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) { + throw new FsError('ENOENT', p); + } + + if (!this.memoryFiles.has(p) && !this.memorySymlinks.has(p) && !this.hostExists(p)) { + throw new FsError('ENOENT', p); + } + + this.memoryFiles.delete(p); + this.memorySymlinks.delete(p); + this.deletedPaths.add(p); + } + + rename(oldPath: string, newPath: string): void { + const op = normalizePath(oldPath); + const np = normalizePath(newPath); + + const content = this.readFile(op); + this.writeFile(np, content); + this.unlink(op); + } + + copyFile(src: string, dest: string): void { + const content = this.readFile(src); + this.writeFile(dest, content); + } + + chmod(path: string, mode: number): void { + const p = normalizePath(path); + if (!this.exists(p)) { + throw new FsError('ENOENT', p); + } + this.memoryModes.set(p, mode); + } + + realpath(path: string): string { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) { + throw new FsError('ENOENT', p); + } + + // Memory paths are already canonical + if (this.memoryFiles.has(p) || this.memoryDirs.has(p)) { + return p; + } + + // For host paths, resolve and ensure it stays within root + try { + const resolved = nodeFs.realpathSync(this.hostPath(p)); + // Check that resolved path is within root + const resolvedNorm = nodePath.normalize(resolved); + const rootNorm = nodePath.normalize(this.root); + if (!resolvedNorm.startsWith(rootNorm)) { + throw new FsError('EACCES', p, `realpath: resolved path escapes root: ${p}`); + } + return p; + } catch (err) { + if (err instanceof FsError) throw err; + throw translateError(err, p); + } + } + + symlink(target: string, linkPath: string): void { + const lp = normalizePath(linkPath); + if (this.exists(lp)) { + throw new FsError('EEXIST', lp); + } + this.memorySymlinks.set(lp, target); + this.deletedPaths.delete(lp); + } + + readlink(path: string): string { + const p = normalizePath(path); + + if (this.deletedPaths.has(p)) { + throw new FsError('ENOENT', p); + } + + const memTarget = this.memorySymlinks.get(p); + if (memTarget !== undefined) return memTarget; + + try { + return nodeFs.readlinkSync(this.hostPath(p), 'utf-8'); + } catch (err) { + throw translateError(err, p); + } + } + + /** + * Get all changes made in the overlay. + * Returns created files, modified files (with content), and deleted paths. + */ + getChanges(): ChangeSet { + const created: FileChange[] = []; + const modified: FileChange[] = []; + + for (const [path, content] of this.memoryFiles) { + if (this.hostExisted.has(path)) { + modified.push({ path, content }); + } else { + created.push({ path, content }); + } + } + + const deleted = Array.from(this.deletedPaths); + + return { created, modified, deleted }; + } + + /** Ensure parent directories exist in memory. */ + private ensureParentDirs(path: string): void { + const segments = path.split('/').filter(Boolean); + let current = ''; + for (let i = 0; i < segments.length - 1; i++) { + current += `/${segments[i]}`; + this.memoryDirs.add(current); + } + } +} diff --git a/src/overlay/types.ts b/src/overlay/types.ts new file mode 100644 index 0000000..9742a23 --- /dev/null +++ b/src/overlay/types.ts @@ -0,0 +1,20 @@ +/** Options for creating an OverlayFs instance. */ +export interface OverlayFsOptions { + /** If set, only these path patterns (glob) are readable from host. */ + allowPaths?: string[]; + /** If set, these path patterns (glob) are blocked from host reads. */ + denyPaths?: string[]; +} + +/** A single file change with its content. */ +export interface FileChange { + path: string; + content: string; +} + +/** Summary of all changes made in the overlay. */ +export interface ChangeSet { + created: FileChange[]; + modified: FileChange[]; + deleted: string[]; +} diff --git a/tests/overlay/overlay.test.ts b/tests/overlay/overlay.test.ts new file mode 100644 index 0000000..75606bf --- /dev/null +++ b/tests/overlay/overlay.test.ts @@ -0,0 +1,215 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Shell } from '../../src/index.js'; +import { OverlayFs } from '../../src/overlay/index.js'; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-test-')); + // Create known files on host + fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello from host'); + fs.writeFileSync(path.join(tmpDir, 'config.json'), '{"key":"value"}'); + fs.mkdirSync(path.join(tmpDir, 'subdir')); + fs.writeFileSync(path.join(tmpDir, 'subdir', 'nested.txt'), 'nested content'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('OverlayFs', () => { + describe('readFile', () => { + it('reads real file from host', () => { + const overlay = new OverlayFs(tmpDir); + expect(overlay.readFile('/hello.txt')).toBe('hello from host'); + }); + + it('reads nested files', () => { + const overlay = new OverlayFs(tmpDir); + expect(overlay.readFile('/subdir/nested.txt')).toBe('nested content'); + }); + }); + + describe('writeFile', () => { + it('writes to memory, does not modify host', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/hello.txt', 'modified'); + expect(overlay.readFile('/hello.txt')).toBe('modified'); + // Host unchanged + expect(fs.readFileSync(path.join(tmpDir, 'hello.txt'), 'utf-8')).toBe('hello from host'); + }); + + it('returns memory content after write', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/new-file.txt', 'new content'); + expect(overlay.readFile('/new-file.txt')).toBe('new content'); + }); + }); + + describe('unlink', () => { + it('makes host file inaccessible', () => { + const overlay = new OverlayFs(tmpDir); + overlay.unlink('/hello.txt'); + expect(() => overlay.readFile('/hello.txt')).toThrow(); + // Host unchanged + expect(fs.existsSync(path.join(tmpDir, 'hello.txt'))).toBe(true); + }); + }); + + describe('readdir', () => { + it('merges host and memory entries', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/memory-only.txt', 'in memory'); + const entries = overlay.readdir('/'); + expect(entries).toContain('hello.txt'); + expect(entries).toContain('memory-only.txt'); + expect(entries).toContain('subdir'); + }); + + it('excludes deleted files', () => { + const overlay = new OverlayFs(tmpDir); + overlay.unlink('/hello.txt'); + const entries = overlay.readdir('/'); + expect(entries).not.toContain('hello.txt'); + expect(entries).toContain('config.json'); + }); + + it('has no duplicates', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/hello.txt', 'overwritten'); + const entries = overlay.readdir('/'); + const helloCount = entries.filter((e: string) => e === 'hello.txt').length; + expect(helloCount).toBe(1); + }); + }); + + describe('mkdir', () => { + it('creates directory recursively with mixed parents', () => { + const overlay = new OverlayFs(tmpDir); + overlay.mkdir('/subdir/deep/nested', { recursive: true }); + expect(overlay.exists('/subdir/deep/nested')).toBe(true); + }); + }); + + describe('exists', () => { + it('returns true for host files', () => { + const overlay = new OverlayFs(tmpDir); + expect(overlay.exists('/hello.txt')).toBe(true); + }); + + it('returns true for memory files', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/mem.txt', 'data'); + expect(overlay.exists('/mem.txt')).toBe(true); + }); + + it('returns false for deleted files', () => { + const overlay = new OverlayFs(tmpDir); + overlay.unlink('/hello.txt'); + expect(overlay.exists('/hello.txt')).toBe(false); + }); + }); + + describe('stat', () => { + it('reports correct type for host file', () => { + const overlay = new OverlayFs(tmpDir); + const s = overlay.stat('/hello.txt'); + expect(s.isFile()).toBe(true); + expect(s.isDirectory()).toBe(false); + }); + + it('reports correct type and size for memory file', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/test.txt', 'abcde'); + const s = overlay.stat('/test.txt'); + expect(s.isFile()).toBe(true); + expect(s.size).toBe(5); + }); + }); + + describe('getChanges', () => { + it('tracks created, modified, and deleted files', () => { + const overlay = new OverlayFs(tmpDir); + overlay.writeFile('/new.txt', 'brand new'); + overlay.writeFile('/hello.txt', 'modified content'); + overlay.unlink('/config.json'); + + const changes = overlay.getChanges(); + expect(changes.created).toHaveLength(1); + expect(changes.created[0].path).toBe('/new.txt'); + expect(changes.created[0].content).toBe('brand new'); + + expect(changes.modified).toHaveLength(1); + expect(changes.modified[0].path).toBe('/hello.txt'); + expect(changes.modified[0].content).toBe('modified content'); + + expect(changes.deleted).toContain('/config.json'); + }); + }); + + describe('access control', () => { + it('allowPaths restricts readable paths', () => { + const overlay = new OverlayFs(tmpDir, { allowPaths: ['/hello.txt'] }); + expect(overlay.readFile('/hello.txt')).toBe('hello from host'); + expect(() => overlay.readFile('/config.json')).toThrow(); + }); + + it('denyPaths blocks listed paths', () => { + const overlay = new OverlayFs(tmpDir, { denyPaths: ['/config.json'] }); + expect(overlay.readFile('/hello.txt')).toBe('hello from host'); + expect(() => overlay.readFile('/config.json')).toThrow(); + }); + + it('denyPaths with glob blocks matching files', () => { + fs.writeFileSync(path.join(tmpDir, 'secret.key'), 'sensitive'); + const overlay = new OverlayFs(tmpDir, { denyPaths: ['*.key'] }); + expect(() => overlay.readFile('/secret.key')).toThrow(); + expect(overlay.readFile('/config.json')).toBe('{"key":"value"}'); + }); + }); + + describe('integration with Shell', () => { + it('works as Shell fs option', async () => { + const overlay = new OverlayFs(tmpDir); + const shell = new Shell({ fs: overlay }); + + const result = await shell.exec('cat /hello.txt'); + expect(result.stdout).toBe('hello from host'); + + await shell.exec('echo "new content" > /output.txt'); + const changes = overlay.getChanges(); + expect(changes.created.some((c) => c.path === '/output.txt')).toBe(true); + }); + }); + + describe('symlinks', () => { + it('symlink and readlink round-trip in memory', () => { + const overlay = new OverlayFs(tmpDir); + overlay.symlink('/hello.txt', '/link.txt'); + expect(overlay.readlink('/link.txt')).toBe('/hello.txt'); + }); + }); + + describe('realpath', () => { + it('rejects paths resolving outside root', () => { + const overlay = new OverlayFs(tmpDir); + // Create a symlink on host pointing outside root + const outsidePath = path.join(os.tmpdir(), 'outside-overlay-test.txt'); + fs.writeFileSync(outsidePath, 'outside'); + try { + fs.symlinkSync(outsidePath, path.join(tmpDir, 'escape-link')); + expect(() => overlay.realpath('/escape-link')).toThrow(); + } finally { + fs.unlinkSync(outsidePath); + try { + fs.unlinkSync(path.join(tmpDir, 'escape-link')); + } catch { + // may not exist + } + } + }); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index e8ae7e1..5db33de 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts', 'src/jq/index.ts'], + entry: ['src/index.ts', 'src/jq/index.ts', 'src/overlay/index.ts'], format: ['esm', 'cjs'], dts: true, sourcemap: true, From dd36ea6f332d2eeb384654539cb766f0f39857b9 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:36:04 +0300 Subject: [PATCH 06/12] add bun to ci matrix Parallel test-bun job runs unit tests via bun runtime to verify cross-runtime compatibility. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6212d12..7b3055c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,3 +59,17 @@ jobs: if [ "$KB" -gt 500 ]; then echo "WARNING: Bundle size exceeds 500KB target (${KB}KB)" fi + + test-bun: + name: Test (Bun) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: bun run vitest run From 737bda2de77de391a9a773debd7c648c89e3dd71 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:41:08 +0300 Subject: [PATCH 07/12] update docs and packaging for v0.1.0 Documentation pass across README, AGENTS.md, and design docs for all v0.1.0 features. Added THREAT_MODEL.md to package files. Verified all entry points. --- AGENTS.md | 4 +-- README.md | 52 +++++++++++++++++++++++++++++++++++++-- docs/design.md | 13 +++++++--- docs/design/commands.md | 22 ++++++++++++++--- docs/design/filesystem.md | 44 ++++++++++++++++++++++++++++++++- docs/design/security.md | 15 +++++++++++ package.json | 24 +++--------------- 7 files changed, 141 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dee7341..794358f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Virtual bash interpreter for AI agents. Pure ECMAScript, zero runtime dependenci - `tests/comparison/commands/` - one file per command - `tests/comparison/jq/` - jq processor tests - **Validation gate:** `pnpm test:all` runs unit + comparison + lint + typecheck -- **CI:** macOS + Linux + Windows +- **CI:** macOS + Linux + Windows + Bun ## Docs @@ -29,7 +29,7 @@ Design docs for AI agents in `docs/`. Read on-demand, not required. - [`docs/design/parser.md`](docs/design/parser.md) - Lexer, AST types, recursive descent parser - [`docs/design/interpreter.md`](docs/design/interpreter.md) - Execution, pipes, expansion phases, control flow signals - [`docs/design/commands.md`](docs/design/commands.md) - Registry, adding commands, custom command API -- [`docs/design/filesystem.md`](docs/design/filesystem.md) - InMemoryFs, lazy files, virtual devices, symlinks +- [`docs/design/filesystem.md`](docs/design/filesystem.md) - InMemoryFs, OverlayFs, lazy files, virtual devices, symlinks - [`docs/design/security.md`](docs/design/security.md) - Execution limits, regex guardrails, threat model - [`docs/design/jq.md`](docs/design/jq.md) - Generator evaluator, builtins, format strings - [`docs/3rd-party/testing-with-smokepod.md`](docs/3rd-party/testing-with-smokepod.md) - Comparison test workflow diff --git a/README.md b/README.md index 3ee2130..fdeb2f5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # @mylocalgpt/shell -Virtual bash interpreter for AI agents. Pure TypeScript, zero runtime dependencies. Runs in any JavaScript runtime - browsers, Node.js, Deno, Bun, and Cloudflare Workers. Ships with 60+ commands, a full jq implementation, and an under 40KB gzipped entry point. +Virtual bash interpreter for AI agents. Pure TypeScript, zero runtime dependencies. Runs in any JavaScript runtime - browsers, Node.js, Deno, Bun, and Cloudflare Workers. Ships with 65+ commands, a full jq implementation, and an under 40KB gzipped entry point. - Pure JS, under 40KB gzipped, zero dependencies, runs anywhere -- 60+ commands including grep, sed, awk, find, xargs, and a full jq implementation +- 65+ commands including grep, sed, awk, find, xargs, curl, and a full jq implementation - Pipes, redirections, variables, control flow, functions, arithmetic - Configurable execution limits, regex guardrails, no eval +- OverlayFs: read-through overlay on real directories with change tracking ## Install @@ -46,6 +47,9 @@ const shell = new Shell(options?: ShellOptions); | `hostname` | `string` | Virtual hostname (used by `hostname` command). | | `username` | `string` | Virtual username (used by `whoami` command). | | `enabledCommands` | `string[]` | Restrict available commands to this allowlist. | +| `network` | `NetworkConfig` | Network handler for curl. See [Network Config](#network-config). | +| `onBeforeCommand` | `(cmd, args) => boolean \| void` | Hook before each command (return false to block). | +| `onCommandResult` | `(cmd, result) => CommandResult` | Hook after each command (can modify result). | ### shell.exec(command, options?) @@ -175,6 +179,10 @@ Clear environment and functions, reset working directory. Filesystem is kept int | `which` | Locate a command | | `tee` | Duplicate stdin to file and stdout | | `sleep` | Pause execution | +| `yes` | Repeat a string (output-capped) | +| `timeout` | Run command with time limit | +| `xxd` | Hex dump (-l, -s) | +| `curl` | HTTP requests via network handler | | `jq` | JSON processor (full implementation) | ## jq Support @@ -252,6 +260,46 @@ const shell = new Shell({ }); ``` +## OverlayFs + +Read-through overlay that reads from a real host directory and writes to memory. The host filesystem is never modified. + +```typescript +import { Shell } from '@mylocalgpt/shell'; +import { OverlayFs } from '@mylocalgpt/shell/overlay'; + +const overlay = new OverlayFs('/path/to/project', { + denyPaths: ['*.env', '*.key', 'node_modules/**'], +}); +const shell = new Shell({ fs: overlay }); + +await shell.exec('cat src/index.ts | wc -l'); +await shell.exec('echo "new file" > output.txt'); + +const changes = overlay.getChanges(); +// { created: [{ path: '/output.txt', content: 'new file\n' }], modified: [], deleted: [] } +``` + +Available as a separate entry point at `@mylocalgpt/shell/overlay`. Requires Node.js (uses `node:fs`). + +## Network Config + +curl delegates all HTTP requests to a consumer-provided handler. The shell never makes real network requests. + +```typescript +const shell = new Shell({ + network: { + handler: async (url, opts) => { + const res = await fetch(url, { method: opts.method, headers: opts.headers, body: opts.body }); + return { status: res.status, body: await res.text(), headers: {} }; + }, + allowlist: ['api.example.com', '*.internal.corp'], + }, +}); + +await shell.exec('curl -s https://api.example.com/data | jq .results'); +``` + ## Security Model See [THREAT_MODEL.md](THREAT_MODEL.md) for the full security model, threat analysis, and explicit non-goals. diff --git a/docs/design.md b/docs/design.md index d6b0032..c9d81a2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -9,8 +9,9 @@ Virtual bash interpreter for AI agents. Hand-written recursive descent parser, s | Parser | Lexer, AST, recursive descent | `src/parser/{ast,lexer,parser}.ts` | 3,200 | | Interpreter | Execution, pipes, expansion, control flow | `src/interpreter/{interpreter,expansion,builtins}.ts` | 3,800 | | Filesystem | In-memory virtual FS, lazy files | `src/fs/{types,memory}.ts` | 760 | -| Commands | One-file-per-command, lazy registry | `src/commands/*.ts` (61 registered) | ~8,000 | +| Commands | One-file-per-command, lazy registry | `src/commands/*.ts` (65 registered) | ~8,500 | | Security | Execution limits, regex guardrails | `src/security/{limits,regex}.ts` | 275 | +| OverlayFs | Read-through overlay for host dirs | `src/overlay/{index,types}.ts` | ~400 | | jq | Full jq processor, generator-based | `src/jq/*.ts` | 5,500 | | Utils | Glob, diff, printf (hand-written) | `src/utils/{glob,diff,printf}.ts` | 1,300 | @@ -19,7 +20,7 @@ Virtual bash interpreter for AI agents. Hand-written recursive descent parser, s ``` input string -> parse() -> AST -> execute() -> expand words (7 phases) - -> resolve builtins (27) or commands (61) + -> resolve builtins (27) or commands (65) -> pipe stdout as string to next command -> CommandResult { stdout, stderr, exitCode } ``` @@ -41,13 +42,17 @@ Flat `Map` keyed by normalized paths. Lazy file content (sync → [design/filesystem.md](design/filesystem.md) ### Commands -One file per command, lazy-loaded on first use. Dual-track registry (definitions Map + cache Map). 61 default commands, 27 builtins. Custom commands via `ShellOptions.commands` or `defineCommand()`. +One file per command, lazy-loaded on first use. Dual-track registry (definitions Map + cache Map). 65 default commands, 27 builtins. Custom commands via `ShellOptions.commands` or `defineCommand()`. → [design/commands.md](design/commands.md) ### Security Prevents resource exhaustion and ReDoS from untrusted scripts. 7 execution limits with configurable caps. Regex guardrails detect nested quantifiers and backreferences in quantified groups before executing patterns. → [design/security.md](design/security.md) +### OverlayFs +Read-through filesystem that overlays a host directory. Reads from host via sync `node:fs`, writes to an in-memory Map. Host is never modified. `getChanges()` returns created/modified/deleted changeset. Separate entry point at `@mylocalgpt/shell/overlay`. +-> [design/filesystem.md](design/filesystem.md) + ### jq Independent module with generator-based evaluator. 31 AST node types, 12-level precedence parser, 80+ builtins. Separate `JqLimits` with higher defaults. Full format string support. → [design/jq.md](design/jq.md) @@ -61,3 +66,5 @@ Independent module with generator-based evaluator. 31 AST node types, 12-level p - **No `node:` imports in core** - pure ECMAScript for portability. Node APIs only in test harness and build scripts. - **Generator-based jq** - `yield*` composes multiple outputs naturally. Matches jq's semantics where filters produce zero or more values. - **Lazy command loading** - commands imported on first use via dynamic `import()`. Reduces startup cost for scripts that use few commands. +- **Read-through OverlayFs** - overlays a host directory in memory. Uses sync `node:fs` APIs because the FileSystem interface allows `string | Promise` returns and sync is simpler for a read-through layer. Host is never written to. +- **Network delegation** - curl never makes real HTTP requests. All network access is delegated to a consumer-provided handler function via `ShellOptions.network`. diff --git a/docs/design/commands.md b/docs/design/commands.md index 7f450cd..49c0deb 100644 --- a/docs/design/commands.md +++ b/docs/design/commands.md @@ -1,6 +1,6 @@ # Commands -One file per command, lazy-loaded on first use. 61 registered default commands + 27 shell builtins. +One file per command, lazy-loaded on first use. 65 registered default commands + 27 shell builtins. ## Files @@ -8,7 +8,7 @@ One file per command, lazy-loaded on first use. 61 registered default commands + |------|------| | `src/commands/types.ts` | Core types: Command, CommandContext, CommandResult, LazyCommandDef | | `src/commands/registry.ts` | Dual-track registry with lazy loading and caching | -| `src/commands/defaults.ts` | 61 default command registrations | +| `src/commands/defaults.ts` | 65 default command registrations | | `src/commands/.ts` | One implementation file per command | ## Key Types @@ -116,9 +116,23 @@ const shell = new Shell({ Custom commands participate fully in pipes, redirections, and all shell features. -## Default Commands (61) +## Default Commands (65) -awk, base64, basename, cat, chmod, column, comm, cp, cut, date, diff, dirname, du, echo, env, expand, expr, file, find, fold, grep, head, hostname, join, jq, ln, ls, md5sum, mkdir, mv, nl, od, paste, printenv, printf, pwd, readlink, realpath, rev, rm, rmdir, sed, seq, sha1sum, sha256sum, sleep, sort, stat, strings, tac, tail, tee, touch, tr, tree, unexpand, uniq, wc, which, whoami, xargs +awk, base64, basename, cat, chmod, column, comm, cp, curl, cut, date, diff, dirname, du, echo, env, expand, expr, file, find, fold, grep, head, hostname, join, jq, ln, ls, md5sum, mkdir, mv, nl, od, paste, printenv, printf, pwd, readlink, realpath, rev, rm, rmdir, sed, seq, sha1sum, sha256sum, sleep, sort, stat, strings, tac, tail, tee, timeout, touch, tr, tree, unexpand, uniq, wc, which, whoami, xargs, xxd, yes + +## New in v0.1.0 + +### curl +Network requests via consumer-provided handler. Core stays network-free. Supports `-X`, `-H`, `-d`, `-o`, `-O`, `-s`, `-L`, `-f`, `-w`. Body from file with `-d @path`. Hostname allowlist via glob matching. Exit 7 on allowlist rejection. + +### timeout +Races `ctx.exec(cmd)` against `setTimeout`. Exit 124 on timeout. Duration 0 means no timeout. + +### yes +Repeats a string (default `y`) up to `SHELL_MAX_OUTPUT` limit. Output capped to prevent unbounded growth. + +### xxd +Basic hex dump: 16 bytes per line, 8-digit offset, paired hex bytes, ASCII sidebar. Supports `-l` (limit) and `-s` (offset). ## Gotchas diff --git a/docs/design/filesystem.md b/docs/design/filesystem.md index ff27f2c..2d4c208 100644 --- a/docs/design/filesystem.md +++ b/docs/design/filesystem.md @@ -1,6 +1,6 @@ # Filesystem -In-memory virtual filesystem. Flat Map storage, lazy file content, no real OS interaction. +Two filesystem implementations: InMemoryFs (default, pure ECMAScript) and OverlayFs (read-through overlay on host directory, uses node:fs). ## Files @@ -8,6 +8,8 @@ In-memory virtual filesystem. Flat Map storage, lazy file content, no real OS in |------|-------|------| | `src/fs/types.ts` | 172 | FileSystem interface, FsError, LazyFileContent type | | `src/fs/memory.ts` | 592 | InMemoryFs implementation | +| `src/overlay/index.ts` | ~350 | OverlayFs implementation | +| `src/overlay/types.ts` | 20 | OverlayFsOptions, ChangeSet, FileChange | ## Storage Model @@ -91,3 +93,43 @@ All virtual devices have mode `0o666`. - **chmod is informational only.** Mode bits are stored but never enforced. `cat` reads any file regardless of permissions. This is intentional - permission enforcement adds complexity without real security value in a virtual FS. - **No hard links.** Only symlinks are supported. - **Directory listing is O(n).** Scans all map keys with matching prefix. Fine for typical AI agent scripts, but not for filesystems with millions of entries. + +## OverlayFs + +Read-through overlay that combines a real host directory with an in-memory write layer. Available as `@mylocalgpt/shell/overlay`. + +### Two-Layer Architecture + +``` +Read: memory Map -> host FS (read-only, via node:fs) +Write: always to memory Map +Delete: adds to deletedPaths Set, shadows host files +``` + +The host filesystem is never modified. All mutations stay in memory. + +### getChanges() + +Returns a `ChangeSet` with three arrays: +- `created`: files written to memory that did not exist on host at first-write time +- `modified`: files written to memory that did exist on host at first-write time +- `deleted`: paths marked as deleted (shadowing host files) + +Host existence is checked at write time (not construction time) to handle files created on host after overlay initialization. + +### Path Filtering + +`allowPaths` and `denyPaths` options use glob patterns to control which host paths are readable: +- `denyPaths`: matching paths return ENOENT even if they exist on host +- `allowPaths`: only matching paths are readable; everything else returns ENOENT +- Neither set: all paths readable + +### Sync node:fs APIs + +OverlayFs uses `readFileSync`, `statSync`, `readdirSync` because the FileSystem interface allows sync string returns and sync is simpler for a read-through layer. This is the only part of the project that imports `node:` modules. + +### Security Properties + +- Host writes are architecturally impossible (no `writeFileSync` calls) +- `realpath` rejects paths that resolve outside the root directory (prevents symlink escape) +- Path filtering via allowPaths/denyPaths blocks unauthorized reads diff --git a/docs/design/security.md b/docs/design/security.md index 75b1fc6..218513a 100644 --- a/docs/design/security.md +++ b/docs/design/security.md @@ -65,6 +65,21 @@ Separate `JqLimits` type in `src/jq/evaluator.ts` with higher defaults (jq opera | maxArraySize | 100,000 | 100,000 | | maxOutputSize | 10,000,000 | 10,000,000 | +## Network Allowlist + +The curl command delegates all HTTP requests to a consumer-provided handler. An optional `allowlist` on `ShellOptions.network` restricts which hostnames curl can reach: +- Hostnames are extracted from URLs without the URL constructor (for runtime portability) +- Patterns use the project's glob matcher (e.g. `*.example.com`) +- Rejected requests return exit code 7 + +## OverlayFs Security + +- Host writes are architecturally impossible (no writeFileSync calls in overlay) +- `realpath` rejects paths that resolve outside the root directory via symlink +- `allowPaths`/`denyPaths` options filter which host paths are readable + +See also: [THREAT_MODEL.md](../../THREAT_MODEL.md) for the full security model. + ## Gotchas - **Regex guardrails are heuristic.** They catch common ReDoS patterns but cannot detect all possible exponential-time regexes. The input caps provide a hard backstop. diff --git a/package.json b/package.json index 3dd773f..0d40b99 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,7 @@ "version": "0.0.2", "type": "module", "description": "Virtual bash interpreter for AI agents. Pure TypeScript, zero dependencies.", - "keywords": [ - "bash", - "shell", - "interpreter", - "virtual", - "sandbox", - "agent", - "ai", - "jq" - ], + "keywords": ["bash", "shell", "interpreter", "virtual", "sandbox", "agent", "ai", "jq"], "repository": { "type": "git", "url": "git+https://github.com/mylocalgpt/shell.git" @@ -35,12 +26,7 @@ "require": "./dist/overlay/index.cjs" } }, - "files": [ - "dist", - "README.md", - "LICENSE", - "AGENTS.md" - ], + "files": ["dist", "README.md", "LICENSE", "AGENTS.md", "THREAT_MODEL.md"], "packageManager": "pnpm@10.32.1", "scripts": { "build": "tsdown", @@ -66,10 +52,6 @@ }, "license": "Apache-2.0", "pnpm": { - "onlyBuiltDependencies": [ - "@biomejs/biome", - "esbuild", - "smokepod" - ] + "onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "smokepod"] } } From 698b183676e568a19e9813f885517ad030901da3 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 16:48:52 +0300 Subject: [PATCH 08/12] fix url parsing, realpath escape, version - curl: use URL constructor instead of manual parsing - overlay: fix realpath root prefix check security hole - version: inject from package.json at build time --- src/commands/curl.ts | 36 ++++++++++++++---------------------- src/index.ts | 4 +++- src/overlay/index.ts | 2 +- tsdown.config.ts | 4 ++++ 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/commands/curl.ts b/src/commands/curl.ts index 430f294..bb9f1a6 100644 --- a/src/commands/curl.ts +++ b/src/commands/curl.ts @@ -6,33 +6,25 @@ function resolvePath(p: string, cwd: string): string { return cwd === '/' ? `/${p}` : `${cwd}/${p}`; } -/** Extract hostname from a URL without URL constructor. */ +/** Extract hostname from a URL string. */ function extractHostname(url: string): string { - // 1. Strip scheme - const schemeIdx = url.indexOf('://'); - const remainder = schemeIdx >= 0 ? url.slice(schemeIdx + 3) : url; - // 2. Strip path - const slashIdx = remainder.indexOf('/'); - const authority = slashIdx >= 0 ? remainder.slice(0, slashIdx) : remainder; - // 3. Strip user:pass@ - const atIdx = authority.lastIndexOf('@'); - const hostPort = atIdx >= 0 ? authority.slice(atIdx + 1) : authority; - // 4. Strip :port - const colonIdx = hostPort.lastIndexOf(':'); - return colonIdx >= 0 ? hostPort.slice(0, colonIdx) : hostPort; + try { + return new URL(url).hostname; + } catch { + return ''; + } } /** Extract filename from URL path's last segment. */ function extractFilename(url: string): string { - const schemeIdx = url.indexOf('://'); - const remainder = schemeIdx >= 0 ? url.slice(schemeIdx + 3) : url; - const slashIdx = remainder.indexOf('/'); - const path = slashIdx >= 0 ? remainder.slice(slashIdx) : '/'; - const queryIdx = path.indexOf('?'); - const cleanPath = queryIdx >= 0 ? path.slice(0, queryIdx) : path; - const lastSlash = cleanPath.lastIndexOf('/'); - const filename = lastSlash >= 0 ? cleanPath.slice(lastSlash + 1) : cleanPath; - return filename || 'index.html'; + try { + const pathname = new URL(url).pathname; + const lastSlash = pathname.lastIndexOf('/'); + const filename = lastSlash >= 0 ? pathname.slice(lastSlash + 1) : pathname; + return filename || 'index.html'; + } catch { + return 'index.html'; + } } export const curl: Command = { diff --git a/src/index.ts b/src/index.ts index 5f975f3..1f7670d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -616,4 +616,6 @@ export class Shell { } } -export const VERSION: '0.0.0' = '0.0.0' as const; +declare const __PACKAGE_VERSION__: string; +export const VERSION: string = + typeof __PACKAGE_VERSION__ !== 'undefined' ? __PACKAGE_VERSION__ : '0.0.0'; diff --git a/src/overlay/index.ts b/src/overlay/index.ts index 04c2810..93a59c4 100644 --- a/src/overlay/index.ts +++ b/src/overlay/index.ts @@ -413,7 +413,7 @@ export class OverlayFs implements FileSystem { // Check that resolved path is within root const resolvedNorm = nodePath.normalize(resolved); const rootNorm = nodePath.normalize(this.root); - if (!resolvedNorm.startsWith(rootNorm)) { + if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}/`)) { throw new FsError('EACCES', p, `realpath: resolved path escapes root: ${p}`); } return p; diff --git a/tsdown.config.ts b/tsdown.config.ts index 5db33de..677a6bc 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'tsdown'; +import pkg from './package.json' with { type: 'json' }; export default defineConfig({ entry: ['src/index.ts', 'src/jq/index.ts', 'src/overlay/index.ts'], @@ -6,4 +7,7 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, + define: { + __PACKAGE_VERSION__: JSON.stringify(pkg.version), + }, }); From 35f595c71c84efcfa64a36bd387cea5bedf5faa1 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 17:02:28 +0300 Subject: [PATCH 09/12] fix fixture recorded_with paths Use absolute /bin/bash path to work around smokepod hang when recorded_with is a short name. --- tests/comparison/fixtures/commands/timeout.fixture.json | 2 +- tests/comparison/fixtures/commands/xxd.fixture.json | 2 +- tests/comparison/fixtures/commands/yes.fixture.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/comparison/fixtures/commands/timeout.fixture.json b/tests/comparison/fixtures/commands/timeout.fixture.json index 5b07c63..916251e 100644 --- a/tests/comparison/fixtures/commands/timeout.fixture.json +++ b/tests/comparison/fixtures/commands/timeout.fixture.json @@ -1,6 +1,6 @@ { "source": "tests/comparison/commands/timeout.test", - "recorded_with": "bash", + "recorded_with": "/bin/bash", "platform": { "os": "darwin", "arch": "arm64", diff --git a/tests/comparison/fixtures/commands/xxd.fixture.json b/tests/comparison/fixtures/commands/xxd.fixture.json index 23b7b7f..172bedd 100644 --- a/tests/comparison/fixtures/commands/xxd.fixture.json +++ b/tests/comparison/fixtures/commands/xxd.fixture.json @@ -1,6 +1,6 @@ { "source": "tests/comparison/commands/xxd.test", - "recorded_with": "bash", + "recorded_with": "/bin/bash", "platform": { "os": "darwin", "arch": "arm64", diff --git a/tests/comparison/fixtures/commands/yes.fixture.json b/tests/comparison/fixtures/commands/yes.fixture.json index 8157328..518c6c7 100644 --- a/tests/comparison/fixtures/commands/yes.fixture.json +++ b/tests/comparison/fixtures/commands/yes.fixture.json @@ -1,6 +1,6 @@ { "source": "tests/comparison/commands/yes.test", - "recorded_with": "bash", + "recorded_with": "/bin/bash", "platform": { "os": "darwin", "arch": "arm64", From 1c0a43fcb2a9e387ed988653e489a7d1f26e1c8b Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 17:37:26 +0300 Subject: [PATCH 10/12] Address review issues --- docs/design/security.md | 2 +- src/commands/curl.ts | 42 +++++++++++++++++++++++++++++++++-- src/commands/timeout.ts | 10 +++++++-- src/overlay/index.ts | 49 +++++++++++++++++++++++++++++++++-------- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docs/design/security.md b/docs/design/security.md index 218513a..4444ed2 100644 --- a/docs/design/security.md +++ b/docs/design/security.md @@ -68,7 +68,7 @@ Separate `JqLimits` type in `src/jq/evaluator.ts` with higher defaults (jq opera ## Network Allowlist The curl command delegates all HTTP requests to a consumer-provided handler. An optional `allowlist` on `ShellOptions.network` restricts which hostnames curl can reach: -- Hostnames are extracted from URLs without the URL constructor (for runtime portability) +- Hostnames are extracted from URLs using the `URL` constructor - Patterns use the project's glob matcher (e.g. `*.example.com`) - Rejected requests return exit code 7 diff --git a/src/commands/curl.ts b/src/commands/curl.ts index bb9f1a6..de8da3c 100644 --- a/src/commands/curl.ts +++ b/src/commands/curl.ts @@ -197,11 +197,49 @@ export const curl: Command = { currentBody = undefined; } - finalUrl = location; + // Resolve relative Location against current URL + try { + finalUrl = new URL(location, finalUrl).href; + } catch { + finalUrl = location; + } redirectCount++; + + // Re-check allowlist for redirect target + if (ctx.network.allowlist) { + const redirectHost = extractHostname(finalUrl); + let redirectAllowed = false; + for (let i = 0; i < ctx.network.allowlist.length; i++) { + if (globMatch(ctx.network.allowlist[i], redirectHost, true)) { + redirectAllowed = true; + break; + } + } + if (!redirectAllowed) { + return { + exitCode: 7, + stdout: '', + stderr: `curl: (7) Failed to connect to ${redirectHost}: host not in allowlist\n`, + }; + } + } + + // Strip sensitive headers on cross-origin redirects + let reqHeaders = headers; + if (extractHostname(finalUrl) !== extractHostname(url)) { + const filtered: Record = {}; + const skip = new Set(['authorization', 'cookie']); + for (const key of Object.keys(headers)) { + if (!skip.has(key.toLowerCase())) { + filtered[key] = headers[key]; + } + } + reqHeaders = filtered; + } + response = await ctx.network.handler(finalUrl, { method: currentMethod, - headers, + headers: reqHeaders, body: currentBody, }); } diff --git a/src/commands/timeout.ts b/src/commands/timeout.ts index 35578f7..95945a5 100644 --- a/src/commands/timeout.ts +++ b/src/commands/timeout.ts @@ -31,10 +31,16 @@ export const timeout: Command = { const ms = seconds * 1000; const TIMEOUT_RESULT: CommandResult = { exitCode: 124, stdout: '', stderr: '' }; + let timer: ReturnType | undefined; + const execPromise = ctx.exec(cmd); + execPromise.catch(() => {}); // prevent unhandled rejection if timer wins const result = await Promise.race([ - ctx.exec(cmd), - new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_RESULT), ms)), + execPromise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT_RESULT), ms); + }), ]); + if (timer !== undefined) clearTimeout(timer); return result; }, diff --git a/src/overlay/index.ts b/src/overlay/index.ts index 93a59c4..2c6bbb1 100644 --- a/src/overlay/index.ts +++ b/src/overlay/index.ts @@ -62,7 +62,15 @@ export class OverlayFs implements FileSystem { private readonly memorySymlinks: Map = new Map(); constructor(root: string, options?: OverlayFsOptions) { - this.root = nodePath.resolve(root); + // Resolve symlinks in root itself so safeHostPath checks work on macOS + // where /tmp -> /private/tmp + let resolvedRoot = nodePath.resolve(root); + try { + resolvedRoot = nodeFs.realpathSync(resolvedRoot); + } catch { + // Root doesn't exist yet; use the unresolved path + } + this.root = resolvedRoot; this.allowPaths = options?.allowPaths; this.denyPaths = options?.denyPaths; // Root always exists as a directory in the overlay @@ -90,10 +98,31 @@ export class OverlayFs implements FileSystem { return true; } + /** + * Resolve a host path via realpathSync and verify it stays under root. + * Returns the resolved absolute host path, or throws EACCES if it escapes. + */ + private safeHostPath(virtualPath: string): string { + const raw = this.hostPath(virtualPath); + let resolved: string; + try { + resolved = nodeFs.realpathSync(raw); + } catch { + // Path doesn't exist on host; return raw (caller handles ENOENT) + return raw; + } + const resolvedNorm = nodePath.normalize(resolved); + const rootNorm = nodePath.normalize(this.root); + if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}/`)) { + throw new FsError('EACCES', virtualPath, `path escapes overlay root: ${virtualPath}`); + } + return resolved; + } + /** Check if a path exists on the host filesystem. */ private hostExists(virtualPath: string): boolean { try { - nodeFs.statSync(this.hostPath(virtualPath)); + nodeFs.statSync(this.safeHostPath(virtualPath)); return true; } catch { return false; @@ -134,10 +163,11 @@ export class OverlayFs implements FileSystem { throw new FsError('ENOENT', p); } - // Read from host + // Read from host (safeHostPath checks symlinks don't escape root) try { - return nodeFs.readFileSync(this.hostPath(p), 'utf-8'); + return nodeFs.readFileSync(this.safeHostPath(p), 'utf-8'); } catch (err) { + if (err instanceof FsError) throw err; throw translateError(err, p); } } @@ -228,7 +258,7 @@ export class OverlayFs implements FileSystem { // Host filesystem try { - const hostStat = nodeFs.statSync(this.hostPath(p)); + const hostStat = nodeFs.statSync(this.safeHostPath(p)); const mode = this.memoryModes.get(p) ?? hostStat.mode & 0o7777; return { isFile: () => hostStat.isFile(), @@ -255,7 +285,7 @@ export class OverlayFs implements FileSystem { // Host entries if (this.isAllowed(p)) { try { - const hostEntries = nodeFs.readdirSync(this.hostPath(p)); + const hostEntries = nodeFs.readdirSync(this.safeHostPath(p)); for (let i = 0; i < hostEntries.length; i++) { const childPath = p === '/' ? `/${hostEntries[i]}` : `${p}/${hostEntries[i]}`; if (!this.deletedPaths.has(childPath)) { @@ -410,13 +440,14 @@ export class OverlayFs implements FileSystem { // For host paths, resolve and ensure it stays within root try { const resolved = nodeFs.realpathSync(this.hostPath(p)); - // Check that resolved path is within root const resolvedNorm = nodePath.normalize(resolved); const rootNorm = nodePath.normalize(this.root); if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}/`)) { throw new FsError('EACCES', p, `realpath: resolved path escapes root: ${p}`); } - return p; + // Return canonical virtual path (resolved host path relative to root) + if (resolvedNorm === rootNorm) return '/'; + return `/${nodePath.relative(rootNorm, resolvedNorm).split(nodePath.sep).join('/')}`; } catch (err) { if (err instanceof FsError) throw err; throw translateError(err, p); @@ -443,7 +474,7 @@ export class OverlayFs implements FileSystem { if (memTarget !== undefined) return memTarget; try { - return nodeFs.readlinkSync(this.hostPath(p), 'utf-8'); + return nodeFs.readlinkSync(this.safeHostPath(p), 'utf-8'); } catch (err) { throw translateError(err, p); } From 71a7738bad913f09e59ff3d51974bbd640029996 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 17:40:43 +0300 Subject: [PATCH 11/12] 0.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0d40b99..e3e777a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mylocalgpt/shell", - "version": "0.0.2", + "version": "0.1.0", "type": "module", "description": "Virtual bash interpreter for AI agents. Pure TypeScript, zero dependencies.", "keywords": ["bash", "shell", "interpreter", "virtual", "sandbox", "agent", "ai", "jq"], From 2ad4cf3768bd2d4393a445986e8580b26432b3f0 Mon Sep 17 00:00:00 2001 From: Peter Etelej Date: Thu, 19 Mar 2026 17:44:06 +0300 Subject: [PATCH 12/12] strip proxy-auth on redirects, fix path sep - curl: also filter proxy-authorization header on cross-origin redirects - overlay: use nodePath.sep for Windows compatibility in escape checks --- docs/design/commands.md | 21 ++++++--------- package.json | 2 +- pnpm-lock.yaml | 58 ++++++++++++++++++++--------------------- src/commands/curl.ts | 2 +- src/overlay/index.ts | 4 +-- 5 files changed, 41 insertions(+), 46 deletions(-) diff --git a/docs/design/commands.md b/docs/design/commands.md index 49c0deb..73dc9da 100644 --- a/docs/design/commands.md +++ b/docs/design/commands.md @@ -120,19 +120,14 @@ Custom commands participate fully in pipes, redirections, and all shell features awk, base64, basename, cat, chmod, column, comm, cp, curl, cut, date, diff, dirname, du, echo, env, expand, expr, file, find, fold, grep, head, hostname, join, jq, ln, ls, md5sum, mkdir, mv, nl, od, paste, printenv, printf, pwd, readlink, realpath, rev, rm, rmdir, sed, seq, sha1sum, sha256sum, sleep, sort, stat, strings, tac, tail, tee, timeout, touch, tr, tree, unexpand, uniq, wc, which, whoami, xargs, xxd, yes -## New in v0.1.0 - -### curl -Network requests via consumer-provided handler. Core stays network-free. Supports `-X`, `-H`, `-d`, `-o`, `-O`, `-s`, `-L`, `-f`, `-w`. Body from file with `-d @path`. Hostname allowlist via glob matching. Exit 7 on allowlist rejection. - -### timeout -Races `ctx.exec(cmd)` against `setTimeout`. Exit 124 on timeout. Duration 0 means no timeout. - -### yes -Repeats a string (default `y`) up to `SHELL_MAX_OUTPUT` limit. Output capped to prevent unbounded growth. - -### xxd -Basic hex dump: 16 bytes per line, 8-digit offset, paired hex bytes, ASCII sidebar. Supports `-l` (limit) and `-s` (offset). +## Commands with Non-obvious Behavior + +| Command | Behavior | +|---------|----------| +| `curl` | Delegates to `ShellOptions.network.handler` callback; core stays network-free. Flags: `-X`, `-H`, `-d`, `-o`, `-O`, `-s`, `-L`, `-f`, `-w`. Hostname allowlist via glob. Exit 7 on rejection | +| `timeout` | `Promise.race` between `ctx.exec()` and `setTimeout`. Exit 124 on expiry. Duration 0 means no timeout. Virtual `sleep` returns instantly, so `timeout 5 sleep 100` completes immediately rather than timing out | +| `yes` | Output capped by `SHELL_MAX_OUTPUT` env var (default 10MB) to prevent unbounded string growth | +| `xxd` | Basic hex dump only (no `-r` reverse). `-l` limit, `-s` offset | ## Gotchas diff --git a/package.json b/package.json index e3e777a..069ff78 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/node": "^25.5.0", - "smokepod": "^1.1.2", + "smokepod": "^1.1.3", "tsdown": "^0.21.0", "tsx": "^4.19.4", "typescript": "^5.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f325fe..0892f24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^25.5.0 version: 25.5.0 smokepod: - specifier: ^1.1.2 - version: 1.1.2 + specifier: ^1.1.3 + version: 1.1.3 tsdown: specifier: ^0.21.0 version: 0.21.4(typescript@5.9.3) @@ -294,33 +294,33 @@ packages: '@oxc-project/types@0.115.0': resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} - '@peteretelej/smokepod-darwin-arm64@1.1.2': - resolution: {integrity: sha512-S/xG4br81mO7MhWvioPnW0Zu6NB6Ff+UgNLWZWzEIVLnf2akSpzbyygYRCUIw+KS3HZXBwbvX/NpuGfVa/kpPQ==} + '@peteretelej/smokepod-darwin-arm64@1.1.3': + resolution: {integrity: sha512-+q/mVNb3HQOYVl6M+y5weTSkgGThFV+lqGIRudvWE7rHAB5ul9tRmtdkW79t8uA4A476Fu0Q0SVFwPjlLPKqsw==} cpu: [arm64] os: [darwin] - '@peteretelej/smokepod-darwin-x64@1.1.2': - resolution: {integrity: sha512-exSB5ZL5OvqqlIgoO6zHxYJCZ+eNn/+80HDzHE3nbgDY3Z2ws2124yeCzd1+Gz+aeKL/mpLAKtj6K2FGBQkGUQ==} + '@peteretelej/smokepod-darwin-x64@1.1.3': + resolution: {integrity: sha512-/vwj9+pizcKTYu/Iyz8tpc5GwtaKqEFMJXuWpu9vRWw4WyyoqVn3U6kjDVncd+wymxnR+Ago8oAP2nsqqFVS4Q==} cpu: [x64] os: [darwin] - '@peteretelej/smokepod-linux-arm64@1.1.2': - resolution: {integrity: sha512-fwROemhTkJtBlpQWp8ou859tENQYE1N/EzsvBWXpPscPrZcdKYqveyptL46QzgYlkGIma228O97WfFmlnUDDNg==} + '@peteretelej/smokepod-linux-arm64@1.1.3': + resolution: {integrity: sha512-H28Z3RnKJtNXiQc8YMROa65ZZ6tV3gOssmuKHGuWkZ6CmGKnxqIpXKR6w81sAr/oEOLjOB+wO2g7aqRONA7I6g==} cpu: [arm64] os: [linux] - '@peteretelej/smokepod-linux-x64@1.1.2': - resolution: {integrity: sha512-Oid0yW15mjXflINIzfKQC7YU8i1SkdQ6OMFo1r+nJGRyN7w06Mvv4ZpJsMT/gyXWSRR4YjOeYbnEFkKSiyr2Yw==} + '@peteretelej/smokepod-linux-x64@1.1.3': + resolution: {integrity: sha512-xIeS0gUicJWf5O4PDN+LjvS2aurarnPNeJlgCXIyWveNxXPp+K/FZwlVk7qD61UtZbwdHIRlnf+rzXdI7CZQpA==} cpu: [x64] os: [linux] - '@peteretelej/smokepod-win32-arm64@1.1.2': - resolution: {integrity: sha512-6+6B4UPL1agLnRgP3LqA+lBRSGzonZFFjPNKzZDHUe3ey4POx6q7LgPE4FXycbkJn9xmS/eSndJPYKm7lmo7Ag==} + '@peteretelej/smokepod-win32-arm64@1.1.3': + resolution: {integrity: sha512-E8CEy6gLlNSZiE7a+82VKtp6dKy9UL/fcAEF/9AGW0YPm9WvVOAHCfTInId3EN9I332m5BgbuxiKHHyVCm0gWg==} cpu: [arm64] os: [win32] - '@peteretelej/smokepod-win32-x64@1.1.2': - resolution: {integrity: sha512-qx64EtWI6ORVcrmOEKFGhTAAuduGG0RzZBDbmfyhzAfbhT6XwW2DheoWs/OoisBOzwiiEcHM/ucoLzqtw/oC1Q==} + '@peteretelej/smokepod-win32-x64@1.1.3': + resolution: {integrity: sha512-lHIUUDigb0t+BzUufCJD+lTEvs9qtLcrEHW6uDPRCoQwdUM48i6HGI7Vdr5KKNIDJwlWIWnG5Wur1nxiaIrp+w==} cpu: [x64] os: [win32] @@ -799,8 +799,8 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - smokepod@1.1.2: - resolution: {integrity: sha512-aj2pP2E++6+KP7aRFISVuMkyTE55RorJinRWRPy1FelxKh/Pk0Q+mgwLwRdnRZLPLhfsSrbvsq25RN4seLshmQ==} + smokepod@1.1.3: + resolution: {integrity: sha512-mowCK48whbC0DPpsQMbk7LKQfupDzOv8e4OjF37XXycE5D3zdwjUPPKv83/8KATqd9wBhHnLmIx8aEydBIWeOw==} engines: {node: '>=20'} hasBin: true @@ -1158,22 +1158,22 @@ snapshots: '@oxc-project/types@0.115.0': {} - '@peteretelej/smokepod-darwin-arm64@1.1.2': + '@peteretelej/smokepod-darwin-arm64@1.1.3': optional: true - '@peteretelej/smokepod-darwin-x64@1.1.2': + '@peteretelej/smokepod-darwin-x64@1.1.3': optional: true - '@peteretelej/smokepod-linux-arm64@1.1.2': + '@peteretelej/smokepod-linux-arm64@1.1.3': optional: true - '@peteretelej/smokepod-linux-x64@1.1.2': + '@peteretelej/smokepod-linux-x64@1.1.3': optional: true - '@peteretelej/smokepod-win32-arm64@1.1.2': + '@peteretelej/smokepod-win32-arm64@1.1.3': optional: true - '@peteretelej/smokepod-win32-x64@1.1.2': + '@peteretelej/smokepod-win32-x64@1.1.3': optional: true '@quansync/fs@1.0.0': @@ -1566,14 +1566,14 @@ snapshots: siginfo@2.0.0: {} - smokepod@1.1.2: + smokepod@1.1.3: optionalDependencies: - '@peteretelej/smokepod-darwin-arm64': 1.1.2 - '@peteretelej/smokepod-darwin-x64': 1.1.2 - '@peteretelej/smokepod-linux-arm64': 1.1.2 - '@peteretelej/smokepod-linux-x64': 1.1.2 - '@peteretelej/smokepod-win32-arm64': 1.1.2 - '@peteretelej/smokepod-win32-x64': 1.1.2 + '@peteretelej/smokepod-darwin-arm64': 1.1.3 + '@peteretelej/smokepod-darwin-x64': 1.1.3 + '@peteretelej/smokepod-linux-arm64': 1.1.3 + '@peteretelej/smokepod-linux-x64': 1.1.3 + '@peteretelej/smokepod-win32-arm64': 1.1.3 + '@peteretelej/smokepod-win32-x64': 1.1.3 source-map-js@1.2.1: {} diff --git a/src/commands/curl.ts b/src/commands/curl.ts index de8da3c..99ac179 100644 --- a/src/commands/curl.ts +++ b/src/commands/curl.ts @@ -228,7 +228,7 @@ export const curl: Command = { let reqHeaders = headers; if (extractHostname(finalUrl) !== extractHostname(url)) { const filtered: Record = {}; - const skip = new Set(['authorization', 'cookie']); + const skip = new Set(['authorization', 'cookie', 'proxy-authorization']); for (const key of Object.keys(headers)) { if (!skip.has(key.toLowerCase())) { filtered[key] = headers[key]; diff --git a/src/overlay/index.ts b/src/overlay/index.ts index 2c6bbb1..5727c45 100644 --- a/src/overlay/index.ts +++ b/src/overlay/index.ts @@ -113,7 +113,7 @@ export class OverlayFs implements FileSystem { } const resolvedNorm = nodePath.normalize(resolved); const rootNorm = nodePath.normalize(this.root); - if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}/`)) { + if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}${nodePath.sep}`)) { throw new FsError('EACCES', virtualPath, `path escapes overlay root: ${virtualPath}`); } return resolved; @@ -442,7 +442,7 @@ export class OverlayFs implements FileSystem { const resolved = nodeFs.realpathSync(this.hostPath(p)); const resolvedNorm = nodePath.normalize(resolved); const rootNorm = nodePath.normalize(this.root); - if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}/`)) { + if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(`${rootNorm}${nodePath.sep}`)) { throw new FsError('EACCES', p, `realpath: resolved path escapes root: ${p}`); } // Return canonical virtual path (resolved host path relative to root)