typescript-virtual-container 1.0.1 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,8 @@ permissions:
8
8
  contents: read
9
9
 
10
10
  jobs:
11
- typecheck:
11
+ setup:
12
12
  runs-on: ubuntu-latest
13
-
14
13
  steps:
15
14
  - name: Checkout repository
16
15
  uses: actions/checkout@v4
@@ -23,12 +22,36 @@ jobs:
23
22
  - name: Install dependencies
24
23
  run: bun install
25
24
 
25
+ - name: Cache dependencies
26
+ uses: actions/cache@v3
27
+ with:
28
+ path: node_modules
29
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
30
+
31
+ typecheck:
32
+ needs: setup
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - name: Checkout repository
36
+ uses: actions/checkout@v4
37
+
38
+ - name: Setup Bun
39
+ uses: oven-sh/setup-bun@v1
40
+ with:
41
+ bun-version: latest
42
+
43
+ - name: Restore dependencies
44
+ uses: actions/cache@v3
45
+ with:
46
+ path: node_modules
47
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
48
+
26
49
  - name: Run typecheck
27
50
  run: bun check
28
51
 
29
52
  lint:
53
+ needs: setup
30
54
  runs-on: ubuntu-latest
31
-
32
55
  steps:
33
56
  - name: Checkout repository
34
57
  uses: actions/checkout@v4
@@ -38,18 +61,42 @@ jobs:
38
61
  with:
39
62
  bun-version: latest
40
63
 
41
- - name: Install dependencies
42
- run: bun install
64
+ - name: Restore dependencies
65
+ uses: actions/cache@v3
66
+ with:
67
+ path: node_modules
68
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
43
69
 
44
70
  - name: Run lint
45
71
  run: bun lint
46
72
 
73
+ tests:
74
+ needs: setup
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - name: Checkout repository
78
+ uses: actions/checkout@v4
79
+
80
+ - name: Setup Bun
81
+ uses: oven-sh/setup-bun@v1
82
+ with:
83
+ bun-version: latest
84
+
85
+ - name: Restore dependencies
86
+ uses: actions/cache@v3
87
+ with:
88
+ path: node_modules
89
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }}
90
+
91
+ - name: Run tests
92
+ run: bun test
93
+
47
94
  test-battery:
48
95
  needs:
49
96
  - typecheck
50
97
  - lint
98
+ - tests
51
99
  runs-on: ubuntu-latest
52
-
53
100
  steps:
54
101
  - name: Test battery passed
55
102
  run: echo "Typecheck, lint and tests succeeded."
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ The format is based on Keep a Changelog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.4] - 2026-04-15
10
+
11
+ ### Added
12
+
13
+ - Shell pipeline parser and executor with support for:
14
+ - Pipes (`|`)
15
+ - Input redirection (`<`)
16
+ - Output redirection (`>`)
17
+ - Append redirection (`>>`)
18
+ - New built-in commands:
19
+ - `echo`
20
+ - `grep`
21
+ - `set`
22
+ - `env`
23
+ - `export`
24
+ - `unset`
25
+ - `sh` (with `bash` alias)
26
+ - Command stdin support in runtime context so commands can consume piped input.
27
+
28
+ ### Changed
29
+
30
+ - Argument parsing now respects quoted strings, including for commands like `sh -c "echo hi"`.
31
+ - `echo` now expands environment variables (`$VAR`) and can read from stdin when no explicit text argument is provided.
32
+ - `grep` now supports stdin input (e.g. `ls | grep ".txt"`) in addition to file operands.
33
+
34
+ ### Fixed
35
+
36
+ - Relative file paths in redirections are now resolved from current working directory during pipeline execution.
37
+ - Example fixed behavior: `echo hi > cat.txt` writes to `./cat.txt` in current virtual directory.
38
+ - Pipeline chaining now correctly passes command stdout as stdin to next command.
39
+
40
+ ## [1.0.2] - 2026-04-14
41
+
9
42
  ### Added
10
43
 
11
44
  - Governance and community files:
@@ -14,6 +47,13 @@ The format is based on Keep a Changelog.
14
47
  - SECURITY.md
15
48
  - CODE_OF_CONDUCT.md
16
49
  - GitHub issue and PR templates
