bashboy 0.1.0__tar.gz → 0.2.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 → bashboy-0.2.0}/PKG-INFO +72 -15
- {bashboy-0.1.0 → bashboy-0.2.0}/README.md +71 -14
- {bashboy-0.1.0 → bashboy-0.2.0}/pyproject.toml +1 -1
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/__init__.py +1 -1
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/classify.py +4 -2
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/cli.py +173 -1
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/config.py +55 -9
- bashboy-0.2.0/src/bashboy/credentials.py +254 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/provider.py +13 -1
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/conftest.py +5 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_classify.py +52 -0
- bashboy-0.2.0/tests/test_cli.py +484 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_config.py +73 -1
- bashboy-0.2.0/tests/test_credentials.py +340 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_provider.py +1 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/uv.lock +1 -1
- bashboy-0.1.0/tests/test_cli.py +0 -171
- {bashboy-0.1.0 → bashboy-0.2.0}/.github/workflows/release.yml +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/.gitignore +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/LICENSE +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/__main__.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/hooks/__init__.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/hooks/claude.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/installers/__init__.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/installers/claude.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/installers/opencode.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/src/bashboy/rules.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_claude_hook.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_installers.py +0 -0
- {bashboy-0.1.0 → bashboy-0.2.0}/tests/test_rules.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bashboy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
48
53
|
```
|
|
49
54
|
|
|
50
|
-
|
|
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
|
|
60
|
+
```
|
|
61
|
+
|
|
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,54 @@ 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
|
|
66
98
|
```
|
|
67
99
|
|
|
100
|
+
`auth set` writes the file atomically, creates `~/.bashboy` with mode `0700` on
|
|
101
|
+
POSIX, and sets the file itself to `0600`. The token is never accepted as a
|
|
102
|
+
command-line argument — it would land in shell history and process listings — and
|
|
103
|
+
it is never printed, logged, or stored in `config.toml` (which is shareable and
|
|
104
|
+
gets pasted into issues). `auth status` never prints the value and makes no
|
|
105
|
+
network call unless you pass `--check`.
|
|
106
|
+
|
|
107
|
+
Permission policy:
|
|
108
|
+
|
|
109
|
+
- **POSIX:** any group/other permission bit (`mode & 0o077`) is refused with a
|
|
110
|
+
message naming `chmod 600 <path>`, and the model path fails closed. Failing
|
|
111
|
+
closed beats silently consuming a leaked secret.
|
|
112
|
+
- **Windows:** the file lives under your user profile, whose default ACL is
|
|
113
|
+
owner-only. `bashboy auth status` inspects the ACL with `icacls` and reports
|
|
114
|
+
any extra principals; it warns but still uses the key, because inherited
|
|
115
|
+
entries are normal. The ACL probe never runs on the `bashboy check` hot path.
|
|
116
|
+
|
|
117
|
+
The token is read from the file at call time, so rotating it needs no host
|
|
118
|
+
restart. A missing or empty file simply means "no key".
|
|
119
|
+
|
|
68
120
|
## Quick start
|
|
69
121
|
|
|
70
122
|
```bash
|
|
@@ -75,7 +127,7 @@ bashboy check --command "git status"
|
|
|
75
127
|
# allow (allowlist): matches allowlist: git status
|
|
76
128
|
|
|
77
129
|
bashboy check --command "docker run --rm alpine sh" --json
|
|
78
|
-
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key
|
|
130
|
+
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key configured (set CLOCKED_API_KEY or run `bashboy auth set`)", ...}
|
|
79
131
|
```
|
|
80
132
|
|
|
81
133
|
Exit codes:
|
|
@@ -106,6 +158,7 @@ A missing file means these defaults:
|
|
|
106
158
|
[provider]
|
|
107
159
|
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
108
160
|
api_key_env = "CLOCKED_API_KEY"
|
|
161
|
+
api_key_file = "~/.bashboy/credentials"
|
|
109
162
|
timeout_ms = 4000
|
|
110
163
|
|
|
111
164
|
[policy]
|
|
@@ -128,6 +181,7 @@ loosens because a file failed to parse.
|
|
|
128
181
|
| `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
|
|
129
182
|
| `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
|
|
130
183
|
| `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
|
|
184
|
+
| `BASHBOY_API_KEY_FILE` | Path to the credential file; overrides `[provider].api_key_file` |
|
|
131
185
|
| `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
|
|
132
186
|
| `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
|
|
133
187
|
| `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
|
|
@@ -177,8 +231,9 @@ bashboy install --claude --from /path/to/BashBoy
|
|
|
177
231
|
|
|
178
232
|
`--from SPEC` rewrites the argv to
|
|
179
233
|
`["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
|
|
180
|
-
local checkout.
|
|
181
|
-
|
|
234
|
+
local checkout. Use it only for a checkout or an unreleased build; a normal
|
|
235
|
+
published install needs no `--from`. The path is written verbatim as a single
|
|
236
|
+
argv element — never interpolated into a shell string.
|
|
182
237
|
|
|
183
238
|
The hook reads one JSON object on stdin. On `deny` it prints the
|
|
184
239
|
`hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
|
|
@@ -242,8 +297,9 @@ Nothing else is transmitted; no files are uploaded.
|
|
|
242
297
|
|
|
243
298
|
Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
|
|
244
299
|
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
|
|
300
|
+
read at call time from the environment or the credential file, sent only as the
|
|
301
|
+
`Authorization` header, and never logged, persisted in `config.toml`, or included
|
|
302
|
+
in `config show`.
|
|
247
303
|
|
|
248
304
|
## Known limitation: unverified Clocked envelope
|
|
249
305
|
|
|
@@ -283,8 +339,9 @@ Layout:
|
|
|
283
339
|
|
|
284
340
|
```
|
|
285
341
|
src/bashboy/
|
|
286
|
-
cli.py check / install / config / selftest / version
|
|
287
|
-
config.py TOML defaults, env overrides, API key
|
|
342
|
+
cli.py check / install / config / auth / selftest / version
|
|
343
|
+
config.py TOML defaults, env overrides, API key resolution
|
|
344
|
+
credentials.py credential file read/write/clear + permission checks
|
|
288
345
|
rules.py structural allowlist + hard denylist (pure)
|
|
289
346
|
provider.py Clocked client, tolerant envelope parser
|
|
290
347
|
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
|
|
31
36
|
```
|
|
32
37
|
|
|
33
|
-
|
|
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
|
|
43
|
+
```
|
|
44
|
+
|
|
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,54 @@ 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
|
|
49
81
|
```
|
|
50
82
|
|
|
83
|
+
`auth set` writes the file atomically, creates `~/.bashboy` with mode `0700` on
|
|
84
|
+
POSIX, and sets the file itself to `0600`. The token is never accepted as a
|
|
85
|
+
command-line argument — it would land in shell history and process listings — and
|
|
86
|
+
it is never printed, logged, or stored in `config.toml` (which is shareable and
|
|
87
|
+
gets pasted into issues). `auth status` never prints the value and makes no
|
|
88
|
+
network call unless you pass `--check`.
|
|
89
|
+
|
|
90
|
+
Permission policy:
|
|
91
|
+
|
|
92
|
+
- **POSIX:** any group/other permission bit (`mode & 0o077`) is refused with a
|
|
93
|
+
message naming `chmod 600 <path>`, and the model path fails closed. Failing
|
|
94
|
+
closed beats silently consuming a leaked secret.
|
|
95
|
+
- **Windows:** the file lives under your user profile, whose default ACL is
|
|
96
|
+
owner-only. `bashboy auth status` inspects the ACL with `icacls` and reports
|
|
97
|
+
any extra principals; it warns but still uses the key, because inherited
|
|
98
|
+
entries are normal. The ACL probe never runs on the `bashboy check` hot path.
|
|
99
|
+
|
|
100
|
+
The token is read from the file at call time, so rotating it needs no host
|
|
101
|
+
restart. A missing or empty file simply means "no key".
|
|
102
|
+
|
|
51
103
|
## Quick start
|
|
52
104
|
|
|
53
105
|
```bash
|
|
@@ -58,7 +110,7 @@ bashboy check --command "git status"
|
|
|
58
110
|
# allow (allowlist): matches allowlist: git status
|
|
59
111
|
|
|
60
112
|
bashboy check --command "docker run --rm alpine sh" --json
|
|
61
|
-
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key
|
|
113
|
+
# {"decision": "deny", "source": "fallback", "reason": "classifier unavailable: no API key configured (set CLOCKED_API_KEY or run `bashboy auth set`)", ...}
|
|
62
114
|
```
|
|
63
115
|
|
|
64
116
|
Exit codes:
|
|
@@ -89,6 +141,7 @@ A missing file means these defaults:
|
|
|
89
141
|
[provider]
|
|
90
142
|
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
91
143
|
api_key_env = "CLOCKED_API_KEY"
|
|
144
|
+
api_key_file = "~/.bashboy/credentials"
|
|
92
145
|
timeout_ms = 4000
|
|
93
146
|
|
|
94
147
|
[policy]
|
|
@@ -111,6 +164,7 @@ loosens because a file failed to parse.
|
|
|
111
164
|
| `BASHBOY_CONFIG` | Path to the config file (default `~/.bashboy/config.toml`) |
|
|
112
165
|
| `BASHBOY_ENDPOINT` | Overrides `[provider].endpoint` |
|
|
113
166
|
| `BASHBOY_API_KEY_ENV` | Name of the environment variable holding the key |
|
|
167
|
+
| `BASHBOY_API_KEY_FILE` | Path to the credential file; overrides `[provider].api_key_file` |
|
|
114
168
|
| `BASHBOY_FAIL_MODE` | `closed` (default) or `open`; anything else is an error |
|
|
115
169
|
| `BASHBOY_DISABLE` | `1`/`true`/`yes` disables **all** checks (see below) |
|
|
116
170
|
| `BASHBOY_DEBUG` | `1`/`true`/`yes` writes diagnostics to stderr only |
|
|
@@ -160,8 +214,9 @@ bashboy install --claude --from /path/to/BashBoy
|
|
|
160
214
|
|
|
161
215
|
`--from SPEC` rewrites the argv to
|
|
162
216
|
`["--from", SPEC, "bashboy", "check", "--claude-hook"]`, so `uvx` runs the given
|
|
163
|
-
local checkout.
|
|
164
|
-
|
|
217
|
+
local checkout. Use it only for a checkout or an unreleased build; a normal
|
|
218
|
+
published install needs no `--from`. The path is written verbatim as a single
|
|
219
|
+
argv element — never interpolated into a shell string.
|
|
165
220
|
|
|
166
221
|
The hook reads one JSON object on stdin. On `deny` it prints the
|
|
167
222
|
`hookSpecificOutput` envelope with `permissionDecision: "deny"`; on `allow`, blank
|
|
@@ -225,8 +280,9 @@ Nothing else is transmitted; no files are uploaded.
|
|
|
225
280
|
|
|
226
281
|
Fast-path verdicts (`denylist`, `allowlist`, `disabled`) never leave the machine.
|
|
227
282
|
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
|
|
283
|
+
read at call time from the environment or the credential file, sent only as the
|
|
284
|
+
`Authorization` header, and never logged, persisted in `config.toml`, or included
|
|
285
|
+
in `config show`.
|
|
230
286
|
|
|
231
287
|
## Known limitation: unverified Clocked envelope
|
|
232
288
|
|
|
@@ -266,8 +322,9 @@ Layout:
|
|
|
266
322
|
|
|
267
323
|
```
|
|
268
324
|
src/bashboy/
|
|
269
|
-
cli.py check / install / config / selftest / version
|
|
270
|
-
config.py TOML defaults, env overrides, API key
|
|
325
|
+
cli.py check / install / config / auth / selftest / version
|
|
326
|
+
config.py TOML defaults, env overrides, API key resolution
|
|
327
|
+
credentials.py credential file read/write/clear + permission checks
|
|
271
328
|
rules.py structural allowlist + hard denylist (pure)
|
|
272
329
|
provider.py Clocked client, tolerant envelope parser
|
|
273
330
|
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: ").strip()
|
|
147
|
+
if token:
|
|
148
|
+
confirm = _getpass("Repeat to confirm: ").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
|