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.
Files changed (30) hide show
  1. {bashboy-0.1.0 → bashboy-0.2.1}/PKG-INFO +81 -15
  2. {bashboy-0.1.0 → bashboy-0.2.1}/README.md +80 -14
  3. {bashboy-0.1.0 → bashboy-0.2.1}/pyproject.toml +1 -1
  4. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/__init__.py +1 -1
  5. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/classify.py +4 -2
  6. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/cli.py +173 -1
  7. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/config.py +55 -9
  8. bashboy-0.2.1/src/bashboy/credentials.py +254 -0
  9. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/provider.py +18 -1
  10. {bashboy-0.1.0 → bashboy-0.2.1}/tests/conftest.py +5 -0
  11. {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_classify.py +52 -0
  12. bashboy-0.2.1/tests/test_cli.py +484 -0
  13. {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_config.py +73 -1
  14. bashboy-0.2.1/tests/test_credentials.py +340 -0
  15. {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_provider.py +3 -0
  16. {bashboy-0.1.0 → bashboy-0.2.1}/uv.lock +1 -1
  17. bashboy-0.1.0/tests/test_cli.py +0 -171
  18. {bashboy-0.1.0 → bashboy-0.2.1}/.github/workflows/release.yml +0 -0
  19. {bashboy-0.1.0 → bashboy-0.2.1}/.gitignore +0 -0
  20. {bashboy-0.1.0 → bashboy-0.2.1}/LICENSE +0 -0
  21. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/__main__.py +0 -0
  22. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/hooks/__init__.py +0 -0
  23. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/hooks/claude.py +0 -0
  24. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/installers/__init__.py +0 -0
  25. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/installers/claude.py +0 -0
  26. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/installers/opencode.py +0 -0
  27. {bashboy-0.1.0 → bashboy-0.2.1}/src/bashboy/rules.py +0 -0
  28. {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_claude_hook.py +0 -0
  29. {bashboy-0.1.0 → bashboy-0.2.1}/tests/test_installers.py +0 -0
  30. {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.0
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
+ [![PyPI](https://img.shields.io/pypi/v/bashboy.svg)](https://pypi.org/project/bashboy/)
21
+ [![Python](https://img.shields.io/pypi/pyversions/bashboy.svg)](https://pypi.org/project/bashboy/)
22
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 once
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 (uvx resolves the project from the path):
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
- The API key is read from the environment (default `CLOCKED_API_KEY`). It is never
61
- written to disk:
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
- export CLOCKED_API_KEY=... # bash
65
- $env:CLOCKED_API_KEY = "..." # PowerShell
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. The path is written verbatim as a single argv element — never
181
- interpolated into a shell string.
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 at call time, sent only as the `Authorization` header,
246
- and never logged, persisted, or included in `config show`.
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 lookup
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
+ [![PyPI](https://img.shields.io/pypi/v/bashboy.svg)](https://pypi.org/project/bashboy/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/bashboy.svg)](https://pypi.org/project/bashboy/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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 once
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 (uvx resolves the project from the path):
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
- The API key is read from the environment (default `CLOCKED_API_KEY`). It is never
44
- written to disk:
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
- export CLOCKED_API_KEY=... # bash
48
- $env:CLOCKED_API_KEY = "..." # PowerShell
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. The path is written verbatim as a single argv element — never
164
- interpolated into a shell string.
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 at call time, sent only as the `Authorization` header,
229
- and never logged, persisted, or included in `config show`.
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 lookup
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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bashboy"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Classify shell commands as allow or deny before an agent runs them."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,5 @@
1
1
  """BashBoy: a deny-only shell-command gate for coding agents."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -19,7 +19,7 @@ import sys
19
19
  import time
20
20
  from dataclasses import dataclass
21
21
 
22
- from .config import Config, api_key, load_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
- model = provider if provider is not None else Provider(cfg.provider, key=api_key(cfg))
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