hubble-cli 4.0.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.
hubble/main.py ADDED
@@ -0,0 +1,299 @@
1
+ """`hubble` entry point: interactive REPL, headless -p mode, session resume."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from hubble import __version__
9
+ from hubble.permissions import MODES, Permissions
10
+ from hubble.prompts import PERSONAS
11
+ from hubble.provider import OpenAICompatProvider
12
+ from hubble.session import SessionStore
13
+ from hubble.settings import ConfigError, load_settings, require_api_key, trust_folder
14
+ from hubble.tools import ToolContext, detect_shell
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(
19
+ prog="hubble", description="Hubble: agentic coding CLI for AIHub and other OpenAI-compatible APIs.",
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog="""examples:
22
+ hubble start an interactive session
23
+ hubble "explain this repo" start a session with a first prompt
24
+ hubble -p "fix the failing test" --permission-mode accept-edits
25
+ git diff | hubble -p "review this diff" --output-format json
26
+ hubble -c continue the latest session here
27
+ hubble -r pick a session to resume
28
+ """)
29
+ p.add_argument("prompt", nargs="?", help="initial prompt")
30
+ p.add_argument("-p", "--print", dest="print_mode", action="store_true",
31
+ help="non-interactive: run the prompt, print the result and exit")
32
+ p.add_argument("-c", "--continue", dest="continue_", action="store_true", help="continue the latest session")
33
+ p.add_argument("-r", "--resume", nargs="?", const="", metavar="ID", help="resume a session (lists them without ID)")
34
+ p.add_argument("-m", "--model", help="model id")
35
+ p.add_argument("--provider", help="provider name from /provider (default: last used)")
36
+ p.add_argument("--permission-mode", choices=MODES, help="default | accept-edits | plan | yolo")
37
+ p.add_argument("--yolo", action="store_true", help="shortcut for --permission-mode yolo (no approvals)")
38
+ p.add_argument("--persona", choices=list(PERSONAS), help="system persona")
39
+ p.add_argument("--output-format", choices=["text", "json"], default="text", help="output format for -p")
40
+ p.add_argument("--max-turns", type=int, help="max model calls per prompt")
41
+ p.add_argument("--cwd", help="workspace root (default: current directory)")
42
+ p.add_argument("--base-url", help="API base URL")
43
+ p.add_argument("--api-key", help="API key (default: HUBBLE_API_KEY / .env)")
44
+ p.add_argument("--allow", action="append", default=[], metavar="RULE", help='allow rule, e.g. "shell(pytest*)"')
45
+ p.add_argument("--deny", action="append", default=[], metavar="RULE", help='deny rule, e.g. "shell(git push*)"')
46
+ p.add_argument("--no-session", action="store_true", help="do not save this session")
47
+ p.add_argument("--quiet", action="store_true", help="with -p: hide tool progress on stderr")
48
+ p.add_argument("--test", metavar="MODEL", help="check that a model responds, then exit")
49
+ p.add_argument("-v", "--version", action="version", version=f"hubble {__version__}")
50
+ return p
51
+
52
+
53
+ def main(argv=None):
54
+ if sys.platform == "win32":
55
+ for stream in (sys.stdout, sys.stderr):
56
+ try:
57
+ stream.reconfigure(encoding="utf-8") # model output often contains emoji
58
+ except (AttributeError, ValueError):
59
+ pass
60
+ args = build_parser().parse_args(argv)
61
+ root = Path(args.cwd or ".").resolve()
62
+ if not root.is_dir():
63
+ sys.exit(f"hubble: workspace not found: {root}")
64
+
65
+ overrides = {
66
+ "model": args.model, "base_url": args.base_url, "api_key": args.api_key,
67
+ "persona": args.persona, "max_turns": args.max_turns,
68
+ "permission_mode": "yolo" if args.yolo else args.permission_mode,
69
+ }
70
+ interactive = not args.print_mode and sys.stdin.isatty()
71
+ try:
72
+ settings = load_settings(root, overrides)
73
+ ignored = settings["_ignored_project_keys"]
74
+ if ignored:
75
+ if _ask_trust(root, ignored, interactive=interactive):
76
+ trust_folder(root)
77
+ settings = load_settings(root, overrides)
78
+ except ConfigError as e:
79
+ sys.exit(f"hubble: {e}")
80
+
81
+ from hubble.providers import DEFAULT_PROVIDER, load_providers
82
+ providers = load_providers(settings)
83
+ active = args.provider or settings.get("provider") or DEFAULT_PROVIDER
84
+ if args.api_key or args.base_url:
85
+ active = DEFAULT_PROVIDER # explicit credentials on the command line win
86
+ if active not in providers:
87
+ if args.provider:
88
+ sys.exit(f"hubble: unknown provider '{args.provider}'. Known: {', '.join(providers) or 'none'}")
89
+ if providers:
90
+ active = DEFAULT_PROVIDER if DEFAULT_PROVIDER in providers else next(iter(providers))
91
+ elif interactive:
92
+ active = _first_run(providers, settings)
93
+ else:
94
+ try:
95
+ require_api_key(settings)
96
+ except ConfigError as e:
97
+ sys.exit(f"hubble: {e}")
98
+ cfg = providers[active]
99
+ settings["provider"] = active
100
+ provider = OpenAICompatProvider(cfg.base_url, cfg.api_key)
101
+
102
+ if args.test:
103
+ res = provider.ping(args.test)
104
+ print(f"{'OK' if res['ok'] else 'FAILED'} {args.test} {res['latency_ms']} ms {res['msg']}")
105
+ sys.exit(0 if res["ok"] else 1)
106
+
107
+ ctx = ToolContext(
108
+ root=root,
109
+ extra_dirs=[Path(d).expanduser().resolve() for d in settings.get("additional_dirs", [])],
110
+ allow_secrets=bool(settings.get("allow_secret_files")),
111
+ shell_argv=detect_shell(settings.get("shell", "auto")),
112
+ shell_timeout=int(settings.get("shell_timeout", 120)),
113
+ sandbox=settings.get("shell_sandbox", "off"),
114
+ sandbox_image=settings.get("sandbox_image", "python:3.12-slim"),
115
+ sandbox_memory=settings.get("sandbox_memory", "1g"),
116
+ sandbox_cpus=str(settings.get("sandbox_cpus", "2")),
117
+ sandbox_network=bool(settings.get("sandbox_network", True)),
118
+ )
119
+ perms = Permissions(settings["permission_mode"],
120
+ allow=settings["permissions"].get("allow", []) + args.allow,
121
+ deny=settings["permissions"].get("deny", []) + args.deny)
122
+ store = SessionStore(root)
123
+
124
+ prompt = args.prompt or ""
125
+ if args.resume and not any(args.resume in it["id"] for it in store.list(limit=500)):
126
+ # `hubble -r "fix the bug"`: argparse took the prompt as a session id.
127
+ prompt = f"{args.resume} {prompt}".strip()
128
+ args.resume = ""
129
+ if args.print_mode and not sys.stdin.isatty():
130
+ piped = sys.stdin.read()
131
+ if piped.strip():
132
+ prompt = f"{prompt}\n\n<stdin>\n{piped}\n</stdin>" if prompt else piped
133
+
134
+ if args.print_mode:
135
+ args._providers = providers
136
+ sys.exit(run_headless(args, settings, provider, ctx, perms, store, prompt,
137
+ _fallback_client(providers, active, provider)))
138
+
139
+ from hubble.agent import Agent
140
+ try:
141
+ from hubble.repl import Repl
142
+ from hubble.ui import ReplEvents, console
143
+ except ImportError as e:
144
+ sys.exit(f"hubble: missing dependency ({e.name}). Install with:\n"
145
+ f' "{sys.executable}" -m pip install -r "{Path(__file__).resolve().parent.parent / "requirements.txt"}"')
146
+
147
+ agent = Agent(provider, settings, ctx, perms, ReplEvents(ctx, show_reasoning=bool(settings.get("show_reasoning"))))
148
+ agent.fallback_client = _fallback_client(providers, active, provider)
149
+ repl = Repl(agent, store, providers)
150
+ resumed = _resume(args, store, agent, console)
151
+ if resumed is None:
152
+ return
153
+ if not resumed and not args.no_session:
154
+ agent.session = store.new(agent.model)
155
+ repl.run(initial_prompt=prompt or None)
156
+
157
+
158
+ def _headless_resolver(settings, providers, agent):
159
+ from hubble.providers import resolve_fallback
160
+ clients = {agent.provider_name: agent.provider}
161
+
162
+ def resolve(provider_name, model):
163
+ choice = resolve_fallback(settings, providers, provider_name, model)
164
+ if not choice:
165
+ return None
166
+ name, fb = choice
167
+ if name not in clients:
168
+ if name not in providers:
169
+ return None
170
+ clients[name] = OpenAICompatProvider(providers[name].base_url, providers[name].api_key)
171
+ return clients[name], fb
172
+ return resolve
173
+
174
+
175
+ def _fallback_client(providers, active, current):
176
+ """fallback_model lives on the built-in hubble provider."""
177
+ from hubble.providers import DEFAULT_PROVIDER
178
+ if active == DEFAULT_PROVIDER:
179
+ return current
180
+ cfg = providers.get(DEFAULT_PROVIDER)
181
+ return OpenAICompatProvider(cfg.base_url, cfg.api_key) if cfg else None
182
+
183
+
184
+ def _first_run(providers, settings) -> str:
185
+ from hubble.onboarding import add_provider_wizard
186
+ from hubble.repl import register_provider
187
+ from hubble.settings import save_user_setting
188
+ from hubble.ui import console, pick
189
+
190
+ result = add_provider_wizard(providers.keys(), first_run=True)
191
+ if not result:
192
+ sys.exit("hubble: no provider configured.")
193
+ cfg, ids = result
194
+ register_provider(cfg, ids)
195
+ providers[cfg.name] = cfg
196
+ model = pick(f"Choose a model ({len(ids)} listed)", [(m, m, "") for m in sorted(ids)]) or sorted(ids)[0]
197
+ settings["model"] = model
198
+ save_user_setting("provider", cfg.name)
199
+ save_user_setting("model", model)
200
+ console.print(f"[green]Saved. Using {cfg.name}: {model}[/green]")
201
+ return cfg.name
202
+
203
+
204
+ def _ask_trust(root: Path, ignored, interactive: bool) -> bool:
205
+ never = [k for k in ignored if k in ("base_url", "api_key")]
206
+ if never:
207
+ print(f"hubble: ignoring {', '.join(never)} from {root / '.hubble'} (only user settings or env may set them)",
208
+ file=sys.stderr)
209
+ rest = [k for k in ignored if k not in ("base_url", "api_key")]
210
+ if not rest:
211
+ return False
212
+ if not interactive:
213
+ print(f"hubble: ignoring untrusted project settings: {', '.join(rest)}. Run hubble interactively once "
214
+ "in this folder to trust it.", file=sys.stderr)
215
+ return False
216
+ print(f"This folder's .hubble settings want to change security options: {', '.join(rest)}.")
217
+ try:
218
+ answer = input(f"Trust {root} and apply them? [y/N] ").strip().lower()
219
+ except (EOFError, KeyboardInterrupt):
220
+ return False
221
+ return answer in ("y", "yes")
222
+
223
+
224
+ def _resume(args, store, agent, console):
225
+ """True if a session was loaded, False to start fresh, None to exit."""
226
+ if args.resume == "" and not args.continue_:
227
+ from hubble.repl import session_items
228
+ from hubble.ui import pick
229
+ items = store.list()
230
+ if not items:
231
+ console.print("[dim]No sessions to resume; starting a new one.[/dim]")
232
+ return False
233
+ sid = pick("Resume a session (Esc for a new one)", session_items(items))
234
+ if not sid:
235
+ return False
236
+ elif args.resume or args.continue_:
237
+ sid = args.resume or store.latest()
238
+ if not sid:
239
+ console.print("[dim]No previous session here; starting a new one.[/dim]")
240
+ return False
241
+ else:
242
+ return False
243
+ try:
244
+ session, messages, meta = store.load(sid)
245
+ except FileNotFoundError as e:
246
+ console.print(f"[red]{e}[/red]")
247
+ return None
248
+ agent.session = session
249
+ agent.load_history(messages)
250
+ # Keep the model only if the session used the provider that is active now.
251
+ from hubble.providers import normalize_provider_name
252
+ if meta.get("model") and not args.model and normalize_provider_name(meta.get("provider")) == agent.provider_name:
253
+ agent.model = meta["model"]
254
+ console.print(f"[green]Resumed session {session.id}[/green] [dim]({len(messages)} messages)[/dim]")
255
+ from hubble.repl import print_history_tail
256
+ print_history_tail(messages)
257
+ return True
258
+
259
+
260
+ def run_headless(args, settings, provider, ctx, perms, store, prompt, fallback_client=None) -> int:
261
+ from hubble.agent import Agent
262
+ from hubble.ui import HeadlessEvents
263
+
264
+ if not prompt.strip():
265
+ print("hubble: -p needs a prompt (argument or stdin)", file=sys.stderr)
266
+ return 2
267
+ events = HeadlessEvents(stream_text=args.output_format == "text", quiet=args.quiet)
268
+ agent = Agent(provider, settings, ctx, perms, events)
269
+ agent.fallback_client = fallback_client
270
+ agent.fallback_resolver = _headless_resolver(settings, getattr(args, "_providers", {}), agent)
271
+ if args.continue_ or args.resume:
272
+ sid = args.resume or store.latest()
273
+ if sid:
274
+ try:
275
+ session, messages, _ = store.load(sid)
276
+ agent.session = session
277
+ agent.load_history(messages)
278
+ except FileNotFoundError as e:
279
+ print(f"hubble: {e}", file=sys.stderr)
280
+ return 2
281
+ if agent.session is None and not args.no_session:
282
+ agent.session = store.new(agent.model)
283
+
284
+ result = agent.run(prompt)
285
+ stats = agent.last_stats
286
+ is_error = bool(stats.error) or stats.interrupted
287
+ if args.output_format == "json":
288
+ print(json.dumps({
289
+ "result": result, "is_error": is_error, "error": stats.error,
290
+ "session_id": agent.session.id if agent.session else None, "model": agent.model,
291
+ "num_model_calls": stats.model_calls, "num_tool_calls": stats.tool_calls,
292
+ "usage": {"prompt_tokens": stats.prompt_tokens, "completion_tokens": stats.completion_tokens},
293
+ "duration_s": round(stats.duration, 2),
294
+ }, ensure_ascii=False, indent=2))
295
+ return 1 if is_error else 0
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()
hubble/models.py ADDED
@@ -0,0 +1,92 @@
1
+ """Model registry: curated categories plus the latest scan from test_models.py."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+
7
+ PROJECT_DIR = Path(__file__).resolve().parent.parent
8
+ SCAN_FILE = PROJECT_DIR / "available_models.json"
9
+
10
+ DEFAULT_MODEL = "codestral-latest"
11
+
12
+ MODEL_CATEGORIES = {
13
+ "Coding Specialists": [
14
+ "codestral-latest",
15
+ "codestral-2508",
16
+ "mistral-code-latest",
17
+ "mistral-code-fim-latest",
18
+ ],
19
+ "Reasoning & Logic": [
20
+ "nvidia/nemotron-3-super-120b-a12b",
21
+ "intern-s2-preview",
22
+ "intern-s1-mini",
23
+ "intern-s1",
24
+ "intern-s1-pro",
25
+ ],
26
+ "Fast Chat & General": [
27
+ "ministral-14b-latest",
28
+ "open-mistral-nemo",
29
+ "ministral-8b-latest",
30
+ "ministral-3b-latest",
31
+ "mistral-tiny-latest",
32
+ "qwen-8b",
33
+ ],
34
+ "Multimodal & Vision": [
35
+ "meta/llama-3.2-11b-vision-instruct",
36
+ "internvl3.5-latest",
37
+ "internvl-latest",
38
+ ],
39
+ }
40
+
41
+ RELIABLE_MODELS = [
42
+ "codestral-latest",
43
+ "codestral-2508",
44
+ "mistral-code-latest",
45
+ "mistral-code-fim-latest",
46
+ "nvidia/nemotron-3-super-120b-a12b",
47
+ "ministral-14b-latest",
48
+ "open-mistral-nemo",
49
+ "ministral-8b-latest",
50
+ "intern-s2-preview",
51
+ "intern-s1-mini",
52
+ ]
53
+
54
+
55
+ def load_scanned_models(path: Path = SCAN_FILE) -> List[Dict[str, Any]]:
56
+ """Working models found by the last `python test_models.py` run."""
57
+ try:
58
+ with open(path, "r", encoding="utf-8") as f:
59
+ return json.load(f).get("working_models", [])
60
+ except (OSError, ValueError):
61
+ return []
62
+
63
+
64
+ def all_models() -> List[Dict[str, Any]]:
65
+ """Curated models first, then any extra scanned ones.
66
+
67
+ Each entry has model, category, latency_ms and available (None when there is no scan yet).
68
+ """
69
+ scanned = {m["model"]: m for m in load_scanned_models() if m.get("model")}
70
+ have_scan = bool(scanned)
71
+ out: List[Dict[str, Any]] = []
72
+ seen = set()
73
+ for category, names in MODEL_CATEGORIES.items():
74
+ for name in names:
75
+ out.append({"model": name, "category": category,
76
+ "latency_ms": scanned.get(name, {}).get("latency_ms"),
77
+ "available": (name in scanned) if have_scan else None})
78
+ seen.add(name)
79
+ for name, info in scanned.items():
80
+ if name not in seen:
81
+ out.append({"model": name, "category": "Other Verified",
82
+ "latency_ms": info.get("latency_ms"), "available": True})
83
+ return out
84
+
85
+
86
+ def resolve_model(query: str, candidates: List[str]) -> List[str]:
87
+ """Exact match wins, otherwise case-insensitive substring matches."""
88
+ q = query.strip().lower()
89
+ exact = [m for m in candidates if m.lower() == q]
90
+ if exact:
91
+ return exact
92
+ return [m for m in candidates if q in m.lower()]
hubble/onboarding.py ADDED
@@ -0,0 +1,94 @@
1
+ """Interactive wizard for adding a provider (base URL + API key), with verification."""
2
+
3
+ from typing import Iterable, List, Optional, Tuple
4
+ from urllib.parse import urlparse
5
+
6
+ from rich.markup import escape
7
+
8
+ from hubble.provider import normalize_base_url
9
+ from hubble.providers import KNOWN_EXAMPLES, NAME_RX, ProviderConfig, verify
10
+ from hubble.ui import console, pick
11
+
12
+
13
+ def _ask(message: str, default: str = "", password: bool = False) -> Optional[str]:
14
+ from prompt_toolkit import prompt
15
+ try:
16
+ return prompt(message, default=default, is_password=password).strip()
17
+ except (EOFError, KeyboardInterrupt):
18
+ return None
19
+
20
+
21
+ def _confirm(message: str, default: bool = True) -> bool:
22
+ hint = "[Y/n]" if default else "[y/N]"
23
+ answer = _ask(f"{message} {hint} ")
24
+ if answer is None:
25
+ return False
26
+ return default if not answer else answer.lower() in ("y", "yes")
27
+
28
+
29
+ def _suggest_name(url: str, taken: Iterable[str]) -> str:
30
+ host = urlparse(url).hostname or "provider"
31
+ parts = [p for p in host.split(".") if p not in ("www", "api", "com", "ai", "io", "xyz", "net", "org", "dev")]
32
+ base = (parts[0] if parts else "provider").lower()
33
+ base = "".join(ch for ch in base if ch.isalnum() or ch in "-_")[:24] or "provider"
34
+ name, n = base, 2
35
+ while name in taken:
36
+ name, n = f"{base}{n}", n + 1
37
+ return name
38
+
39
+
40
+ def add_provider_wizard(taken: Iterable[str], first_run: bool = False) -> Optional[Tuple[ProviderConfig, List[str]]]:
41
+ """Returns (config, model_ids) after a successful check, or None if cancelled."""
42
+ taken = set(taken)
43
+ if first_run:
44
+ console.print("[bold cyan]✦ Welcome to Hubble[/bold cyan]\n"
45
+ " No API key is configured yet. Add any OpenAI-compatible provider to start.\n")
46
+ else:
47
+ console.print("[bold]Add a provider[/bold] [dim](any OpenAI-compatible API; Esc/Ctrl+C cancels)[/dim]")
48
+
49
+ choice = pick("Provider", [(url, name, url) for name, url in KNOWN_EXAMPLES] +
50
+ [("custom", "Custom URL...", "enter any OpenAI-compatible base URL")])
51
+ if choice is None:
52
+ return None
53
+ default_url = "" if choice == "custom" else choice
54
+
55
+ while True:
56
+ raw_url = _ask("Base URL: ", default=default_url)
57
+ if not raw_url:
58
+ return None
59
+ url = normalize_base_url(raw_url)
60
+ local = urlparse(url).hostname in ("localhost", "127.0.0.1", "::1")
61
+ key = _ask("API key" + (" (optional for local servers)" if local else "") + ": ", password=True)
62
+ if key is None:
63
+ return None
64
+ key = key or ("none" if local else "")
65
+ if not key:
66
+ console.print("[red]An API key is required.[/red]")
67
+ continue
68
+
69
+ with console.status(f"[dim]Checking {escape(url)} ...[/dim]"):
70
+ ok, message, ids = verify(url, key)
71
+ if ok:
72
+ console.print(f"[green]✔ {escape(message)}[/green]")
73
+ break
74
+ console.print(f"[red]✘ {escape(message)}[/red]")
75
+ if not _confirm("Try again?"):
76
+ return None
77
+ default_url = raw_url
78
+
79
+ while True:
80
+ name = _ask("Name for this provider: ", default=_suggest_name(url, taken))
81
+ if name is None:
82
+ return None
83
+ name = name.lower()
84
+ if not NAME_RX.match(name):
85
+ console.print("[red]Use lowercase letters, digits, - or _ (max 31 chars).[/red]")
86
+ elif name in taken:
87
+ console.print(f"[red]'{escape(name)}' already exists.[/red]")
88
+ else:
89
+ break
90
+
91
+ console.print(f"[dim] Checking availability sends one tiny request to each of the {len(ids)} models. "
92
+ "That is free on most gateways but can cost a little on paid APIs.[/dim]")
93
+ check = _confirm(f"Check which of the {len(ids)} models respond now (runs in the background)?")
94
+ return ProviderConfig(name, url, key, check_models=check), ids
hubble/permissions.py ADDED
@@ -0,0 +1,139 @@
1
+ """Permission modes and allow/deny rules.
2
+
3
+ Rule syntax: `tool` or `tool(pattern)`, e.g. `shell(git status*)`, `edit_file(src/*)`,
4
+ `read_file(secrets/*)`. Patterns use fnmatch against the tool's target (a workspace-relative
5
+ path, or a command). Deny rules win, then allow rules, then the mode default.
6
+ """
7
+
8
+ import fnmatch
9
+ import re
10
+ from typing import List, Optional, Tuple
11
+
12
+ MODES = ["default", "accept-edits", "plan", "yolo"]
13
+ CYCLE_MODES = ["default", "accept-edits", "plan"] # yolo only by explicit choice
14
+ MODE_HELP = {
15
+ "default": "ask before edits and shell commands",
16
+ "accept-edits": "auto-approve file edits, ask before shell commands",
17
+ "plan": "read-only: explore and propose a plan, no edits or commands",
18
+ "yolo": "auto-approve everything except deny rules",
19
+ }
20
+ ALIASES = {"bash": "shell", "run": "shell", "edit": "edit_file", "write": "write_file",
21
+ "read": "read_file", "ls": "list_dir", "find": "glob", "todo": "todo_write"}
22
+ # A command may ride on a pattern allow rule only if it is one plain program invocation:
23
+ # no separators, subexpressions, redirects, variables or quotes in any shell (cmd, PowerShell, bash).
24
+ SAFE_COMMAND = re.compile(r"^[\w ./:=+,@\-]+$")
25
+ # Split points used to apply deny rules to every part of a chained command.
26
+ SEPARATORS = re.compile(r"&&|\|\||[;|&\r\n()`{}]|\$\(")
27
+ MULTI_WORD_LAUNCHERS = {"python", "python3", "py", "node", "npx", "uv", "uvx", "poetry", "pipx", "dotnet", "go",
28
+ "cargo", "npm", "pnpm", "yarn", "git", "docker", "kubectl", "pip", "make"}
29
+
30
+ RULE_RX = re.compile(r"^\s*([\w-]+)\s*(?:\((.*)\))?\s*$")
31
+
32
+
33
+ def parse_rule(rule: str) -> Optional[Tuple[str, Optional[str]]]:
34
+ m = RULE_RX.match(rule)
35
+ if not m:
36
+ return None
37
+ name = m.group(1).lower()
38
+ return ALIASES.get(name, name), m.group(2)
39
+
40
+
41
+ def rule_matches(rule: str, tool: str, target: str) -> bool:
42
+ parsed = parse_rule(rule)
43
+ if not parsed:
44
+ return False
45
+ name, pattern = parsed
46
+ if name not in (tool, "*"):
47
+ return False
48
+ if pattern is None or pattern == "*":
49
+ return True
50
+ norm = target.replace("\\", "/").strip()
51
+ return fnmatch.fnmatchcase(norm, pattern) or fnmatch.fnmatchcase(norm.lower(), pattern.lower())
52
+
53
+
54
+ def command_prefix(command: str) -> str:
55
+ """`python -m pytest -q tests` -> `python -m pytest`; `git status -s` -> `git status`; `pytest -q` -> `pytest`."""
56
+ words = command.strip().split()
57
+ if not words:
58
+ return ""
59
+ prefix = [words[0]]
60
+ exe = words[0].lower().removesuffix(".exe")
61
+ if exe in MULTI_WORD_LAUNCHERS:
62
+ rest = words[1:]
63
+ if rest[:1] == ["-m"] and len(rest) > 1:
64
+ prefix += rest[:2]
65
+ elif rest and re.match(r"^[a-z][\w:-]*$", rest[0]):
66
+ prefix.append(rest[0])
67
+ if exe in ("npm", "pnpm", "yarn") and rest[0] == "run" and len(rest) > 1:
68
+ prefix.append(rest[1])
69
+ return " ".join(prefix)
70
+
71
+
72
+ class Permissions:
73
+ def __init__(self, mode: str = "default", allow: Optional[List[str]] = None,
74
+ deny: Optional[List[str]] = None):
75
+ self.mode = mode if mode in MODES else "default"
76
+ self.allow = list(allow or [])
77
+ self.deny = list(deny or [])
78
+ self.session_allow: List[str] = []
79
+
80
+ def cycle_mode(self) -> str:
81
+ idx = CYCLE_MODES.index(self.mode) if self.mode in CYCLE_MODES else -1
82
+ self.mode = CYCLE_MODES[(idx + 1) % len(CYCLE_MODES)]
83
+ return self.mode
84
+
85
+ def _denied(self, tool: str, kind: str, target: str) -> Optional[str]:
86
+ targets = [target]
87
+ if kind == "exec":
88
+ targets += [part.strip() for part in SEPARATORS.split(target) if part.strip()]
89
+ for rule in self.deny:
90
+ if any(rule_matches(rule, tool, t) for t in targets):
91
+ return rule
92
+ return None
93
+
94
+ def check(self, tool: str, kind: str, target: str) -> Tuple[str, str]:
95
+ """Returns (decision, reason) where decision is allow | ask | deny."""
96
+ rule = self._denied(tool, kind, target)
97
+ if rule:
98
+ return "deny", f"blocked by deny rule {rule}"
99
+ if kind == "read":
100
+ return "allow", ""
101
+ if self.mode == "plan" and kind != "web": # fetching docs is fine while planning
102
+ return "deny", ("plan mode is read-only. Finish exploring, then present your plan; "
103
+ "the user will switch modes to implement it")
104
+ plain = kind != "exec" or bool(SAFE_COMMAND.match(target.strip()))
105
+ for rule in self.allow + self.session_allow:
106
+ if rule_matches(rule, tool, target):
107
+ parsed = parse_rule(rule)
108
+ if not plain and parsed and parsed[1] not in (None, "*"):
109
+ continue
110
+ return "allow", f"allowed by rule {rule}"
111
+ if self.mode == "yolo":
112
+ return "allow", "yolo mode"
113
+ if kind == "edit" and self.mode == "accept-edits":
114
+ return "allow", "accept-edits mode"
115
+ return "ask", ""
116
+
117
+ def _add(self, rule: str):
118
+ if rule not in self.session_allow:
119
+ self.session_allow.append(rule)
120
+
121
+ def always_rule(self, tool: str, kind: str, target: str) -> str:
122
+ """Session rule(s) added when the user answers 'always'. Returns a description."""
123
+ if kind == "exec":
124
+ prefix = command_prefix(target)
125
+ if not prefix or not SAFE_COMMAND.match(target.strip()):
126
+ self._add(f"{tool}({target.strip()})")
127
+ return f"{tool}({target.strip()})"
128
+ self._add(f"{tool}({prefix})")
129
+ self._add(f"{tool}({prefix} *)")
130
+ return f"{tool}({prefix} *)"
131
+ if kind == "web":
132
+ self._add(f"{tool}({target})")
133
+ return f"{tool}({target})"
134
+ if kind == "edit":
135
+ self._add("edit_file")
136
+ self._add("write_file")
137
+ return "edit_file, write_file"
138
+ self._add(tool)
139
+ return tool