Skip to content

Commit fbac33c

Browse files
authored
Merge pull request #24 from PIsberg/chore/bump-deps-latest
Harden Bashful: execution policy, security fix, stdin support, and a real test suite
2 parents 998e5f9 + 0c23ec4 commit fbac33c

13 files changed

Lines changed: 3194 additions & 58 deletions

‎CLAUDE.md‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,24 @@ bun run bashful.ts <command> # Wrap a command using --hel
1010
bun run bashful.ts curl \| wget # Multiple commands, one endpoint each
1111
bun run bashful.ts curl --help \| wget --help # Explicit help commands (pipe mode)
1212
bun run bashful.ts --debug curl \| wget # Run with debug logging
13+
bun run bashful.ts --config policy.json curl # Run with an access-control policy
1314
bun run start # Alias: wraps curl
1415
```
1516

1617
```bash
1718
bun test # Run all tests
19+
bun test -t "authorizeFlags" # Run one describe block / test by name
20+
bunx tsc --noEmit # Typecheck (tsconfig is noEmit; there is no build step)
1821
```
1922

23+
CI (`.github/workflows/test.yml`) runs `bun install` + `bun test` on push/PR to `main`. Lint is not wired into `package.json` — ESLint, gitleaks, and whitespace fixers run via `.pre-commit-config.yaml`.
24+
25+
**Env vars:** `PORT` (default `3000`), `HOST` (default `127.0.0.1`), `BASHFUL_CONFIG` (policy file path).
26+
27+
## Docs
28+
29+
`docs/usage.md` (invocation, endpoints, payloads, access control), `docs/architecture.md` (schema synthesis, enforcement layering, invariants), `docs/testing.md` (suite layout, integration-test mechanics). Keep them in sync when changing behaviour they describe.
30+
2031
## Architecture
2132

2233
The entire application lives in a single file: `bashful.ts`.
@@ -25,19 +36,38 @@ The entire application lives in a single file: `bashful.ts`.
2536

2637
1. **Ingestion** — Executes `<command> --help` (or `<explicit command>` in `pipe` mode) via `Bun.spawnSync` and captures stdout+stderr.
2738
2. **Schema synthesis** — Parses the help text with a heuristic regex (the "Bashful Regex") that extracts short flags (`-x`), long flags (`--foo`), argument types (`<val>`, `[val]`, or `ALL_CAPS`), and descriptions into a `schema` object keyed by flag name.
28-
3. **Server** — Spins up a `Bun.serve` HTTP server on port 3000 with three routes:
39+
3. **Policy** — Loads an optional JSON config (`--config <file>`, `$BASHFUL_CONFIG`, or `./bashful.config.json`) and refuses to wrap any denied command. Denied flags are stripped from the served schema so the UI can't offer them.
40+
4. **Server** — Spins up a `Bun.serve` HTTP server on port 3000 with three routes:
2941
- `GET /` (also `/docs`, `/ui`) — Returns a self-contained HTML page (the Swagger-like UI) with the schema baked in via template literal.
3042
- `GET /<command>/schema` — Returns the parsed schema as JSON.
31-
- `POST /<command>` or `GET /<command>` — Translates the JSON body (or query params) back into CLI arguments and executes the command with `Bun.spawn`, streaming stdout as the response.
43+
- `POST /<command>` or `GET /<command>` — Checks the payload against the policy (403 with a `reason` if blocked), then translates the JSON body (or query params) back into CLI arguments and executes the command with `Bun.spawn`, streaming stdout as the response.
3244

3345
**Payload conventions (POST `/<command>`):**
3446
- `_args`: positional arguments (string or string array)
47+
- `_stdin`: string piped to the command's stdin. Absent → stdin is closed, not inherited (else a stdin-reading tool hangs).
3548
- Boolean flags: `{ "silent": true }` → `--silent`
3649
- Value flags: `{ "output": "file.html" }` → `--output file.html`
37-
- Unknown single-char keys fall back to `-x` short-flag style.
50+
- Array values repeat the flag: `{ "header": ["a", "b"] }` → `--header a --header b`
51+
- Unknown single-char keys fall back to `-x` short-flag style; objects are rejected with 400.
52+
- `Accept: application/json` buffers and returns `{exitCode, stdout, stderr, timedOut}` instead of streaming.
3853

3954
**Multiple commands:** Separate commands with `\|` (escaped pipe character). Each segment becomes its own endpoint and tab in the UI.
4055
- `bashful.ts curl \| wget` — two endpoints: `/curl` and `/wget`
4156
- `bashful.ts curl --help \| wget --help` — pipe mode per segment: runs the full command as-is to get help text (useful when `--help` alone fails or outputs to stderr)
4257

43-
**`--debug` flag:** logs startup time, number of parsed flags, and each execution command.
58+
**Access control:** an optional config file gates commands, flags, and flag values. `mode` is `blacklist` (allow unless denied) or `whitelist` (deny unless allowed). `commands.allow`/`deny` gate whole commands; `flags.<cmd>.allow`/`deny` gate individual flags; `flags.<cmd>.denyCombinations`/`allowCombinations` gate *sets* of flags used together; `flags.<cmd>.values` maps a flag to a regex its value must match. The `"*"` key under `flags` applies to every command; `"*"` inside a list means "everything". Rules name payload keys (`output`, `_args`), not CLI spellings (`--output`). Deny always beats allow. See `bashful.config.example.json` and `docs/usage.md`.
59+
60+
**`--debug` flag:** logs startup time, number of parsed flags, config load, blocked requests, and each execution command.
61+
62+
## Conventions
63+
64+
**Pure core, imperative shell.** Everything above the `// ── Entry point ──` divider in `bashful.ts` is exported pure functions (`splitSegments`, `parseSchema`, `buildCLIArgs`, `normalizeConfig`, `authorizeCommand`, `authorizeFlags`, `filterSchema`, …); everything below runs under `if (import.meta.main)`. Tests import the module directly, so new logic belongs above the divider — anything below it only runs as a process and can't be unit-tested.
65+
66+
**Invariants that are easy to undo accidentally:**
67+
- The server binds to `127.0.0.1` by default. It executes arbitrary CLI commands, so it must not listen on `0.0.0.0` unless the operator deliberately sets `HOST`.
68+
- **Browser hardening works only as a set** — no CORS headers unless `--allow-origin` names one; exec requires `POST` + `Content-Type: application/json` (this is what forces a preflight, which then fails); the `Host` header must be loopback (defeats DNS rebinding); `GET` exec is off unless `--allow-get`. Loosening any single one re-opens command execution to any web page the user visits. See `docs/architecture.md#invariants`.
69+
- The exec route merges stdout **and** stderr into one stream. Many CLIs write their real output or diagnostics to stderr; returning stdout alone silently yields empty responses.
70+
71+
**Tests** (`bashful.test.ts`) are unit tests plus integration tests that spawn the real server via `bun run ./bashful.ts` on fixed ports (3005–3009). New integration suites need their own unused port, and policy fixtures are written to `tmpdir()` and passed with `--config`.
72+
73+
**Windows:** `safeSpawn` retries a failed `ENOENT` spawn through `cmd /c`, which is how shell builtins and `.cmd` shims resolve. The `\|` command separator is escaped because an unescaped `|` would be consumed by the shell.

