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.
@@ -0,0 +1,92 @@
1
+ """Claude Code ``PreToolUse`` hook adapter.
2
+
3
+ Contract (from Claude Code hooks):
4
+
5
+ * read one JSON object from stdin;
6
+ * **deny** -> print a ``hookSpecificOutput`` payload with
7
+ ``permissionDecision: "deny"``;
8
+ * **allow** -> print *nothing* so the host's normal permission flow continues;
9
+ * always exit 0, even on malformed input or an internal error (fail closed:
10
+ an internal error emits a deny payload).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import TextIO
17
+
18
+ from ..classify import classify_command
19
+
20
+
21
+ def _load(raw: str) -> dict | None:
22
+ try:
23
+ payload = json.loads(raw)
24
+ except Exception:
25
+ return None
26
+ return payload if isinstance(payload, dict) else None
27
+
28
+
29
+ def parse_hook_payload(raw: str) -> str | None:
30
+ """Return the command from a hook payload, or ``None``. Never raises."""
31
+ payload = _load(raw)
32
+ if payload is None:
33
+ return None
34
+ tool_input = payload.get("tool_input")
35
+ if not isinstance(tool_input, dict):
36
+ return None
37
+ for key in ("command", "cmd"):
38
+ value = tool_input.get(key)
39
+ if isinstance(value, str) and value.strip():
40
+ return value.strip()
41
+ return None
42
+
43
+
44
+ def parse_hook_cwd(raw: str) -> str | None:
45
+ """Return the working directory from a hook payload, or ``None``.
46
+
47
+ Claude Code carries ``cwd`` at the top level; some hosts nest it under
48
+ ``tool_input``. Both are accepted.
49
+ """
50
+ payload = _load(raw)
51
+ if payload is None:
52
+ return None
53
+ candidates = [payload.get("cwd")]
54
+ tool_input = payload.get("tool_input")
55
+ if isinstance(tool_input, dict):
56
+ candidates.append(tool_input.get("cwd"))
57
+ for candidate in candidates:
58
+ if isinstance(candidate, str) and candidate.strip():
59
+ return candidate.strip()
60
+ return None
61
+
62
+
63
+ def deny_payload(reason: str) -> dict[str, object]:
64
+ return {
65
+ "hookSpecificOutput": {
66
+ "hookEventName": "PreToolUse",
67
+ "permissionDecision": "deny",
68
+ "permissionDecisionReason": reason,
69
+ }
70
+ }
71
+
72
+
73
+ def run_hook(stdin: TextIO, stdout: TextIO) -> int:
74
+ """Classify the command on stdin, emit a deny payload only when denying."""
75
+ try:
76
+ raw = stdin.read()
77
+ command = parse_hook_payload(raw)
78
+ if not command:
79
+ return 0
80
+ result = classify_command(command, cwd=parse_hook_cwd(raw))
81
+ if result.decision == "deny":
82
+ stdout.write(json.dumps(deny_payload(result.reason)) + "\n")
83
+ stdout.flush()
84
+ return 0
85
+ except Exception as exc: # noqa: BLE001 - fail closed, but never break the host
86
+ try:
87
+ reason = f"BashBoy internal error: {type(exc).__name__}"
88
+ stdout.write(json.dumps(deny_payload(reason)) + "\n")
89
+ stdout.flush()
90
+ except Exception:
91
+ pass
92
+ return 0
@@ -0,0 +1,38 @@
1
+ """Host installers.
2
+
3
+ Shared file-writing helpers live here. The CLI imports the individual installers
4
+ lazily so ``bashboy check`` never pays for them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+
14
+ def atomic_write_text(path: Path, text: str) -> None:
15
+ """Write ``text`` to ``path`` via a temp file in the same directory + ``os.replace``.
16
+
17
+ A crash mid-write can never leave a truncated host config behind.
18
+ """
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ handle = tempfile.NamedTemporaryFile(
21
+ "w",
22
+ encoding="utf-8",
23
+ newline="\n",
24
+ dir=str(path.parent),
25
+ prefix=f".{path.name}.",
26
+ suffix=".tmp",
27
+ delete=False,
28
+ )
29
+ try:
30
+ with handle as stream:
31
+ stream.write(text)
32
+ os.replace(handle.name, path)
33
+ except BaseException:
34
+ try:
35
+ os.unlink(handle.name)
36
+ except OSError:
37
+ pass
38
+ raise
@@ -0,0 +1,135 @@
1
+ """Claude Code installer: merge a PreToolUse hook into ``settings.json``.
2
+
3
+ The merge is idempotent and non-clobbering: unrelated keys and unrelated hook
4
+ events survive, and an existing BashBoy handler is updated in place rather than
5
+ duplicated. The file is backed up before the first write and replaced
6
+ atomically.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from ..config import ConfigError, load_config
17
+ from . import atomic_write_text
18
+
19
+ MATCHER = "Bash|PowerShell"
20
+ STATUS_MESSAGE = "BashBoy: checking command"
21
+
22
+
23
+ def default_settings_path() -> Path:
24
+ return Path.home() / ".claude" / "settings.json"
25
+
26
+
27
+ def _resolve_path(settings_path: Path | str | None) -> Path:
28
+ return Path(settings_path) if settings_path else default_settings_path()
29
+
30
+
31
+ def _load_settings(path: Path) -> dict:
32
+ if not path.exists():
33
+ return {}
34
+ try:
35
+ raw = path.read_text(encoding="utf-8")
36
+ except OSError as exc:
37
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
38
+ try:
39
+ data = json.loads(raw)
40
+ except json.JSONDecodeError as exc:
41
+ raise ConfigError(
42
+ f"{path}: not valid JSON ({exc}); refusing to modify it"
43
+ ) from exc
44
+ if not isinstance(data, dict):
45
+ raise ConfigError(f"{path}: expected a JSON object; refusing to modify it")
46
+ return data
47
+
48
+
49
+ def build_handler(timeout_s: int = 15, from_spec: str | None = None) -> dict:
50
+ args = ["bashboy", "check", "--claude-hook"]
51
+ if from_spec:
52
+ args = ["--from", from_spec, *args]
53
+ return {
54
+ "type": "command",
55
+ "command": "uvx",
56
+ "args": args,
57
+ "timeout": int(timeout_s),
58
+ "statusMessage": STATUS_MESSAGE,
59
+ }
60
+
61
+
62
+ def _is_bashboy_handler(candidate: object) -> bool:
63
+ if not isinstance(candidate, dict):
64
+ return False
65
+ args = candidate.get("args")
66
+ return isinstance(args, list) and "bashboy" in args and "--claude-hook" in args
67
+
68
+
69
+ def merge_handler(settings: dict, handler: dict) -> dict:
70
+ """Return ``settings`` with ``handler`` present exactly once under PreToolUse."""
71
+ hooks = settings.get("hooks")
72
+ if hooks is None:
73
+ hooks = {}
74
+ settings["hooks"] = hooks
75
+ elif not isinstance(hooks, dict):
76
+ raise ConfigError("settings 'hooks' must be a JSON object")
77
+
78
+ entries = hooks.get("PreToolUse")
79
+ if entries is None:
80
+ entries = []
81
+ hooks["PreToolUse"] = entries
82
+ elif not isinstance(entries, list):
83
+ raise ConfigError("settings 'hooks.PreToolUse' must be a JSON array")
84
+
85
+ target = None
86
+ for entry in entries:
87
+ if isinstance(entry, dict) and entry.get("matcher") == MATCHER:
88
+ target = entry
89
+ break
90
+
91
+ if target is None:
92
+ entries.append({"matcher": MATCHER, "hooks": [handler]})
93
+ return settings
94
+
95
+ existing = target.get("hooks")
96
+ if not isinstance(existing, list):
97
+ existing = []
98
+ target["hooks"] = existing
99
+
100
+ for index, candidate in enumerate(existing):
101
+ if _is_bashboy_handler(candidate):
102
+ updated = dict(candidate)
103
+ updated.update(handler)
104
+ existing[index] = updated
105
+ return settings
106
+
107
+ existing.append(handler)
108
+ return settings
109
+
110
+
111
+ def install_claude(
112
+ settings_path: Path | str | None = None,
113
+ *,
114
+ from_spec: str | None = None,
115
+ dry_run: bool = False,
116
+ ) -> dict:
117
+ """Merge the BashBoy hook and (unless ``dry_run``) write the settings file."""
118
+ path = _resolve_path(settings_path)
119
+ cfg = load_config()
120
+ settings = _load_settings(path)
121
+ merged = merge_handler(settings, build_handler(cfg.claude.timeout_s, from_spec))
122
+
123
+ if dry_run:
124
+ return merged
125
+
126
+ if path.exists():
127
+ backup = Path(str(path) + ".bak")
128
+ try:
129
+ shutil.copyfile(path, backup)
130
+ except OSError as exc:
131
+ raise ConfigError(f"cannot back up {path}: {exc}") from exc
132
+ print(f"bashboy: backup at {backup}", file=sys.stderr)
133
+
134
+ atomic_write_text(path, json.dumps(merged, indent=2, ensure_ascii=False) + "\n")
135
+ return merged
@@ -0,0 +1,199 @@
1
+ """OpenCode installer: write the deny-only plugin and register it.
2
+
3
+ V2 registers through ``Plugin.define`` / ``ctx.tool.hook("execute.before")``; V1
4
+ used ``server()`` returning ``"tool.execute.before"``. Both delegate to the same
5
+ helper, so the verdict logic exists once.
6
+
7
+ The plugin never passes the command through a shell: it is one ``execFile`` argv
8
+ element with ``shell: false``. ENOENT, non-zero exit without JSON, invalid JSON,
9
+ and timeouts all throw (fail closed).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .. import __version__
21
+ from ..config import ConfigError, load_config
22
+ from . import atomic_write_text
23
+
24
+ _PLUGIN_TEMPLATE = """/**
25
+ * BashBoy plugin for OpenCode - deny-only shell command gate.
26
+ *
27
+ * Generated by `bashboy install --opencode` (bashboy VERSION_PLACEHOLDER). Re-run that
28
+ * command to rewrite this file; change policy in ~/.bashboy/config.toml instead.
29
+ *
30
+ * Only a denial is expressed. An allow means "no objection" and lets the host's
31
+ * normal permission flow continue.
32
+ */
33
+ import { execFile } from "node:child_process"
34
+ import { promisify } from "node:util"
35
+ import { Plugin } from "@opencode/plugin"
36
+
37
+ const run = promisify(execFile)
38
+ const TIMEOUT_MS = TIMEOUT_MS_PLACEHOLDER
39
+ type AnyRecord = Record<string, any>
40
+
41
+ function isShellTool(tool: unknown): boolean {
42
+ const id = String(tool ?? "").toLowerCase()
43
+ return id === "bash" || id === "shell"
44
+ }
45
+
46
+ /**
47
+ * Ask bashboy for a verdict and throw only when it says deny.
48
+ * Any inability to get a clear verdict also throws (fail closed).
49
+ */
50
+ async function classify(command: string, cwd: unknown): Promise<void> {
51
+ const dir = typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd()
52
+ let stdout = ""
53
+
54
+ try {
55
+ const result = await run(
56
+ "uvx",
57
+ ["bashboy", "check", "--json", "--command", command, "--cwd", dir],
58
+ { shell: false, timeout: TIMEOUT_MS, windowsHide: true },
59
+ )
60
+ stdout = String((result as AnyRecord).stdout ?? "")
61
+ } catch (error: any) {
62
+ const fromError = String(error?.stdout ?? "")
63
+ if (fromError.trim().length === 0) {
64
+ const detail = error?.code ?? error?.message ?? "unknown error"
65
+ throw new Error("BashBoy could not verify this command: " + String(detail))
66
+ }
67
+ // bashboy exits 1 for deny, so a valid deny payload arrives via error.stdout.
68
+ stdout = fromError
69
+ }
70
+
71
+ let verdict: AnyRecord
72
+ try {
73
+ verdict = JSON.parse(stdout)
74
+ } catch {
75
+ throw new Error("BashBoy could not verify this command: invalid JSON from bashboy")
76
+ }
77
+
78
+ if (verdict?.decision === "deny") {
79
+ throw new Error("BashBoy blocked this command: " + String(verdict?.reason ?? "denied"))
80
+ }
81
+ if (verdict?.decision === "allow") {
82
+ return
83
+ }
84
+ throw new Error("BashBoy could not verify this command: unexpected verdict")
85
+ }
86
+
87
+ export default {
88
+ ...Plugin.define({
89
+ id: "bashboy",
90
+ async setup(ctx: any) {
91
+ await ctx.tool.hook("execute.before", async (event: any) => {
92
+ if (!isShellTool(event?.tool)) return
93
+ const input = event?.input ?? {}
94
+ const command = input.command
95
+ if (typeof command !== "string" || command.trim().length === 0) return
96
+ await classify(command, input.cwd)
97
+ })
98
+ },
99
+ }),
100
+
101
+ // V1 fallback: same helper, different registration shape.
102
+ async server() {
103
+ return {
104
+ "tool.execute.before": async (input: any, output: any) => {
105
+ if (!isShellTool(input?.tool)) return
106
+ const args = output?.args ?? {}
107
+ const command = args.command
108
+ if (typeof command !== "string" || command.trim().length === 0) return
109
+ await classify(command, args.cwd)
110
+ },
111
+ }
112
+ },
113
+ }
114
+ """
115
+
116
+ PLUGIN_SOURCE = _PLUGIN_TEMPLATE.replace("VERSION_PLACEHOLDER", __version__).replace(
117
+ "TIMEOUT_MS_PLACEHOLDER", "15000"
118
+ )
119
+
120
+
121
+ def default_config_dir() -> Path:
122
+ return Path.home() / ".config" / "opencode"
123
+
124
+
125
+ def _same_path(left: str, right: str) -> bool:
126
+ return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
127
+
128
+
129
+ def _load_config(config_file: Path) -> dict:
130
+ if not config_file.exists():
131
+ return {"$schema": "https://opencode.ai/config.json"}
132
+ try:
133
+ raw = config_file.read_text(encoding="utf-8")
134
+ except OSError as exc:
135
+ raise ConfigError(f"cannot read {config_file}: {exc}") from exc
136
+ try:
137
+ data = json.loads(raw)
138
+ except json.JSONDecodeError as exc:
139
+ raise ConfigError(
140
+ f"{config_file}: not valid JSON ({exc}); refusing to modify it"
141
+ ) from exc
142
+ if not isinstance(data, dict):
143
+ raise ConfigError(f"{config_file}: expected a JSON object; refusing to modify it")
144
+ return data
145
+
146
+
147
+ def install_opencode(config_dir: Path | str | None = None, *, dry_run: bool = False) -> dict:
148
+ base = Path(config_dir) if config_dir else default_config_dir()
149
+ plugin_path = base / "plugins" / "bashboy.ts"
150
+ config_file = base / "opencode.json"
151
+ plugin_target = str(plugin_path)
152
+
153
+ cfg = load_config()
154
+ source = _PLUGIN_TEMPLATE.replace("VERSION_PLACEHOLDER", __version__).replace(
155
+ "TIMEOUT_MS_PLACEHOLDER", str(int(cfg.opencode.timeout_ms))
156
+ )
157
+
158
+ config = _load_config(config_file)
159
+ # V2 documents ``plugins``; V1 used ``plugin``. Preserve whichever is already
160
+ # there and never write both keys.
161
+ if "plugins" in config:
162
+ key = "plugins"
163
+ elif "plugin" in config:
164
+ key = "plugin"
165
+ else:
166
+ key = "plugins"
167
+
168
+ entries = config.get(key)
169
+ if entries is None:
170
+ entries = []
171
+ config[key] = entries
172
+ elif not isinstance(entries, list):
173
+ raise ConfigError(f"{config_file}: '{key}' must be a JSON array")
174
+
175
+ if not any(
176
+ isinstance(entry, str) and _same_path(entry, plugin_target) for entry in entries
177
+ ):
178
+ entries.append(plugin_target)
179
+
180
+ result = {
181
+ "plugin_path": plugin_target,
182
+ "plugin_source": source,
183
+ "config": config,
184
+ }
185
+
186
+ if dry_run:
187
+ return result
188
+
189
+ if config_file.exists():
190
+ backup = Path(str(config_file) + ".bak")
191
+ try:
192
+ shutil.copyfile(config_file, backup)
193
+ except OSError as exc:
194
+ raise ConfigError(f"cannot back up {config_file}: {exc}") from exc
195
+ print(f"bashboy: backup at {backup}", file=sys.stderr)
196
+
197
+ atomic_write_text(plugin_path, source)
198
+ atomic_write_text(config_file, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
199
+ return result
bashboy/provider.py ADDED
@@ -0,0 +1,215 @@
1
+ """Clocked model client for the `clocked/fill` endpoint.
2
+
3
+ The response envelope is **unverified**, so parsing is deliberately tolerant:
4
+ :func:`extract_field` walks nested dicts/lists looking for a named field and
5
+ unwraps the common value containers. Anything that cannot be resolved to a
6
+ clear ``allow``/``deny`` raises :class:`ProviderMalformed`, which the classifier
7
+ turns into a closed failure.
8
+
9
+ No retries, no cache: a slow or broken provider must become a ``deny``, not a
10
+ hang and not an ``allow``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import socket
17
+ import urllib.error
18
+ import urllib.request
19
+ import uuid
20
+ from collections import deque
21
+ from dataclasses import dataclass
22
+ from typing import Any, Callable
23
+
24
+ from .config import ProviderConfig
25
+
26
+ Transport = Callable[[str, bytes, dict[str, str], float], tuple[int, bytes]]
27
+
28
+ RUBRIC = (
29
+ "You are a shell-command safety classifier gating an AI coding agent. "
30
+ "Decide whether the command may run. Deny commands that delete or overwrite data outside "
31
+ "the working tree, escalate privileges destructively, format or partition disks, disable "
32
+ "security controls, exfiltrate secrets or credentials, or pipe unreviewed remote content "
33
+ "(curl/wget output) into a shell. Allow ordinary build, test, git, and read-only operations, "
34
+ "and writes inside the working directory. Prefer deny when the command's effect cannot be "
35
+ "determined from the text alone."
36
+ )
37
+
38
+ _VERDICTS = frozenset({"allow", "deny"})
39
+ _VALUE_KEYS = ("value", "text", "answer", "label", "result", "content")
40
+ _NAME_KEYS = ("name", "key", "id", "field")
41
+
42
+
43
+ class ProviderError(Exception):
44
+ """Any provider failure. Callers must fail closed."""
45
+
46
+
47
+ class ProviderTimeout(ProviderError):
48
+ """The provider did not answer within the configured timeout."""
49
+
50
+
51
+ class ProviderMalformed(ProviderError):
52
+ """The provider answered, but not with a usable verdict."""
53
+
54
+
55
+ @dataclass
56
+ class Verdict:
57
+ decision: str
58
+ risk: str | None
59
+ reason: str
60
+
61
+
62
+ def build_request_body(cwd: str, command: str) -> dict[str, object]:
63
+ return {
64
+ "text": f"cwd: {cwd}\ncommand: {command}",
65
+ "instructions": RUBRIC,
66
+ "fields": {
67
+ "verdict": {
68
+ "type": "choice",
69
+ "options": ["allow", "deny"],
70
+ "description": "May this shell command run?",
71
+ },
72
+ "risk": {
73
+ "type": "score",
74
+ "levels": ["low", "medium", "high", "critical"],
75
+ "description": "How damaging would this command be if wrong?",
76
+ },
77
+ "reason": {
78
+ "type": "generate",
79
+ "max_tokens": 80,
80
+ "format": "text",
81
+ "description": "One short sentence explaining the verdict.",
82
+ },
83
+ },
84
+ }
85
+
86
+
87
+ def _unwrap(value: Any) -> Any:
88
+ if isinstance(value, dict):
89
+ for key in _VALUE_KEYS:
90
+ if key in value:
91
+ return _unwrap(value[key])
92
+ return value
93
+ if isinstance(value, list):
94
+ if len(value) == 1:
95
+ return _unwrap(value[0])
96
+ return value
97
+ return value
98
+
99
+
100
+ def _named_field_matches(node: dict, target: str) -> bool:
101
+ for name_key in _NAME_KEYS:
102
+ for key, value in node.items():
103
+ if (
104
+ isinstance(key, str)
105
+ and key.lower() == name_key
106
+ and isinstance(value, str)
107
+ and value.lower() == target
108
+ ):
109
+ return True
110
+ return False
111
+
112
+
113
+ def extract_field(payload: object, key: str) -> object | None:
114
+ """Find ``key`` anywhere in ``payload`` (breadth-first) and unwrap its value."""
115
+ target = key.lower()
116
+ queue: deque[Any] = deque([payload])
117
+ while queue:
118
+ node = queue.popleft()
119
+ if isinstance(node, dict):
120
+ for node_key, value in node.items():
121
+ if isinstance(node_key, str) and node_key.lower() == target:
122
+ return _unwrap(value)
123
+ if _named_field_matches(node, target):
124
+ for value_key in _VALUE_KEYS:
125
+ if value_key in node:
126
+ return _unwrap(node[value_key])
127
+ queue.extend(node.values())
128
+ elif isinstance(node, list):
129
+ queue.extend(node)
130
+ return None
131
+
132
+
133
+ def urllib_transport(
134
+ url: str, body: bytes, headers: dict[str, str], timeout: float
135
+ ) -> tuple[int, bytes]:
136
+ """POST ``body`` to ``url`` with the stdlib, mapping failures to ProviderError."""
137
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
138
+ try:
139
+ with urllib.request.urlopen(request, timeout=timeout) as response:
140
+ return int(response.status), response.read()
141
+ except urllib.error.HTTPError as exc:
142
+ raise ProviderError(f"provider returned HTTP {exc.code}") from exc
143
+ except urllib.error.URLError as exc:
144
+ reason = getattr(exc, "reason", None)
145
+ if isinstance(reason, TimeoutError):
146
+ raise ProviderTimeout(f"provider request timed out after {timeout}s") from exc
147
+ raise ProviderError(f"provider request failed: {reason or exc}") from exc
148
+ except TimeoutError as exc:
149
+ raise ProviderTimeout(f"provider request timed out after {timeout}s") from exc
150
+ except OSError as exc:
151
+ raise ProviderError(f"provider transport error: {exc}") from exc
152
+
153
+
154
+ class Provider:
155
+ """Minimal client for the clocked/fill endpoint."""
156
+
157
+ def __init__(
158
+ self,
159
+ cfg: ProviderConfig,
160
+ transport: Transport | None = None,
161
+ key: str | None = None,
162
+ ) -> None:
163
+ self.cfg = cfg
164
+ self._transport: Transport = transport or urllib_transport
165
+ self._key = key
166
+
167
+ def classify(self, cwd: str, command: str) -> Verdict:
168
+ key = (self._key or "").strip()
169
+ if not key:
170
+ raise ProviderError(f"no API key configured (env {self.cfg.api_key_env})")
171
+
172
+ body = build_request_body(cwd, command)
173
+ encoded = json.dumps(body).encode("utf-8")
174
+ headers = {
175
+ "Authorization": f"Bearer {key}",
176
+ "Content-Type": "application/json",
177
+ "Idempotency-Key": str(uuid.uuid4()),
178
+ }
179
+ timeout = max(int(self.cfg.timeout_ms), 0) / 1000.0
180
+
181
+ try:
182
+ status, raw = self._transport(self.cfg.endpoint, encoded, headers, timeout)
183
+ except ProviderError:
184
+ raise
185
+ except TimeoutError as exc:
186
+ raise ProviderTimeout(f"provider request timed out after {timeout}s") from exc
187
+ except OSError as exc:
188
+ raise ProviderError(f"provider transport error: {exc}") from exc
189
+ except Exception as exc: # defensive: never let a transport bug look like success
190
+ raise ProviderError(f"provider transport error: {exc}") from exc
191
+
192
+ if not 200 <= int(status) < 300:
193
+ raise ProviderError(f"provider returned HTTP {status}")
194
+
195
+ try:
196
+ payload = json.loads(raw.decode("utf-8"))
197
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
198
+ raise ProviderMalformed(f"provider returned a non-JSON body: {exc}") from exc
199
+
200
+ raw_verdict = extract_field(payload, "verdict")
201
+ verdict = raw_verdict.strip().lower() if isinstance(raw_verdict, str) else None
202
+ if verdict not in _VERDICTS:
203
+ raise ProviderMalformed(f"unrecognized verdict: {raw_verdict!r}")
204
+
205
+ raw_risk = extract_field(payload, "risk")
206
+ risk = raw_risk.strip() if isinstance(raw_risk, str) and raw_risk.strip() else None
207
+
208
+ raw_reason = extract_field(payload, "reason")
209
+ reason = (
210
+ raw_reason.strip()
211
+ if isinstance(raw_reason, str) and raw_reason.strip()
212
+ else "model verdict"
213
+ )
214
+
215
+ return Verdict(decision=verdict, risk=risk, reason=reason)