diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee55c7a..7b3055c 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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 6d15c8e..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,8 +29,9 @@ 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 +- [`THREAT_MODEL.md`](THREAT_MODEL.md) - Security model, protections, threat analysis, non-goals diff --git a/README.md b/README.md index 12fcff5..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 @@ -36,7 +37,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. | @@ -45,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?) @@ -174,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 @@ -251,8 +260,50 @@ 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. + **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/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/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..73dc9da 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,18 @@ 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 + +## 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/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..4444ed2 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 using the `URL` constructor +- 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 6fe4aa0..069ff78 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"], @@ -19,9 +19,14 @@ "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", "THREAT_MODEL.md"], "packageManager": "pnpm@10.32.1", "scripts": { "build": "tsdown", @@ -38,7 +43,8 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.4", - "smokepod": "^1.1.2", + "@types/node": "^25.5.0", + "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 8a1d42b..0892f24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,12 @@ 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 + specifier: ^1.1.3 + version: 1.1.3 tsdown: specifier: ^0.21.0 version: 0.21.4(typescript@5.9.3) @@ -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: @@ -291,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] @@ -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==} @@ -793,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 @@ -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'} @@ -1149,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': @@ -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: @@ -1553,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: {} @@ -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/commands/curl.ts b/src/commands/curl.ts new file mode 100644 index 0000000..99ac179 --- /dev/null +++ b/src/commands/curl.ts @@ -0,0 +1,292 @@ +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 string. */ +function extractHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +/** Extract filename from URL path's last segment. */ +function extractFilename(url: string): string { + 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 = { + 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; + } + + // 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', 'proxy-authorization']); + 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: reqHeaders, + 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 ec96128..6d50777 100644 --- a/src/commands/defaults.ts +++ b/src/commands/defaults.ts @@ -259,6 +259,24 @@ 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), + }); + + // Network commands + registry.register({ + name: 'curl', + load: () => import('./curl.js').then((m) => m.curl), + }); // JSON processing registry.register({ diff --git a/src/commands/timeout.ts b/src/commands/timeout.ts new file mode 100644 index 0000000..95945a5 --- /dev/null +++ b/src/commands/timeout.ts @@ -0,0 +1,47 @@ +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: '' }; + + let timer: ReturnType | undefined; + const execPromise = ctx.exec(cmd); + execPromise.catch(() => {}); // prevent unhandled rejection if timer wins + const result = await Promise.race([ + execPromise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT_RESULT), ms); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + + return result; + }, +}; 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/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/src/index.ts b/src/index.ts index edb65af..1f7670d 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'; @@ -123,9 +131,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.). */ @@ -171,6 +186,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). */ @@ -180,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; } /** @@ -245,29 +282,43 @@ 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 readonly _network: NetworkConfig | undefined; 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; + this._onBeforeCommand = options?.onBeforeCommand; + this._onCommandResult = options?.onCommandResult; + this._network = options?.network; // Set up command registry this.registry = new CommandRegistry(); @@ -533,6 +584,11 @@ export class Shell { env, this.initialCwd, this._limits, + { + onBeforeCommand: this._onBeforeCommand, + onCommandResult: this._onCommandResult, + }, + this._network, ); registerBuiltins(this.interpreter); } @@ -560,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/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index a9c7ae0..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 { @@ -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,8 @@ export class Interpreter { string, (args: string[], ctx: InterpreterContext) => Promise >; + private readonly hooks: InterpreterHooks; + private readonly network?: NetworkConfig; constructor( fs: FileSystem, @@ -123,6 +134,8 @@ export class Interpreter { env?: Map, cwd?: string, limits?: Partial, + hooks?: InterpreterHooks, + network?: NetworkConfig, ) { this.fs = fs; this.registry = registry; @@ -144,6 +157,11 @@ export class Interpreter { this.readonlyVars = new Set(); 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)); } /** Get the current shell state for the expansion engine. */ @@ -523,6 +541,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]; @@ -573,6 +601,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); @@ -620,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/src/overlay/index.ts b/src/overlay/index.ts new file mode 100644 index 0000000..5727c45 --- /dev/null +++ b/src/overlay/index.ts @@ -0,0 +1,513 @@ +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) { + // 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 + 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; + } + + /** + * 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}${nodePath.sep}`)) { + 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.safeHostPath(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 (safeHostPath checks symlinks don't escape root) + try { + return nodeFs.readFileSync(this.safeHostPath(p), 'utf-8'); + } catch (err) { + if (err instanceof FsError) throw 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.safeHostPath(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.safeHostPath(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)); + const resolvedNorm = nodePath.normalize(resolved); + const rootNorm = nodePath.normalize(this.root); + 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) + 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); + } + } + + 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.safeHostPath(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/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'); + }); +}); 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..916251e --- /dev/null +++ b/tests/comparison/fixtures/commands/timeout.fixture.json @@ -0,0 +1,29 @@ +{ + "source": "tests/comparison/commands/timeout.test", + "recorded_with": "/bin/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..172bedd --- /dev/null +++ b/tests/comparison/fixtures/commands/xxd.fixture.json @@ -0,0 +1,38 @@ +{ + "source": "tests/comparison/commands/xxd.test", + "recorded_with": "/bin/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..518c6c7 --- /dev/null +++ b/tests/comparison/fixtures/commands/yes.fixture.json @@ -0,0 +1,29 @@ +{ + "source": "tests/comparison/commands/yes.test", + "recorded_with": "/bin/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 + } + ] + } +} 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'); + }); + }); +}); 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/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', () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index e8ae7e1..677a6bc 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'tsdown'; +import pkg from './package.json' with { type: 'json' }; 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, clean: true, + define: { + __PACKAGE_VERSION__: JSON.stringify(pkg.version), + }, });