bashboy 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- bashboy/__main__.py +5 -0
- bashboy/classify.py +124 -0
- bashboy/cli.py +271 -0
- bashboy/config.py +219 -0
- bashboy/hooks/__init__.py +1 -0
- bashboy/hooks/claude.py +92 -0
- bashboy/installers/__init__.py +38 -0
- bashboy/installers/claude.py +135 -0
- bashboy/installers/opencode.py +199 -0
- bashboy/provider.py +215 -0
- bashboy/rules.py +225 -0
- bashboy-0.1.0.dist-info/METADATA +298 -0
- bashboy-0.1.0.dist-info/RECORD +17 -0
- bashboy-0.1.0.dist-info/WHEEL +4 -0
- bashboy-0.1.0.dist-info/entry_points.txt +2 -0
- bashboy-0.1.0.dist-info/licenses/LICENSE +21 -0
bashboy/__init__.py
ADDED
bashboy/__main__.py
ADDED
bashboy/classify.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Verdict pipeline: fast local paths first, model second, fail closed.
|
|
2
|
+
|
|
3
|
+
Precedence is fixed and auditable:
|
|
4
|
+
|
|
5
|
+
1. ``BASHBOY_DISABLE`` -- allow, source ``disabled`` (documented escape hatch).
|
|
6
|
+
2. Blank command -- deny, source ``fallback``.
|
|
7
|
+
3. Denylist -- deny, source ``denylist``.
|
|
8
|
+
4. Allowlist -- allow, source ``allowlist``.
|
|
9
|
+
5. Model -- allow/deny, source ``model``; any failure becomes ``fallback``.
|
|
10
|
+
|
|
11
|
+
The provider is imported lazily inside branch 5 so the fast paths never pay for
|
|
12
|
+
``urllib`` or ``json`` at startup.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from .config import Config, api_key, load_config
|
|
23
|
+
from .rules import check_allowlist, check_denylist
|
|
24
|
+
|
|
25
|
+
_TRUTHY = frozenset({"1", "true", "yes"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log(msg: str) -> None:
|
|
29
|
+
"""Write a diagnostic line to stderr, but only when ``BASHBOY_DEBUG`` is truthy.
|
|
30
|
+
|
|
31
|
+
Never pass the API key, an ``Authorization`` header, or a full request body.
|
|
32
|
+
"""
|
|
33
|
+
value = os.environ.get("BASHBOY_DEBUG", "")
|
|
34
|
+
if value.strip().lower() in _TRUTHY:
|
|
35
|
+
print(f"bashboy: {msg}", file=sys.stderr)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _enabled(name: str) -> bool:
|
|
39
|
+
return os.environ.get(name, "").strip().lower() in _TRUTHY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Classification:
|
|
44
|
+
"""The final verdict the CLI prints and the hooks act on."""
|
|
45
|
+
|
|
46
|
+
decision: str
|
|
47
|
+
source: str
|
|
48
|
+
reason: str
|
|
49
|
+
risk: str | None = None
|
|
50
|
+
latency_ms: int = 0
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
object.__setattr__(self, "latency_ms", int(self.latency_ms))
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, object]:
|
|
56
|
+
# Key order is part of the CLI contract; keep it stable.
|
|
57
|
+
return {
|
|
58
|
+
"decision": self.decision,
|
|
59
|
+
"source": self.source,
|
|
60
|
+
"reason": self.reason,
|
|
61
|
+
"risk": self.risk,
|
|
62
|
+
"latency_ms": self.latency_ms,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def _elapsed_ms(started: float) -> int:
|
|
66
|
+
return max(int((time.perf_counter() - started) * 1000), 0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def classify_command(
|
|
70
|
+
command: str,
|
|
71
|
+
cwd: str | None = None,
|
|
72
|
+
cfg: Config | None = None,
|
|
73
|
+
provider: object | None = None,
|
|
74
|
+
) -> Classification:
|
|
75
|
+
"""Classify ``command``. Never raises for provider problems; fails closed."""
|
|
76
|
+
started = time.perf_counter()
|
|
77
|
+
if cfg is None:
|
|
78
|
+
cfg = load_config()
|
|
79
|
+
|
|
80
|
+
if _enabled("BASHBOY_DISABLE"):
|
|
81
|
+
latency = _elapsed_ms(started)
|
|
82
|
+
log(f"BASHBOY_DISABLE is set: allowing without checks (latency={latency}ms)")
|
|
83
|
+
return Classification("allow", "disabled", "BASHBOY_DISABLE is set", None, latency)
|
|
84
|
+
|
|
85
|
+
if not isinstance(command, str) or not command.strip():
|
|
86
|
+
return Classification("deny", "fallback", "no command provided", None, _elapsed_ms(started))
|
|
87
|
+
|
|
88
|
+
if cfg.policy.denylist:
|
|
89
|
+
hit = check_denylist(command, cwd)
|
|
90
|
+
if hit is not None:
|
|
91
|
+
latency = _elapsed_ms(started)
|
|
92
|
+
log(f"denylist hit: {hit.reason} (latency={latency}ms)")
|
|
93
|
+
return Classification("deny", "denylist", hit.reason, None, latency)
|
|
94
|
+
|
|
95
|
+
if cfg.policy.allowlist:
|
|
96
|
+
label = check_allowlist(command)
|
|
97
|
+
if label is not None:
|
|
98
|
+
latency = _elapsed_ms(started)
|
|
99
|
+
log(f"allowlist hit: {label} (latency={latency}ms)")
|
|
100
|
+
return Classification(
|
|
101
|
+
"allow", "allowlist", f"matches allowlist: {label}", None, latency
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
from .provider import Provider
|
|
105
|
+
|
|
106
|
+
target_cwd = cwd or os.getcwd()
|
|
107
|
+
try:
|
|
108
|
+
model = provider if provider is not None else Provider(cfg.provider, key=api_key(cfg))
|
|
109
|
+
verdict = model.classify(target_cwd, command) # type: ignore[attr-defined]
|
|
110
|
+
except Exception as exc: # noqa: BLE001 - every failure must map to a verdict
|
|
111
|
+
fail_open = cfg.policy.fail_mode == "open"
|
|
112
|
+
decision = "allow" if fail_open else "deny"
|
|
113
|
+
latency = _elapsed_ms(started)
|
|
114
|
+
log(f"classifier unavailable: {exc} (latency={latency}ms)")
|
|
115
|
+
return Classification(
|
|
116
|
+
decision, "fallback", f"classifier unavailable: {exc}", None, latency
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
latency = _elapsed_ms(started)
|
|
120
|
+
log(
|
|
121
|
+
f"model verdict {verdict.decision} from {cfg.provider.endpoint} "
|
|
122
|
+
f"(latency={latency}ms)"
|
|
123
|
+
)
|
|
124
|
+
return Classification(verdict.decision, "model", verdict.reason, verdict.risk, latency)
|
bashboy/cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
stdout is reserved for verdict output (``--json``), hook payloads, and installer
|
|
4
|
+
JSON in dry-run mode. Diagnostics go to stderr so ``| jq`` and hooks stay clean.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .classify import classify_command, log
|
|
18
|
+
from .config import (
|
|
19
|
+
DEFAULT_CONFIG_TOML,
|
|
20
|
+
Config,
|
|
21
|
+
ConfigError,
|
|
22
|
+
api_key,
|
|
23
|
+
config_path,
|
|
24
|
+
load_config,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _toml_bool(value: bool) -> str:
|
|
29
|
+
return "true" if value else "false"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_config(cfg: Config) -> str:
|
|
33
|
+
lines = [
|
|
34
|
+
f"# source: {cfg.path}",
|
|
35
|
+
"[provider]",
|
|
36
|
+
f'endpoint = "{cfg.provider.endpoint}"',
|
|
37
|
+
f'api_key_env = "{cfg.provider.api_key_env}"',
|
|
38
|
+
f"timeout_ms = {cfg.provider.timeout_ms}",
|
|
39
|
+
"",
|
|
40
|
+
"[policy]",
|
|
41
|
+
f'fail_mode = "{cfg.policy.fail_mode}"',
|
|
42
|
+
f"denylist = {_toml_bool(cfg.policy.denylist)}",
|
|
43
|
+
f"allowlist = {_toml_bool(cfg.policy.allowlist)}",
|
|
44
|
+
"",
|
|
45
|
+
"[integrations.claude]",
|
|
46
|
+
f"timeout_s = {cfg.claude.timeout_s}",
|
|
47
|
+
"",
|
|
48
|
+
"[integrations.opencode]",
|
|
49
|
+
f"timeout_ms = {cfg.opencode.timeout_ms}",
|
|
50
|
+
]
|
|
51
|
+
return "\n".join(lines) + "\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- check ----------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
58
|
+
if args.claude_hook:
|
|
59
|
+
from .hooks.claude import run_hook
|
|
60
|
+
|
|
61
|
+
return run_hook(sys.stdin, sys.stdout)
|
|
62
|
+
|
|
63
|
+
if args.command_text is None:
|
|
64
|
+
print("bashboy: --command is required (or use --claude-hook)", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
cfg = load_config()
|
|
68
|
+
log(f"config: {cfg.path}")
|
|
69
|
+
result = classify_command(args.command_text, cwd=args.cwd, cfg=cfg)
|
|
70
|
+
|
|
71
|
+
if args.json:
|
|
72
|
+
print(json.dumps(result.to_dict()))
|
|
73
|
+
else:
|
|
74
|
+
print(f"{result.decision} ({result.source}): {result.reason}")
|
|
75
|
+
return 0 if result.decision == "allow" else 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- config ---------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_config(args: argparse.Namespace) -> int:
|
|
82
|
+
path = config_path()
|
|
83
|
+
if args.action is None or args.action == "path":
|
|
84
|
+
print(path)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
if args.action == "show":
|
|
88
|
+
cfg = load_config()
|
|
89
|
+
sys.stdout.write(render_config(cfg))
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
if args.action == "init":
|
|
93
|
+
if path.exists() and not args.force:
|
|
94
|
+
print(f"{path} already exists; re-run with --force to overwrite")
|
|
95
|
+
return 1
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8")
|
|
98
|
+
print(f"wrote {path}")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
print(f"bashboy: unknown config action {args.action!r}", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- install --------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
_DEFAULT_CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cmd_install(args: argparse.Namespace) -> int:
|
|
111
|
+
both = args.all or (not args.claude and not args.opencode)
|
|
112
|
+
run_claude = args.claude or both
|
|
113
|
+
run_opencode = args.opencode or both
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
from .installers.claude import install_claude
|
|
117
|
+
from .installers.opencode import install_opencode
|
|
118
|
+
except ImportError as exc: # pragma: no cover - installers ship with the package
|
|
119
|
+
print(f"bashboy: installers unavailable: {exc}", file=sys.stderr)
|
|
120
|
+
return 2
|
|
121
|
+
|
|
122
|
+
results: dict[str, dict] = {}
|
|
123
|
+
try:
|
|
124
|
+
if run_claude:
|
|
125
|
+
results["claude"] = install_claude(
|
|
126
|
+
args.settings, from_spec=args.from_spec, dry_run=args.dry_run
|
|
127
|
+
)
|
|
128
|
+
if run_opencode:
|
|
129
|
+
results["opencode"] = install_opencode(args.config_dir, dry_run=args.dry_run)
|
|
130
|
+
except (ConfigError, OSError, ValueError) as exc:
|
|
131
|
+
print(f"bashboy: {exc}", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
if args.dry_run:
|
|
135
|
+
if run_claude and run_opencode:
|
|
136
|
+
print(json.dumps({"claude": results["claude"], "opencode": results["opencode"]}))
|
|
137
|
+
elif run_claude:
|
|
138
|
+
print(json.dumps(results["claude"]))
|
|
139
|
+
else:
|
|
140
|
+
print(json.dumps(results["opencode"]))
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
if run_claude:
|
|
144
|
+
settings = Path(args.settings) if args.settings else _DEFAULT_CLAUDE_SETTINGS
|
|
145
|
+
print(f"claude: settings updated at {settings}")
|
|
146
|
+
if run_opencode:
|
|
147
|
+
print(f"opencode: plugin written to {results['opencode']['plugin_path']}")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- selftest / version ---------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _FailingProvider:
|
|
155
|
+
"""Stand-in provider used by ``selftest`` to prove the fail-closed path."""
|
|
156
|
+
|
|
157
|
+
def classify(self, cwd: str, command: str) -> object:
|
|
158
|
+
raise RuntimeError("selftest: simulated provider outage")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cmd_selftest(args: argparse.Namespace) -> int:
|
|
162
|
+
cfg = load_config()
|
|
163
|
+
checks: list[tuple[str, bool]] = []
|
|
164
|
+
|
|
165
|
+
deny_result = classify_command("rm -rf /", cfg=cfg)
|
|
166
|
+
checks.append(
|
|
167
|
+
("deny 'rm -rf /' -> deny/denylist",
|
|
168
|
+
deny_result.decision == "deny" and deny_result.source == "denylist")
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
allow_result = classify_command("git status", cfg=cfg)
|
|
172
|
+
checks.append(
|
|
173
|
+
("allow 'git status' -> allow/allowlist",
|
|
174
|
+
allow_result.decision == "allow" and allow_result.source == "allowlist")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
fallback = classify_command("docker run --rm alpine sh", cfg=cfg, provider=_FailingProvider())
|
|
178
|
+
checks.append(
|
|
179
|
+
("provider outage -> deny/fallback",
|
|
180
|
+
fallback.decision == "deny" and fallback.source == "fallback")
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
for name, ok in checks:
|
|
184
|
+
print(f"{'PASS' if ok else 'FAIL'} {name}")
|
|
185
|
+
passed = sum(1 for _, ok in checks if ok)
|
|
186
|
+
print(f"{passed}/{len(checks)} checks passed")
|
|
187
|
+
|
|
188
|
+
key = api_key(cfg)
|
|
189
|
+
if key:
|
|
190
|
+
try:
|
|
191
|
+
from .provider import Provider
|
|
192
|
+
|
|
193
|
+
verdict = Provider(cfg.provider, key=key).classify(os.getcwd(), "git status")
|
|
194
|
+
print(f"provider: ok ({verdict.decision})")
|
|
195
|
+
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
|
196
|
+
print(f"provider: FAILED ({exc})")
|
|
197
|
+
else:
|
|
198
|
+
print("provider: skipped (no API key)")
|
|
199
|
+
|
|
200
|
+
return 0 if passed == len(checks) else 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cmd_version(args: argparse.Namespace) -> int:
|
|
204
|
+
print(f"bashboy {__version__} (python {platform.python_version()})")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --- parser ---------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
212
|
+
parser = argparse.ArgumentParser(
|
|
213
|
+
prog="bashboy",
|
|
214
|
+
description="Classify shell commands as allow or deny before an agent runs them.",
|
|
215
|
+
)
|
|
216
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
217
|
+
|
|
218
|
+
check = sub.add_parser("check", help="classify a command")
|
|
219
|
+
check.add_argument("--command", dest="command_text", default=None, help="command to classify")
|
|
220
|
+
check.add_argument("--cwd", default=os.getcwd(), help="working directory for the command")
|
|
221
|
+
check.add_argument("--json", action="store_true", help="emit one JSON verdict object")
|
|
222
|
+
check.add_argument(
|
|
223
|
+
"--claude-hook",
|
|
224
|
+
action="store_true",
|
|
225
|
+
help="read a Claude Code PreToolUse payload from stdin",
|
|
226
|
+
)
|
|
227
|
+
check.set_defaults(handler=_cmd_check)
|
|
228
|
+
|
|
229
|
+
install = sub.add_parser("install", help="install host integrations")
|
|
230
|
+
install.add_argument("--claude", action="store_true", help="install the Claude Code hook")
|
|
231
|
+
install.add_argument("--opencode", action="store_true", help="install the OpenCode plugin")
|
|
232
|
+
install.add_argument("--all", action="store_true", help="install both integrations")
|
|
233
|
+
install.add_argument("--dry-run", action="store_true", help="print what would be written")
|
|
234
|
+
install.add_argument("--from", dest="from_spec", default=None, help="uvx --from SPEC")
|
|
235
|
+
install.add_argument("--settings", default=None, help="Claude settings.json path")
|
|
236
|
+
install.add_argument("--config-dir", default=None, help="OpenCode config directory")
|
|
237
|
+
install.set_defaults(handler=_cmd_install)
|
|
238
|
+
|
|
239
|
+
config = sub.add_parser("config", help="inspect or create the config file")
|
|
240
|
+
config.add_argument("action", nargs="?", choices=["path", "show", "init"], default="path")
|
|
241
|
+
config.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
242
|
+
config.set_defaults(handler=_cmd_config)
|
|
243
|
+
|
|
244
|
+
selftest = sub.add_parser("selftest", help="run offline self-checks")
|
|
245
|
+
selftest.set_defaults(handler=_cmd_selftest)
|
|
246
|
+
|
|
247
|
+
version = sub.add_parser("version", help="print the version")
|
|
248
|
+
version.set_defaults(handler=_cmd_version)
|
|
249
|
+
|
|
250
|
+
return parser
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv: list[str] | None = None) -> int:
|
|
254
|
+
parser = _build_parser()
|
|
255
|
+
args = parser.parse_args(argv)
|
|
256
|
+
handler = getattr(args, "handler", None)
|
|
257
|
+
if handler is None:
|
|
258
|
+
parser.print_help(sys.stderr)
|
|
259
|
+
return 2
|
|
260
|
+
try:
|
|
261
|
+
return int(handler(args))
|
|
262
|
+
except ConfigError as exc:
|
|
263
|
+
print(f"bashboy: {exc}", file=sys.stderr)
|
|
264
|
+
return 2
|
|
265
|
+
except KeyboardInterrupt: # pragma: no cover - interactive only
|
|
266
|
+
print("bashboy: interrupted", file=sys.stderr)
|
|
267
|
+
return 2
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__": # pragma: no cover
|
|
271
|
+
raise SystemExit(main())
|
bashboy/config.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Configuration loading for BashBoy.
|
|
2
|
+
|
|
3
|
+
The config file is optional: a missing file means "documented defaults". A file
|
|
4
|
+
that exists but cannot be parsed is a hard error (exit 2 at the CLI), never a
|
|
5
|
+
silent fallback to defaults -- silently loosening policy would be worse than
|
|
6
|
+
refusing to run.
|
|
7
|
+
|
|
8
|
+
The API key is never read from, or written to, the config file. It is resolved
|
|
9
|
+
from the environment at call time.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import tomllib
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG_TOML = """\
|
|
20
|
+
[provider]
|
|
21
|
+
endpoint = "https://clocked.rezolve.ai/clocked/fill"
|
|
22
|
+
api_key_env = "CLOCKED_API_KEY"
|
|
23
|
+
timeout_ms = 4000
|
|
24
|
+
|
|
25
|
+
[policy]
|
|
26
|
+
fail_mode = "closed" # closed | open
|
|
27
|
+
denylist = true
|
|
28
|
+
allowlist = true
|
|
29
|
+
|
|
30
|
+
[integrations.claude]
|
|
31
|
+
timeout_s = 15
|
|
32
|
+
|
|
33
|
+
[integrations.opencode]
|
|
34
|
+
timeout_ms = 15000
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
FAIL_MODES = ("closed", "open")
|
|
38
|
+
|
|
39
|
+
DEFAULT_ENDPOINT = "https://clocked.rezolve.ai/clocked/fill"
|
|
40
|
+
DEFAULT_API_KEY_ENV = "CLOCKED_API_KEY"
|
|
41
|
+
DEFAULT_TIMEOUT_MS = 4000
|
|
42
|
+
DEFAULT_CLAUDE_TIMEOUT_S = 15
|
|
43
|
+
DEFAULT_OPENCODE_TIMEOUT_MS = 15000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfigError(Exception):
|
|
47
|
+
"""Raised when the configuration file or environment is unusable."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ProviderConfig:
|
|
52
|
+
endpoint: str = DEFAULT_ENDPOINT
|
|
53
|
+
api_key_env: str = DEFAULT_API_KEY_ENV
|
|
54
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PolicyConfig:
|
|
59
|
+
fail_mode: str = "closed"
|
|
60
|
+
denylist: bool = True
|
|
61
|
+
allowlist: bool = True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ClaudeIntegration:
|
|
66
|
+
timeout_s: int = DEFAULT_CLAUDE_TIMEOUT_S
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class OpencodeIntegration:
|
|
71
|
+
timeout_ms: int = DEFAULT_OPENCODE_TIMEOUT_MS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Config:
|
|
76
|
+
provider: ProviderConfig
|
|
77
|
+
policy: PolicyConfig
|
|
78
|
+
claude: ClaudeIntegration
|
|
79
|
+
opencode: OpencodeIntegration
|
|
80
|
+
path: Path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def config_path() -> Path:
|
|
84
|
+
"""Return the config file location: ``BASHBOY_CONFIG`` or ``~/.bashboy/config.toml``."""
|
|
85
|
+
override = os.environ.get("BASHBOY_CONFIG")
|
|
86
|
+
if override and override.strip():
|
|
87
|
+
return Path(override.strip()).expanduser()
|
|
88
|
+
return Path.home() / ".bashboy" / "config.toml"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _table(parent: dict, key: str, where: Path) -> dict:
|
|
92
|
+
value = parent.get(key)
|
|
93
|
+
if value is None:
|
|
94
|
+
return {}
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
raise ConfigError(f"{where}: [{key}] must be a table")
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _as_str(table: dict, key: str, default: str, where: Path) -> str:
|
|
101
|
+
if key not in table:
|
|
102
|
+
return default
|
|
103
|
+
value = table[key]
|
|
104
|
+
if not isinstance(value, str):
|
|
105
|
+
raise ConfigError(f"{where}: '{key}' must be a string")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _as_int(table: dict, key: str, default: int, where: Path) -> int:
|
|
110
|
+
if key not in table:
|
|
111
|
+
return default
|
|
112
|
+
value = table[key]
|
|
113
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
114
|
+
raise ConfigError(f"{where}: '{key}' must be an integer")
|
|
115
|
+
return int(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _as_bool(table: dict, key: str, default: bool, where: Path) -> bool:
|
|
119
|
+
if key not in table:
|
|
120
|
+
return default
|
|
121
|
+
value = table[key]
|
|
122
|
+
if not isinstance(value, bool):
|
|
123
|
+
raise ConfigError(f"{where}: '{key}' must be true or false")
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_config(path: Path | None = None) -> Config:
|
|
128
|
+
"""Load configuration, applying documented defaults and environment overrides."""
|
|
129
|
+
target = Path(path) if path is not None else config_path()
|
|
130
|
+
|
|
131
|
+
raw = b""
|
|
132
|
+
try:
|
|
133
|
+
raw = target.read_bytes()
|
|
134
|
+
except FileNotFoundError:
|
|
135
|
+
raw = b""
|
|
136
|
+
except OSError as exc:
|
|
137
|
+
raise ConfigError(f"cannot read {target}: {exc}") from exc
|
|
138
|
+
|
|
139
|
+
data: dict = {}
|
|
140
|
+
if raw:
|
|
141
|
+
try:
|
|
142
|
+
decoded = raw.decode("utf-8")
|
|
143
|
+
except UnicodeDecodeError as exc:
|
|
144
|
+
raise ConfigError(f"{target}: not valid UTF-8: {exc}") from exc
|
|
145
|
+
try:
|
|
146
|
+
data = tomllib.loads(decoded)
|
|
147
|
+
except tomllib.TOMLDecodeError as exc:
|
|
148
|
+
raise ConfigError(f"{target}: invalid TOML: {exc}") from exc
|
|
149
|
+
if not isinstance(data, dict):
|
|
150
|
+
raise ConfigError(f"{target}: top-level value must be a table")
|
|
151
|
+
|
|
152
|
+
provider_tbl = _table(data, "provider", target)
|
|
153
|
+
policy_tbl = _table(data, "policy", target)
|
|
154
|
+
integrations_tbl = _table(data, "integrations", target)
|
|
155
|
+
claude_tbl = _table(integrations_tbl, "claude", target)
|
|
156
|
+
opencode_tbl = _table(integrations_tbl, "opencode", target)
|
|
157
|
+
|
|
158
|
+
provider = ProviderConfig(
|
|
159
|
+
endpoint=_as_str(provider_tbl, "endpoint", DEFAULT_ENDPOINT, target),
|
|
160
|
+
api_key_env=_as_str(provider_tbl, "api_key_env", DEFAULT_API_KEY_ENV, target),
|
|
161
|
+
timeout_ms=_as_int(provider_tbl, "timeout_ms", DEFAULT_TIMEOUT_MS, target),
|
|
162
|
+
)
|
|
163
|
+
policy = PolicyConfig(
|
|
164
|
+
fail_mode=_as_str(policy_tbl, "fail_mode", "closed", target),
|
|
165
|
+
denylist=_as_bool(policy_tbl, "denylist", True, target),
|
|
166
|
+
allowlist=_as_bool(policy_tbl, "allowlist", True, target),
|
|
167
|
+
)
|
|
168
|
+
claude = ClaudeIntegration(
|
|
169
|
+
timeout_s=_as_int(claude_tbl, "timeout_s", DEFAULT_CLAUDE_TIMEOUT_S, target),
|
|
170
|
+
)
|
|
171
|
+
opencode = OpencodeIntegration(
|
|
172
|
+
timeout_ms=_as_int(opencode_tbl, "timeout_ms", DEFAULT_OPENCODE_TIMEOUT_MS, target),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
env_endpoint = os.environ.get("BASHBOY_ENDPOINT")
|
|
176
|
+
if env_endpoint and env_endpoint.strip():
|
|
177
|
+
provider = ProviderConfig(
|
|
178
|
+
endpoint=env_endpoint.strip(),
|
|
179
|
+
api_key_env=provider.api_key_env,
|
|
180
|
+
timeout_ms=provider.timeout_ms,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
env_key_env = os.environ.get("BASHBOY_API_KEY_ENV")
|
|
184
|
+
if env_key_env and env_key_env.strip():
|
|
185
|
+
provider = ProviderConfig(
|
|
186
|
+
endpoint=provider.endpoint,
|
|
187
|
+
api_key_env=env_key_env.strip(),
|
|
188
|
+
timeout_ms=provider.timeout_ms,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
env_fail_mode = os.environ.get("BASHBOY_FAIL_MODE")
|
|
192
|
+
if env_fail_mode and env_fail_mode.strip():
|
|
193
|
+
policy = PolicyConfig(
|
|
194
|
+
fail_mode=env_fail_mode.strip(),
|
|
195
|
+
denylist=policy.denylist,
|
|
196
|
+
allowlist=policy.allowlist,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if policy.fail_mode not in FAIL_MODES:
|
|
200
|
+
raise ConfigError(
|
|
201
|
+
f"{target}: fail_mode must be one of {', '.join(FAIL_MODES)} (got {policy.fail_mode!r})"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return Config(
|
|
205
|
+
provider=provider,
|
|
206
|
+
policy=policy,
|
|
207
|
+
claude=claude,
|
|
208
|
+
opencode=opencode,
|
|
209
|
+
path=target,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def api_key(cfg: Config) -> str | None:
|
|
214
|
+
"""Return the API key from the environment named by the config, or ``None``."""
|
|
215
|
+
raw = os.environ.get(cfg.provider.api_key_env)
|
|
216
|
+
if raw is None:
|
|
217
|
+
return None
|
|
218
|
+
raw = raw.strip()
|
|
219
|
+
return raw or None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Host adapters. Kept as a package so imports stay explicit and lazy."""
|