50
+ - Security hardening for virtual auth storage and shell access:
51
+ - Non-root commands now block access to `/virtual-env-js/.auth/**`.
52
+ - Auth files are persisted with restrictive modes (`0700` for `.auth`, `0600` for `htpasswd` and `sudoers`).
53
+ - Root password no longer falls back to a fixed default when `SSH_MIMIC_ROOT_PASSWORD` is unset; startup generates an ephemeral password instead.
54
+ - New environment toggle `SSH_MIMIC_AUTO_SUDO_NEW_USERS` to control whether newly created users are added to sudoers by default.
55
+ - README and security docs now describe the new auth hardening and configuration flags.
56
+ - Added tests covering `.auth` path protection and auto-sudo behavior.
17
57
 
18
58
  ## [1.0.1] - 2026-04-14
19
59
 
package/README.md CHANGED
@@ -39,7 +39,7 @@
39
39
  - **Virtual Filesystem**: In-memory filesystem with optional compression, persistence to disk via tar.gz snapshots, and programmatic access.
40
40
  - **User Management**: Create, authenticate, and manage virtual users with strict password hashing (scrypt) and sudo-like privilege elevation.
41
41
  - **Programmatic Shell API**: Execute shell commands and query filesystem state directly from TypeScript without SSH overhead.
42
- - **Built-in Commands**: `ls`, `cd`, `pwd`, `cat`, `mkdir`, `touch`, `rm`, `tree`, `whoami`, `hostname`, `who`, `sudo`, `su`, `adduser`, `deluser`, `nano` (text editor), `curl`, `wget`, and more.
42
+ - **Built-in Commands**: `ls`, `cd`, `pwd`, `cat`, `mkdir`, `touch`, `rm`, `tree`, `whoami`, `hostname`, `who`, `sudo`, `su`, `adduser`, `deluser`, `nano` (text editor), `curl`, `wget`, and a growing set of additional commands. Not everything is implemented yet, and shell compatibility is still being expanded.
43
43
  - **Full TypeScript Support**: Complete JSDoc coverage, exported types, and first-class async/await for all operations.
44
44
 
45
45
  ## Why This Package
@@ -140,7 +140,7 @@ ssh.stop();
140
140
 
141
141
  ## Architecture Overview
142
142
 
143
- ### Core Components
143
+ <!-- ### Core Components
144
144
 
145
145
  ```
146
146
  ┌─────────────────────────────────────────────┐
@@ -158,7 +158,7 @@ ssh.stop();
158
158
  │ Backed by disk
159
159
  │ .vfs/mirror.tar.gz
160
160
  └──────────────────────────────────┘
161
- ```
161
+ ``` -->
162
162
 
163
163
  ### Execution Modes
164
164
 
@@ -1006,7 +1006,7 @@ ssh.stop();
1006
1006
 
1007
1007
  ## Built-in Commands
1008
1008
 
1009
- The following commands are available in both SSH shell mode and via `SshClient.exec()`:
1009
+ The following commands are available in both SSH shell mode and via `SshClient.exec()`. This list is intentionally incomplete: some commands, flags, and edge cases are still missing or only partially compatible with real shells, and that will continue to be worked on.
1010
1010
 
1011
1011
  | Command | Purpose | Notes |
1012
1012
  |---------|---------|-------|
@@ -1040,13 +1040,15 @@ The following commands are available in both SSH shell mode and via `SshClient.e
1040
1040
  ### Environment Variables
1041
1041
 
1042
1042
  - **`SSH_MIMIC_HOSTNAME`**: Override server hostname at startup (default: "typescript-vm")
1043
- - **`SSH_MIMIC_ROOT_PASSWORD`**: Set root password (default: "root")
1043
+ - **`SSH_MIMIC_ROOT_PASSWORD`**: Set root password. If unset, a random ephemeral password is generated at startup and logged once.
1044
+ - **`SSH_MIMIC_AUTO_SUDO_NEW_USERS`**: Control whether new users are added to sudoers automatically (default: enabled). Set to `0`, `false`, `no`, or `off` to disable.
1044
1045
 
1045
1046
  **Example:**
1046
1047
 
1047
1048
  ```bash
1048
1049
  export SSH_MIMIC_HOSTNAME=production-lab
1049
1050
  export SSH_MIMIC_ROOT_PASSWORD=SecurePass123
1051
+ export SSH_MIMIC_AUTO_SUDO_NEW_USERS=false
1050
1052
  npm run start
1051
1053
  ```
1052
1054
 
