typescript-virtual-container 0.1.0 → 1.0.3

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.
@@ -3,11 +3,10 @@ name: Auto Create Pull Request
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - 'dev'
7
6
 
8
7
  jobs:
9
8
  create-pull-request:
10
- if: github.ref == 'refs/heads/dev'
9
+ if: github.ref != 'refs/heads/main'
11
10
  runs-on: ubuntu-latest
12
11
  steps:
13
12
  - name: Validate pull request token
@@ -26,18 +25,21 @@ jobs:
26
25
  script: |
27
26
  const owner = context.repo.owner;
28
27
  const repo = context.repo.repo;
29
- const head = `${owner}:dev`;
28
+ const sourceBranch = context.ref.startsWith('refs/heads/')
29
+ ? context.ref.slice('refs/heads/'.length)
30
+ : context.ref;
31
+ const head = `${owner}:${sourceBranch}`;
30
32
  const base = 'main';
31
33
 
32
- // Check if there are commits between main and dev
34
+ // Check if there are commits between main and the current branch
33
35
  const { data: comparison } = await github.rest.repos.compareCommits({
34
36
  owner,
35
37
  repo,
36
38
  base: 'main',
37
- head: 'dev',
39
+ head: sourceBranch,
38
40
  }).catch(err => {
39
41
  if (err.status === 404 && err.message.includes('No commits')) {
40
- core.info('No commits between main and dev - skipping PR creation');
42
+ core.info('No commits between main and ' + sourceBranch + ' - skipping PR creation');
41
43
  return { data: { ahead_by: 0 } };
42
44
  }
43
45
  throw err;
@@ -64,13 +66,13 @@ jobs:
64
66
  const { data: pullRequest } = await github.rest.pulls.create({
65
67
  owner,
66
68
  repo,
67
- head: 'dev',
69
+ head: sourceBranch,
68
70
  base,
69
- title: 'chore: auto PR from dev to main',
71
+ title: `chore: auto PR from ${sourceBranch} to main`,
70
72
  body: [
71
73
  'This pull request was created automatically by GitHub Actions.',
72
74
  '',
73
- `- source branch: \`dev\``,
75
+ `- source branch: \`${sourceBranch}\``,
74
76
  `- target branch: \`main\``,
75
77
  `- triggered by commit \`${context.sha}\``,
76
78
  ].join('\n'),
@@ -3,16 +3,13 @@ name: Test battery
3
3
  on:
4
4
  pull_request:
5
5
  branches:
6
- - main
7
- - dev
8
6
 
9
7
  permissions:
10
8
  contents: read
11
9
 
12
10
  jobs:
13
- typecheck:
11
+ setup:
14
12
  runs-on: ubuntu-latest
15
-
16
13
  steps:
17
14
  - name: Checkout repository
18
15
  uses: actions/checkout@v4
@@ -25,12 +22,36 @@ jobs:
25
22
  - name: Install dependencies
26
23
  run: bun install
27
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
+
28
49
  - name: Run typecheck
29
50
  run: bun check
30
51
 
31
52
  lint:
53
+ needs: setup
32
54
  runs-on: ubuntu-latest
33
-
34
55
  steps:
35
56
  - name: Checkout repository
36
57
  uses: actions/checkout@v4
@@ -40,8 +61,11 @@ jobs:
40
61
  with:
41
62
  bun-version: latest
42
63
 
43
- - name: Install dependencies
44
- 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') }}
45
69
 
46
70
  - name: Run lint
47
71
  run: bun lint
@@ -51,7 +75,6 @@ jobs:
51
75
  - typecheck
52
76
  - lint
53
77
  runs-on: ubuntu-latest
54
-
55
78
  steps:
56
79
  - name: Test battery passed
57
80
  run: echo "Typecheck, lint and tests succeeded."
package/CHANGELOG.md CHANGED
@@ -6,6 +6,8 @@ The format is based on Keep a Changelog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.2] - 2026-04-14
10
+
9
11
  ### Added
10
12
 
11
13
  - Governance and community files:
@@ -14,6 +16,39 @@ The format is based on Keep a Changelog.
14
16
  - SECURITY.md
15
17
  - CODE_OF_CONDUCT.md
16
18
  - GitHub issue and PR templates
19
+ - Security hardening for virtual auth storage and shell access:
20
+ - Non-root commands now block access to `/virtual-env-js/.auth/**`.
21
+ - Auth files are persisted with restrictive modes (`0700` for `.auth`, `0600` for `htpasswd` and `sudoers`).
22
+ - Root password no longer falls back to a fixed default when `SSH_MIMIC_ROOT_PASSWORD` is unset; startup generates an ephemeral password instead.
23
+ - New environment toggle `SSH_MIMIC_AUTO_SUDO_NEW_USERS` to control whether newly created users are added to sudoers by default.
24
+ - README and security docs now describe the new auth hardening and configuration flags.
25
+ - Added tests covering `.auth` path protection and auto-sudo behavior.
26
+
27
+ ## [1.0.1] - 2026-04-14
28
+
29
+ ### Added
30
+
31
+ - `ls -l` / `ls --long` support with long listing format (permissions, size, updated time).
32
+ - Host-command mirroring for network tools:
33
+ - `curl` now runs through host `curl` via `child_process`.
34
+ - `wget` now runs through host `wget` via `child_process`.
35
+ - Temporary host download flow for `wget` using `/tmp` before import into VFS.
36
+ - Terminal line normalization utility for command help and diagnostics rendering.
37
+
38
+ ### Changed
39
+
40
+ - `curl` behavior is now aligned with the host binary output and exit codes.
41
+ - `curl -o` writes host command output to the virtual filesystem target path.
42
+ - `wget` writes downloaded payloads to VFS after host-side transfer, preserving command semantics.
43
+ - URL fetch helper now accepts host-only inputs by normalizing missing protocol to `http://`.
44
+ - Auto pull-request GitHub workflow now targets any non-`main` branch instead of only `dev`.
45
+ - Auto PR metadata now uses the dynamic source branch name in PR head/title/body.
46
+ - Test workflow trigger scope was generalized by removing hardcoded branch filters.
47
+
48
+ ### Fixed
49
+
50
+ - Resolved large horizontal spacing artifacts in SSH terminal output by normalizing TTY line endings (`\r\n`) in both interactive shell and exec paths.
51
+ - Reduced excessive whitespace in help output rendering (`curl --help`, `wget --help`) by normalizing tabs and over-padded spacing.
17
52
 
18
53
  ## [1.0.0] - 2026-04-14
19
54
 
package/README.md CHANGED
@@ -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
 
@@ -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.
package/biome.json CHANGED
@@ -1,4 +1,11 @@
1
1
  {
2
+ "assist": {
3
+ "actions": {
4
+ "source": {
5
+ "organizeImports": "off"
6
+ }
7
+ }
8
+ },
2
9
  "linter": {
3
10
  "rules": {
4
11
  "suspicious": {
@@ -10,11 +17,5 @@
10
17
  "noNonNullAssertion": "off"
11
18
  }
12
19
  }
13
- },
14
- "assist": {
15
- "source": {
16
- "autoImport": "on",
17
- "importModuleSpecifierPreference": "relative"
18
- }
19
20
  }
20
21
  }
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": "0.1.0",
6
+ "version": "1.0.3",
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,27 +1,158 @@
1
+ import { spawn } from "node:child_process";
1
2
  import type { ShellModule } from "../../types/commands";
2
- import { fetchResource, parseOutputPath, resolvePath } from "./helpers";
3
+ import {
4
+ assertPathAccess,
5
+ normalizeTerminalOutput,
6
+ resolvePath,
7
+ } from "./helpers";
8
+
9
+ function parseCurlOutputPath(args: string[]): {
10
+ outputPath: string | null;
11
+ inputArgs: string[];
12
+ } {
13
+ const filtered: string[] = [];
14
+ let outputPath: string | null = null;
15
+
16
+ for (let index = 0; index < args.length; index += 1) {
17
+ const arg = args[index]!;
18
+
19
+ if (arg === "-o" || arg === "--output") {
20
+ outputPath = args[index + 1] ?? null;
21
+ index += 1;
22
+ continue;
23
+ }
24
+
25
+ if (arg.startsWith("-o=")) {
26
+ outputPath = arg.slice(3);
27
+ continue;
28
+ }
29
+
30
+ if (arg.startsWith("--output=")) {
31
+ outputPath = arg.slice("--output=".length);
32
+ continue;
33
+ }
34
+
35
+ filtered.push(arg);
36
+ }
37
+
38
+ return { outputPath, inputArgs: filtered };
39
+ }
40
+
41
+ function runHostCurl(args: string[]): Promise<{
42
+ stdout: string;
43
+ stderr: string;
44
+ exitCode: number;
45
+ }> {
46
+ return new Promise((resolve) => {
47
+ let childProcess: ReturnType<typeof spawn>;
48
+
49
+ try {
50
+ childProcess = spawn("curl", args, {
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ });
53
+ } catch (error) {
54
+ resolve({
55
+ stdout: "",
56
+ stderr: `curl: ${error instanceof Error ? error.message : String(error)}`,
57
+ exitCode: 1,
58
+ });
59
+ return;
60
+ }
61
+
62
+ let stdout = "";
63
+ let stderr = "";
64
+ const stdoutStream = childProcess.stdout;
65
+ const stderrStream = childProcess.stderr;
66
+
67
+ if (!stdoutStream || !stderrStream) {
68
+ resolve({
69
+ stdout: "",
70
+ stderr: "curl: failed to capture process output",
71
+ exitCode: 1,
72
+ });
73
+ return;
74
+ }
75
+
76
+ stdoutStream.setEncoding("utf8");
77
+ stderrStream.setEncoding("utf8");
78
+
79
+ stdoutStream.on("data", (chunk: string) => {
80
+ stdout += chunk;
81
+ });
82
+
83
+ stderrStream.on("data", (chunk: string) => {
84
+ stderr += chunk;
85
+ });
86
+
87
+ childProcess.on("error", (error) => {
88
+ const errorCode =
89
+ error instanceof Error && "code" in error
90
+ ? String((error as NodeJS.ErrnoException).code ?? "")
91
+ : "";
92
+ resolve({
93
+ stdout: "",
94
+ stderr: `curl: ${error.message}`,
95
+ exitCode: errorCode === "ENOENT" ? 127 : 1,
96
+ });
97
+ });
98
+
99
+ childProcess.on("close", (code) => {
100
+ resolve({
101
+ stdout,
102
+ stderr,
103
+ exitCode: code ?? 1,
104
+ });
105
+ });
106
+ });
107
+ }
3
108
 
4
109
  export const curlCommand: ShellModule = {
5
110
  name: "curl",
6
111
  params: ["[-o file] <url>"],
7
- run: async ({ vfs, cwd, args }) => {
8
- const { outputPath, inputArgs } = parseOutputPath(args);
112
+ run: async ({ authUser, vfs, cwd, args }) => {
113
+ const { outputPath, inputArgs } = parseCurlOutputPath(args);
9
114
  const url = inputArgs[0];
115
+ const isHelpLike = inputArgs.some(
116
+ (arg) =>
117
+ arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
118
+ );
10
119
 
11
120
  if (!url) {
12
121
  return { stderr: "curl: missing URL", exitCode: 1 };
13
122
  }
14
123
 
15
- const result = await fetchResource(url);
16
- if (result.status >= 400) {
17
- return { stderr: `curl: HTTP ${result.status}`, exitCode: 22 };
124
+ const passthroughArgs = outputPath ? [...inputArgs, "-o", "-"] : inputArgs;
125
+ const result = await runHostCurl(passthroughArgs);
126
+
127
+ if (result.exitCode !== 0) {
128
+ return {
129
+ stderr: normalizeTerminalOutput(
130
+ result.stderr || `curl: exited with code ${result.exitCode}`,
131
+ ),
132
+ exitCode: result.exitCode,
133
+ };
18
134
  }
19
135
 
20
136
  if (outputPath) {
21
- vfs.writeFile(resolvePath(cwd, outputPath), result.text);
22
- return { exitCode: 0 };
137
+ const target = resolvePath(cwd, outputPath);
138
+ assertPathAccess(authUser, target, "curl");
139
+ vfs.writeFile(target, result.stdout);
140
+ return {
141
+ stderr: result.stderr
142
+ ? normalizeTerminalOutput(result.stderr)
143
+ : undefined,
144
+ exitCode: 0,
145
+ };
23
146
  }
24
147
 
25
- return { stdout: result.text, exitCode: 0 };
148
+ return {
149
+ stdout: isHelpLike
150
+ ? normalizeTerminalOutput(result.stdout)
151
+ : result.stdout,
152
+ stderr: result.stderr
153
+ ? normalizeTerminalOutput(result.stderr)
154
+ : undefined,
155
+ exitCode: 0,
156
+ };
26
157
  },
27
158
  };
@@ -1,6 +1,30 @@
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
+
6
+ function normalizeFetchUrl(input: string): string {
7
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
8
+ return input;
9
+ }
10
+
11
+ return `http://${input}`;
12
+ }
13
+
14
+ export function normalizeTerminalOutput(text: string): string {
15
+ return text
16
+ .replace(/\r\n/g, "\n")
17
+ .replace(/\r/g, "\n")
18
+ .replace(/\t/g, " ")
19
+ .split("\n")
20
+ .map((line) =>
21
+ line.replace(/^[ \u00A0]{8,}/, " ").replace(/[ \u00A0]{3,}/g, " "),
22
+ )
23
+ .join("\n")
24
+ .replace(/\n{3,}/g, "\n\n")
25
+ .trimEnd();
26
+ }
27
+
4
28
  export function resolvePath(cwd: string, inputPath: string): string {
5
29
  if (!inputPath || inputPath.trim() === "") {
6
30
  return cwd;
@@ -10,6 +34,30 @@ export function resolvePath(cwd: string, inputPath: string): string {
10
34
  : path.posix.normalize(path.posix.join(cwd, inputPath));
11
35
  }
12
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
+
13
61
  export function parseOutputPath(args: string[]): {
14
62
  outputPath: string | null;
15
63
  inputArgs: string[];
@@ -56,7 +104,7 @@ export function stripUrlFilename(url: string): string {
56
104
  export async function fetchResource(
57
105
  url: string,
58
106
  ): Promise<{ text: string; status: number; contentType: string | null }> {
59
- const response = await fetch(url);
107
+ const response = await fetch(normalizeFetchUrl(url));
60
108
  const contentType = response.headers.get("content-type");
61
109
  return {
62
110
  text: await response.text(),
@@ -1,14 +1,49 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
- import { joinListWithType, resolvePath } from "./helpers";
2
+ import { assertPathAccess, joinListWithType, resolvePath } from "./helpers";
3
+
4
+ function formatPermissions(mode: number, isDirectory: boolean): string {
5
+ const fileType = isDirectory ? "d" : "-";
6
+ const permissionBits = [
7
+ [0o400, "r"],
8
+ [0o200, "w"],
9
+ [0o100, "x"],
10
+ [0o040, "r"],
11
+ [0o020, "w"],
12
+ [0o010, "x"],
13
+ [0o004, "r"],
14
+ [0o002, "w"],
15
+ [0o001, "x"],
16
+ ] as const;
17
+ const permissions = permissionBits
18
+ .map(([bit, symbol]) => (mode & bit ? symbol : "-"))
19
+ .join("");
20
+
21
+ return `${fileType}${permissions}`;
22
+ }
23
+
24
+ function formatDate(date: Date): string {
25
+ return date.toISOString().replace("T", " ").slice(0, 16);
26
+ }
3
27
 
4
28
  export const lsCommand: ShellModule = {
5
29
  name: "ls",
6
30
  params: ["[path]"],
7
- run: ({ vfs, cwd, args }) => {
31
+ run: ({ authUser, vfs, cwd, args }) => {
32
+ const longFormat = args.includes("-l") || args.includes("--long");
8
33
  const targetArg = args.find((arg) => !arg.startsWith("-"));
9
34
  const target = resolvePath(cwd, targetArg ?? cwd);
35
+ assertPathAccess(authUser, target, "ls");
10
36
  const items = vfs.list(target).filter((name) => !name.startsWith("."));
11
- const rendered = joinListWithType(target, items, (p) => vfs.stat(p));
37
+ const rendered = longFormat
38
+ ? items
39
+ .map((name) => {
40
+ const childPath = resolvePath(target, name);
41
+ const stat = vfs.stat(childPath);
42
+ const size = stat.type === "file" ? stat.size : stat.childrenCount;
43
+ return `${formatPermissions(stat.mode, stat.type === "directory")} 1 ${size} ${formatDate(stat.updatedAt)} ${name}${stat.type === "directory" ? "/" : ""}`;
44
+ })
45
+ .join("\n")
46
+ : joinListWithType(target, items, (p) => vfs.stat(p));
12
47
  return { stdout: rendered, exitCode: 0 };
13
48
  },
14
49
  };
@@ -1,16 +1,18 @@
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 mkdirCommand: ShellModule = {
5
5
  name: "mkdir",
6
6
  params: ["<dir>"],
7
- run: ({ vfs, cwd, args }) => {
7
+ run: ({ authUser, vfs, cwd, args }) => {
8
8
  if (args.length === 0) {
9
9
  return { stderr: "mkdir: missing operand", exitCode: 1 };
10
10
  }
11
11
 
12
12
  for (const dir of args) {
13
- vfs.mkdir(resolvePath(cwd, dir));
13
+ const target = resolvePath(cwd, dir);
14
+ assertPathAccess(authUser, target, "mkdir");
15
+ vfs.mkdir(target);
14
16
  }
15
17
  return { exitCode: 0 };
16
18
  },
@@ -1,17 +1,18 @@
1
1
  import * as path from "node:path";
2
2
  import type { ShellModule } from "../../types/commands";
3
- import { resolvePath } from "./helpers";
3
+ import { assertPathAccess, resolvePath } from "./helpers";
4
4
 
5
5
  export const nanoCommand: ShellModule = {
6
6
  name: "nano",
7
7
  params: ["<file>"],
8
- run: ({ vfs, cwd, args }) => {
8
+ run: ({ authUser, vfs, cwd, args }) => {
9
9
  const fileArg = args[0];
10
10
  if (!fileArg) {
11
11
  return { stderr: "nano: missing file operand", exitCode: 1 };
12
12
  }
13
13
 
14
14
  const targetPath = resolvePath(cwd, fileArg);
15
+ assertPathAccess(authUser, targetPath, "nano");
15
16
  const initialContent = vfs.exists(targetPath)
16
17
  ? vfs.readFile(targetPath)
17
18
  : "";
@@ -1,10 +1,10 @@
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 rmCommand: ShellModule = {
5
5
  name: "rm",
6
6
  params: ["[-r|-rf] <path>"],
7
- run: ({ vfs, cwd, args }) => {
7
+ run: ({ authUser, vfs, cwd, args }) => {
8
8
  if (args.length === 0) {
9
9
  return { stderr: "rm: missing operand", exitCode: 1 };
10
10
  }
@@ -18,7 +18,9 @@ export const rmCommand: ShellModule = {
18
18
  }
19
19
 
20
20
  for (const target of targets) {
21
- vfs.remove(resolvePath(cwd, target), { recursive });
21
+ const resolvedTarget = resolvePath(cwd, target);
22
+ assertPathAccess(authUser, resolvedTarget, "rm");
23
+ vfs.remove(resolvedTarget, { recursive });
22
24
  }
23
25
 
24
26
  return { exitCode: 0 };
@@ -1,16 +1,17 @@
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 touchCommand: ShellModule = {
5
5
  name: "touch",
6
6
  params: ["<file>"],
7
- run: ({ vfs, cwd, args }) => {
7
+ run: ({ authUser, vfs, cwd, args }) => {
8
8
  if (args.length === 0) {
9
9
  return { stderr: "touch: missing file operand", exitCode: 1 };
10
10
  }
11
11
 
12
12
  for (const file of args) {
13
13
  const target = resolvePath(cwd, file);
14
+ assertPathAccess(authUser, target, "touch");
14
15
  if (!vfs.exists(target)) {
15
16
  vfs.writeFile(target, "");
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 treeCommand: ShellModule = {
5
5
  name: "tree",
6
6
  params: ["[path]"],
7
- run: ({ vfs, cwd, args }) => {
7
+ run: ({ authUser, vfs, cwd, args }) => {
8
8
  const target = resolvePath(cwd, args[0] ?? cwd);
9
+ assertPathAccess(authUser, target, "tree");
9
10
  return { stdout: vfs.tree(target), exitCode: 0 };
10
11
  },
11
12
  };
@@ -1,33 +1,137 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
1
5
  import type { ShellModule } from "../../types/commands";
2
6
  import {
3
- fetchResource,
7
+ assertPathAccess,
8
+ normalizeTerminalOutput,
4
9
  parseOutputPath,
5
10
  resolvePath,
6
11
  stripUrlFilename,
7
12
  } from "./helpers";
8
13
 
14
+ function runHostWget(args: string[]): Promise<{
15
+ stdout: string;
16
+ stderr: string;
17
+ exitCode: number;
18
+ }> {
19
+ return new Promise((resolve) => {
20
+ let childProcess: ReturnType<typeof spawn>;
21
+
22
+ try {
23
+ childProcess = spawn("wget", args, {
24
+ stdio: ["ignore", "pipe", "pipe"],
25
+ });
26
+ } catch (error) {
27
+ resolve({
28
+ stdout: "",
29
+ stderr: `wget: ${error instanceof Error ? error.message : String(error)}`,
30
+ exitCode: 1,
31
+ });
32
+ return;
33
+ }
34
+
35
+ let stdout = "";
36
+ let stderr = "";
37
+ const stdoutStream = childProcess.stdout;
38
+ const stderrStream = childProcess.stderr;
39
+
40
+ if (!stdoutStream || !stderrStream) {
41
+ resolve({
42
+ stdout: "",
43
+ stderr: "wget: failed to capture process output",
44
+ exitCode: 1,
45
+ });
46
+ return;
47
+ }
48
+
49
+ stdoutStream.setEncoding("utf8");
50
+ stderrStream.setEncoding("utf8");
51
+
52
+ stdoutStream.on("data", (chunk: string) => {
53
+ stdout += chunk;
54
+ });
55
+
56
+ stderrStream.on("data", (chunk: string) => {
57
+ stderr += chunk;
58
+ });
59
+
60
+ childProcess.on("error", (error) => {
61
+ const errorCode =
62
+ error instanceof Error && "code" in error
63
+ ? String((error as NodeJS.ErrnoException).code ?? "")
64
+ : "";
65
+ resolve({
66
+ stdout: "",
67
+ stderr: `wget: ${error.message}`,
68
+ exitCode: errorCode === "ENOENT" ? 127 : 1,
69
+ });
70
+ });
71
+
72
+ childProcess.on("close", (code) => {
73
+ resolve({
74
+ stdout,
75
+ stderr,
76
+ exitCode: code ?? 1,
77
+ });
78
+ });
79
+ });
80
+ }
81
+
9
82
  export const wgetCommand: ShellModule = {
10
83
  name: "wget",
11
84
  params: ["[url]"],
12
- run: async ({ vfs, cwd, args }) => {
85
+ run: async ({ authUser, vfs, cwd, args }) => {
13
86
  const { outputPath, inputArgs } = parseOutputPath(args);
14
87
  const url = inputArgs[0];
88
+ const isHelpLike = inputArgs.some(
89
+ (arg) =>
90
+ arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
91
+ );
15
92
 
16
93
  if (!url) {
17
94
  return { stderr: "wget: missing URL", exitCode: 1 };
18
95
  }
19
96
 
20
- const result = await fetchResource(url);
21
- if (result.status >= 400) {
22
- return { stderr: `wget: HTTP ${result.status}`, exitCode: 8 };
97
+ if (isHelpLike) {
98
+ const result = await runHostWget(inputArgs);
99
+ return {
100
+ stdout: normalizeTerminalOutput(result.stdout),
101
+ stderr: result.stderr
102
+ ? normalizeTerminalOutput(result.stderr)
103
+ : undefined,
104
+ exitCode: result.exitCode,
105
+ };
23
106
  }
24
107
 
25
- const target = resolvePath(cwd, outputPath ?? stripUrlFilename(url));
26
- vfs.writeFile(target, result.text);
108
+ const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-wget-"));
109
+ const tempFile = join(tempDir, "download");
110
+
111
+ try {
112
+ const hostArgs = [...inputArgs, "-O", tempFile];
113
+ const result = await runHostWget(hostArgs);
27
114
 
28
- return {
29
- stdout: `saved ${target}`,
30
- exitCode: 0,
31
- };
115
+ if (result.exitCode !== 0) {
116
+ return {
117
+ stderr: normalizeTerminalOutput(
118
+ result.stderr || `wget: exited with code ${result.exitCode}`,
119
+ ),
120
+ exitCode: result.exitCode,
121
+ };
122
+ }
123
+
124
+ const content = await readFile(tempFile, "utf8");
125
+ const target = resolvePath(cwd, outputPath ?? stripUrlFilename(url));
126
+ assertPathAccess(authUser, target, "wget");
127
+ vfs.writeFile(target, content);
128
+
129
+ return {
130
+ stdout: `saved ${target}`,
131
+ exitCode: 0,
132
+ };
133
+ } finally {
134
+ await rm(tempDir, { recursive: true, force: true });
135
+ }
32
136
  },
33
137
  };
@@ -3,6 +3,13 @@ import type VirtualFileSystem from "../VirtualFileSystem";
3
3
  import { runCommand } from "./commands";
4
4
  import type { VirtualUserManager } from "./users";
5
5
 
6
+ function toTtyLines(text: string): string {
7
+ return text
8
+ .replace(/\r\n/g, "\n")
9
+ .replace(/\r/g, "\n")
10
+ .replace(/\n/g, "\r\n");
11
+ }
12
+
6
13
  export function runExec(
7
14
  stream: ExecStream,
8
15
  cmd: string,
@@ -23,11 +30,11 @@ export function runExec(
23
30
  ),
24
31
  ).then((result) => {
25
32
  if (result.stdout) {
26
- stream.write(`${result.stdout}\n`);
33
+ stream.write(`${toTtyLines(result.stdout)}\r\n`);
27
34
  }
28
35
 
29
36
  if (result.stderr) {
30
- stream.stderr.write(`${result.stderr}\n`);
37
+ stream.stderr.write(`${toTtyLines(result.stderr)}\r\n`);
31
38
  }
32
39
 
33
40
  stream.exit(result.exitCode ?? 0);
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { Server as SshServer } from "ssh2";
2
3
  import VirtualFileSystem from "../VirtualFileSystem";
3
4
  import { runExec } from "./exec";
@@ -5,6 +6,28 @@ import { loadOrCreateHostKey } from "./hostKey";
5
6
  import { startShell } from "./shell";
6
7
  import { VirtualUserManager } from "./users";
7
8
 
9
+ function resolveRootPassword(): string {
10
+ const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
11
+ if (configured && configured.trim().length > 0) {
12
+ return configured;
13
+ }
14
+
15
+ const generated = randomBytes(18).toString("base64url");
16
+ console.warn(
17
+ `[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
18
+ );
19
+ return generated;
20
+ }
21
+
22
+ function resolveAutoSudoForNewUsers(): boolean {
23
+ const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
24
+ if (!configured) {
25
+ return true;
26
+ }
27
+
28
+ return !["0", "false", "no", "off"].includes(configured.toLowerCase());
29
+ }
30
+
8
31
  /**
9
32
  * SSH server wrapper that exposes virtual shell and exec sessions.
10
33
  *
@@ -52,7 +75,8 @@ class SshMimic {
52
75
  await this.vfs.restoreMirror();
53
76
  this.users = new VirtualUserManager(
54
77
  this.vfs,
55
- process.env.SSH_MIMIC_ROOT_PASSWORD ?? "root",
78
+ resolveRootPassword(),
79
+ resolveAutoSudoForNewUsers(),
56
80
  );
57
81
  await this.users.initialize();
58
82
 
@@ -33,6 +33,13 @@ interface TerminalSize {
33
33
  rows: number;
34
34
  }
35
35
 
36
+ function toTtyLines(text: string): string {
37
+ return text
38
+ .replace(/\r\n/g, "\n")
39
+ .replace(/\r/g, "\n")
40
+ .replace(/\n/g, "\r\n");
41
+ }
42
+
36
43
  export function startShell(
37
44
  stream: ShellStream,
38
45
  authUser: string,
@@ -198,11 +205,11 @@ export function startShell(
198
205
  }
199
206
 
200
207
  if (result.stdout) {
201
- stream.write(`${result.stdout}\r\n`);
208
+ stream.write(`${toTtyLines(result.stdout)}\r\n`);
202
209
  }
203
210
 
204
211
  if (result.stderr) {
205
- stream.write(`${result.stderr}\r\n`);
212
+ stream.write(`${toTtyLines(result.stderr)}\r\n`);
206
213
  }
207
214
 
208
215
  if (result.switchUser) {
@@ -671,11 +678,11 @@ export function startShell(
671
678
  }
672
679
 
673
680
  if (result.stdout) {
674
- stream.write(`${result.stdout}\r\n`);
681
+ stream.write(`${toTtyLines(result.stdout)}\r\n`);
675
682
  }
676
683
 
677
684
  if (result.stderr) {
678
- stream.write(`${result.stderr}\r\n`);
685
+ stream.write(`${toTtyLines(result.stderr)}\r\n`);
679
686
  }
680
687
 
681
688
  if (result.closeSession) {
@@ -31,6 +31,7 @@ export interface VirtualActiveSession {
31
31
  export class VirtualUserManager {
32
32
  private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
33
33
  private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
34
+ private readonly authDirPath = "/virtual-env-js/.auth";
34
35
  private readonly users = new Map<string, VirtualUserRecord>();
35
36
  private readonly sudoers = new Set<string>();
36
37
  private readonly activeSessions = new Map<string, VirtualActiveSession>();
@@ -45,6 +46,7 @@ export class VirtualUserManager {
45
46
  constructor(
46
47
  private readonly vfs: VirtualFileSystem,
47
48
  private readonly defaultRootPassword: string = "root",
49
+ private readonly autoSudoForNewUsers: boolean = true,
48
50
  ) {}
49
51
 
50
52
  /**
@@ -54,12 +56,7 @@ export class VirtualUserManager {
54
56
  this.loadFromVfs();
55
57
  this.loadSudoersFromVfs();
56
58
 
57
- if (!this.users.has("root")) {
58
- this.users.set(
59
- "root",
60
- this.createRecord("root", this.defaultRootPassword),
61
- );
62
- }
59
+ this.users.set("root", this.createRecord("root", this.defaultRootPassword));
63
60
 
64
61
  this.sudoers.add("root");
65
62
 
@@ -97,7 +94,9 @@ export class VirtualUserManager {
97
94
  }
98
95
 
99
96
  this.users.set(username, this.createRecord(username, password));
100
- this.sudoers.add(username);
97
+ if (this.autoSudoForNewUsers) {
98
+ this.sudoers.add(username);
99
+ }
101
100
  const homePath = `/home/${username}`;
102
101
  if (!this.vfs.exists(homePath)) {
103
102
  this.vfs.mkdir(homePath, 0o755);
@@ -290,6 +289,10 @@ export class VirtualUserManager {
290
289
  }
291
290
 
292
291
  private async persist(): Promise<void> {
292
+ if (!this.vfs.exists(this.authDirPath)) {
293
+ this.vfs.mkdir(this.authDirPath, 0o700);
294
+ }
295
+
293
296
  const content = Array.from(this.users.values())
294
297
  .sort((left, right) => left.username.localeCompare(right.username))
295
298
  .map((record) =>
@@ -300,11 +303,13 @@ export class VirtualUserManager {
300
303
  this.vfs.writeFile(
301
304
  this.usersPath,
302
305
  content.length > 0 ? `${content}\n` : "",
306
+ { mode: 0o600 },
303
307
  );
304
308
  const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
305
309
  this.vfs.writeFile(
306
310
  this.sudoersPath,
307
311
  sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
312
+ { mode: 0o600 },
308
313
  );
309
314
  await this.vfs.flushMirror();
310
315
  }
@@ -326,6 +331,10 @@ export class VirtualUserManager {
326
331
  if (!username || username.trim() === "") {
327
332
  throw new Error("invalid username");
328
333
  }
334
+
335
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(username)) {
336
+ throw new Error("invalid username");
337
+ }
329
338
  }
330
339
 
331
340
  private validatePassword(password: string): void {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export type {
10
10
  CommandResult,
11
11
  NanoEditorSession,
12
12
  ShellModule,
13
- SudoChallenge
13
+ SudoChallenge,
14
14
  } from "./types/commands";
15
15
  export type { ExecStream, ShellStream } from "./types/streams";
16
16
  export type {
@@ -25,10 +25,12 @@ export type {
25
25
  VfsSnapshotDirectoryNode,
26
26
  VfsSnapshotFileNode,
27
27
  VfsSnapshotNode,
28
- WriteFileOptions
28
+ WriteFileOptions,
29
29
  } from "./types/vfs";
30
30
 
31
31
  export {
32
- SshClient, VirtualFileSystem, SshMimic as VirtualMachine, VirtualUserManager
32
+ SshClient,
33
+ VirtualFileSystem,
34
+ SshMimic as VirtualMachine,
35
+ VirtualUserManager,
33
36
  };
34
-
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { assertPathAccess } from "../src/SSHMimic/commands/helpers";
3
+
4
+ describe("assertPathAccess", () => {
5
+ test("blocks non-root access to auth store", () => {
6
+ expect(() =>
7
+ assertPathAccess("alice", "/virtual-env-js/.auth/htpasswd", "cat"),
8
+ ).toThrow("cat: permission denied: /virtual-env-js/.auth/htpasswd");
9
+ });
10
+
11
+ test("allows root access to auth store", () => {
12
+ expect(() =>
13
+ assertPathAccess("root", "/virtual-env-js/.auth/htpasswd", "cat"),
14
+ ).not.toThrow();
15
+ });
16
+
17
+ test("allows non-root access outside protected paths", () => {
18
+ expect(() =>
19
+ assertPathAccess("alice", "/home/alice/README.txt", "cat"),
20
+ ).not.toThrow();
21
+ });
22
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { VirtualUserManager } from "../src/SSHMimic/users";
6
+ import VirtualFileSystem from "../src/VirtualFileSystem";
7
+
8
+ async function withTempVfs(
9
+ run: (vfs: VirtualFileSystem) => Promise<void>,
10
+ ): Promise<void> {
11
+ const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-test-"));
12
+ try {
13
+ const vfs = new VirtualFileSystem(tempDir);
14
+ await vfs.restoreMirror();
15
+ await run(vfs);
16
+ } finally {
17
+ await rm(tempDir, { recursive: true, force: true });
18
+ }
19
+ }
20
+
21
+ describe("VirtualUserManager auto sudo", () => {
22
+ test("adds new users to sudoers by default", async () => {
23
+ await withTempVfs(async (vfs) => {
24
+ const users = new VirtualUserManager(vfs, "root-pass");
25
+ await users.initialize();
26
+ await users.addUser("alice", "alice-pass");
27
+
28
+ expect(users.isSudoer("alice")).toBe(true);
29
+ });
30
+ });
31
+
32
+ test("does not auto-add sudoers when disabled", async () => {
33
+ await withTempVfs(async (vfs) => {
34
+ const users = new VirtualUserManager(vfs, "root-pass", false);
35
+ await users.initialize();
36
+ await users.addUser("bob", "bob-pass");
37
+
38
+ expect(users.isSudoer("bob")).toBe(false);
39
+ });
40
+ });
41
+ });
package/tsconfig.json CHANGED
@@ -26,6 +26,6 @@
26
26
  "noUnusedParameters": false,
27
27
  "noPropertyAccessFromIndexSignature": false,
28
28
 
29
- "types": ["node"]
29
+ "types": ["node", "bun"]
30
30
  }
31
31
  }