‎README.md‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ No config files. No code. Starts in milliseconds.
3535

3636
---
3737

38+
## Documentation
39+
40+
- **[Usage](docs/usage.md)** — invocation modes, endpoints, payload conventions, access control, environment variables.
41+
- **[Architecture](docs/architecture.md)** — how the help text becomes a schema, how a payload becomes a command line, and the invariants that keep it safe.
42+
- **[Testing](docs/testing.md)** — how the suite is organized and how to add to it.
43+
44+
---
45+
3846
## Prerequisites
3947

4048
- [Bun](https://bun.sh/) v1.0+
@@ -84,6 +92,9 @@ Open [http://localhost:3000](http://localhost:3000) in your browser to view the
8492
> The POST above translates dynamically to the native CLI invocation:
8593
> `curl --silent --output example.html http://example.com`
8694
95+
> [!IMPORTANT]
96+
> Bashful executes real commands, so it is hardened against the browser: no CORS by default, exec requires `POST` with `Content-Type: application/json`, the `Host` header must be loopback, and `GET` execution is off unless you pass `--allow-get`. See [Browser safety](docs/usage.md#browser-safety).
97+
8798
---
8899

89100
## Multiple commands
@@ -134,6 +145,65 @@ bun run bashful.ts curl --help \| wget
134145

135146
---
136147

148+
## Access control (whitelist / blacklist)
149+
150+
Bashful executes real commands, so you can restrict what it will run with an optional JSON config — both **which commands** are wrappable and **which flags (and combinations of flags)** each command accepts.
151+
152+
Bashful loads its config from, in order: `--config <file>`, `$BASHFUL_CONFIG`, or `./bashful.config.json` if it exists. With no config, nothing is restricted and behaviour is unchanged.
153+
154+
```bash
155+
bun run bashful.ts --config bashful.config.json curl \| wget
156+
```
157+
158+
See [`bashful.config.example.json`](bashful.config.example.json) for a working starting point.
159+
160+
### Config format
161+
162+
```json
163+
{
164+
"mode": "blacklist",
165+
"commands": {
166+
"allow": ["curl", "wget"],
167+
"deny": ["rm", "sudo"]
168+
},
169+
"flags": {
170+
"*": { "deny": ["config"] },
171+
"curl": {
172+
"allow": ["_args", "silent", "output"],
173+
"deny": ["upload-file"],
174+
"denyCombinations": [["output", "proxy"]],
175+
"allowCombinations": [["_args", "silent"], ["_args", "output"]],
176+
"values": { "_args": "^https://api\\.example\\.com/", "output": "^/tmp/" }
177+
}
178+
}
179+
}
180+
```
181+
182+
| Key | Meaning |
183+
|---|---|
184+
| `mode` | `"blacklist"` (default) — everything is allowed unless denied. `"whitelist"` — nothing is allowed unless explicitly allowed. |
185+
| `commands.allow` / `commands.deny` | Which commands may be wrapped at all. |
186+
| `flags.<cmd>` | Flag rules for one command. The `"*"` key applies to every command and is merged with the command's own rules. |
187+
| `flags.<cmd>.allow` / `.deny` | Individual flags. Naming an `allow` list whitelists that command's flags even in blacklist mode. |
188+
| `flags.<cmd>.denyCombinations` | List of flag sets. A request is rejected if it uses **all** flags of any listed set — the flags remain fine on their own. |
189+
| `flags.<cmd>.allowCombinations` | List of flag sets. A request is rejected unless every flag it uses fits inside **one** listed set. |
190+
| `flags.<cmd>.values` | Flag → regex its value must match, e.g. `{"output": "^/tmp/", "_args": "^https://api\\.example\\.com/"}`. Allowing a flag doesn't constrain it; this does. |
191+
192+
Rules use **payload key names**, not CLI spellings: write `output`, not `--output`. Positional arguments are governed under the name `_args`, and `"*"` in any list means "everything". Full reference: [docs/usage.md](docs/usage.md#access-control).
193+
194+
**Deny always beats allow.** A flag set to `false` builds to nothing, so it is ignored by the rules.
195+
196+
### How it is enforced
197+
198+
- **At startup** — wrapping a denied command is refused and Bashful exits with a non-zero status.
199+
- **At request time** — a blocked payload gets `403 Forbidden` with a JSON `reason`, and the command never runs. This covers both `POST` bodies and `GET` query params, including keys that never appeared in the parsed schema.
200+
- **In the schema and UI** — `GET /<cmd>/schema` only advertises the flags the policy permits, so the generated form can't offer a flag that would be rejected. Combination rules can't be expressed in a form, so those are enforced on the request.
201+
202+
> [!WARNING]
203+
> This gates the flags Bashful passes to a command; it does not sandbox the command itself. A wrapped tool that can read files or reach the network can still do so within the flags you permit. Bashful binds to `127.0.0.1` by default for the same reason.
204+
205+
---
206+
137207
## Debug mode
138208

139209
Pass `--debug` anywhere before the command to log startup time, parsed flag counts, and each execution:
@@ -147,10 +217,12 @@ bun run bashful.ts --debug curl \| wget
147217
## Running tests
148218

149219
```bash
150-
bun test
220+
bun test # everything
221+
bun test -t "authorizeFlags" # a single describe block or test
222+
bunx tsc --noEmit # typecheck (no build step)
151223
```
152224

153-
Tests cover the three pure functions at the core of Bashful: `splitSegments` (arg parsing), `parseSchema` (regex-based help text parsing), and `buildCLIArgs` (payload → CLI translation).
225+
Tests cover the pure functions at the core of Bashful — `splitSegments` (arg parsing), `parseSchema` (regex-based help text parsing), `buildCLIArgs` (payload → CLI translation), and the access-control layer (`parseConfig`, `authorizeCommand`, `authorizeFlags`, `filterSchema`) — plus integration tests that run the real server and check routing and policy enforcement end to end. See [docs/testing.md](docs/testing.md).
154226

155227
---
156228

@@ -161,9 +233,12 @@ Everything lives in a single file: `bashful.ts`.
161233
- **`splitSegments(args)`** — splits CLI args on `|` into per-command segments.
162234
- **`parseSchema(helpText)`** — the "Bashful Regex" extracts short flags, long flags, value types (`<val>`, `[val]`, `ALL_CAPS`), and descriptions into a keyed schema object.
163235
- **`buildCLIArgs(payload, schema)`** — translates a JSON payload back into a flat CLI argument array.
236+
- **Access control** — `parseConfig` validates the policy file; `authorizeCommand` / `authorizeFlags` decide what may run; `filterSchema` hides forbidden flags from the schema and UI.
164237
- **Server** — `Bun.serve` on port 3000. Routes: `GET /` (UI), `GET /<cmd>/schema`, `POST /<cmd>`.
165238
- **Execution** — `Bun.spawn` runs the real command and streams stdout directly as the HTTP response.
166239

240+
Deeper dive — including the Bashful Regex, the enforcement layering, and the invariants worth not breaking: [docs/architecture.md](docs/architecture.md).
241+
167242
---
168243

169244
## License

‎bashful.config.example.json‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"mode": "blacklist",
3+
"commands": {
4+
"deny": ["rm", "shutdown", "sudo"]
5+
},
6+
"flags": {
7+
"*": {
8+
"deny": ["config"]
9+
},
10+
"curl": {
11+
"deny": ["upload-file"],
12+
"denyCombinations": [["output", "proxy"]],
13+
"values": {
14+
"_args": "^https://api\\.example\\.com/",
15+
"output": "^/tmp/"
16+
}
17+
},
18+
"wget": {
19+
"allow": ["_args", "quiet", "output-document"]
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)