@@ -1123,7 +1125,7 @@ No. It emulates SSH sessions, users, and filesystem behavior in memory. It is id
1123
1125
 
1124
1126
  ### Can I use this in production?
1125
1127
 
1126
- You can use it in production-like automation contexts (sandboxed command runners, test harnesses, training environments), but it is not a security boundary like a real container/VM.
1128
+ You can use it in production-like automation contexts (sandboxed command runners, test harnesses, training environments), but it is not a security boundary like a real container/VM. And at the moment, all commands are not implemented with full fidelity, so it may not be suitable for all production use cases.
1127
1129
 
1128
1130
  ### Does data persist between restarts?
1129
1131
 
@@ -1231,6 +1233,8 @@ const ssh = new VirtualMachine({ port: 2222, hostname: "hostname" });
1231
1233
  - Passwords are hashed with `scrypt` in the virtual auth store.
1232
1234
  - Root account is always protected and cannot be deleted.
1233
1235
  - Sudo privileges are explicit and persisted in sudoers data.
1236
+ - Protect the root password in production by setting `SSH_MIMIC_ROOT_PASSWORD`; otherwise startup logs a generated ephemeral password.
1237
+ - Disable `SSH_MIMIC_AUTO_SUDO_NEW_USERS` when you want newly created users to stay unprivileged by default.
1234
1238
  - This project is not intended to provide kernel-level or process-level isolation.
1235
1239
 
1236
1240
  If you discover a vulnerability, avoid public disclosure in issues and contact maintainers privately first.
@@ -1258,26 +1262,4 @@ MIT License. See LICENSE file for details.
1258
1262
  - [ ] Improved shell compatibility for complex piping and redirection
1259
1263
  - [ ] Snapshot diff tooling for test assertions
1260
1264
  - [ ] Structured event hooks (session open/close, file write, sudo challenge)
1261
-
1262
- ---
1263
-
1264
- ## Changelog
1265
-
1266
- ### v1.0.0 (2026-04-14)
1267
-
1268
- **Initial Release**
1269
-
1270
- - ✨ SSH server with password auth
1271
- - ✨ Virtual filesystem with persistence
1272
- - ✨ User management & sudoers
1273
- - ✨ Programmatic `SshClient` API
1274
- - ✨ 20+ built-in shell commands
1275
- - ✨ Full TypeScript support & JSDoc coverage
1276
- - ✨ tar.gz snapshot persistence
1277
- - ✨ Session & TTY management
1278
- - ✨ Interactive `nano` editor
1279
- - ✨ Sudo/su privilege escalation
1280
-
1281
- ---
1282
-
1283
- **Made with ❤️ for testing, automation, and interactive TypeScript development.**
1265
+ - [ ] WebSocket-based remote shell client (experimental)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "In-memory SSH server with virtual filesystem and typed programmatic API",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "1.0.1",
6
+ "version": "1.0.4",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "ssh",
@@ -1,16 +1,17 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
- import { resolveReadablePath } from "./helpers";
2
+ import { assertPathAccess, resolveReadablePath } from "./helpers";
3
3
 
