bashboy 0.1.0__tar.gz
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.
- bashboy-0.1.0/.github/workflows/release.yml +23 -0
- bashboy-0.1.0/.gitignore +25 -0
- bashboy-0.1.0/LICENSE +21 -0
- bashboy-0.1.0/PKG-INFO +298 -0
- bashboy-0.1.0/README.md +281 -0
- bashboy-0.1.0/pyproject.toml +41 -0
- bashboy-0.1.0/src/bashboy/__init__.py +5 -0
- bashboy-0.1.0/src/bashboy/__main__.py +5 -0
- bashboy-0.1.0/src/bashboy/classify.py +124 -0
- bashboy-0.1.0/src/bashboy/cli.py +271 -0
- bashboy-0.1.0/src/bashboy/config.py +219 -0
- bashboy-0.1.0/src/bashboy/hooks/__init__.py +1 -0
- bashboy-0.1.0/src/bashboy/hooks/claude.py +92 -0
- bashboy-0.1.0/src/bashboy/installers/__init__.py +38 -0
- bashboy-0.1.0/src/bashboy/installers/claude.py +135 -0
- bashboy-0.1.0/src/bashboy/installers/opencode.py +199 -0
- bashboy-0.1.0/src/bashboy/provider.py +215 -0
- bashboy-0.1.0/src/bashboy/rules.py +225 -0
- bashboy-0.1.0/tests/conftest.py +22 -0
- bashboy-0.1.0/tests/test_classify.py +236 -0
- bashboy-0.1.0/tests/test_claude_hook.py +115 -0
- bashboy-0.1.0/tests/test_cli.py +171 -0
- bashboy-0.1.0/tests/test_config.py +184 -0
- bashboy-0.1.0/tests/test_installers.py +246 -0
- bashboy-0.1.0/tests/test_provider.py +207 -0
- bashboy-0.1.0/tests/test_rules.py +178 -0
- bashboy-0.1.0/uv.lock +174 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write # OIDC for PyPI trusted publishing
|
|
16
|
+
contents: read
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: astral-sh/setup-uv@v6
|
|
20
|
+
- name: Build sdist and wheel
|
|
21
|
+
run: uv build
|
|
22
|
+
- name: Publish to PyPI
|
|
23
|
+
run: uv publish --trusted-publishing always
|
bashboy-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.venv/
|
|
6
|
+
venv/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
|
|
10
|
+
# Tooling
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# Local state
|
|
18
|
+
.bashboy/
|
|
19
|
+
*.log
|
|
20
|
+
|
|
21
|
+
# OS / editors
|
|
22
|
+
.DS_Store
|
|
23
|
+
Thumbs.db
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
bashboy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mann10
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
bashboy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bashboy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Classify shell commands as allow or deny before an agent runs them.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mann10/bashboy
|
|
6
|
+
Project-URL: Issues, https://github.com/Mann10/bashboy/issues
|
|
7
|
+
Project-URL: Source, https://github.com/Mann10/bashboy
|
|
8
|
+
Author-email: Mann10 <82712171+Mann10@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,claude-code,guardrails,opencode,security,shell
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# BashBoy
|
|
19
|
+
|
|
20
|
+
BashBoy is a small, dependency-free Python CLI that classifies a shell command as
|
|
21
|
+
`allow` or `deny` **before** a coding agent runs it. It sits in front of Claude Code
|
|
22
|
+
(`PreToolUse` hook) and OpenCode (plugin) and answers one question: is this command
|
|
23
|
+
safe to run unattended?
|
|
24
|
+
|
|
25
|
+
> **BashBoy only narrows; `allow` means *no objection*.** It never approves a tool
|
|
26
|
+
> call. When BashBoy says `allow` it prints nothing (Claude) or returns normally
|
|
27
|
+
> (OpenCode), and the host's normal permission flow still applies. There is no
|
|
28
|
+
> `ask` verdict, no auto-approve path, and no way for BashBoy to widen permissions.
|
|
29
|
+
|
|
30
|
+
Three layers, in order:
|
|
31
|
+
|
|
32
|
+
1. **Local fast paths** (no network): a structural allowlist for single read-only
|
|
33
|
+
commands, and a hard denylist for destructive ones.
|
|
34
|
+
2. **Model classification** against the Clocked (`clocked/fill`) endpoint for everything
|
|
35
|
+
else.
|
|
36
|
+
3. **Host adapters** that express denial only.
|
|
37
|
+
|
|
38
|
+
Anything the fast paths do not recognise goes to the model. Anything the model path
|
|
39
|
+
cannot answer clearly becomes `deny`.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install bashboy # install once
|
|
47
|
+
uvx bashboy --help # or run without installing
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
From a local checkout (uvx resolves the project from the path):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uvx --from /path/to/BashBoy bashboy check --command "git status"
|
|
54
|
+
uvx --from /path/to/BashBoy bashboy install --claude
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The package has **zero runtime dependencies** — everything is the Python standard
|
|
58
|
+
library. `pytest` is a development-only dependency.
|
|
59
|
+
|
|
60
|
+
The API key is read from the environment (default `CLOCKED_API_KEY`). It is never
|
|
61
|
+
written to disk:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
export CLOCKED_API_KEY=... # bash
|
|
65
|
+
$env:CLOCKED_API_KEY = "..." # PowerShell
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bashboy check --command "rm -rf /"
|
|
72
|
+
# deny (denylist): recursive delete of a protected path
|
|
73
|
+
|
|
74
|
+
bashboy check --command "git status"
|
|
75
|
+
# allow (allowlist): matches allowlist: git status
|
|
76
|
+
|
|
77
|
+
bashboy check --command "docker run --rm alpine sh" --json
|
|
78
|
+
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key ...", ...}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Exit codes:
|
|
82
|
+
|
|
83
|
+
| Code | Meaning |
|
|
84
|
+
|------|---------|
|
|
85
|
+
| `0` | `allow` |
|
|
86
|
+
| `1` | `deny` |
|
|
87
|
+
| `2` | configuration or usage error (never a silent allow) |
|
|
88
|
+
|
|
89
|
+
`--json` prints exactly one object, in this key order:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{"decision":"deny","source":"denylist","reason":"recursive delete of a protected path","risk":null,"latency_ms":0}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`source` is one of `denylist`, `allowlist`, `model`, `fallback`, `disabled`.
|
|
96
|
+
`--cwd DIR` supplies the working directory used for context (default: current
|
|
97
|
+
directory).
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
Optional file at `~/.bashboy/config.toml` (override with `BASHBOY_CONFIG`).
|
|
102
|
+
Create it with `bashboy config init`; inspect with `bashboy config show`.
|
|
103
|
+
A missing file means these defaults:
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
[provider]
|
|
107
|
+
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
108
|
+
api_key_env = "CLOCKED_API_KEY"
|
|
109
|
+
timeout_ms = 4000
|
|
110
|
+
|
|
111
|
+
[policy]
|
|
112
|
+
fail_mode = "closed" # closed | open
|
|
113
|
+
denylist = true
|
|
114
|
+
allowlist = true
|
|
115
|
+
|
|
116
|
+
[integrations.claude]
|
|
117
|
+
timeout_s = 15
|
|
118
|
+
|
|
119
|
+
[integrations.opencode]
|
|
120
|
+
timeout_ms = 15000
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Malformed TOML is a hard error (exit 2) from `check` — the gate never silently
|
|
124
|
+
loosens because a file failed to parse.
|
|
125
|
+
|
|
126
|
+
| Variable | Effect |
|
|
127
|
+
|----------|--------|
|
|
128
|
+
| `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
|
|
129
|
+
| `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
|
|
130
|
+
| `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
|
|
131
|
+
| `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
|
|
132
|
+
| `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
|
|
133
|
+
| `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
|
|
134
|
+
|
|
135
|
+
## Claude Code setup
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
bashboy install --claude
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This merges a `PreToolUse` hook into `~/.claude/settings.json` (override with
|
|
142
|
+
`--settings PATH`). Unrelated keys and unrelated hook events are preserved; an
|
|
143
|
+
existing BashBoy handler is updated in place, so re-running never duplicates it.
|
|
144
|
+
The previous file is copied to `settings.json.bak` and the new file is written
|
|
145
|
+
atomically.
|
|
146
|
+
|
|
147
|
+
The exact entry it writes:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"hooks": {
|
|
152
|
+
"PreToolUse": [
|
|
153
|
+
{
|
|
154
|
+
"matcher": "Bash|PowerShell",
|
|
155
|
+
"hooks": [
|
|
156
|
+
{
|
|
157
|
+
"type": "command",
|
|
158
|
+
"command": "uvx",
|
|
159
|
+
"args": ["bashboy", "check", "--claude-hook"],
|
|
160
|
+
"timeout": 15,
|
|
161
|
+
"statusMessage": "BashBoy: checking command"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Options:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
bashboy install --claude --dry-run # print the resulting JSON, write nothing
|
|
174
|
+
bashboy install --claude --settings ./settings.json
|
|
175
|
+
bashboy install --claude --from /path/to/BashBoy
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`--from SPEC` rewrites the argv to
|
|
179
|
+
`["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
|
|
180
|
+
local checkout. The path is written verbatim as a single argv element — never
|
|
181
|
+
interpolated into a shell string.
|
|
182
|
+
|
|
183
|
+
The hook reads one JSON object on stdin. On `deny` it prints the
|
|
184
|
+
`hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
|
|
185
|
+
input, or malformed input it prints nothing and exits `0` so the host's normal
|
|
186
|
+
permission flow continues.
|
|
187
|
+
|
|
188
|
+
## OpenCode setup
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
bashboy install --opencode
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This writes two files under `~/.config/opencode` (override with `--config-dir DIR`):
|
|
195
|
+
|
|
196
|
+
- `plugins/bashboy.ts` — the generated plugin;
|
|
197
|
+
- `opencode.json` — registers the plugin's absolute path.
|
|
198
|
+
|
|
199
|
+
`opencode.json` is created with `{"$schema": "https://opencode.ai/config.json"}` if
|
|
200
|
+
absent. Existing keys and existing plugin entries are preserved, the BashBoy path
|
|
201
|
+
is appended only once, and the previous config is backed up before an atomic write.
|
|
202
|
+
OpenCode V2 documents the key as `plugins`; V1 used `plugin`. BashBoy reads
|
|
203
|
+
whichever key already exists, preserves it, defaults to `plugins` when neither is
|
|
204
|
+
present, and never writes both.
|
|
205
|
+
|
|
206
|
+
The plugin hooks both API generations with one shared helper:
|
|
207
|
+
|
|
208
|
+
- **V2:** `Plugin.define({ id: "bashboy", setup })` registering
|
|
209
|
+
`ctx.tool.hook("execute.before", ...)`, reading `event.input.command`.
|
|
210
|
+
- **V1 fallback:** `server()` returning `"tool.execute.before"`, reading
|
|
211
|
+
`output.args.command`.
|
|
212
|
+
|
|
213
|
+
It acts only for the `bash`/`shell` tools and runs
|
|
214
|
+
`uvx bashboy check --json --command <cmd> --cwd <dir>` via
|
|
215
|
+
`execFile(..., { shell: false, timeout: 15000, windowsHide: true })`. The command is
|
|
216
|
+
a single argv element; it is never passed through a shell and never concatenated
|
|
217
|
+
into a command line. On `deny` it throws
|
|
218
|
+
`Error("BashBoy blocked this command: <reason>")`. If `uvx` is missing, the exit is
|
|
219
|
+
non-zero without parseable JSON, the JSON is invalid, the call times out, or the
|
|
220
|
+
verdict is unexpected, it throws
|
|
221
|
+
`Error("BashBoy could not verify this command: <detail>")` — fail closed.
|
|
222
|
+
|
|
223
|
+
`bashboy install --opencode --dry-run` prints one JSON object with
|
|
224
|
+
`plugin_path`, `plugin_source`, and the would-be `config`, and writes nothing.
|
|
225
|
+
|
|
226
|
+
## Escape hatch
|
|
227
|
+
|
|
228
|
+
> **`BASHBOY_DISABLE=1` disables all checks.** Commands are allowed with
|
|
229
|
+
> `source: "disabled"` and no denylist, allowlist, or model evaluation runs.
|
|
230
|
+
> For emergencies and CI debugging only. Never set it in an agent's environment
|
|
231
|
+
> or in a shell that an agent controls.
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
BASHBOY_DISABLE=1 bashboy check --command "rm -rf /" # allow (disabled)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Privacy
|
|
238
|
+
|
|
239
|
+
For model classification, the **command text and the working directory** are sent
|
|
240
|
+
to the configured Clocked endpoint over HTTPS (`text: "cwd: <cwd>\ncommand: <cmd>"`).
|
|
241
|
+
Nothing else is transmitted; no files are uploaded.
|
|
242
|
+
|
|
243
|
+
Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
|
|
244
|
+
There is no analytics, no audit log, no cache, and no telemetry. The API key is
|
|
245
|
+
read from the environment at call time, sent only as the `Authorization` header,
|
|
246
|
+
and never logged, persisted, or included in `config show`.
|
|
247
|
+
|
|
248
|
+
## Known limitation: unverified Clocked envelope
|
|
249
|
+
|
|
250
|
+
The response shape of the Clocked (`clocked/fill`) endpoint is **unverified**. Parsing
|
|
251
|
+
is deliberately tolerant (`extract_field` walks nested objects and arrays
|
|
252
|
+
breadth-first, and unwraps `value`/`text`/`answer`/`label`/`result`/`content`
|
|
253
|
+
containers). If the response cannot be resolved to a clear `allow` or `deny`, the
|
|
254
|
+
verdict is `deny` with `source: "fallback"`. Report envelope mismatches with the
|
|
255
|
+
raw (redacted) response so the parser can be extended.
|
|
256
|
+
|
|
257
|
+
## Limitations
|
|
258
|
+
|
|
259
|
+
- The structural allowlist is conservative by design: any operator, substitution,
|
|
260
|
+
redirection, or chaining disqualifies a command. False negatives simply go to the
|
|
261
|
+
model.
|
|
262
|
+
- The denylist is a fast path, not a complete policy. It catches literal destructive
|
|
263
|
+
patterns and payloads hidden behind `;`, `&&`, `||`, `|`, newlines, `$(...)`, and
|
|
264
|
+
backticks — but it does not decode base64, `r''m`-style quoting, or other
|
|
265
|
+
obfuscation. Such commands are not allowlisted and therefore reach the model,
|
|
266
|
+
which fails closed.
|
|
267
|
+
- PowerShell/bash parity is best-effort. The allowlist covers a small bash-oriented
|
|
268
|
+
set; PowerShell users should expect more commands to reach the model.
|
|
269
|
+
- `git reset --hard` is intentionally **not** denied: it is recoverable via the
|
|
270
|
+
reflog. `git clean -fdx /` at a filesystem root is denied.
|
|
271
|
+
- `--from SPEC` is trusted input: a user who runs `install --from <path>` is
|
|
272
|
+
explicitly pointing BashBoy at that checkout.
|
|
273
|
+
- v1 has no audit log; the host's own transcript remains the record.
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
uv run pytest # offline; no network access required
|
|
279
|
+
uv run bashboy selftest
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Layout:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
src/bashboy/
|
|
286
|
+
cli.py check / install / config / selftest / version
|
|
287
|
+
config.py TOML defaults, env overrides, API key lookup
|
|
288
|
+
rules.py structural allowlist + hard denylist (pure)
|
|
289
|
+
provider.py Clocked client, tolerant envelope parser
|
|
290
|
+
classify.py verdict pipeline (fast paths -> model -> fail closed)
|
|
291
|
+
hooks/claude.py Claude Code PreToolUse adapter
|
|
292
|
+
installers/ Claude settings.json and OpenCode plugin installers
|
|
293
|
+
tests/ offline test suite
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
There are **no runtime dependencies beyond the standard library**. The provider is
|
|
297
|
+
imported lazily inside the model branch so the hook path stays fast, and the
|
|
298
|
+
installers are imported lazily by `install` only.
|
bashboy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# BashBoy
|
|
2
|
+
|
|
3
|
+
BashBoy is a small, dependency-free Python CLI that classifies a shell command as
|
|
4
|
+
`allow` or `deny` **before** a coding agent runs it. It sits in front of Claude Code
|
|
5
|
+
(`PreToolUse` hook) and OpenCode (plugin) and answers one question: is this command
|
|
6
|
+
safe to run unattended?
|
|
7
|
+
|
|
8
|
+
> **BashBoy only narrows; `allow` means *no objection*.** It never approves a tool
|
|
9
|
+
> call. When BashBoy says `allow` it prints nothing (Claude) or returns normally
|
|
10
|
+
> (OpenCode), and the host's normal permission flow still applies. There is no
|
|
11
|
+
> `ask` verdict, no auto-approve path, and no way for BashBoy to widen permissions.
|
|
12
|
+
|
|
13
|
+
Three layers, in order:
|
|
14
|
+
|
|
15
|
+
1. **Local fast paths** (no network): a structural allowlist for single read-only
|
|
16
|
+
commands, and a hard denylist for destructive ones.
|
|
17
|
+
2. **Model classification** against the Clocked (`clocked/fill`) endpoint for everything
|
|
18
|
+
else.
|
|
19
|
+
3. **Host adapters** that express denial only.
|
|
20
|
+
|
|
21
|
+
Anything the fast paths do not recognise goes to the model. Anything the model path
|
|
22
|
+
cannot answer clearly becomes `deny`.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install bashboy # install once
|
|
30
|
+
uvx bashboy --help # or run without installing
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
From a local checkout (uvx resolves the project from the path):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uvx --from /path/to/BashBoy bashboy check --command "git status"
|
|
37
|
+
uvx --from /path/to/BashBoy bashboy install --claude
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The package has **zero runtime dependencies** — everything is the Python standard
|
|
41
|
+
library. `pytest` is a development-only dependency.
|
|
42
|
+
|
|
43
|
+
The API key is read from the environment (default `CLOCKED_API_KEY`). It is never
|
|
44
|
+
written to disk:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export CLOCKED_API_KEY=... # bash
|
|
48
|
+
$env:CLOCKED_API_KEY = "..." # PowerShell
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bashboy check --command "rm -rf /"
|
|
55
|
+
# deny (denylist): recursive delete of a protected path
|
|
56
|
+
|
|
57
|
+
bashboy check --command "git status"
|
|
58
|
+
# allow (allowlist): matches allowlist: git status
|
|
59
|
+
|
|
60
|
+
bashboy check --command "docker run --rm alpine sh" --json
|
|
61
|
+
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key ...", ...}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Exit codes:
|
|
65
|
+
|
|
66
|
+
| Code | Meaning |
|
|
67
|
+
|------|---------|
|
|
68
|
+
| `0` | `allow` |
|
|
69
|
+
| `1` | `deny` |
|
|
70
|
+
| `2` | configuration or usage error (never a silent allow) |
|
|
71
|
+
|
|
72
|
+
`--json` prints exactly one object, in this key order:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{"decision":"deny","source":"denylist","reason":"recursive delete of a protected path","risk":null,"latency_ms":0}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`source` is one of `denylist`, `allowlist`, `model`, `fallback`, `disabled`.
|
|
79
|
+
`--cwd DIR` supplies the working directory used for context (default: current
|
|
80
|
+
directory).
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Optional file at `~/.bashboy/config.toml` (override with `BASHBOY_CONFIG`).
|
|
85
|
+
Create it with `bashboy config init`; inspect with `bashboy config show`.
|
|
86
|
+
A missing file means these defaults:
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
[provider]
|
|
90
|
+
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
91
|
+
api_key_env = "CLOCKED_API_KEY"
|
|
92
|
+
timeout_ms = 4000
|
|
93
|
+
|
|
94
|
+
[policy]
|
|
95
|
+
fail_mode = "closed" # closed | open
|
|
96
|
+
denylist = true
|
|
97
|
+
allowlist = true
|
|
98
|
+
|
|
99
|
+
[integrations.claude]
|
|
100
|
+
timeout_s = 15
|
|
101
|
+
|
|
102
|
+
[integrations.opencode]
|
|
103
|
+
timeout_ms = 15000
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Malformed TOML is a hard error (exit 2) from `check` — the gate never silently
|
|
107
|
+
loosens because a file failed to parse.
|
|
108
|
+
|
|
109
|
+
| Variable | Effect |
|
|
110
|
+
|----------|--------|
|
|
111
|
+
| `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
|
|
112
|
+
| `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
|
|
113
|
+
| `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
|
|
114
|
+
| `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
|
|
115
|
+
| `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
|
|
116
|
+
| `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
|
|
117
|
+
|
|
118
|
+
## Claude Code setup
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
bashboy install --claude
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This merges a `PreToolUse` hook into `~/.claude/settings.json` (override with
|
|
125
|
+
`--settings PATH`). Unrelated keys and unrelated hook events are preserved; an
|
|
126
|
+
existing BashBoy handler is updated in place, so re-running never duplicates it.
|
|
127
|
+
The previous file is copied to `settings.json.bak` and the new file is written
|
|
128
|
+
atomically.
|
|
129
|
+
|
|
130
|
+
The exact entry it writes:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"hooks": {
|
|
135
|
+
"PreToolUse": [
|
|
136
|
+
{
|
|
137
|
+
"matcher": "Bash|PowerShell",
|
|
138
|
+
"hooks": [
|
|
139
|
+
{
|
|
140
|
+
"type": "command",
|
|
141
|
+
"command": "uvx",
|
|
142
|
+
"args": ["bashboy", "check", "--claude-hook"],
|
|
143
|
+
"timeout": 15,
|
|
144
|
+
"statusMessage": "BashBoy: checking command"
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bashboy install --claude --dry-run # print the resulting JSON, write nothing
|
|
157
|
+
bashboy install --claude --settings ./settings.json
|
|
158
|
+
bashboy install --claude --from /path/to/BashBoy
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`--from SPEC` rewrites the argv to
|
|
162
|
+
`["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
|
|
163
|
+
local checkout. The path is written verbatim as a single argv element — never
|
|
164
|
+
interpolated into a shell string.
|
|
165
|
+
|
|
166
|
+
The hook reads one JSON object on stdin. On `deny` it prints the
|
|
167
|
+
`hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
|
|
168
|
+
input, or malformed input it prints nothing and exits `0` so the host's normal
|
|
169
|
+
permission flow continues.
|
|
170
|
+
|
|
171
|
+
## OpenCode setup
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bashboy install --opencode
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This writes two files under `~/.config/opencode` (override with `--config-dir DIR`):
|
|
178
|
+
|
|
179
|
+
- `plugins/bashboy.ts` — the generated plugin;
|
|
180
|
+
- `opencode.json` — registers the plugin's absolute path.
|
|
181
|
+
|
|
182
|
+
`opencode.json` is created with `{"$schema": "https://opencode.ai/config.json"}` if
|
|
183
|
+
absent. Existing keys and existing plugin entries are preserved, the BashBoy path
|
|
184
|
+
is appended only once, and the previous config is backed up before an atomic write.
|
|
185
|
+
OpenCode V2 documents the key as `plugins`; V1 used `plugin`. BashBoy reads
|
|
186
|
+
whichever key already exists, preserves it, defaults to `plugins` when neither is
|
|
187
|
+
present, and never writes both.
|
|
188
|
+
|
|
189
|
+
The plugin hooks both API generations with one shared helper:
|
|
190
|
+
|
|
191
|
+
- **V2:** `Plugin.define({ id: "bashboy", setup })` registering
|
|
192
|
+
`ctx.tool.hook("execute.before", ...)`, reading `event.input.command`.
|
|
193
|
+
- **V1 fallback:** `server()` returning `"tool.execute.before"`, reading
|
|
194
|
+
`output.args.command`.
|
|
195
|
+
|
|
196
|
+
It acts only for the `bash`/`shell` tools and runs
|
|
197
|
+
`uvx bashboy check --json --command <cmd> --cwd <dir>` via
|
|
198
|
+
`execFile(..., { shell: false, timeout: 15000, windowsHide: true })`. The command is
|
|
199
|
+
a single argv element; it is never passed through a shell and never concatenated
|
|
200
|
+
into a command line. On `deny` it throws
|
|
201
|
+
`Error("BashBoy blocked this command: <reason>")`. If `uvx` is missing, the exit is
|
|
202
|
+
non-zero without parseable JSON, the JSON is invalid, the call times out, or the
|
|
203
|
+
verdict is unexpected, it throws
|
|
204
|
+
`Error("BashBoy could not verify this command: <detail>")` — fail closed.
|
|
205
|
+
|
|
206
|
+
`bashboy install --opencode --dry-run` prints one JSON object with
|
|
207
|
+
`plugin_path`, `plugin_source`, and the would-be `config`, and writes nothing.
|
|
208
|
+
|
|
209
|
+
## Escape hatch
|
|
210
|
+
|
|
211
|
+
> **`BASHBOY_DISABLE=1` disables all checks.** Commands are allowed with
|
|
212
|
+
> `source: "disabled"` and no denylist, allowlist, or model evaluation runs.
|
|
213
|
+
> For emergencies and CI debugging only. Never set it in an agent's environment
|
|
214
|
+
> or in a shell that an agent controls.
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
BASHBOY_DISABLE=1 bashboy check --command "rm -rf /" # allow (disabled)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Privacy
|
|
221
|
+
|
|
222
|
+
For model classification, the **command text and the working directory** are sent
|
|
223
|
+
to the configured Clocked endpoint over HTTPS (`text: "cwd: <cwd>\ncommand: <cmd>"`).
|
|
224
|
+
Nothing else is transmitted; no files are uploaded.
|
|
225
|
+
|
|
226
|
+
Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
|
|
227
|
+
There is no analytics, no audit log, no cache, and no telemetry. The API key is
|
|
228
|
+
read from the environment at call time, sent only as the `Authorization` header,
|
|
229
|
+
and never logged, persisted, or included in `config show`.
|
|
230
|
+
|
|
231
|
+
## Known limitation: unverified Clocked envelope
|
|
232
|
+
|
|
233
|
+
The response shape of the Clocked (`clocked/fill`) endpoint is **unverified**. Parsing
|
|
234
|
+
is deliberately tolerant (`extract_field` walks nested objects and arrays
|
|
235
|
+
breadth-first, and unwraps `value`/`text`/`answer`/`label`/`result`/`content`
|
|
236
|
+
containers). If the response cannot be resolved to a clear `allow` or `deny`, the
|
|
237
|
+
verdict is `deny` with `source: "fallback"`. Report envelope mismatches with the
|
|
238
|
+
raw (redacted) response so the parser can be extended.
|
|
239
|
+
|
|
240
|
+
## Limitations
|
|
241
|
+
|
|
242
|
+
- The structural allowlist is conservative by design: any operator, substitution,
|
|
243
|
+
redirection, or chaining disqualifies a command. False negatives simply go to the
|
|
244
|
+
model.
|
|
245
|
+
- The denylist is a fast path, not a complete policy. It catches literal destructive
|
|
246
|
+
patterns and payloads hidden behind `;`, `&&`, `||`, `|`, newlines, `$(...)`, and
|
|
247
|
+
backticks — but it does not decode base64, `r''m`-style quoting, or other
|
|
248
|
+
obfuscation. Such commands are not allowlisted and therefore reach the model,
|
|
249
|
+
which fails closed.
|
|
250
|
+
- PowerShell/bash parity is best-effort. The allowlist covers a small bash-oriented
|
|
251
|
+
set; PowerShell users should expect more commands to reach the model.
|
|
252
|
+
- `git reset --hard` is intentionally **not** denied: it is recoverable via the
|
|
253
|
+
reflog. `git clean -fdx /` at a filesystem root is denied.
|
|
254
|
+
- `--from SPEC` is trusted input: a user who runs `install --from <path>` is
|
|
255
|
+
explicitly pointing BashBoy at that checkout.
|
|
256
|
+
- v1 has no audit log; the host's own transcript remains the record.
|
|
257
|
+
|
|
258
|
+
## Development
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
uv run pytest # offline; no network access required
|
|
262
|
+
uv run bashboy selftest
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Layout:
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
src/bashboy/
|
|
269
|
+
cli.py check / install / config / selftest / version
|
|
270
|
+
config.py TOML defaults, env overrides, API key lookup
|
|
271
|
+
rules.py structural allowlist + hard denylist (pure)
|
|
272
|
+
provider.py Clocked client, tolerant envelope parser
|
|
273
|
+
classify.py verdict pipeline (fast paths -> model -> fail closed)
|
|
274
|
+
hooks/claude.py Claude Code PreToolUse adapter
|
|
275
|
+
installers/ Claude settings.json and OpenCode plugin installers
|
|
276
|
+
tests/ offline test suite
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
There are **no runtime dependencies beyond the standard library**. The provider is
|
|
280
|
+
imported lazily inside the model branch so the hook path stays fast, and the
|
|
281
|
+
installers are imported lazily by `install` only.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bashboy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Classify shell commands as allow or deny before an agent runs them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
authors = [{ name = "Mann10", email = "82712171+Mann10@users.noreply.github.com" }]
|
|
15
|
+
keywords = ["security", "agents", "claude-code", "opencode", "shell", "guardrails"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/Mann10/bashboy"
|
|
24
|
+
Issues = "https://github.com/Mann10/bashboy/issues"
|
|
25
|
+
Source = "https://github.com/Mann10/bashboy"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
bashboy = "bashboy.cli:main"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/bashboy"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
exclude = [".planning", "dist", ".venv", ".pytest_cache"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
addopts = "-q"
|