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/hooks/claude.py
ADDED
|
@@ -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)
|