4
4
  export const catCommand: ShellModule = {
5
5
  name: "cat",
6
6
  params: ["<file>"],
7
- run: ({ vfs, cwd, args }) => {
7
+ run: ({ authUser, vfs, cwd, args }) => {
8
8
  const fileArg = args[0];
9
9
  if (!fileArg) {
10
10
  return { stderr: "cat: missing file operand", exitCode: 1 };
11
11
  }
12
12
 
13
13
  const target = resolveReadablePath(vfs, cwd, fileArg);
14
+ assertPathAccess(authUser, target, "cat");
14
15
  return { stdout: vfs.readFile(target), exitCode: 0 };
15
16
  },
16
17
  };
@@ -1,11 +1,12 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
- import { resolvePath } from "./helpers";
2
+ import { assertPathAccess, resolvePath } from "./helpers";
3
3
 
4
4
  export const cdCommand: ShellModule = {
5
5
  name: "cd",
6
6
  params: ["[path]"],
7
- run: ({ vfs, cwd, args, mode }) => {
7
+ run: ({ authUser, vfs, cwd, args, mode }) => {
8
8
  const target = resolvePath(cwd, args[0] ?? "/virtual-env-js");
9
+ assertPathAccess(authUser, target, "cd");
9
10
  const stats = vfs.stat(target);
10
11
  if (stats.type !== "directory") {
11
12
  return { stderr: `cd: not a directory: ${target}`, exitCode: 1 };
@@ -1,6 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { ShellModule } from "../../types/commands";
3
- import { normalizeTerminalOutput, resolvePath } from "./helpers";
3
+ import {
4
+ assertPathAccess,
5
+ normalizeTerminalOutput,
6
+ resolvePath,
7
+ } from "./helpers";
4
8
 
5
9
  function parseCurlOutputPath(args: string[]): {
6
10
  outputPath: string | null;
@@ -105,7 +109,7 @@ function runHostCurl(args: string[]): Promise<{
105
109
  export const curlCommand: ShellModule = {
106
110
  name: "curl",
107
111
  params: ["[-o file] <url>"],
108
- run: async ({ vfs, cwd, args }) => {
112
+ run: async ({ authUser, vfs, cwd, args }) => {
109
113
  const { outputPath, inputArgs } = parseCurlOutputPath(args);
110
114
  const url = inputArgs[0];
111
115
  const isHelpLike = inputArgs.some(
@@ -130,7 +134,9 @@ export const curlCommand: ShellModule = {
130
134
  }
131
135
 
132
136
  if (outputPath) {
133
- vfs.writeFile(resolvePath(cwd, outputPath), result.stdout);
137
+ const target = resolvePath(cwd, outputPath);
138
+ assertPathAccess(authUser, target, "curl");
139
+ vfs.writeFile(target, result.stdout);
134
140
  return {
135
141
  stderr: result.stderr
136
142
  ? normalizeTerminalOutput(result.stderr)
@@ -0,0 +1,26 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { getAllEnvVars } from "./set";
3
+
4
+ function expandEnvVars(input: string, env: Record<string, string>): string {
5
+ return input.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name: string) => {
6
+ return env[name] ?? "";
7
+ });
8
+ }
9
+
10
+ export const echoCommand: ShellModule = {
11
+ name: "echo",
12
+ params: ["[options] [text...]"],
13
+ run: ({ args, authUser, stdin }) => {
14
+ const newline = !args.includes("-n");
15
+ const filteredArgs = args.filter((arg) => arg !== "-n");
16
+ const env = getAllEnvVars(authUser);
17
+ const rawText =
18
+ filteredArgs.length > 0 ? filteredArgs.join(" ") : (stdin ?? "");
19
+ const text = expandEnvVars(rawText, env);
20
+
21
+ return {
22
+ stdout: newline ? text : text.trimEnd(),
23
+ exitCode: 0,
24
+ };
25
+ },
26
+ };
@@ -0,0 +1,22 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { getAllEnvVars } from "./set";
3
+
4
+ export const envCommand: ShellModule = {
5
+ name: "env",
6
+ params: ["[VAR=value...] [command]"],
7
+ run: ({ authUser }) => {
8
+ // For now, just display all environment variables
9
+ // In a full implementation, this would also handle running commands with modified env
10
+
11
+ const allVars = getAllEnvVars(authUser);
12
+ const envVarsOutput = Object.entries(allVars)
13
+ .map(([key, value]) => `${key}=${value}`)
14
+ .sort()
15
+ .join("\n");
16
+
17
+ return {
18
+ stdout: envVarsOutput,
19
+ exitCode: 0,
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,32 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { getEnvVar, setEnvVar } from "./set";
3
+
4
+ export const exportCommand: ShellModule = {
5
+ name: "export",
6
+ params: ["[VAR=value]"],
7
+ run: ({ args }) => {
8
+ // export VAR=value or export VAR (to make it available to child processes)
9
+ if (args.length === 0) {
10
+ // List all exported variables
11
+ return {
12
+ stdout: "# export command - sets variables for child processes",
13
+ exitCode: 0,
14
+ };
15
+ }
16
+
17
+ // Parse VAR=value format
18
+ for (const arg of args) {
19
+ if (arg.includes("=")) {
20
+ const [varName, varValue] = arg.split("=", 2);
21
+ if (varName && varValue !== undefined) {
22
+ setEnvVar(varName, varValue);
23
+ }
24
+ } else {
25
+ // export VAR_NAME makes it available but we just set it
26
+ setEnvVar(arg, getEnvVar(arg) || "");
27
+ }
28
+ }
29
+
30
+ return { exitCode: 0 };
31
+ },
32
+ };
@@ -0,0 +1,81 @@
1
+ import type { ShellModule } from "../../types/commands";
2
+ import { assertPathAccess, resolvePath } from "./helpers";
3
+
4
+ export const grepCommand: ShellModule = {
5
+ name: "grep",
6
+ params: ["[-i] [-v] <pattern> [file...]"],
7
+ run: ({ authUser, vfs, cwd, args, stdin }) => {
8
+ const caseInsensitive = args.includes("-i");
9
+ const invertMatch = args.includes("-v");
10
+ const filteredArgs = args.filter((arg) => arg !== "-i" && arg !== "-v");
11
+
12
+ if (filteredArgs.length === 0) {
13
+ return { stderr: "grep: no pattern specified", exitCode: 1 };
14
+ }
15
+
16
+ const pattern = filteredArgs[0];
17
+ const files = filteredArgs.slice(1);
18
+
19
+ let regex: RegExp;
20
+ try {
21
+ const flags = caseInsensitive ? "gmi" : "gm";
22
+ regex = new RegExp(pattern as string, flags);
23
+ } catch {
24
+ return { stderr: `grep: invalid regex: ${pattern}`, exitCode: 1 };
25
+ }
26
+
27
+ const results: string[] = [];
28
+
29
+ // If no files specified, read from stdin (pipe/input redirection).
30
+ if (files.length === 0) {
31
+ if (!stdin) {
32
+ return { stdout: "", exitCode: 1 };
33
+ }
34
+
35
+ const lines = stdin.split("\n");
36
+ for (const line of lines) {
37
+ regex.lastIndex = 0;
38
+ const matches = regex.test(line);
39
+ const shouldInclude = invertMatch ? !matches : matches;
40
+
41
+ if (shouldInclude) {
42
+ results.push(line);
43
+ }
44
+ }
45
+
46
+ return {
47
+ stdout: results.length > 0 ? results.join("\n") : "",
48
+ exitCode: results.length > 0 ? 0 : 1,
49
+ };
50
+ }
51
+
52
+ for (const file of files) {
53
+ const target = resolvePath(cwd, file);
54
+ try {
55
+ assertPathAccess(authUser, target, "grep");
56
+ const content = vfs.readFile(target);
57
+ const lines = content.split("\n");
58
+
59
+ for (const line of lines) {
60
+ regex.lastIndex = 0;
61
+ const matches = regex.test(line);
62
+ const shouldInclude = invertMatch ? !matches : matches;
63
+
64
+ if (shouldInclude) {
65
+ results.push(line);
66
+ }
67
+ }
68
+ } catch {
69
+ return {
70
+ stderr: `grep: ${file}: No such file or directory`,
71
+ exitCode: 1,
72
+ };
73
+ }
74
+ }
75
+
76
+ return {
77
+ stdout: results.length > 0 ? results.join("\n") : "",
78
+ exitCode: results.length > 0 ? 0 : 1,
79
+ };
80
+ },
81
+ };
@@ -1,6 +1,8 @@
1
1
  import * as path from "node:path";
2
2
  import type VirtualFileSystem from "../../VirtualFileSystem";
3
3
 
4
+ const PROTECTED_PREFIXES = ["/virtual-env-js/.auth"] as const;
5
+
4
6
  function normalizeFetchUrl(input: string): string {
5
7
  if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
6
8
  return input;
@@ -32,6 +34,30 @@ export function resolvePath(cwd: string, inputPath: string): string {
32
34
  : path.posix.normalize(path.posix.join(cwd, inputPath));
33
35
  }
34
36
 
37
+ function isProtectedPath(targetPath: string): boolean {
38
+ const normalized = targetPath.startsWith("/")
39
+ ? path.posix.normalize(targetPath)
40
+ : path.posix.normalize(`/${targetPath}`);
41
+
42
+ return PROTECTED_PREFIXES.some(
43
+ (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
44
+ );
45
+ }
46
+
47
+ export function assertPathAccess(
48
+ authUser: string,
49
+ targetPath: string,
50
+ operation: string,
51
+ ): void {
52
+ if (authUser === "root") {
53
+ return;
54
+ }
55
+
56
+ if (isProtectedPath(targetPath)) {
57
+ throw new Error(`${operation}: permission denied: ${targetPath}`);
58
+ }
59
+ }
60
+
35
61
  export function parseOutputPath(args: string[]): {
36
62
  outputPath: string | null;
37
63
  inputArgs: string[];