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.
- package/.github/workflows/test-battery.yml +53 -6
- package/CHANGELOG.md +40 -0
- package/README.md +11 -29
- package/package.json +1 -1
- package/src/SSHMimic/commands/cat.ts +3 -2
- package/src/SSHMimic/commands/cd.ts +3 -2
- package/src/SSHMimic/commands/curl.ts +9 -3
- package/src/SSHMimic/commands/echo.ts +26 -0
- package/src/SSHMimic/commands/env.ts +22 -0
- package/src/SSHMimic/commands/export.ts +32 -0
- package/src/SSHMimic/commands/grep.ts +81 -0
- package/src/SSHMimic/commands/helpers.ts +26 -0
- package/src/SSHMimic/commands/index.ts +153 -1
- package/src/SSHMimic/commands/ls.ts +3 -2
- package/src/SSHMimic/commands/mkdir.ts +5 -3
- package/src/SSHMimic/commands/nano.ts +3 -2
- package/src/SSHMimic/commands/rm.ts +5 -3
- package/src/SSHMimic/commands/set.ts +67 -0
- package/src/SSHMimic/commands/sh.ts +121 -0
- package/src/SSHMimic/commands/touch.ts +3 -2
- package/src/SSHMimic/commands/tree.ts +3 -2
- package/src/SSHMimic/commands/unset.ts +19 -0
- package/src/SSHMimic/commands/wget.ts +3 -1
- package/src/SSHMimic/executor.ts +201 -0
- package/src/SSHMimic/index.ts +25 -1
- package/src/SSHMimic/shellParser.ts +203 -0
- package/src/SSHMimic/users.ts +16 -7
- package/src/types/commands.ts +2 -0
- package/src/types/pipeline.ts +23 -0
- package/tests/helpers.test.ts +22 -0
- package/tests/users.test.ts +41 -0
- package/tsconfig.json +1 -1
|
@@ -8,9 +8,8 @@ permissions:
|
|
|
8
8
|
contents: read
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
|
-
|
|
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:
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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[];
|