bashboy 0.1.0__tar.gz → 0.2.1__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 → bashboy-0.2.1}/PKG-INFO +81 -15
- {bashboy-0.1.0 → bashboy-0.2.1}/README.md +80 -14
- {bashboy-0.1.0 → bashboy-0.2.1}/pyproject.toml +1 -1
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/__init__.py +1 -1
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/classify.py +4 -2
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/cli.py +173 -1
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/config.py +55 -9
- bashboy-0.2.1/src/bashboy/credentials.py +254 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/provider.py +18 -1
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/conftest.py +5 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_classify.py +52 -0
- bashboy-0.2.1/tests/test_cli.py +484 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_config.py +73 -1
- bashboy-0.2.1/tests/test_credentials.py +340 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_provider.py +3 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/uv.lock +1 -1
- bashboy-0.1.0/tests/test_cli.py +0 -171
- {bashboy-0.1.0 → bashboy-0.2.1}/.github/workflows/release.yml +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/.gitignore +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/LICENSE +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/__main__.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/hooks/__init__.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/hooks/claude.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/installers/__init__.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/installers/claude.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/installers/opencode.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/rules.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_claude_hook.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_installers.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_rules.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bashboy
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Classify shell commands as allow or deny before an agent runs them.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Mann10/bashboy
|
|
6
6
|
Project-URL: Issues, https://github.com/Mann10/bashboy/issues
|
|
@@ -17,6 +17,10 @@ Description-Content-Type: text/markdown
|
|
|
17
17
|
|
|
18
18
|
# BashBoy
|
|
19
19
|
|
|
20
|
+
[](https://pypi.org/project/bashboy/)
|
|
21
|
+
[](https://pypi.org/project/bashboy/)
|
|
22
|
+
[](LICENSE)
|
|
23
|
+
|
|
20
24
|
BashBoy is a small, dependency-free Python CLI that classifies a shell command as
|
|
21
25
|
`allow` or `deny` **before** a coding agent runs it. It sits in front of Claude Code
|
|
22
26
|
(`PreToolUse` hook) and OpenCode (plugin) and answers one question: is this command
|
|
@@ -40,14 +44,22 @@ cannot answer clearly becomes `deny`.
|
|
|
40
44
|
|
|
41
45
|
## Install
|
|
42
46
|
|
|
43
|
-
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
47
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/) (recommended; `pipx` works too).
|
|
44
48
|
|
|
45
49
|
```bash
|
|
46
|
-
uv tool install bashboy # install
|
|
50
|
+
uv tool install bashboy # install the `bashboy` command
|
|
47
51
|
uvx bashboy --help # or run without installing
|
|
52
|
+
pipx install bashboy # pipx alternative
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Verify the install:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bashboy version # bashboy 0.2.0
|
|
59
|
+
bashboy selftest # offline checks; reports provider: ok when the key works
|
|
48
60
|
```
|
|
49
61
|
|
|
50
|
-
From a local checkout (
|
|
62
|
+
From a local checkout (for development, before a release is published):
|
|
51
63
|
|
|
52
64
|
```bash
|
|
53
65
|
uvx --from /path/to/BashBoy bashboy check --command "git status"
|
|
@@ -57,14 +69,63 @@ uvx --from /path/to/BashBoy bashboy install --claude
|
|
|
57
69
|
The package has **zero runtime dependencies** — everything is the Python standard
|
|
58
70
|
library. `pytest` is a development-only dependency.
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
### API key
|
|
73
|
+
|
|
74
|
+
The token is resolved at call time; the first hit wins:
|
|
75
|
+
|
|
76
|
+
| # | Source | Notes |
|
|
77
|
+
|---|--------|-------|
|
|
78
|
+
| 1 | `CLOCKED_API_KEY` (or the variable named by `[provider].api_key_env`) | Process environment; wins over everything |
|
|
79
|
+
| 2 | `BASHBOY_API_KEY_FILE` | Full path to a credential file |
|
|
80
|
+
| 3 | `[provider].api_key_file` in `config.toml` | `~` is expanded |
|
|
81
|
+
| 4 | `~/.bashboy/credentials` | Default per-user file |
|
|
82
|
+
| 5 | nothing | Model-path calls fail closed (`deny`, `source: "fallback"`) |
|
|
83
|
+
|
|
84
|
+
GUI-launched hosts (Claude Code desktop, OpenCode's background service) do not
|
|
85
|
+
inherit a shell profile, so a variable exported in a terminal is invisible to
|
|
86
|
+
them. `setx` fixes that by exposing the key to every process on the machine. A
|
|
87
|
+
per-user credential file is the middle ground: one host-agnostic location, no
|
|
88
|
+
machine-wide secret, and rotation is a single file write.
|
|
62
89
|
|
|
63
90
|
```bash
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
bashboy auth set # prompt twice; input is never echoed
|
|
92
|
+
bashboy auth status # path, source, presence, permissions
|
|
93
|
+
bashboy auth status --check # + one live provider call
|
|
94
|
+
bashboy auth clear # delete after confirmation
|
|
95
|
+
|
|
96
|
+
# provisioning / CI
|
|
97
|
+
echo "$CLOCKED_API_KEY" | bashboy auth set --stdin --force
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
On Windows, `getpass` reads raw keystrokes with echo off, and pasting into that
|
|
101
|
+
prompt is unreliable — Ctrl+V can land nothing or drop characters. If the
|
|
102
|
+
prompt seems to ignore your paste, put the key on the clipboard and pipe it
|
|
103
|
+
instead (it never touches shell history):
|
|
104
|
+
|
|
105
|
+
```powershell
|
|
106
|
+
Get-Clipboard | bashboy auth set --stdin --force
|
|
66
107
|
```
|
|
67
108
|
|
|
109
|
+
`auth set` writes the file atomically, creates `~/.bashboy` with mode `0700` on
|
|
110
|
+
POSIX, and sets the file itself to `0600`. The token is never accepted as a
|
|
111
|
+
command-line argument — it would land in shell history and process listings — and
|
|
112
|
+
it is never printed, logged, or stored in `config.toml` (which is shareable and
|
|
113
|
+
gets pasted into issues). `auth status` never prints the value and makes no
|
|
114
|
+
network call unless you pass `--check`.
|
|
115
|
+
|
|
116
|
+
Permission policy:
|
|
117
|
+
|
|
118
|
+
- **POSIX:** any group/other permission bit (`mode & 0o077`) is refused with a
|
|
119
|
+
message naming `chmod 600 <path>`, and the model path fails closed. Failing
|
|
120
|
+
closed beats silently consuming a leaked secret.
|
|
121
|
+
- **Windows:** the file lives under your user profile, whose default ACL is
|
|
122
|
+
owner-only. `bashboy auth status` inspects the ACL with `icacls` and reports
|
|
123
|
+
any extra principals; it warns but still uses the key, because inherited
|
|
124
|
+
entries are normal. The ACL probe never runs on the `bashboy check` hot path.
|
|
125
|
+
|
|
126
|
+
The token is read from the file at call time, so rotating it needs no host
|
|
127
|
+
restart. A missing or empty file simply means "no key".
|
|
128
|
+
|
|
68
129
|
## Quick start
|
|
69
130
|
|
|
70
131
|
```bash
|
|
@@ -75,7 +136,7 @@ bashboy check --command "git status"
|
|
|
75
136
|
# allow (allowlist): matches allowlist: git status
|
|
76
137
|
|
|
77
138
|
bashboy check --command "docker run --rm alpine sh" --json
|
|
78
|
-
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key
|
|
139
|
+
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key configured (set CLOCKED_API_KEY or run `bashboy auth set`)", ...}
|
|
79
140
|
```
|
|
80
141
|
|
|
81
142
|
Exit codes:
|
|
@@ -106,6 +167,7 @@ A missing file means these defaults:
|
|
|
106
167
|
[provider]
|
|
107
168
|
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
108
169
|
api_key_env = "CLOCKED_API_KEY"
|
|
170
|
+
api_key_file = "~/.bashboy/credentials"
|
|
109
171
|
timeout_ms = 4000
|
|
110
172
|
|
|
111
173
|
[policy]
|
|
@@ -128,6 +190,7 @@ loosens because a file failed to parse.
|
|
|
128
190
|
| `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
|
|
129
191
|
| `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
|
|
130
192
|
| `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
|
|
193
|
+
| `BASHBOY_API_KEY_FILE` | Path to the credential file; overrides `[provider].api_key_file` |
|
|
131
194
|
| `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
|
|
132
195
|
| `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
|
|
133
196
|
| `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
|
|
@@ -177,8 +240,9 @@ bashboy install --claude --from /path/to/BashBoy
|
|
|
177
240
|
|
|
178
241
|
`--from SPEC` rewrites the argv to
|
|
179
242
|
`["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
|
|
180
|
-
local checkout.
|
|
181
|
-
|
|
243
|
+
local checkout. Use it only for a checkout or an unreleased build; a normal
|
|
244
|
+
published install needs no `--from`. The path is written verbatim as a single
|
|
245
|
+
argv element — never interpolated into a shell string.
|
|
182
246
|
|
|
183
247
|
The hook reads one JSON object on stdin. On `deny` it prints the
|
|
184
248
|
`hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
|
|
@@ -242,8 +306,9 @@ Nothing else is transmitted; no files are uploaded.
|
|
|
242
306
|
|
|
243
307
|
Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
|
|
244
308
|
There is no analytics, no audit log, no cache, and no telemetry. The API key is
|
|
245
|
-
read from the environment
|
|
246
|
-
and never logged, persisted
|
|
309
|
+
read at call time from the environment or the credential file, sent only as the
|
|
310
|
+
`Authorization` header, and never logged, persisted in `config.toml`, or included
|
|
311
|
+
in `config show`.
|
|
247
312
|
|
|
248
313
|
## Known limitation: unverified Clocked envelope
|
|
249
314
|
|
|
@@ -283,8 +348,9 @@ Layout:
|
|
|
283
348
|
|
|
284
349
|
```
|
|
285
350
|
src/bashboy/
|
|
286
|
-
cli.py check / install / config / selftest / version
|
|
287
|
-
config.py TOML defaults, env overrides, API key
|
|
351
|
+
cli.py check / install / config / auth / selftest / version
|
|
352
|
+
config.py TOML defaults, env overrides, API key resolution
|
|
353
|
+
credentials.py credential file read/write/clear + permission checks
|
|
288
354
|
rules.py structural allowlist + hard denylist (pure)
|
|
289
355
|
provider.py Clocked client, tolerant envelope parser
|
|
290
356
|
classify.py verdict pipeline (fast paths -> model -> fail closed)
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# BashBoy
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/bashboy/)
|
|
4
|
+
[](https://pypi.org/project/bashboy/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
3
7
|
BashBoy is a small, dependency-free Python CLI that classifies a shell command as
|
|
4
8
|
`allow` or `deny` **before** a coding agent runs it. It sits in front of Claude Code
|
|
5
9
|
(`PreToolUse` hook) and OpenCode (plugin) and answers one question: is this command
|
|
@@ -23,14 +27,22 @@ cannot answer clearly becomes `deny`.
|
|
|
23
27
|
|
|
24
28
|
## Install
|
|
25
29
|
|
|
26
|
-
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
30
|
+
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/) (recommended; `pipx` works too).
|
|
27
31
|
|
|
28
32
|
```bash
|
|
29
|
-
uv tool install bashboy # install
|
|
33
|
+
uv tool install bashboy # install the `bashboy` command
|
|
30
34
|
uvx bashboy --help # or run without installing
|
|
35
|
+
pipx install bashboy # pipx alternative
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Verify the install:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bashboy version # bashboy 0.2.0
|
|
42
|
+
bashboy selftest # offline checks; reports provider: ok when the key works
|
|
31
43
|
```
|
|
32
44
|
|
|
33
|
-
From a local checkout (
|
|
45
|
+
From a local checkout (for development, before a release is published):
|
|
34
46
|
|
|
35
47
|
```bash
|
|
36
48
|
uvx --from /path/to/BashBoy bashboy check --command "git status"
|
|
@@ -40,14 +52,63 @@ uvx --from /path/to/BashBoy bashboy install --claude
|
|
|
40
52
|
The package has **zero runtime dependencies** — everything is the Python standard
|
|
41
53
|
library. `pytest` is a development-only dependency.
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
### API key
|
|
56
|
+
|
|
57
|
+
The token is resolved at call time; the first hit wins:
|
|
58
|
+
|
|
59
|
+
| # | Source | Notes |
|
|
60
|
+
|---|--------|-------|
|
|
61
|
+
| 1 | `CLOCKED_API_KEY` (or the variable named by `[provider].api_key_env`) | Process environment; wins over everything |
|
|
62
|
+
| 2 | `BASHBOY_API_KEY_FILE` | Full path to a credential file |
|
|
63
|
+
| 3 | `[provider].api_key_file` in `config.toml` | `~` is expanded |
|
|
64
|
+
| 4 | `~/.bashboy/credentials` | Default per-user file |
|
|
65
|
+
| 5 | nothing | Model-path calls fail closed (`deny`, `source: "fallback"`) |
|
|
66
|
+
|
|
67
|
+
GUI-launched hosts (Claude Code desktop, OpenCode's background service) do not
|
|
68
|
+
inherit a shell profile, so a variable exported in a terminal is invisible to
|
|
69
|
+
them. `setx` fixes that by exposing the key to every process on the machine. A
|
|
70
|
+
per-user credential file is the middle ground: one host-agnostic location, no
|
|
71
|
+
machine-wide secret, and rotation is a single file write.
|
|
45
72
|
|
|
46
73
|
```bash
|
|
47
|
-
|
|
48
|
-
|
|
74
|
+
bashboy auth set # prompt twice; input is never echoed
|
|
75
|
+
bashboy auth status # path, source, presence, permissions
|
|
76
|
+
bashboy auth status --check # + one live provider call
|
|
77
|
+
bashboy auth clear # delete after confirmation
|
|
78
|
+
|
|
79
|
+
# provisioning / CI
|
|
80
|
+
echo "$CLOCKED_API_KEY" | bashboy auth set --stdin --force
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
On Windows, `getpass` reads raw keystrokes with echo off, and pasting into that
|
|
84
|
+
prompt is unreliable — Ctrl+V can land nothing or drop characters. If the
|
|
85
|
+
prompt seems to ignore your paste, put the key on the clipboard and pipe it
|
|
86
|
+
instead (it never touches shell history):
|
|
87
|
+
|
|
88
|
+
```powershell
|
|
89
|
+
Get-Clipboard | bashboy auth set --stdin --force
|
|
49
90
|
```
|
|
50
91
|
|
|
92
|
+
`auth set` writes the file atomically, creates `~/.bashboy` with mode `0700` on
|
|
93
|
+
POSIX, and sets the file itself to `0600`. The token is never accepted as a
|
|
94
|
+
command-line argument — it would land in shell history and process listings — and
|
|
95
|
+
it is never printed, logged, or stored in `config.toml` (which is shareable and
|
|
96
|
+
gets pasted into issues). `auth status` never prints the value and makes no
|
|
97
|
+
network call unless you pass `--check`.
|
|
98
|
+
|
|
99
|
+
Permission policy:
|
|
100
|
+
|
|
101
|
+
- **POSIX:** any group/other permission bit (`mode & 0o077`) is refused with a
|
|
102
|
+
message naming `chmod 600 <path>`, and the model path fails closed. Failing
|
|
103
|
+
closed beats silently consuming a leaked secret.
|
|
104
|
+
- **Windows:** the file lives under your user profile, whose default ACL is
|
|
105
|
+
owner-only. `bashboy auth status` inspects the ACL with `icacls` and reports
|
|
106
|
+
any extra principals; it warns but still uses the key, because inherited
|
|
107
|
+
entries are normal. The ACL probe never runs on the `bashboy check` hot path.
|
|
108
|
+
|
|
109
|
+
The token is read from the file at call time, so rotating it needs no host
|
|
110
|
+
restart. A missing or empty file simply means "no key".
|
|
111
|
+
|
|
51
112
|
## Quick start
|
|
52
113
|
|
|
53
114
|
```bash
|
|
@@ -58,7 +119,7 @@ bashboy check --command "git status"
|
|
|
58
119
|
# allow (allowlist): matches allowlist: git status
|
|
59
120
|
|
|
60
121
|
bashboy check --command "docker run --rm alpine sh" --json
|
|
61
|
-
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key
|
|
122
|
+
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key configured (set CLOCKED_API_KEY or run `bashboy auth set`)", ...}
|
|
62
123
|
```
|
|
63
124
|
|
|
64
125
|
Exit codes:
|
|
@@ -89,6 +150,7 @@ A missing file means these defaults:
|
|
|
89
150
|
[provider]
|
|
90
151
|
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
91
152
|
api_key_env = "CLOCKED_API_KEY"
|
|
153
|
+
api_key_file = "~/.bashboy/credentials"
|
|
92
154
|
timeout_ms = 4000
|
|
93
155
|
|
|
94
156
|
[policy]
|
|
@@ -111,6 +173,7 @@ loosens because a file failed to parse.
|
|
|
111
173
|
| `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
|
|
112
174
|
| `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
|
|
113
175
|
| `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
|
|
176
|
+
| `BASHBOY_API_KEY_FILE` | Path to the credential file; overrides `[provider].api_key_file` |
|
|
114
177
|
| `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
|
|
115
178
|
| `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
|
|
116
179
|
| `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
|
|
@@ -160,8 +223,9 @@ bashboy install --claude --from /path/to/BashBoy
|
|
|
160
223
|
|
|
161
224
|
`--from SPEC` rewrites the argv to
|
|
162
225
|
`["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
|
|
163
|
-
local checkout.
|
|
164
|
-
|
|
226
|
+
local checkout. Use it only for a checkout or an unreleased build; a normal
|
|
227
|
+
published install needs no `--from`. The path is written verbatim as a single
|
|
228
|
+
argv element — never interpolated into a shell string.
|
|
165
229
|
|
|
166
230
|
The hook reads one JSON object on stdin. On `deny` it prints the
|
|
167
231
|
`hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
|
|
@@ -225,8 +289,9 @@ Nothing else is transmitted; no files are uploaded.
|
|
|
225
289
|
|
|
226
290
|
Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
|
|
227
291
|
There is no analytics, no audit log, no cache, and no telemetry. The API key is
|
|
228
|
-
read from the environment
|
|
229
|
-
and never logged, persisted
|
|
292
|
+
read at call time from the environment or the credential file, sent only as the
|
|
293
|
+
`Authorization` header, and never logged, persisted in `config.toml`, or included
|
|
294
|
+
in `config show`.
|
|
230
295
|
|
|
231
296
|
## Known limitation: unverified Clocked envelope
|
|
232
297
|
|
|
@@ -266,8 +331,9 @@ Layout:
|
|
|
266
331
|
|
|
267
332
|
```
|
|
268
333
|
src/bashboy/
|
|
269
|
-
cli.py check / install / config / selftest / version
|
|
270
|
-
config.py TOML defaults, env overrides, API key
|
|
334
|
+
cli.py check / install / config / auth / selftest / version
|
|
335
|
+
config.py TOML defaults, env overrides, API key resolution
|
|
336
|
+
credentials.py credential file read/write/clear + permission checks
|
|
271
337
|
rules.py structural allowlist + hard denylist (pure)
|
|
272
338
|
provider.py Clocked client, tolerant envelope parser
|
|
273
339
|
classify.py verdict pipeline (fast paths -> model -> fail closed)
|
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
import time
|
|
20
20
|
from dataclasses import dataclass
|
|
21
21
|
|
|
22
|
-
from .config import Config,
|
|
22
|
+
from .config import Config, load_config, resolve_api_key
|
|
23
23
|
from .rules import check_allowlist, check_denylist
|
|
24
24
|
|
|
25
25
|
_TRUTHY = frozenset({"1", "true", "yes"})
|
|
@@ -105,7 +105,9 @@ def classify_command(
|
|
|
105
105
|
|
|
106
106
|
target_cwd = cwd or os.getcwd()
|
|
107
107
|
try:
|
|
108
|
-
|
|
108
|
+
key, key_source = resolve_api_key(cfg)
|
|
109
|
+
log(f"api key source: {key_source}") # source label only -- never the token
|
|
110
|
+
model = provider if provider is not None else Provider(cfg.provider, key=key)
|
|
109
111
|
verdict = model.classify(target_cwd, command) # type: ignore[attr-defined]
|
|
110
112
|
except Exception as exc: # noqa: BLE001 - every failure must map to a verdict
|
|
111
113
|
fail_open = cfg.policy.fail_mode == "open"
|
|
@@ -7,10 +7,12 @@ JSON in dry-run mode. Diagnostics go to stderr so ``| jq`` and hooks stay clean.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
|
+
import dataclasses
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
12
13
|
import platform
|
|
13
14
|
import sys
|
|
15
|
+
from getpass import getpass as _getpass
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
|
|
16
18
|
from . import __version__
|
|
@@ -22,6 +24,15 @@ from .config import (
|
|
|
22
24
|
api_key,
|
|
23
25
|
config_path,
|
|
24
26
|
load_config,
|
|
27
|
+
resolve_api_key,
|
|
28
|
+
)
|
|
29
|
+
from .credentials import (
|
|
30
|
+
CredentialError,
|
|
31
|
+
CredentialExistsError,
|
|
32
|
+
CredentialPermissionError,
|
|
33
|
+
clear_token,
|
|
34
|
+
permissions,
|
|
35
|
+
write_token,
|
|
25
36
|
)
|
|
26
37
|
|
|
27
38
|
|
|
@@ -29,12 +40,17 @@ def _toml_bool(value: bool) -> str:
|
|
|
29
40
|
return "true" if value else "false"
|
|
30
41
|
|
|
31
42
|
|
|
43
|
+
def _toml_string(value: str) -> str:
|
|
44
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
45
|
+
|
|
46
|
+
|
|
32
47
|
def render_config(cfg: Config) -> str:
|
|
33
48
|
lines = [
|
|
34
49
|
f"# source: {cfg.path}",
|
|
35
50
|
"[provider]",
|
|
36
51
|
f'endpoint = "{cfg.provider.endpoint}"',
|
|
37
52
|
f'api_key_env = "{cfg.provider.api_key_env}"',
|
|
53
|
+
f"api_key_file = {_toml_string(str(cfg.provider.api_key_file))}",
|
|
38
54
|
f"timeout_ms = {cfg.provider.timeout_ms}",
|
|
39
55
|
"",
|
|
40
56
|
"[policy]",
|
|
@@ -102,6 +118,142 @@ def _cmd_config(args: argparse.Namespace) -> int:
|
|
|
102
118
|
return 2
|
|
103
119
|
|
|
104
120
|
|
|
121
|
+
# --- auth -----------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _credential_target(args: argparse.Namespace, cfg: Config) -> Path:
|
|
125
|
+
override = getattr(args, "file", None)
|
|
126
|
+
if override:
|
|
127
|
+
return Path(override).expanduser().resolve()
|
|
128
|
+
return cfg.provider.api_key_file
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _cmd_auth(args: argparse.Namespace) -> int:
|
|
132
|
+
print(
|
|
133
|
+
"usage: bashboy auth {set,status,clear} ... (see `bashboy auth --help`)",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
return 2
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cmd_auth_set(args: argparse.Namespace) -> int:
|
|
140
|
+
cfg = load_config()
|
|
141
|
+
path = _credential_target(args, cfg)
|
|
142
|
+
|
|
143
|
+
if args.stdin:
|
|
144
|
+
token = sys.stdin.read().strip()
|
|
145
|
+
else:
|
|
146
|
+
token = _getpass("Clocked API key (input hidden): ").strip()
|
|
147
|
+
if token:
|
|
148
|
+
confirm = _getpass("Repeat to confirm (input hidden): ").strip()
|
|
149
|
+
if confirm != token:
|
|
150
|
+
print("bashboy: tokens did not match", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
if not token:
|
|
153
|
+
print("bashboy: no token provided", file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
write_token(path, token, overwrite=args.force)
|
|
158
|
+
except CredentialExistsError as exc:
|
|
159
|
+
print(f"bashboy: {exc}", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
except (CredentialError, OSError) as exc:
|
|
162
|
+
print(f"bashboy: {exc}", file=sys.stderr)
|
|
163
|
+
return 2
|
|
164
|
+
|
|
165
|
+
print(f"saved {path}")
|
|
166
|
+
print(f"permissions: {permissions(path).detail}")
|
|
167
|
+
|
|
168
|
+
if os.environ.get(cfg.provider.api_key_env, "").strip():
|
|
169
|
+
print(
|
|
170
|
+
f"note: {cfg.provider.api_key_env} is set and takes precedence over the file",
|
|
171
|
+
file=sys.stderr,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
from .provider import verify_key # lazy: keeps `bashboy --help` fast
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
verdict = verify_key(cfg.provider, token)
|
|
178
|
+
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
|
179
|
+
print(f"provider: FAILED ({exc})")
|
|
180
|
+
return 2
|
|
181
|
+
print(f"provider: ok ({verdict.decision})")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _cmd_auth_status(args: argparse.Namespace) -> int:
|
|
186
|
+
cfg = load_config()
|
|
187
|
+
path = _credential_target(args, cfg)
|
|
188
|
+
if path != cfg.provider.api_key_file:
|
|
189
|
+
# `--file` must drive resolution too, not just the reported path.
|
|
190
|
+
cfg = dataclasses.replace(
|
|
191
|
+
cfg, provider=dataclasses.replace(cfg.provider, api_key_file=path)
|
|
192
|
+
)
|
|
193
|
+
# The only place the Windows ACL is probed: never on the `check` hot path.
|
|
194
|
+
state = permissions(path, probe_acl=True)
|
|
195
|
+
|
|
196
|
+
print(f"config: {cfg.path}")
|
|
197
|
+
print(f"endpoint: {cfg.provider.endpoint}")
|
|
198
|
+
print(f"credential file: {path}")
|
|
199
|
+
print(f"permissions: {state.detail}")
|
|
200
|
+
if state.label == "extra-principals":
|
|
201
|
+
log(f"credential file ACL: {state.detail}")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
key, source = resolve_api_key(cfg)
|
|
205
|
+
except CredentialPermissionError as exc:
|
|
206
|
+
key, source = None, f"refused ({exc})"
|
|
207
|
+
except CredentialError as exc:
|
|
208
|
+
key, source = None, f"unreadable ({exc})"
|
|
209
|
+
if key and state.label in {"extra-principals", "unverified"}:
|
|
210
|
+
# The probe above is more specific than the hot-path label in `source`.
|
|
211
|
+
source = f"file {path} ({state.label})"
|
|
212
|
+
print(f"key source: {source}")
|
|
213
|
+
if key:
|
|
214
|
+
print("key: present")
|
|
215
|
+
elif source.startswith(("refused", "unreadable")):
|
|
216
|
+
print("key: refused")
|
|
217
|
+
else:
|
|
218
|
+
print("key: absent")
|
|
219
|
+
|
|
220
|
+
if args.check:
|
|
221
|
+
if key:
|
|
222
|
+
from .provider import verify_key # lazy: opt-in network call only
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
verdict = verify_key(cfg.provider, key)
|
|
226
|
+
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
|
227
|
+
print(f"provider: FAILED ({exc})")
|
|
228
|
+
else:
|
|
229
|
+
print(f"provider: ok ({verdict.decision})")
|
|
230
|
+
else:
|
|
231
|
+
print("provider: skipped (no API key)")
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cmd_auth_clear(args: argparse.Namespace) -> int:
|
|
236
|
+
cfg = load_config()
|
|
237
|
+
path = _credential_target(args, cfg)
|
|
238
|
+
if not path.is_file():
|
|
239
|
+
print(f"no credential file at {path}")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
if not args.force:
|
|
243
|
+
answer = input(f"delete {path}? [y/N] ")
|
|
244
|
+
if answer.strip().lower() not in {"y", "yes"}:
|
|
245
|
+
print("aborted")
|
|
246
|
+
return 1
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
clear_token(path)
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
print(f"bashboy: {exc}", file=sys.stderr)
|
|
252
|
+
return 2
|
|
253
|
+
print(f"removed {path}")
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
105
257
|
# --- install --------------------------------------------------------------
|
|
106
258
|
|
|
107
259
|
_DEFAULT_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
@@ -241,6 +393,26 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
241
393
|
config.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
242
394
|
config.set_defaults(handler=_cmd_config)
|
|
243
395
|
|
|
396
|
+
auth = sub.add_parser("auth", help="manage the Clocked API key file")
|
|
397
|
+
auth.set_defaults(handler=_cmd_auth)
|
|
398
|
+
auth_sub = auth.add_subparsers(dest="auth_action")
|
|
399
|
+
|
|
400
|
+
auth_set = auth_sub.add_parser("set", help="save the API key to the credential file")
|
|
401
|
+
auth_set.add_argument("--file", default=None, help="credential file path")
|
|
402
|
+
auth_set.add_argument("--stdin", action="store_true", help="read the key from stdin")
|
|
403
|
+
auth_set.add_argument("--force", action="store_true", help="overwrite an existing file")
|
|
404
|
+
auth_set.set_defaults(handler=_cmd_auth_set)
|
|
405
|
+
|
|
406
|
+
auth_status = auth_sub.add_parser("status", help="show the key source and file permissions")
|
|
407
|
+
auth_status.add_argument("--file", default=None, help="credential file path")
|
|
408
|
+
auth_status.add_argument("--check", action="store_true", help="make one live provider call")
|
|
409
|
+
auth_status.set_defaults(handler=_cmd_auth_status)
|
|
410
|
+
|
|
411
|
+
auth_clear = auth_sub.add_parser("clear", help="delete the credential file")
|
|
412
|
+
auth_clear.add_argument("--file", default=None, help="credential file path")
|
|
413
|
+
auth_clear.add_argument("--force", action="store_true", help="skip the confirmation prompt")
|
|
414
|
+
auth_clear.set_defaults(handler=_cmd_auth_clear)
|
|
415
|
+
|
|
244
416
|
selftest = sub.add_parser("selftest", help="run offline self-checks")
|
|
245
417
|
selftest.set_defaults(handler=_cmd_selftest)
|
|
246
418
|
|
|
@@ -259,7 +431,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
259
431
|
return 2
|
|
260
432
|
try:
|
|
261
433
|
return int(handler(args))
|
|
262
|
-
except ConfigError as exc:
|
|
434
|
+
except (ConfigError, CredentialError) as exc:
|
|
263
435
|
print(f"bashboy: {exc}", file=sys.stderr)
|
|
264
436
|
return 2
|
|
265
437
|
except KeyboardInterrupt: # pragma: no cover - interactive only
|