diff --git a/README.md b/README.md index fe5c1ad..5320488 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Other install methods: [one-line install script](#alternative-one-line-install-s ## 🔥🔥🔥 News (Pacific Time) +- August 16, 2026 (**v3.5.87**): **Permission prompts are now reserved for what actually needs a decision.** `auto` mode asks only when an action can change your files, run arbitrary code, or reach outside the session. Auto-approved now: **every** registry-marked read-only tool (18 more than before — diagnostics, task/memory queries, document readers), read-only shell **pipelines** (`git log | head -20`, `ls -la | grep test` — the old check rejected every `|`), session-state tools (tasks/memories/skills), and creating a **new** file inside the workspace. Still asks: overwrites, writes outside the workspace, `.git/hooks` and `.github/workflows` paths, interpreters and test/build runners, anything that deletes or uploads, and sub-agent spawns. The prompt also gained **`s`** — approve and stop asking for *that one command or file* for the session, a scoped alternative to `accept-all` (`/permissions clear` drops grants). The shell check is now a real parser instead of a prefix match, which along the way closed a hole where anything starting with `python `/`node `/`find ` auto-ran. [Details](docs/news.md) +- August 16, 2026: **OpenRouter is now a first-class provider — one key, 400+ models, with the secondary provider pinnable per call (PR #179).** `/model openrouter//` (e.g. `openrouter/deepseek/deepseek-v4-flash`) routes through [OpenRouter](https://openrouter.ai); the key comes from `OPENROUTER_API_KEY` or `/config openrouter_api_key=sk-or-...`, and the model shows up in the `/model` Tab picker and the Web UI picker automatically. Append `@[/]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — to pin which upstream serves the request; it is sent as OpenRouter's `provider` request-body object, so the model field stays a real catalog ID. Shipped alongside four routing fixes that gateway model IDs exposed: the provider is no longer re-derived from an already-stripped model string (which read `openrouter/deepseek/…` as the *DeepSeek* API and leaked DeepSeek-only request fields), cost estimates and context windows now resolve per model instead of defaulting to $0.00 and a flat 128k, and the `@…` routing suffix no longer strips a model of its prompt-family overlay. [Details](docs/news.md) - July 30, 2026 (**v3.5.86**): **Next-prompt ghost text — the REPL predicts the line you'd type next.** After each reply the **auxiliary** (cheap/fast) model drafts your most likely next message and shows it dim at the prompt; **Tab** (or **→**) accepts it in full, typing just types over it, and Enter alone never submits it. Drafting runs on a background thread so the prompt never waits, stays silent on any failure (no key / no model → simply no ghost), and is one-shot per prompt so a stale prediction is never shown. Off with `/config input_suggest=false` or `CHEETAH_SUGGEST=0`. Also in this release: the **terminal tab title now configures itself over Remote-SSH / WSL / devcontainers** — it used to write a settings file on the server that the editor never reads, and never retry; it now targets the remote Machine settings the window actually reads. First tagged release carrying the July 11 tab-title / prompt-cache and July 20 `tool_profile` / bounded-I/O changes. [Details](docs/news.md) - July 20, 2026: **Bounded-I/O fixes and a configurable tool surface.** `tool_profile` selects how many tool schemas are sent each turn — `full` (default, nothing hidden) / `standard` (compact coding) / `research` / `orchestration` — to cut prompt tokens on small-context models, switchable with `/config tool_profile=standard`. Also fixes two bounded-I/O regressions: `SummarizeLargeFile` no longer "summarizes" its own chunk-failure markers (clean `Error` when map/reduce fails), and the DuckDuckGo parser no longer crashes on a valueless `class` attribute. [Details](docs/news.md) - July 11, 2026: **Terminal tab title tracks the live task, plus a cross-turn fix for the Anthropic prompt cache.** [Details](docs/news.md) @@ -165,7 +167,7 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour | Feature | Details | |---|---| -| Multi-provider | Anthropic · OpenAI · Gemini · Kimi · Qwen · Zhipu · DeepSeek · MiniMax · Ollama · LM Studio · Custom endpoint | +| Multi-provider | Anthropic · OpenAI · Gemini · Kimi · Qwen · Zhipu · DeepSeek · MiniMax · OpenRouter · Ollama · LM Studio · Custom endpoint | | Agent loop | Streaming API + automatic tool-use loop; the whole loop is in `agent.py` | | 28 built-in tools | Read · Write · Edit · Bash · Glob · Grep · WebFetch · WebSearch · NotebookEdit · GetDiagnostics · Memory* · Agent/SendMessage · Skill · AskUserQuestion · Task* · SleepTimer · EnterPlanMode/ExitPlanMode · *(MCP + plugin tools auto-added)* | | Tool profiles | `tool_profile` trims the tool surface sent each turn to save prompt tokens: `full` (default, everything) · `standard` (compact coding) · `research` (web + documents) · `orchestration` (agents + tasks). Set with `/config tool_profile=standard`. [Guide](docs/guides/usage.md#tool-profiles-tool_profile) | @@ -175,7 +177,7 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour | Context compression | Four cooperating layers — dynamic `max_tokens` cap, per-model context-window registry, two-layer snip + AI summarize at 70%, and auto-fanout for oversized tool outputs. [Details](docs/guides/reference.md) | | Persistent memory | Dual-scope (user + project), 4 types, confidence/source metadata, conflict detection, recency-weighted search, `/memory consolidate`. Verification-anchored staleness — freshness tracks a `last_verified` date (not file mtime), so reading a memory can't fake-refresh it; only `MemoryVerify` resets the clock. [Details](docs/guides/features.md) | | Multi-agent | Spawn typed sub-agents (coder/reviewer/researcher/…), git-worktree isolation, background mode | -| Permission system | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` modes (`accept-edits` = auto-run edits, still ask for other Bash; hard denylist blocks host-destroying commands in every mode) | +| Permission system | Prompts only for what can change your files, run arbitrary code, or reach outside the session — every read-only tool, read-only shell pipeline (`git log \| head`), and new-file creation in the workspace runs silently. `s` at a prompt grants one command/file for the session (scoped alternative to accept-all). Modes: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`; a hard denylist blocks host-destroying commands in every mode | | Checkpoints & plan mode | Auto-snapshot conversation + files each turn (`/checkpoint`, `/rewind`); `/plan` read-only analysis mode | | Slash commands & themes | 50+ slash commands with Tab-complete; `/theme` offers 15 curated palettes | | Next-prompt ghost text | After each turn the auxiliary (cheap) model drafts the line you'd most likely type next and shows it dim at the prompt — **Tab** (or **→**) accepts it in full, typing ignores it. Background-drafted, never blocks the REPL, silent on failure. Off via `/config input_suggest=false` or `CHEETAH_SUGGEST=0`. [Details](docs/guides/reference.md#next-prompt-ghost-text) | @@ -208,8 +210,11 @@ Claude Code is a powerful, production-grade AI coding assistant — but its sour | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (free tier) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ models, one key)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | varies | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(via litellm)_ | `litellm//` | varies | provider-specific | +> **`openrouter/` gateway:** one key for 400+ models across vendors. The model ID keeps OpenRouter's upstream `/` path, so the call is double-prefixed: `openrouter/deepseek/deepseek-v4-flash`. To pin which upstream provider (and quantization) serves the request, append `@[/]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — which is sent as OpenRouter's `provider` request-body object rather than glued into the model ID. See [usage.md](docs/guides/usage.md#openrouter-400-models-one-key). + > **`litellm/` adapter:** routes to 100+ providers behind one SDK — mainly for upstreams with awkward auth (Bedrock SigV4, Azure deployment routing, Vertex service-account JWTs). For plain OpenAI-shaped endpoints, prefer the zero-dependency `custom/` adapter. Install with `pip install ".[litellm]"`. See [recipes.md](docs/guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider). ### Open-Source (Local via Ollama) @@ -305,7 +310,16 @@ cheetahclaws --model gpt-4o # pick any model cheetahclaws --model deepseek-chat --thinking --verbose ``` -Provider get-key pages: [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com) · [Kimi](https://platform.moonshot.cn) · [Qwen](https://dashscope.aliyun.com) · [Zhipu](https://open.bigmodel.cn) · [DeepSeek](https://platform.deepseek.com) · [MiniMax](https://platform.minimaxi.chat). +Provider get-key pages: [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com) · [Kimi](https://platform.moonshot.cn) · [Qwen](https://dashscope.aliyun.com) · [Zhipu](https://open.bigmodel.cn) · [DeepSeek](https://platform.deepseek.com) · [MiniMax](https://platform.minimaxi.chat) · [OpenRouter](https://openrouter.ai/keys). + +**One key for 400+ models** — [OpenRouter](https://openrouter.ai) fronts every major vendor behind one OpenAI-compatible endpoint, so a single key covers Claude, GPT, Gemini, DeepSeek, Llama, Qwen and the rest: + +```bash +export OPENROUTER_API_KEY=sk-or-... +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash +cheetahclaws --model openrouter/anthropic/claude-sonnet-4-6 +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 # pin the upstream provider +``` **AWS Bedrock / Azure / Vertex** use the `litellm//` form (`pip install ".[litellm]"`) — full env-var recipes in [recipes.md](docs/guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider). @@ -372,6 +386,16 @@ cheetahclaws --model ollama/qwen2.5-coder # 2. provider/model cheetahclaws --model kimi:moonshot-v1-32k # 3. provider:model ``` +**Gateways keep the upstream path.** OpenRouter, NIM and LiteLLM address models by a `/` path of their own, so those calls are double-prefixed — only the **first** segment is the provider, everything after it is passed through verbatim: + +```bash +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash # → OpenRouter, model "deepseek/deepseek-v4-flash" +cheetahclaws --model nim/meta/llama-3.3-70b-instruct # → NVIDIA NIM +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 # + pinned provider / quantization +``` + +OpenRouter additionally accepts an `@[/]` suffix (quantizations: `fp4` · `fp8` · `int4` · `int8`). It never reaches the model field — it is split off and sent as OpenRouter's `provider` routing object (`order` + `allow_fallbacks: false`, plus `quantizations` when given), so pinning `@gmicloud` means the request fails rather than silently landing on a different upstream. + **Auto-detection by prefix:** `claude-`→anthropic · `gpt-`/`o1`/`o3`→openai · `gemini-`→gemini · `moonshot-`/`kimi-`→kimi · `qwen`/`qwq-`→qwen · `glm-`→zhipu · `deepseek-`→deepseek · `MiniMax-`/`abab`→minimax · `llama`/`mistral`/`phi`/`gemma`/`mixtral`/`codellama`→ollama. **Tab-completion (PR #166):** inside the REPL, type `/model ` and press **Tab** for a `provider/model` picker — one default per provider, plus a two-level `litellm//` tree you can drill into. Completions appear as you type when `prompt_toolkit` is present (now a core dependency, so always); otherwise readline serves them on Tab. diff --git a/cheetahclaws/agent.py b/cheetahclaws/agent.py index 1c569bd..6321be3 100644 --- a/cheetahclaws/agent.py +++ b/cheetahclaws/agent.py @@ -60,6 +60,10 @@ class TurnDone: class PermissionRequest: description: str granted: bool = False + # Coarse key for a session-scoped "don't ask again" grant, e.g. + # "Bash:git push" or "Edit:/repo/src/app.py". Empty when the front end + # should offer approve/reject only. See agent._permission_signature. + signature: str = "" @dataclass class QuotaPause: @@ -489,7 +493,10 @@ def run( if config.get("permission_mode") == "plan": permitted = False else: - req = PermissionRequest(description=_permission_desc(tc)) + req = PermissionRequest( + description=_permission_desc(tc), + signature=_permission_signature(tc), + ) yield req permitted = req.granted permissions[tc["id"]] = permitted @@ -682,6 +689,115 @@ def _exec_one(tc): # ── Helpers ─────────────────────────────────────────────────────────────── +# Tools that mutate only CheetahClaws' own session state — task lists, saved +# memories, loaded skill text, a sleep timer. They touch nothing in the +# user's repo, reach no network, and are undone by editing the same store, so +# prompting for them is pure friction. Anything that can reach the user's +# files, the shell, or the outside world stays off this list — notably Agent +# (a sub-agent runs its own tool loop) and MemoryDelete (destroys user data). +_SELF_STATE_TOOLS = frozenset({ + "TaskCreate", "TaskUpdate", "MemorySave", "Skill", "SleepTimer", + "EnterPlanMode", "ExitPlanMode", "AskUserQuestion", +}) + + +def _tool_is_read_only(name: str) -> bool: + """True when the registry marks this tool as never mutating state. + + Read from the registry rather than a hardcoded name list so every + read-only tool — built-in, plugin, or module (Task/Memory/Skill queries, + document readers, browser reads) — is auto-approved on the same rule, and + a new one is covered the day it is registered. Unknown tools (MCP, + third-party) default to False and still prompt. + """ + try: + from cheetahclaws.tool_registry import get_tool + tdef = get_tool(name) + return bool(tdef and tdef.read_only) + except Exception: + return False + + +def _creates_new_workspace_file(tc: dict, config: dict) -> bool: + """True for a Write that creates a *new* file inside the workspace. + + Creating a file destroys nothing: there is no prior content to lose and + the file is inside the directory the session is already working in, so + prompting for it is friction without a decision behind it. Overwriting + an existing file, or writing anywhere outside the workspace, still asks. + + Excluded regardless: any path with a dot-prefixed component + (``.git/hooks/pre-commit``, ``.github/workflows/*``, ``.env``) — those are + configuration and hook locations that get executed or trusted by other + tools, which makes creating one a decision the user should see. + Disable the whole rule with ``/config auto_create_files=false``. + """ + if not config.get("auto_create_files", True): + return False + path = (tc.get("input") or {}).get("file_path") or "" + if not path: + return False + try: + from pathlib import Path as _Path + target = _Path(path).expanduser() + if not target.is_absolute(): + target = _Path.cwd() / target + target = target.resolve() + if target.exists(): + return False # overwriting real content → ask + root = _Path(config.get("allowed_root") + or config.get("_worktree_cwd") + or _Path.cwd()).resolve() + rel = target.relative_to(root) # ValueError → outside the workspace + except Exception: + return False + return not any(part.startswith(".") for part in rel.parts) + + +def _permission_signature(tc: dict) -> str: + """Stable key for a 'don't ask again this session' grant. + + Deliberately coarser than the exact call so a grant is actually useful, + but never so coarse that it covers a different kind of action: + + Bash → the program + its subcommand ("git push", "pytest"), so repeat + runs of the same command with different arguments are covered + Write/Edit/NotebookEdit → the specific file, so approving one file + never approves another + other → the tool name + """ + name = tc["name"] + inp = tc.get("input") or {} + if name == "Bash": + cmd = (inp.get("command", "") or "").strip() + try: + import shlex + parts = shlex.split(cmd)[:2] + except ValueError: + parts = cmd.split()[:2] + if not parts: + return "Bash" + prog = os.path.basename(parts[0]) + _MULTI = {"git", "npm", "pnpm", "yarn", "pip", "pip3", "uv", "cargo", + "go", "docker", "kubectl", "make", "poetry", "conda", + "systemctl", "brew", "gh"} + if prog in _MULTI and len(parts) > 1 and not parts[1].startswith("-"): + return f"Bash:{prog} {parts[1]}" + return f"Bash:{prog}" + path = inp.get("file_path") or inp.get("notebook_path") + if path: + return f"{name}:{os.path.abspath(path)}" + return name + + +def _session_approved(tc: dict, config: dict) -> bool: + """True if the user already granted this signature for the session.""" + try: + return _permission_signature(tc) in runtime.get_ctx(config).approved_sigs + except Exception: + return False + + def _check_permission(tc: dict, config: dict) -> bool: """Return True if operation is auto-approved (no need to ask user).""" perm_mode = config.get("permission_mode", "auto") @@ -709,9 +825,13 @@ def _check_permission(tc: dict, config: dict) -> bool: return False if name == "Bash": from cheetahclaws.tools import _is_safe_bash - return _is_safe_bash(tc["input"].get("command", "")) + return _is_safe_bash(tc["input"].get("command", ""), config) return True # reads are fine + # Already granted for this session by answering "s" at an earlier prompt. + if _session_approved(tc, config): + return True + # "accept-edits" mode: same as "auto", but file edits are pre-approved. # Bash and everything else still follow the auto rules below, so a # non-allow-listed shell command is still prompted (and the hard denylist @@ -719,13 +839,17 @@ def _check_permission(tc: dict, config: dict) -> bool: if perm_mode == "accept-edits" and name in ("Write", "Edit", "NotebookEdit"): return True - # "auto" mode (and accept-edits fall-through): only ask for writes and non-safe bash - if name in ("Read", "Glob", "Grep", "WebFetch", "WebSearch"): + # "auto" mode (and accept-edits fall-through): prompt only for actions + # that can change the user's files, run arbitrary code, or reach outside + # the session — reads and self-state updates run straight through. + if _tool_is_read_only(name) or name in _SELF_STATE_TOOLS: + return True + if name == "Write" and _creates_new_workspace_file(tc, config): return True if name == "Bash": from cheetahclaws.tools import _is_safe_bash - return _is_safe_bash(tc["input"].get("command", "")) - return False # Write, Edit → ask + return _is_safe_bash(tc["input"].get("command", ""), config) + return False # Write, Edit, and anything unclassified → ask def _permission_desc(tc: dict) -> str: diff --git a/cheetahclaws/cli.py b/cheetahclaws/cli.py index ad1f809..098e9f1 100755 --- a/cheetahclaws/cli.py +++ b/cheetahclaws/cli.py @@ -357,9 +357,9 @@ def _compact_perm_desc(desc: str, max_len: int = 100) -> str: return first -def ask_permission_interactive(desc: str, config: dict) -> bool: +def ask_permission_interactive(desc: str, config: dict, signature: str = "") -> bool: # Inline-keyboard buttons for bridges that support them (Telegram today). - # Terminal / Slack / WeChat ignore `options` and the [y/N/a] hint in the + # Terminal / Slack / WeChat ignore `options` and the [y/N/s/a] hint in the # prompt text keeps them functional. # In quiet mode, collapse multi-line commands so the approval prompt stays # a single tidy line instead of dumping the entire script. @@ -369,14 +369,36 @@ def ask_permission_interactive(desc: str, config: dict) -> bool: perm_options = [ ("✅ Approve", "y"), ("❌ Reject", "n"), - ("✅✅ Accept all", "a"), ] + # "s" is the scoped alternative to the blunt accept-all: it grants exactly + # this command/file for the rest of the session, so a task that edits one + # file forty times asks once instead of forty times — without handing over + # the whole tool surface the way accept-all does. + hint = "[y/N/a(ccept-all)]" + if signature: + perm_options.append((f"🔁 Always allow {signature}", "s")) + hint = f"[y/N/s(ession: {signature})/a(ccept-all)]" + perm_options.append(("✅✅ Accept all", "a")) text = ask_input_interactive( - f" Allow: {desc} [y/N/a(ccept-all)] ", + f" Allow: {desc} {hint} ", config, options=perm_options, ).strip().lower() + if signature and text in ("s", "session", "always"): + try: + from cheetahclaws import runtime as _runtime + _runtime.get_ctx(config).approved_sigs.add(signature) + except Exception: + pass # a failed grant just means the next call asks again + msg = f"Allowing {signature} for the rest of this session." + if _is_in_tg_turn(config): + _tg_send(config.get("telegram_token"), config.get("telegram_chat_id"), + f"🔁 {msg}") + else: + ok(f" {msg}") + return True + if text == "a" or text == "accept all" or text == "accept-all": config["permission_mode"] = "accept-all" if _is_in_tg_turn(config): @@ -390,6 +412,33 @@ def ask_permission_interactive(desc: str, config: dict) -> bool: return text in ("y", "yes") +def _ask_permission_event(ev, config: dict) -> bool: + """Route a PermissionRequest to the prompt, tolerating 2-arg handlers. + + ``ask_permission_interactive`` gained a third ``signature`` argument, but + bridges, plugins and tests may hold a two-argument override of it. Calling + those with three positionals raises a TypeError that the callers' `except` + turns into a silent *denial* — the failure mode is invisible and looks + like the model being blocked for no reason. Pass the signature only when + the live callable actually accepts it. + """ + sig = getattr(ev, "signature", "") + fn = ask_permission_interactive # resolved at call time: honours monkeypatching + if sig: + try: + import inspect + params = inspect.signature(fn).parameters.values() + accepts = (len(params) >= 3 + or any(p.kind in (inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD) + for p in params)) + except (TypeError, ValueError): + accepts = False + if accepts: + return fn(ev.description, config, sig) + return fn(ev.description, config) + + # ── Proactive watcher ────────────────────────────────────────────────────── def _proactive_foreign_daemon_running() -> bool: @@ -954,7 +1003,7 @@ def _headless_run_query(prompt: str, is_background: bool = False) -> None: # denied on any failure so a broken bridge never auto-runs # a sensitive tool. try: - ev.granted = ask_permission_interactive(ev.description, config) + ev.granted = _ask_permission_event(ev, config) except Exception: ev.granted = False except Exception: @@ -1344,7 +1393,7 @@ def run_query(user_input: str, is_background: bool = False): elif isinstance(event, PermissionRequest): _stop_tool_spinner() flush_response() - event.granted = ask_permission_interactive(event.description, config) + event.granted = _ask_permission_event(event, config) # Live will restart automatically on next TextChunk elif isinstance(event, ToolEnd): diff --git a/cheetahclaws/commands/config_cmd.py b/cheetahclaws/commands/config_cmd.py index edd3532..2b97607 100644 --- a/cheetahclaws/commands/config_cmd.py +++ b/cheetahclaws/commands/config_cmd.py @@ -203,18 +203,38 @@ def cmd_permissions(args: str, _state, config) -> bool: from cheetahclaws.tools import ask_input_interactive modes = ["auto", "accept-edits", "accept-all", "manual", "plan"] mode_desc = { - "auto": "Auto-run reads + allow-listed Bash; ask before edits and other commands (default)", + "auto": "Auto-run every read-only tool + read-only shell commands; ask before edits, arbitrary commands, sub-agents (default)", "accept-edits": "Like auto, but also auto-run file edits (Write/Edit); other Bash still asks", "accept-all": "Run everything without asking (host-destroying commands are still hard-blocked)", "manual": "Ask before every tool call, including reads", "plan": "Read-only: reads + safe Bash run, all edits/writes are refused (see /plan for the plan-file workflow)", } + # "/permissions clear" drops the session-scoped grants made by answering + # "s" at a prompt — the escape hatch when one was given too broadly. + if args.strip() in ("clear", "reset"): + from cheetahclaws import runtime + grants = runtime.get_ctx(config).approved_sigs + n = len(grants) + grants.clear() + ok(f"Cleared {n} session permission grant{'s' if n != 1 else ''}.") + return True if not args.strip(): current = config.get("permission_mode", "auto") menu_buf = clr("\n ── Permission Mode ──", "dim") for i, m in enumerate(modes): marker = clr("●", "green") if m == current else clr("○", "dim") menu_buf += f"\n {marker} {clr(f'[{i+1}]', 'yellow')} {clr(m, 'cyan')} {clr(mode_desc[m], 'dim')}" + try: + from cheetahclaws import runtime + grants = sorted(runtime.get_ctx(config).approved_sigs) + except Exception: + grants = [] + if grants: + menu_buf += "\n\n " + clr(f"Session grants ({len(grants)}) — /permissions clear to drop:", "dim") + for g in grants[:10]: + menu_buf += "\n " + clr(f"• {g}", "dim") + if len(grants) > 10: + menu_buf += "\n " + clr(f"… {len(grants) - 10} more", "dim") print(menu_buf) print() try: diff --git a/cheetahclaws/config.py b/cheetahclaws/config.py index 3e662b2..86dc71e 100644 --- a/cheetahclaws/config.py +++ b/cheetahclaws/config.py @@ -22,7 +22,18 @@ # the session. WARNING: setting it ABOVE the model's real window disables the # compaction safety net — the API may then reject oversized prompts. "context_window": 0, - "permission_mode": "auto", # auto | accept-all | manual + "permission_mode": "auto", # auto | accept-edits | accept-all | manual | plan + # Extra program names to treat as read-only in the Bash auto-approval + # check (tools/security.py). Use for a project's own reporting/query + # commands so routine calls stop prompting: + # /config bash_safe_extra=["bazel-query","./scripts/status"] + # Only add programs that cannot write, delete, or execute other programs. + "bash_safe_extra": [], + # Auto-approve a Write that CREATES a new file inside the working + # directory (nothing to overwrite, nothing outside the workspace). + # Overwrites, paths outside the workspace, and any dot-prefixed path + # (.git/, .github/, .env) still prompt. Set False to review every write. + "auto_create_files": True, "verbose": False, # terminal_title: set the terminal window/tab title to the current task — # a pulsing glyph while working, a static badge when idle (Claude-Code diff --git a/cheetahclaws/prompts/select.py b/cheetahclaws/prompts/select.py index 93e5138..5447622 100644 --- a/cheetahclaws/prompts/select.py +++ b/cheetahclaws/prompts/select.py @@ -90,7 +90,10 @@ def _family_overlay_for_model(model_id: str) -> str | None: """Return the overlay filename for a model ID, or None.""" if not model_id: return None - tail = model_id.rsplit("/", 1)[-1].lower() + # Drop any OpenRouter "@[/]" routing suffix first — + # without it "…/claude-sonnet-4-6@gmicloud/fp8" tails to "fp8" and the + # model silently loses its family overlay. + tail = model_id.partition("@")[0].rsplit("/", 1)[-1].lower() for keywords, fname in _OVERLAY_RULES: if any(k in tail for k in keywords): return fname diff --git a/cheetahclaws/providers.py b/cheetahclaws/providers.py index 515c61a..c14efae 100644 --- a/cheetahclaws/providers.py +++ b/cheetahclaws/providers.py @@ -322,6 +322,23 @@ def bare_model(model: str) -> str: return model.split("/", 1)[1] if "/" in model else model +def lookup_model_key(model: str) -> str: + """Return the per-model registry key for any model string. + + Gateway providers (openrouter/, nim/, litellm/, custom/) keep the upstream + ``/`` path, and OpenRouter adds an optional + ``@[/]`` routing suffix. Neither belongs in a *model* + lookup, so registries keyed by plain model name (COSTS, + _MODEL_CONTEXT_LIMITS) miss and silently fall back to a default — $0.00 + for a billed gateway, or the wrong context window. + + "openrouter/anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6" + "openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8" → "deepseek-v4-flash" + "gpt-4o" → "gpt-4o" + """ + return model.partition("@")[0].rsplit("/", 1)[-1] + + # Quantization levels OpenRouter accepts in provider.quantizations. _OR_QUANTIZATIONS = frozenset({"fp4", "fp8", "int4", "int8"}) @@ -523,17 +540,36 @@ def get_model_context_window(provider: str, model: str, 3. Provider-level PROVIDERS[provider]['context_limit'] 4. Fallback 128000 """ - bare = bare_model(model) - if bare in _MODEL_CONTEXT_LIMITS: - return _MODEL_CONTEXT_LIMITS[bare] - bare_lc = bare.lower() - for k, v in _MODEL_CONTEXT_LIMITS.items(): - if bare_lc.startswith(k.lower()): - return v + # Two candidates: the provider-stripped ID, and — for gateway routes that + # keep an upstream / path (openrouter/, nim/, custom/) — the + # bare model name. Without the second, every openrouter/* model fell + # through to the provider-level 128000 default regardless of its real + # window: too-late compaction on a 32k model, too-early on a 1M one. + candidates = [bare_model(model)] + key = lookup_model_key(model) + if key != candidates[0]: + candidates.append(key) + for cand in candidates: + if cand in _MODEL_CONTEXT_LIMITS: + return _MODEL_CONTEXT_LIMITS[cand] + cand_lc = cand.lower() + for k, v in _MODEL_CONTEXT_LIMITS.items(): + if cand_lc.startswith(k.lower()): + return v if provider == "custom" and base_url: live = _fetch_custom_model_limit(base_url, model, api_key) if live: return live + # Gateway route whose vendor segment names a provider we know natively + # ("openrouter/anthropic/claude-…" → anthropic): that vendor's window is a + # far better estimate than the gateway's one-size-fits-all default, which + # is necessarily generic across a 400-model catalog. + upstream = candidates[0] + vendor = upstream.split("/", 1)[0] if "/" in upstream else "" + if vendor and vendor != provider: + vendor_ctx = PROVIDERS.get(vendor, {}).get("context_limit") + if vendor_ctx: + return vendor_ctx prov_ctx = PROVIDERS.get(provider, {}).get("context_limit") if prov_ctx: return prov_ctx @@ -667,7 +703,12 @@ def calc_cost(model: str, in_tok: int, out_tok: int, input_tokens, priced at 0.1x (read) / 1.25x (write) of the input rate — omitting them would silently under-report spend once prompt caching is active.""" - ic, oc = COSTS.get(bare_model(model), (0.0, 0.0)) + # Try the provider-stripped ID first ("meta/llama-3.3-70b-instruct" for + # nim/), then the bare model name — the latter is what prices gateway + # routes like openrouter/deepseek/deepseek-v4-flash, which would otherwise + # report $0.00 and sail straight past the cost budget in + # quota.record_usage. + ic, oc = COSTS.get(bare_model(model)) or COSTS.get(lookup_model_key(model), (0.0, 0.0)) cache = (cache_read_tok * 0.1 + cache_write_tok * 1.25) * ic return (in_tok * ic + out_tok * oc + cache) / 1_000_000 @@ -1447,7 +1488,14 @@ def stream_openai_compat( _or_prov = config.get("_openrouter_provider") if _or_prov: kwargs.setdefault("extra_body", {})["provider"] = _or_prov - _prov = detect_provider(model) + # `model` here already has the provider prefix stripped, so re-detecting + # from it mis-reads gateway routes: "deepseek/deepseek-v4-flash" is an + # *OpenRouter* model ID but resolves to the deepseek provider, which then + # injects DeepSeek-only request fields (extra_body.thinking, + # reasoning_effort) and applies DeepSeek's caps instead of OpenRouter's. + # stream() passes the real provider via _provider_name; detect_provider + # stays the fallback for direct callers (tests, embedders). + _prov = config.get("_provider_name") or detect_provider(model) # DeepSeek v4: thinking is ON by default and controlled via extra_body. # `thinking` is tri-state in DEFAULTS (config.py): None = unset (let @@ -1944,6 +1992,10 @@ def stream( model_name, or_routing = parse_openrouter_routing(model_name) if or_routing: config = {**config, "_openrouter_provider": or_routing} + # Downstream helpers get `model_name` with the provider prefix already + # stripped and cannot re-derive the provider for multi-level IDs + # (openrouter/, nim/, custom/), so pass it explicitly. + config = {**config, "_provider_name": provider_name} api_key = get_api_key(provider_name, config) session_id = config.get("_session_id", "default") diff --git a/cheetahclaws/runtime.py b/cheetahclaws/runtime.py index 0fd8aa0..7e08224 100644 --- a/cheetahclaws/runtime.py +++ b/cheetahclaws/runtime.py @@ -113,6 +113,12 @@ class RuntimeContext: plan_file: Optional[str] = None prev_permission_mode: Optional[str] = None + # Scoped permission grants made by answering "s" at a prompt — e.g. + # {"Bash:git push", "Edit:/repo/src/app.py"}. Session-lifetime only: + # never persisted, and dropped with the RuntimeContext, so a broad grant + # can't outlive the task it was given for. See agent._check_permission. + approved_sigs: set = field(default_factory=set) + # Voice voice_device_index: Optional[int] = None diff --git a/cheetahclaws/tools/__init__.py b/cheetahclaws/tools/__init__.py index ee1fdd2..8b7d39d 100644 --- a/cheetahclaws/tools/__init__.py +++ b/cheetahclaws/tools/__init__.py @@ -480,7 +480,7 @@ def _check(desc: str) -> bool: return "Denied: user rejected edit operation" elif name == "Bash": cmd = inputs.get("command", "") or "" - if permission_mode != "accept-all" and not _is_safe_bash(cmd): + if permission_mode != "accept-all" and not _is_safe_bash(cmd, cfg): if not _check(f"Bash: {cmd or ''}"): return "Denied: user rejected bash command" elif name == "NotebookEdit": diff --git a/cheetahclaws/tools/security.py b/cheetahclaws/tools/security.py index b33a2f5..8672891 100644 --- a/cheetahclaws/tools/security.py +++ b/cheetahclaws/tools/security.py @@ -2,36 +2,253 @@ from __future__ import annotations import os +import shlex from pathlib import Path -# Prefixes that are safe to run without a permission prompt -_SAFE_PREFIXES = ( - "ls", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "date", - "which", "type", "env", "printenv", "uname", "whoami", "id", - "git log", "git status", "git diff", "git show", "git branch", - "git remote", "git stash list", "git tag", - "find ", "grep ", "rg ", "ag ", "fd ", - "python ", "python3 ", "node ", "ruby ", "perl ", - "pip show", "pip list", "npm list", "cargo metadata", - "df ", "du ", "free ", "top -bn", "ps ", - "curl -I", "curl --head", -) +# ── Read-only shell vocabulary ──────────────────────────────────────────── +# +# A command is auto-approved (no permission prompt) only when *every* segment +# of the pipeline is a known read-only invocation. The classification is by +# parsed program name — not a string prefix — so `lsof` can never satisfy an +# `ls` rule and `rm` can never hide behind a leading `echo`. +# +# Three ways a segment can qualify: +# 1. the program is in _READ_ONLY_COMMANDS (plus its per-command flag guard) +# 2. the program is in _READ_ONLY_SUBCOMMANDS and its subcommand is listed +# 3. the invocation is nothing but an info flag (`--version`, `--help`) +# +# Anything else prompts. Deliberately NOT here: interpreters (python, node, +# ruby, perl, sh), build/test runners (make, pytest, npm run), and anything +# that takes a user-supplied program to run (xargs, env VAR=…, timeout). +# Those execute arbitrary code, which is exactly what the prompt is for. + +_READ_ONLY_COMMANDS = frozenset({ + # filesystem inspection + "ls", "ll", "pwd", "stat", "file", "tree", "realpath", "readlink", + "basename", "dirname", "du", "df", "mountpoint", + # file contents + "cat", "head", "tail", "nl", "wc", "od", "xxd", "strings", "base64", + "md5sum", "sha1sum", "sha256sum", "sha512sum", "cksum", + "diff", "cmp", "comm", + # text processing (pipeline members) + "sort", "uniq", "cut", "tr", "rev", "fold", "paste", "join", "column", + "jq", "yq", "sed", "grep", "egrep", "fgrep", "rg", "ag", "fd", "find", + # environment / system introspection + "echo", "printf", "date", "cal", "uptime", "hostname", "uname", + "whoami", "id", "groups", "which", "type", "command", "env", "printenv", + "locale", "nproc", "arch", "lscpu", "lsblk", "free", "ps", "top", + "netstat", "ss", "lsof", "ulimit", "tty", + # archives — listing only (see _COMMAND_FLAG_GUARDS) + "tar", "unzip", "zipinfo", "gunzip", "zcat", + # network — header-only fetches (see _COMMAND_FLAG_GUARDS) + "curl", +}) + +# program → subcommands that only read state. +_READ_ONLY_SUBCOMMANDS = { + "git": frozenset({ + "log", "status", "diff", "show", "branch", "remote", "tag", "blame", + "describe", "rev-parse", "rev-list", "ls-files", "ls-tree", "ls-remote", + "cat-file", "shortlog", "whatchanged", "reflog", "count-objects", + "grep", "annotate", "difftool", "verify-commit", "check-ignore", + "config", "stash", "worktree", "submodule", "bisect", "notes", + }), + "docker": frozenset({"ps", "images", "image", "logs", "inspect", "version", + "info", "top", "stats", "port", "diff", "history"}), + "podman": frozenset({"ps", "images", "logs", "inspect", "version", "info"}), + "kubectl": frozenset({"get", "describe", "logs", "top", "version", + "explain", "api-resources", "api-versions", + "cluster-info"}), + "npm": frozenset({"ls", "list", "view", "info", "outdated", "why", "ping"}), + "pnpm": frozenset({"ls", "list", "outdated", "why"}), + "yarn": frozenset({"list", "info", "why", "outdated"}), + "pip": frozenset({"show", "list", "freeze", "check"}), + "pip3": frozenset({"show", "list", "freeze", "check"}), + "uv": frozenset({"tree", "version"}), + "cargo": frozenset({"metadata", "tree", "search", "verify-project"}), + "go": frozenset({"list", "env", "version", "doc"}), + "brew": frozenset({"list", "info", "outdated", "config", "--version"}), + "systemctl": frozenset({"status", "list-units", "list-unit-files", "show", + "is-active", "is-enabled", "cat"}), + "poetry": frozenset({"show", "check", "env"}), + "conda": frozenset({"list", "info", "env"}), +} + +# Subcommands that are read-only only with the right flag. +_SUBCOMMAND_FLAG_GUARDS = { + ("git", "config"): lambda args: any(a in ("--get", "--get-all", "--list", "-l", + "--get-regexp") for a in args), + ("git", "stash"): lambda args: bool(args) and args[0] in ("list", "show"), + ("git", "worktree"): lambda args: bool(args) and args[0] == "list", + ("git", "submodule"): lambda args: bool(args) and args[0] in ("status", "summary"), + ("git", "bisect"): lambda args: bool(args) and args[0] in ("log", "view"), + ("git", "notes"): lambda args: bool(args) and args[0] in ("list", "show"), + ("docker", "image"): lambda args: bool(args) and args[0] in ("ls", "list", + "inspect", "history"), + ("conda", "env"): lambda args: bool(args) and args[0] == "list", + ("poetry", "env"): lambda args: bool(args) and args[0] in ("info", "list"), +} + +# Flags that turn an otherwise read-only command into a mutating one. +def _guard_find(args: list[str]) -> bool: + banned = {"-delete", "-exec", "-execdir", "-ok", "-okdir", + "-fprint", "-fprint0", "-fprintf", "-fls"} + return not any(a in banned for a in args) + + +def _guard_no_flags(banned: set[str]): + return lambda args: not any(a in banned or a.split("=", 1)[0] in banned + for a in args) + +_COMMAND_FLAG_GUARDS = { + "find": _guard_find, + "fd": _guard_no_flags({"-x", "--exec", "-X", "--exec-batch"}), + "sed": _guard_no_flags({"-i", "--in-place", "-s"}), + "sort": _guard_no_flags({"-o", "--output"}), + # archives: listing only + "tar": lambda args: any(a in ("-t", "--list") or + (a.startswith("-") and not a.startswith("--") and "t" in a) + for a in args) + and not any(a in ("-x", "-c", "--extract", "--create", "-r", "-u") + or (a.startswith("-") and not a.startswith("--") + and any(ch in a for ch in "xcru")) + for a in args), + "unzip": lambda args: any(a in ("-l", "-v", "-t") for a in args), + # network: header-only requests, never a body fetch or an -o download + "curl": lambda args: any(a in ("-I", "--head") for a in args) + and not any(a in ("-o", "--output", "-O", "--remote-name", + "-d", "--data", "-X", "--request", "-T", + "--upload-file") for a in args), + # `top` must be in batch mode or it never exits + "top": lambda args: any(a.startswith("-b") for a in args), + # `env` alone prints the environment; `env FOO=1 cmd` runs a command + "env": lambda args: not args, + "command": lambda args: bool(args) and args[0] in ("-v", "-V"), + "type": lambda args: True, +} -_CHAIN_OPERATORS = (";", "&&", "||", "|", "`", "$(", "\n") +# Never auto-approved, even as a pipeline member: these run whatever they are +# handed, so no argument inspection makes them safe. +_NEVER_SAFE = frozenset({ + "sudo", "doas", "su", "sh", "bash", "zsh", "fish", "dash", "ksh", + "eval", "exec", "source", ".", "xargs", "nohup", "setsid", "timeout", + "watch", "nice", "ionice", "strace", "ltrace", "gdb", "awk", "gawk", + "mawk", "perl", "python", "python3", "ruby", "node", "php", "tee", + "ssh", "scp", "rsync", "make", "cmake", "ninja", "npx", "pipx", +}) + +# An invocation that is nothing but one of these is always safe, whatever the +# program — `python --version` tells you a version, it does not run your code. +# Long forms only, plus `-V`: the single-letter short flags are too ambiguous +# to trust across unknown programs (`-v` is verbose for most commands, and +# `shutdown -h` halts the machine rather than printing help). +_INFO_ONLY_FLAGS = frozenset({"--version", "-V", "--help", "--usage", + "version", "help"}) + +# Structures that can smuggle a second command past segment parsing. +_UNSAFE_SUBSTRINGS = ("`", "$(", "<(", ">(", "&>", "|&", "${!") + +# Shell operators that separate one command from the next. +_SEGMENT_SEPARATORS = {"|", "||", "&&", ";", "\n"} + +# Operators we refuse outright: output redirection writes files, `&` +# backgrounds a process past the turn, parentheses spawn a subshell. +_REJECTED_OPERATORS = {">", ">>", ">|", "&", "(", ")"} + + +def _classify_segment(tokens: list[str], extra_safe: frozenset[str]) -> bool: + """Return True if one pipeline segment is a known read-only invocation.""" + if not tokens: + return False + prog_raw = tokens[0] + # `VAR=value cmd` — an assignment prefix hides the real program. + if "=" in prog_raw and not prog_raw.startswith("-"): + return False + prog = os.path.basename(prog_raw) + args = tokens[1:] + if prog in _NEVER_SAFE and prog not in extra_safe: + # `python --version` and friends stay safe — nothing is executed. + return bool(args) and all(a in _INFO_ONLY_FLAGS for a in args) -def _is_safe_bash(cmd: str) -> bool: + if args and all(a in _INFO_ONLY_FLAGS for a in args): + return True + + if prog in _READ_ONLY_SUBCOMMANDS: + # Skip global flags before the subcommand, including the ones that + # take a value — `git -C /repo status` is still `git status`. + _VALUE_FLAGS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", + "--exec-path", "--context", "-n", "--namespace"} + sub, skip = "", False + for a in args: + if skip: + skip = False + continue + if a.startswith("-"): + skip = a in _VALUE_FLAGS and "=" not in a + continue + sub = a + break + if sub not in _READ_ONLY_SUBCOMMANDS[prog]: + return False + guard = _SUBCOMMAND_FLAG_GUARDS.get((prog, sub)) + if guard: + rest = args[args.index(sub) + 1:] + return bool(guard(rest)) + return True + + if prog in _READ_ONLY_COMMANDS or prog in extra_safe: + guard = _COMMAND_FLAG_GUARDS.get(prog) + return bool(guard(args)) if guard else True + + return False + + +def _is_safe_bash(cmd: str, config: dict | None = None) -> bool: """Return True if cmd is read-only and never needs a permission prompt. - Rejects commands that contain shell chaining operators (;, &&, ||, |, - backticks, $(…)) — these could execute arbitrary code after a safe prefix. + The command is parsed, split on pipeline/list operators, and *every* + segment must be a known read-only invocation — so `git log | head -20` + and `ls -la | grep test` auto-run, while `ls && rm -rf build` does not. + Output redirection, backgrounding, subshells and command substitution are + refused outright, since they can act (or hide a second command) after a + safe-looking prefix. + + ``config['bash_safe_extra']`` may list additional program names to treat + as read-only (see docs/guides/reference.md). """ - c = cmd.strip() - # Reject any command that chains multiple commands - if any(op in c for op in _CHAIN_OPERATORS): + c = (cmd or "").strip() + if not c: + return False + if any(s in c for s in _UNSAFE_SUBSTRINGS): return False - return any(c.startswith(p) for p in _SAFE_PREFIXES) + + extra = config.get("bash_safe_extra") if config else None + extra_safe = frozenset(extra) if isinstance(extra, (list, tuple, set)) else frozenset() + + try: + lexer = shlex.shlex(c, posix=True, punctuation_chars=True) + lexer.whitespace_split = True + tokens = list(lexer) + except ValueError: + return False # unbalanced quotes — don't guess + if not tokens: + return False + + segment: list[str] = [] + for tok in tokens: + if tok in _REJECTED_OPERATORS or tok.startswith(">"): + return False + if tok in _SEGMENT_SEPARATORS: + if not _classify_segment(segment, extra_safe): + return False + segment = [] + continue + if tok == "<": + continue # input redirection reads a file: harmless + segment.append(tok) + return _classify_segment(segment, extra_safe) # Path patterns that hold credentials or system secrets — never accessed by diff --git a/docs/architecture.md b/docs/architecture.md index d92923c..5814fd9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -307,6 +307,7 @@ based on the model string: "ollama/qwen2.5-coder" → ollama (explicit prefix) "custom/my-endpoint" → custom "nim/meta/llama-3.3-70b-instruct" → nim (build.nvidia.com free tier) +"openrouter/deepseek/deepseek-v4-flash" → openrouter (400+ models, one key) ``` `stream(model, system, messages, tool_schemas, config) -> Generator` @@ -314,6 +315,29 @@ is the one entry point agent.py uses. Internally it dispatches to `stream_anthropic()` (native SDK) or `stream_openai_compat()` (used by every OpenAI-compatible provider). +**Gateway model IDs are multi-level.** OpenRouter, NIM and LiteLLM +address models by an upstream `/` path, so only the first +segment is the provider and `bare_model()` deliberately strips just that +one. Two consequences the rest of the file relies on: + +* The provider name cannot be re-derived downstream — `bare_model()`'s + output (`deepseek/deepseek-v4-flash`) reads as a *different* provider. + `stream()` therefore passes the resolved name in `config["_provider_name"]`, + and `stream_openai_compat()` reads that instead of re-detecting. +* Per-model registries (`COSTS`, `_MODEL_CONTEXT_LIMITS`) are keyed by + plain model name, so lookups fall back to `lookup_model_key()` — which + drops both the vendor path and OpenRouter's optional + `@[/]` routing suffix. Without it a gateway + route silently prices at $0 and reads the provider-level context + default instead of the model's own. + +**OpenRouter provider pinning.** `parse_openrouter_routing()` splits the +`@[/]` suffix off the model ID and stores it in +`config["_openrouter_provider"]`; `stream_openai_compat()` forwards it as +the `provider` object in `extra_body`. Provider selection is a +request-body element in OpenRouter's API — glued into the model ID it +would be rejected as an unknown model. + **NIM 429 cascade.** The `nim` provider points at `build.nvidia.com`'s free OpenAI-compatible endpoint with a curated 10-model chain (deepseek-r1, llama-3.3-70b, qwen2.5-coder-32b, …). When one model diff --git a/docs/guides/features.md b/docs/guides/features.md index 37050b1..b79b263 100644 --- a/docs/guides/features.md +++ b/docs/guides/features.md @@ -23,7 +23,7 @@ and indexed in the [README Documentation section](../../README.md#documentation) | Multi-agent | Spawn typed sub-agents (coder/reviewer/researcher/…), git worktree isolation, background mode | | Skills | Built-in `/commit` · `/review` + custom markdown skills with argument substitution and fork/inline execution | | Plugin tools | Register custom tools via `tool_registry.py` | -| Permission system | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` modes. `accept-edits` auto-runs file edits but still prompts for non-allow-listed Bash (the middle ground between `auto` and `accept-all`); a hard denylist blocks host-destroying commands in every mode | +| Permission system | The prompt is reserved for actions that can change your files, run arbitrary code, or reach outside the session. Auto-approved in `auto` mode: every registry-marked read-only tool, read-only shell commands **and pipelines of them** (`ls \| grep`, `git log \| head`), session-state tools (tasks/memories/skills), and creating a new file inside the workspace. Answering `s` at a prompt grants that one command or file for the session (`/permissions clear` drops grants) — a scoped alternative to `accept-all`. Modes: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`; a hard denylist blocks host-destroying commands in every mode. [Details](security.md#what-runs-without-asking-auto-mode) | | Checkpoints | Auto-snapshot conversation + file state after each turn; `/checkpoint` to list, `/checkpoint ` to rewind; `/rewind` alias; 100-snapshot sliding window | | Plan mode | `/plan ` enters read-only analysis mode; Claude writes only to the plan file; `EnterPlanMode` / `ExitPlanMode` agent tools for autonomous planning | | 50+ slash commands | `/model` · `/config` · `/save` · `/cost` · `/memory` · `/skills` · `/agents` · `/voice` · `/proactive` · `/checkpoint` · `/plan` · `/compact` · `/status` · `/doctor` · `/theme` · … | diff --git a/docs/guides/recipes.md b/docs/guides/recipes.md index 7e7481f..fe18f30 100644 --- a/docs/guides/recipes.md +++ b/docs/guides/recipes.md @@ -138,10 +138,17 @@ for the full list of 100+ supported providers. **When to prefer `custom/` over `litellm/`:** if your endpoint speaks plain OpenAI Chat Completions and accepts a bearer token (vLLM, LM -Studio, TGI, Together, Fireworks, Groq, OpenRouter, …), `custom/` is +Studio, TGI, Together, Fireworks, Groq, …), `custom/` is zero-dependency and zero-config beyond `CUSTOM_BASE_URL`. Reach for `litellm/` only when the auth gymnastics above are the actual blocker. +**OpenRouter has its own provider now** — prefer `openrouter//` +over both `custom/` and `litellm/openrouter/…`: the base URL and key env +(`OPENROUTER_API_KEY`) are built in, the models show up in the `/model` Tab +picker, and it is the only route that supports pinning the upstream provider +with the `@[/]` suffix. See +[usage.md](usage.md#openrouter-400-models-one-key). + --- ## 2. Remote Control via Telegram diff --git a/docs/guides/reference.md b/docs/guides/reference.md index 7d01b37..4482a72 100644 --- a/docs/guides/reference.md +++ b/docs/guides/reference.md @@ -52,7 +52,7 @@ Type `/` and press **Tab** to see all commands with descriptions. Continue typin | `/help` | Show all commands | | `/clear` | Clear conversation history | | `/model` | Show current model + list all available models | -| `/model ` | Switch model (takes effect immediately). Type `/model ` and press **Tab** for a `provider/model` completion picker — one default per provider + a two-level `litellm//` tree (PR #166) | +| `/model ` | Switch model (takes effect immediately). Type `/model ` and press **Tab** for a `provider/model` completion picker — one default per provider + a two-level `litellm//` tree (PR #166). Gateways keep their upstream path, so only the first segment is the provider: `openrouter/deepseek/deepseek-v4-flash`, optionally `@[/]` to pin the upstream (PR #179) | | `/config` | Show all current config values | | `/config key=value` | Set a config value (persisted to disk). v3.5.78+ parses JSON values: `["a","b"]`, `{"k":"v"}`, signed numbers, quoted strings — list/dict configs no longer get silently saved as literal strings. | | `/config context_window=` | Override the context window (tokens) for the session. `0` = use the model's default. Drives the prompt `%` indicator, `/context`, the compaction trigger, **and** the per-call output-token cap — all consistently. Distinct from `max_tokens` (which is the **output** cap, not the window). Bidirectional: a smaller value forces earlier compaction; a larger value corrects a stale default. Read live, so it takes effect on the next prompt (no restart). Warns if set above the model's real window (that would disable compaction and the API may reject oversized prompts). | @@ -320,6 +320,7 @@ export DASHSCOPE_API_KEY=sk-... # Qwen export ZHIPU_API_KEY=... # Zhipu GLM export DEEPSEEK_API_KEY=sk-... # DeepSeek export MINIMAX_API_KEY=... # MiniMax +export OPENROUTER_API_KEY=sk-or-... # OpenRouter (400+ models, one key) ``` #### `.env` file (loaded automatically) @@ -356,6 +357,7 @@ The env var always wins over any persisted value in `~/.cheetahclaws/config.json /config zhipu_api_key=... /config deepseek_api_key=sk-... /config minimax_api_key=... +/config openrouter_api_key=sk-or-... ``` Keys are saved to `~/.cheetahclaws/config.json` and loaded automatically on next launch. @@ -382,7 +384,8 @@ Keys are saved to `~/.cheetahclaws/config.json` and loaded automatically on next "qwen_api_key": "sk-...", "kimi_api_key": "sk-...", "deepseek_api_key": "sk-...", - "minimax_api_key": "..." + "minimax_api_key": "...", + "openrouter_api_key": "sk-or-..." } ``` @@ -390,9 +393,11 @@ Keys are saved to `~/.cheetahclaws/config.json` and loaded automatically on next ## Permission System +The prompt is reserved for actions that can **change your files, run arbitrary code, or reach outside the session**. Everything else runs silently. + | Mode | Behavior | |---|---| -| `auto` (default) | Reads + allow-listed Bash run automatically; prompts before file writes (`Write`/`Edit`) and any other Bash command. | +| `auto` (default) | Every read-only tool and every read-only shell pipeline runs automatically, as does creating a new file inside the workspace; prompts before overwriting/editing files, running arbitrary commands, and spawning sub-agents. | | `accept-edits` | Like `auto`, but also auto-runs file edits (`Write`/`Edit`/`NotebookEdit`); other (non-allow-listed) Bash still prompts. The middle ground between `auto` and `accept-all`. | | `accept-all` | Never prompts; all operations proceed automatically. | | `manual` | Prompts before every single operation, including reads. | @@ -403,15 +408,45 @@ A **hard denylist** (`rm -rf /`, `mkfs`, `dd` to a raw disk device, `chmod -R 77 **When prompted:** ``` - Allow: Run: git commit -am "fix bug" [y/N/a(ccept-all)] + Allow: Run: git commit -am "fix bug" [y/N/s(ession: Bash:git commit)/a(ccept-all)] ``` - `y` — approve this one action - `n` or Enter — deny +- `s` — approve **and stop asking for this one thing** for the rest of the session - `a` — approve and switch to `accept-all` for the rest of the session -**Commands always auto-approved in `auto` mode:** -`ls`, `cat`, `head`, `tail`, `wc`, `pwd`, `echo`, `git status`, `git log`, `git diff`, `git show`, `find`, `grep`, `rg`, `python`, `node`, `pip show`, `npm list`, and other read-only shell commands. +`s` is the scoped alternative to `a`. The grant covers one signature, not the +whole tool surface: `Bash:git commit` covers any `git commit …` but no other +`git` subcommand; `Edit:/repo/app.py` covers repeat edits to that one file but +no other file. Grants live in memory for the session only — never written to +`config.json` — and `/permissions` lists them, `/permissions clear` drops them. + +**Auto-approved in `auto` mode:** + +| Category | Examples | +|---|---| +| Every tool the registry marks read-only | `Read` · `Glob` · `Grep` · `WebFetch` · `WebSearch` · `GetDiagnostics` · `TaskList` · `MemorySearch` · `SkillList` · `ReadPDF` · `SummarizeLargeFile` … | +| Session-state tools (no repo file, no shell, no network) | `TaskCreate` · `TaskUpdate` · `MemorySave` · `Skill` · `SleepTimer` | +| Read-only shell commands **and pipelines of them** | `ls -la \| grep test` · `git log --oneline \| head -20` · `cat` · `wc` · `stat` · `jq` · `rg` · `find` (without `-delete`/`-exec`) · `sed -n` (not `-i`) · `git status/log/diff/show/blame/config --get` · `docker ps` · `kubectl get` · `pip list` · `npm ls` · `curl -I` · any `--version`/`--help` invocation | +| Creating a **new** file inside the workspace | `Write` to a path that does not exist yet, under the working directory (or `allowed_root`) | + +**Still prompts** — overwriting or editing an existing file, writing anywhere +outside the workspace, any dot-prefixed path (`.git/hooks/*`, `.github/workflows/*`, +`.env`), interpreters and build/test runners (`python script.py`, `pytest`, `make`, +`npm run`), anything that writes/deletes/uploads (`rm`, `git push`, `pip install`, +`curl -o`), shell redirection (`>`), backgrounding (`&`), command substitution +(`` ` ``, `$(…)`), sub-agent spawns (`Agent`), and any unclassified MCP/plugin tool. + +**Tuning it:** + +```bash +/config bash_safe_extra=["bazel-query","./scripts/status"] # extra read-only programs +/config auto_create_files=false # review new files too +/permissions accept-edits # stop asking for edits +/permissions manual # ask for everything +/permissions clear # drop session grants +``` --- diff --git a/docs/guides/security.md b/docs/guides/security.md index 1fa7eb6..ec82faf 100644 --- a/docs/guides/security.md +++ b/docs/guides/security.md @@ -150,6 +150,53 @@ A dropped key prints a one-line `[mcp] Dropped potentially-dangerous env keys …` notice to stderr. Set `CHEETAHCLAWS_MCP_TRUST_ENV=1` if a legitimate MCP server actually needs one of these. +## What runs without asking (`auto` mode) + +The permission prompt exists for actions that can **change your files, run +arbitrary code, or reach outside the session**. Everything else runs silently, +because a prompt a user always answers "yes" to trains them to stop reading it +— and a user who reaches for `accept-all` out of prompt fatigue ends up with +*less* protection than one who is asked only about the things that matter. + +Auto-approval is decided by classification, never by string prefix: + +- **Tools** — `ToolDef.read_only` in the registry. Reads, searches, document + extraction, diagnostics, task/memory queries. Anything unclassified + (MCP servers, third-party plugins) defaults to prompting. +- **Shell** — the command is parsed with `shlex`, split on `|`, `&&`, `||`, + `;`, and *every* segment must be a known read-only invocation. Output + redirection (`>`), backgrounding (`&`), subshells, and command substitution + (`` ` ``, `$(…)`, `<(…)`) are refused outright, since each can act — or hide + a second command — after a safe-looking prefix. +- **Writes** — creating a *new* file inside the workspace destroys nothing and + runs; overwriting an existing file, writing outside the workspace, or + creating any dot-prefixed path (`.git/hooks/*`, `.github/workflows/*`, + `.env` — locations other tools execute or trust) still prompts. + +Two things are deliberately **not** auto-approved even though they read as +routine: interpreters (`python`, `node`, `ruby`, `perl`, `sh`) and anything +that runs a program it is handed (`xargs`, `env VAR=… cmd`, `timeout`, `make`, +`npm run`, `pytest`). `python script.py` is indistinguishable from +`python -c 'os.system("…")'` at the permission layer, so both ask. Their +`--version`/`--help` forms still run silently — those execute nothing. + +> Earlier versions matched a list of *prefixes* and auto-ran anything starting +> with `python `, `node `, `ruby `, `perl `, or `find ` — which auto-approved +> arbitrary code execution and `find … -delete`. Those are now classified +> properly and prompt. + +**Session grants.** Answering `s` at a prompt grants one signature +(`Bash:git commit`, `Edit:/repo/app.py`) for the rest of the session. Grants +are held on the in-memory `RuntimeContext`, never persisted, dropped when the +session ends, ignored entirely in `manual` mode, and clearable with +`/permissions clear`. Prefer it to `accept-all`: it removes the repeat prompt +without removing the gate on everything else. + +**Extending the allowlist.** `/config bash_safe_extra=["your-query-tool"]` +adds project-specific read-only programs. Only add a program that cannot +write, delete, or execute another program — the entry bypasses the prompt for +every invocation of it, whatever its arguments. + ## Permission mode `accept-all` `/permissions accept-all` (or clicking "Accept all" at the permission diff --git a/docs/guides/usage.md b/docs/guides/usage.md index 6bec7fa..8648da4 100644 --- a/docs/guides/usage.md +++ b/docs/guides/usage.md @@ -110,6 +110,53 @@ cheetahclaws --model minimax/MiniMax-VL-01 cheetahclaws --model minimax/abab6.5s-chat ``` +### OpenRouter (400+ models, one key) + +[OpenRouter](https://openrouter.ai) fronts every major vendor behind one +OpenAI-compatible endpoint, so a single key reaches Claude, GPT, Gemini, +DeepSeek, Llama, Qwen and ~400 more. Get a key at +[openrouter.ai/keys](https://openrouter.ai/keys). + +```bash +export OPENROUTER_API_KEY=sk-or-... # or: /config openrouter_api_key=sk-or-... + +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash +cheetahclaws --model openrouter/anthropic/claude-sonnet-4-6 +cheetahclaws --model openrouter/openai/gpt-5 +``` + +**The model ID is double-prefixed.** OpenRouter addresses models by an +upstream `/` path, so the first segment (`openrouter`) is the +provider and *everything after it* is passed through verbatim as the model +ID. `openrouter/deepseek/deepseek-v4-flash` therefore sends +`deepseek/deepseek-v4-flash` — which is exactly what OpenRouter's catalog +lists. (`nim/` works the same way.) + +**Pinning the upstream provider / quantization.** OpenRouter picks a +secondary provider for you by default. To force one, append +`@[/]` to the model: + +```bash +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@gmicloud # this provider only +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8 # + quantization +cheetahclaws --model openrouter/deepseek/deepseek-v4-flash@fp8 # quantization only +``` + +The suffix is **split off before the request** and sent as OpenRouter's +`provider` routing object — the model field always stays a real catalog ID +(gluing the provider into the model ID makes OpenRouter reject the call as +an unknown model). Accepted quantizations: `fp4` · `fp8` · `int4` · `int8`. +Pinning a provider also sets `allow_fallbacks: false`, so a request fails +loudly instead of silently landing on a different upstream — drop the +suffix if you would rather have OpenRouter reroute around an outage. + +> **Cost tracking:** `/cost` and the `/quota` budget price OpenRouter usage +> from the same per-model table as direct calls, so a model CheetahClaws +> already knows (`deepseek-v4-flash`, `claude-sonnet-4-6`, …) is billed the +> same whichever route it takes. A model with no price entry still records +> tokens but estimates $0 — set a **token** budget rather than a dollar one +> if you rely on hard caps for exotic models. + ### LiteLLM (AWS Bedrock / Azure / Vertex AI) Use the `litellm/` prefix when the upstream needs auth that's painful to diff --git a/docs/i18n/README.CN.MD b/docs/i18n/README.CN.MD index 6961177..0b6d148 100644 --- a/docs/i18n/README.CN.MD +++ b/docs/i18n/README.CN.MD @@ -41,6 +41,7 @@ cheetahclaws # start chatting! ## 🔥🔥🔥 新闻(太平洋时间) +- 2026 年 8 月 16 日(**v3.5.87**):**权限确认只留给真正需要你做决定的操作,外加 OpenRouter 成为一等公民供应商。**`auto` 模式的判定重写为一条规则:只有可能**改动你的文件、执行任意代码、或触及会话之外**的动作才询问。现在静默执行的包括:注册表中标记为只读的**全部**工具(比之前多 18 个——诊断、任务/记忆查询、文档读取等)、只读 shell **管道**(`git log | head -20`、`ls -la | grep test`——旧实现只要含 `|` 就一律询问)、会话状态类工具(任务/记忆/技能),以及在工作区内**新建**文件。仍然询问:覆盖已有文件、写到工作区外、`.git/hooks` 与 `.github/workflows` 等路径、解释器与构建/测试运行器、任何删除或上传动作、以及子 agent 生成。确认框新增 **`s`**:批准并且**这一条命令或这一个文件**本会话不再问——accept-all 的有作用域替代品(`/permissions clear` 可清空)。shell 判定也从前缀匹配换成了真正的解析,顺带堵掉了一个洞:旧词表会把 `python `、`node `、`find ` 开头的命令直接放行,等于自动执行任意代码。**OpenRouter(PR #179)**:`/model openrouter//` 一把 key 直达 400+ 模型,可用 `@[/]` 固定上游提供商;随之修复了网关模型串带来的四个路由问题(provider 误判、成本记 0、上下文窗口一刀切 128k、量化后缀吃掉 prompt overlay)。[详情](../news.md) - 2026 年 7 月 30 日(**v3.5.86**):**输入框「幽灵提示」—— REPL 预测你下一句要输入的内容。** 每轮回答结束后,由**辅助(便宜/快速)模型**草拟你最可能输入的下一句,以浅灰斜体显示在提示符里:按 **Tab**(或 **→**)完整填入,直接打字即覆盖,只按回车不会提交它。草拟在后台线程进行,不阻塞 REPL;任何失败都静默处理(没有辅助模型 / 没有 API key / 供应商故障 → 只是不显示提示);每条提示只对当前这一个提示符有效,不会残留成过期建议。关闭方式:`/config input_suggest=false` 或 `CHEETAH_SUGGEST=0`。本版本还修复了 **Remote-SSH / WSL / devcontainer 下终端标签标题无法自动配置**的问题:此前它把设置写进服务器端一个 VS Code 永远不会读的文件并从此不再重试,现在会写入窗口真正读取的远端 Machine 设置。本版本也是首个包含 7 月 11 日(终端标签标题 + Anthropic 提示缓存修复)与 7 月 20 日(`tool_profile` + bounded-I/O 修复)改动的正式版本。[详情](../news.md) - 2026 年 7 月 9 日:**官方 Docker 镜像 + 一条命令发布。** Docker Hub 上提供预构建镜像(`docker pull chauncygu/cheetahclaws`),无需克隆即可运行 Web UI;修复了首次运行时的 `PermissionError`,方法是预先创建由非 root 用户所有的 `.cheetahclaws`/`workspace` 目录,使 compose 的 `image` 可通过 `CHEETAH_IMAGE` 覆盖,并新增 `scripts/docker-publish.sh`(自动读取版本,支持多/单架构)。新增文档章节:**从 Docker Hub 拉取** 与 **交互式设置 / CLI 模式**。[详情](../news.md) - 2026 年 7 月 8 日:新增 **`/workspace`** 命令,用于管理 `~/.cheetahclaws/workspaces` 下的隔离工作目录(`list`/`switch`/`default`/`create`/`delete`)(PR #162);启动时自动切换现为通过 `workspace_auto` **可选开启**(默认关闭,因此在项目目录中启动的行为保持不变),且 `default` 现在是一个独立于「最近使用」的固定键。[详情](../news.md) @@ -177,7 +178,7 @@ Claude Code 是一款强大的、生产级的 AI 编码助手 —— 但它的 | 上下文压缩 | 四个协同层 —— 动态 `max_tokens` 上限、按模型的上下文窗口注册表、在 70% 时的两层裁剪 + AI 摘要,以及对超大工具输出的自动 fanout。[详情](../guides/reference.md) | | 持久记忆 | 双作用域(用户 + 项目)、4 种类型、置信度/来源元数据、冲突检测、按新近度加权的搜索、`/memory consolidate`。基于验证锚定的陈旧度 —— 新鲜度追踪 `last_verified` 日期(而非文件 mtime),因此读取一条记忆无法伪造刷新它;只有 `MemoryVerify` 能重置该计时。[详情](../guides/features.md) | | 多 Agent | 派生有类型的子 agent(coder/reviewer/researcher/…)、git-worktree 隔离、后台模式 | -| 权限系统 | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` 模式(`accept-edits` = 自动执行编辑,但对其他 Bash 仍会询问;硬性拒绝列表在所有模式下都会阻止会毁坏主机的命令) | +| 权限系统 | 只有可能改动你的文件、执行任意代码、或触及会话之外的动作才会询问 —— 所有只读工具、只读 shell 管道(`git log \| head`)、以及在工作区内新建文件都静默执行。在确认框按 `s` 可将**这一条命令或这一个文件**授权到本会话结束(accept-all 的有作用域替代品,`/permissions clear` 清空)。模式:`auto` / `accept-edits` / `accept-all` / `manual` / `plan`;硬性拒绝列表在所有模式下都会阻止会毁坏主机的命令。[详情](../guides/security.md#what-runs-without-asking-auto-mode) | | 检查点与 plan 模式 | 每一轮自动快照对话 + 文件(`/checkpoint`、`/rewind`);`/plan` 只读分析模式 | | 斜杠命令与主题 | 50+ 个带 Tab 补全的斜杠命令;`/theme` 提供 15 套精选配色 | | 下一句输入预测(幽灵文字) | 每轮结束后由辅助(便宜)模型草拟你最可能输入的下一句,浅色显示在提示符里 —— **Tab**(或 **→**)完整填入,直接打字即忽略。后台草拟,不阻塞 REPL,失败静默。可通过 `/config input_suggest=false` 或 `CHEETAH_SUGGEST=0` 关闭。[详情](../guides/reference.md#next-prompt-ghost-text) | @@ -210,8 +211,11 @@ Claude Code 是一款强大的、生产级的 AI 编码助手 —— 但它的 | **智谱 (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash`(免费层) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ 模型,一把 key)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | 视情况而定 | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(通过 litellm)_ | `litellm//` | 视情况而定 | 特定于提供商 | +> **`openrouter/` 网关:** 一把 key 覆盖跨厂商的 400+ 模型。模型 ID 保留 OpenRouter 上游的 `/` 路径,因此调用是双层前缀的:`openrouter/deepseek/deepseek-v4-flash`。若要指定由哪个上游提供商(及量化精度)来服务该请求,在模型后追加 `@[/]` —— `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` —— 它会作为 OpenRouter 的 `provider` 请求体对象发送,而不是拼进模型 ID。参见 [usage.md](../guides/usage.md#openrouter-400-models-one-key)。 + > **`litellm/` 适配器:** 在单一 SDK 后路由到 100+ 提供商 —— 主要用于认证方式较棘手的上游(Bedrock SigV4、Azure 部署路由、Vertex 服务账号 JWT)。对于普通的 OpenAI 形态端点,优先使用零依赖的 `custom/` 适配器。用 `pip install ".[litellm]"` 安装。参见 [recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider)。 ### 开源(通过 Ollama 本地运行) diff --git a/docs/i18n/README.DE.MD b/docs/i18n/README.DE.MD index d99c1bc..496b9bc 100644 --- a/docs/i18n/README.DE.MD +++ b/docs/i18n/README.DE.MD @@ -176,7 +176,7 @@ Claude Code ist ein leistungsstarker, produktionsreifer KI-Coding-Assistent – | Kontextkompression | Vier zusammenwirkende Schichten – dynamische `max_tokens`-Obergrenze, modellspezifisches Kontextfenster-Register, zweischichtiges Kürzen + KI-Zusammenfassung bei 70 %, und automatisches Fanout für übergroße Werkzeugausgaben. [Details](../guides/reference.md) | | Persistenter Speicher | Dualer Geltungsbereich (Benutzer + Projekt), 4 Typen, Konfidenz-/Quellen-Metadaten, Konflikterkennung, aktualitätsgewichtete Suche, `/memory consolidate`. Verifizierungsverankerte Veralterung – Aktualität orientiert sich an einem `last_verified`-Datum (nicht an der Datei-mtime), sodass das Lesen eines Speichers ihn nicht scheinbar auffrischen kann; nur `MemoryVerify` setzt die Uhr zurück. [Details](../guides/features.md) | | Multi-Agent | Erzeuge typisierte Sub-Agenten (coder/reviewer/researcher/…), Git-Worktree-Isolation, Hintergrundmodus | -| Berechtigungssystem | `auto` / `accept-edits` / `accept-all` / `manual` / `plan`-Modi (`accept-edits` = Bearbeitungen automatisch ausführen, für anderes Bash weiterhin nachfragen; harte Denylist blockiert hostzerstörende Befehle in jedem Modus) | +| Berechtigungssystem | Gefragt wird nur bei Aktionen, die deine Dateien ändern, beliebigen Code ausführen oder über die Sitzung hinausreichen können. Alle als read-only markierten Tools, read-only Shell-Pipelines (`git log \| head`) und das Anlegen einer **neuen** Datei im Workspace laufen ohne Rückfrage. Mit `s` an der Eingabeaufforderung erlaubst du genau **diesen einen Befehl bzw. diese eine Datei** für den Rest der Sitzung – die begrenzte Alternative zu accept-all (`/permissions clear` verwirft sie). Modi: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`; eine harte Sperrliste blockiert hostzerstörende Befehle in jedem Modus. [Details](../guides/security.md#what-runs-without-asking-auto-mode) | | Checkpoints & Plan-Modus | Automatischer Snapshot von Konversation + Dateien in jeder Runde (`/checkpoint`, `/rewind`); `/plan` schreibgeschützter Analysemodus | | Slash-Befehle & Themes | 50+ Slash-Befehle mit Tab-Vervollständigung; `/theme` bietet 15 kuratierte Paletten | | Brainstorm → Worker | `/brainstorm` führt eine Debatte mit N Personas durch → `todo_list.txt`; `/worker` implementiert die ausstehenden Aufgaben automatisch | @@ -208,8 +208,11 @@ Claude Code ist ein leistungsstarker, produktionsreifer KI-Coding-Assistent – | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (kostenlose Stufe) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ Modelle, ein Schlüssel)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | variiert | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(über litellm)_ | `litellm//` | variiert | anbieterspezifisch | +> **`openrouter/`-Gateway:** ein Schlüssel für über 400 Modelle verschiedener Anbieter. Die Modell-ID behält den Upstream-Pfad `/` von OpenRouter bei, der Aufruf ist also doppelt präfixiert: `openrouter/deepseek/deepseek-v4-flash`. Um festzulegen, welcher Upstream-Anbieter (und welche Quantisierung) die Anfrage bedient, hänge `@[/]` an — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — das als `provider`-Objekt im Request-Body gesendet wird, statt in die Modell-ID eingebaut zu werden. Siehe [usage.md](../guides/usage.md#openrouter-400-models-one-key). + > **`litellm/`-Adapter:** leitet zu über 100 Anbietern hinter einem SDK weiter – hauptsächlich für Upstreams mit umständlicher Authentifizierung (Bedrock SigV4, Azure-Deployment-Routing, Vertex-Service-Account-JWTs). Für einfache OpenAI-förmige Endpunkte ist der abhängigkeitsfreie `custom/`-Adapter vorzuziehen. Installiere mit `pip install ".[litellm]"`. Siehe [recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider). ### Open-Source (lokal über Ollama) diff --git a/docs/i18n/README.ES.MD b/docs/i18n/README.ES.MD index e81ba1b..8e5b4cd 100644 --- a/docs/i18n/README.ES.MD +++ b/docs/i18n/README.ES.MD @@ -176,7 +176,7 @@ Claude Code es un asistente de programación con IA potente y de nivel de produc | Compresión de contexto | Cuatro capas cooperantes — límite dinámico de `max_tokens`, registro de ventanas de contexto por modelo, recorte de dos capas + resumen por IA al 70%, y auto-fanout para salidas de herramientas sobredimensionadas. [Detalles](../guides/reference.md) | | Memoria persistente | Doble ámbito (usuario + proyecto), 4 tipos, metadatos de confianza/origen, detección de conflictos, búsqueda ponderada por recencia, `/memory consolidate`. Caducidad anclada a verificación — la frescura sigue una fecha `last_verified` (no el mtime del archivo), de modo que leer una memoria no puede refrescarla falsamente; solo `MemoryVerify` reinicia el reloj. [Detalles](../guides/features.md) | | Multi-agente | Genera subagentes tipados (coder/reviewer/researcher/…), aislamiento con git-worktree, modo en segundo plano | -| Sistema de permisos | Modos `auto` / `accept-edits` / `accept-all` / `manual` / `plan` (`accept-edits` = ejecuta ediciones automáticamente, sigue preguntando por otros Bash; una lista de denegación estricta bloquea comandos destructivos del host en todos los modos) | +| Sistema de permisos | Solo pregunta por acciones que puedan cambiar tus archivos, ejecutar código arbitrario o salir de la sesión. Todas las herramientas de solo lectura, las tuberías de shell de solo lectura (`git log \| head`) y la creación de un archivo **nuevo** dentro del espacio de trabajo se ejecutan sin preguntar. Pulsar `s` en el aviso concede **ese único comando o ese único archivo** durante el resto de la sesión: la alternativa acotada a accept-all (`/permissions clear` las descarta). Modos: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`; una lista de denegación estricta bloquea comandos destructivos en todos los modos. [Detalles](../guides/security.md#what-runs-without-asking-auto-mode) | | Puntos de control y modo plan | Instantánea automática de la conversación + archivos en cada turno (`/checkpoint`, `/rewind`); modo de análisis de solo lectura `/plan` | | Comandos slash y temas | 50+ comandos slash con autocompletado por Tab; `/theme` ofrece 15 paletas seleccionadas | | Brainstorm → Worker | `/brainstorm` ejecuta un debate de N personas → `todo_list.txt`; `/worker` implementa automáticamente las tareas pendientes | @@ -208,8 +208,11 @@ Claude Code es un asistente de programación con IA potente y de nivel de produc | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (nivel gratuito) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ modelos, una sola clave)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | varía | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(vía litellm)_ | `litellm//` | varía | específico del proveedor | +> **Pasarela `openrouter/`:** una sola clave para más de 400 modelos de distintos proveedores. El ID del modelo conserva la ruta upstream `/` de OpenRouter, así que la llamada lleva doble prefijo: `openrouter/deepseek/deepseek-v4-flash`. Para fijar qué proveedor upstream (y qué cuantización) atiende la petición, añade `@[/]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — que se envía como el objeto `provider` del cuerpo de la petición en lugar de pegarse al ID del modelo. Consulta [usage.md](../guides/usage.md#openrouter-400-models-one-key). + > **Adaptador `litellm/`:** enruta a más de 100 proveedores tras un único SDK — principalmente para upstreams con autenticación incómoda (Bedrock SigV4, enrutamiento de despliegues de Azure, JWT de cuentas de servicio de Vertex). Para endpoints con forma OpenAI simple, prefiere el adaptador `custom/` sin dependencias. Instala con `pip install ".[litellm]"`. Consulta [recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider). ### Código abierto (local vía Ollama) diff --git a/docs/i18n/README.FR.MD b/docs/i18n/README.FR.MD index 970d3b3..d1317fb 100644 --- a/docs/i18n/README.FR.MD +++ b/docs/i18n/README.FR.MD @@ -176,7 +176,7 @@ Claude Code est un assistant de codage IA puissant et de qualité production — | Compression de contexte | Quatre couches coopérantes — plafond dynamique de `max_tokens`, registre de fenêtre de contexte par modèle, découpage à deux couches + résumé par IA à 70 %, et éclatement automatique pour les sorties d'outils surdimensionnées. [Détails](../guides/reference.md) | | Mémoire persistante | Double portée (utilisateur + projet), 4 types, métadonnées de confiance/source, détection de conflits, recherche pondérée par ancienneté, `/memory consolidate`. Obsolescence ancrée sur la vérification — la fraîcheur suit une date `last_verified` (et non le mtime du fichier), de sorte que lire une mémoire ne peut pas la rafraîchir faussement ; seul `MemoryVerify` réinitialise l'horloge. [Détails](../guides/features.md) | | Multi-agent | Créer des sous-agents typés (coder/reviewer/researcher/…), isolation par git-worktree, mode arrière-plan | -| Système de permissions | Modes `auto` / `accept-edits` / `accept-all` / `manual` / `plan` (`accept-edits` = exécution automatique des modifications, tout en demandant pour les autres Bash ; une liste noire stricte bloque les commandes destructrices pour l'hôte dans tous les modes) | +| Système de permissions | La confirmation n'est demandée que pour les actions pouvant modifier vos fichiers, exécuter du code arbitraire ou sortir de la session. Tous les outils en lecture seule, les pipelines shell en lecture seule (`git log \| head`) et la création d'un **nouveau** fichier dans l'espace de travail s'exécutent sans invite. Répondre `s` accorde **cette seule commande ou ce seul fichier** jusqu'à la fin de la session — l'alternative circonscrite à accept-all (`/permissions clear` les supprime). Modes : `auto` / `accept-edits` / `accept-all` / `manual` / `plan` ; une liste de refus stricte bloque les commandes destructrices dans tous les modes. [Détails](../guides/security.md#what-runs-without-asking-auto-mode) | | Points de contrôle & mode plan | Instantané automatique de la conversation + fichiers à chaque tour (`/checkpoint`, `/rewind`) ; mode d'analyse en lecture seule `/plan` | | Commandes slash & thèmes | 50+ commandes slash avec complétion par Tab ; `/theme` propose 15 palettes soignées | | Brainstorm → Worker | `/brainstorm` lance un débat à N personas → `todo_list.txt` ; `/worker` implémente automatiquement les tâches en attente | @@ -208,8 +208,11 @@ Claude Code est un assistant de codage IA puissant et de qualité production — | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (niveau gratuit) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ modèles, une seule clé)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | variable | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(via litellm)_ | `litellm//` | variable | spécifique au fournisseur | +> **Passerelle `openrouter/` :** une seule clé pour plus de 400 modèles, tous fournisseurs confondus. L'ID du modèle conserve le chemin upstream `/` d'OpenRouter, l'appel porte donc un double préfixe : `openrouter/deepseek/deepseek-v4-flash`. Pour imposer le fournisseur upstream (et la quantification) qui traite la requête, ajoutez `@[/]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — envoyé comme objet `provider` dans le corps de la requête plutôt que collé à l'ID du modèle. Voir [usage.md](../guides/usage.md#openrouter-400-models-one-key). + > **Adaptateur `litellm/` :** achemine vers 100+ fournisseurs derrière un seul SDK — principalement pour les upstreams à l'authentification délicate (SigV4 de Bedrock, routage de déploiement Azure, JWT de compte de service Vertex). Pour les points de terminaison standard au format OpenAI, préférez l'adaptateur `custom/` sans dépendance. Installez avec `pip install ".[litellm]"`. Voir [recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider). ### Open-source (local via Ollama) diff --git a/docs/i18n/README.JP.MD b/docs/i18n/README.JP.MD index 640a8b8..1d6db8b 100644 --- a/docs/i18n/README.JP.MD +++ b/docs/i18n/README.JP.MD @@ -176,7 +176,7 @@ Claude Code は強力で本番グレードの AI コーディングアシスタ | コンテキスト圧縮 | 協調する4つのレイヤー — 動的な `max_tokens` の上限、モデルごとのコンテキストウィンドウレジストリ、70% で2層の切り取り + AI 要約、そして肥大化したツール出力の自動ファンアウト。[詳細](../guides/reference.md) | | 永続メモリ | デュアルスコープ(ユーザー + プロジェクト)、4種類、信頼度/ソースのメタデータ、競合検出、新しさ重み付け検索、`/memory consolidate`。検証を基準とした陳腐化 — 鮮度は(ファイルの mtime ではなく)`last_verified` の日付を追跡するため、メモリを読むだけでは偽って更新できません。`MemoryVerify` だけがタイマーをリセットします。[詳細](../guides/features.md) | | マルチエージェント | 型付きサブエージェント(coder/reviewer/researcher/…)を起動、git-worktree による分離、バックグラウンドモード | -| 権限システム | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` モード(`accept-edits` = 編集は自動実行、他の Bash は引き続き確認。ハードな拒否リストがあらゆるモードでホスト破壊コマンドをブロック) | +| 権限システム | 確認を求めるのは、ファイルを変更する・任意のコードを実行する・セッションの外に到達する可能性のある操作だけです。読み取り専用ツール、読み取り専用のシェルパイプライン(`git log \| head`)、ワークスペース内での新規ファイル作成はすべて確認なしで実行されます。プロンプトで `s` を選ぶと、**そのコマンド 1 つ/そのファイル 1 つ**だけをセッション中に限り許可できます(accept-all のスコープ付き代替、`/permissions clear` で解除)。モード: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`。ハードな拒否リストはあらゆるモードでホスト破壊コマンドをブロックします。[詳細](../guides/security.md#what-runs-without-asking-auto-mode) | | チェックポイント & プランモード | ターンごとに会話 + ファイルを自動スナップショット(`/checkpoint`、`/rewind`)。`/plan` は読み取り専用の分析モード | | スラッシュコマンド & テーマ | Tab 補完付きの50以上のスラッシュコマンド。`/theme` は15の厳選パレットを提供 | | Brainstorm → Worker | `/brainstorm` は N ペルソナの討論を実行 → `todo_list.txt`。`/worker` は保留中のタスクを自動実装 | @@ -208,8 +208,11 @@ Claude Code は強力で本番グレードの AI コーディングアシスタ | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash`(無料枠) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ モデル、キー1つ)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | 様々 | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(litellm 経由)_ | `litellm//` | 様々 | プロバイダー固有 | +> **`openrouter/` ゲートウェイ:** 1つのキーでベンダーをまたぐ400以上のモデルにアクセスできます。モデル ID は OpenRouter のアップストリーム `/` パスをそのまま保持するため、呼び出しは二重プレフィックスになります: `openrouter/deepseek/deepseek-v4-flash`。どのアップストリームプロバイダー(および量子化)がリクエストを処理するかを固定するには、`@[/]` を付加します — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — これはモデル ID に連結されるのではなく、OpenRouter の `provider` リクエストボディオブジェクトとして送信されます。[usage.md](../guides/usage.md#openrouter-400-models-one-key) を参照。 + > **`litellm/` アダプター:** 1つの SDK の背後で100以上のプロバイダーにルーティングします — 主に扱いにくい認証を持つアップストリーム(Bedrock SigV4、Azure のデプロイメントルーティング、Vertex のサービスアカウント JWT)向けです。素の OpenAI 形式のエンドポイントには、依存関係ゼロの `custom/` アダプターを推奨します。`pip install ".[litellm]"` でインストールしてください。[recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider) を参照。 ### オープンソース(Ollama 経由のローカル) diff --git a/docs/i18n/README.KO.MD b/docs/i18n/README.KO.MD index 9743b32..7d045ae 100644 --- a/docs/i18n/README.KO.MD +++ b/docs/i18n/README.KO.MD @@ -176,7 +176,7 @@ Claude Code는 강력한 프로덕션 등급 AI 코딩 어시스턴트입니다 | 컨텍스트 압축 | 협력하는 네 개의 계층 — 동적 `max_tokens` 상한, 모델별 컨텍스트 윈도우 레지스트리, 70%에서의 2계층 축약 + AI 요약, 과대 도구 출력에 대한 자동 팬아웃. [자세히](../guides/reference.md) | | 영속 메모리 | 이중 스코프(사용자 + 프로젝트), 4가지 유형, 신뢰도/출처 메타데이터, 충돌 감지, 최신성 가중 검색, `/memory consolidate`. 검증 기준 노후화 — 최신성은 파일 mtime이 아닌 `last_verified` 날짜를 추적하므로, 메모리를 읽는 것만으로 거짓 갱신할 수 없고 `MemoryVerify`만이 시계를 재설정합니다. [자세히](../guides/features.md) | | 멀티 에이전트 | 유형화된 서브 에이전트(coder/reviewer/researcher/…) 생성, git-worktree 격리, 백그라운드 모드 | -| 권한 시스템 | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` 모드 (`accept-edits` = 편집은 자동 실행하되 다른 Bash는 확인; 하드 거부 목록이 모든 모드에서 호스트 파괴 명령을 차단) | +| 권한 시스템 | 파일을 변경하거나, 임의의 코드를 실행하거나, 세션 밖에 접근할 수 있는 동작에 대해서만 묻습니다. 읽기 전용 도구, 읽기 전용 셸 파이프라인(`git log \| head`), 작업 공간 안에서의 새 파일 생성은 모두 조용히 실행됩니다. 프롬프트에서 `s`를 선택하면 **그 명령 하나 또는 그 파일 하나**만 세션 동안 허용합니다(accept-all의 범위 제한 대안, `/permissions clear`로 해제). 모드: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`. 하드 거부 목록은 모든 모드에서 호스트 파괴 명령을 차단합니다. [자세히](../guides/security.md#what-runs-without-asking-auto-mode) | | 체크포인트 & 플랜 모드 | 매 턴 대화 + 파일 자동 스냅샷(`/checkpoint`, `/rewind`); `/plan` 읽기 전용 분석 모드 | | 슬래시 명령 & 테마 | Tab 자동 완성이 되는 50개 이상의 슬래시 명령; `/theme`가 엄선된 15개 팔레트 제공 | | Brainstorm → Worker | `/brainstorm`이 N-페르소나 토론을 실행 → `todo_list.txt`; `/worker`가 대기 중인 작업을 자동 구현 | @@ -208,8 +208,11 @@ Claude Code는 강력한 프로덕션 등급 AI 코딩 어시스턴트입니다 | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (무료 티어) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ 모델, 키 하나)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | 다양 | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(litellm 경유)_ | `litellm//` | 다양 | 프로바이더별 상이 | +> **`openrouter/` 게이트웨이:** 키 하나로 여러 벤더의 400개 이상 모델을 사용합니다. 모델 ID는 OpenRouter의 업스트림 `/` 경로를 그대로 유지하므로 호출은 접두사가 두 겹입니다: `openrouter/deepseek/deepseek-v4-flash`. 어떤 업스트림 프로바이더(및 양자화)가 요청을 처리할지 고정하려면 `@[/]`를 덧붙이세요 — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — 이는 모델 ID에 붙는 대신 OpenRouter의 `provider` 요청 본문 객체로 전송됩니다. [usage.md](../guides/usage.md#openrouter-400-models-one-key) 참고. + > **`litellm/` 어댑터:** 하나의 SDK 뒤에서 100개 이상의 프로바이더로 라우팅합니다 — 주로 까다로운 인증이 필요한 업스트림(Bedrock SigV4, Azure 배포 라우팅, Vertex 서비스 계정 JWT)을 위한 것입니다. 일반적인 OpenAI 형태의 엔드포인트에는 의존성이 없는 `custom/` 어댑터를 선호하세요. `pip install ".[litellm]"`로 설치합니다. [recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider) 참고. ### 오픈소스 (Ollama 경유 로컬) diff --git a/docs/i18n/README.PT.MD b/docs/i18n/README.PT.MD index c1f632a..13a63bf 100644 --- a/docs/i18n/README.PT.MD +++ b/docs/i18n/README.PT.MD @@ -176,7 +176,7 @@ O Claude Code é um assistente de IA para codificação poderoso e de nível de | Compressão de contexto | Quatro camadas cooperantes — limite dinâmico de `max_tokens`, registro de janela de contexto por modelo, corte em duas camadas + resumo por IA a 70%, e auto-fanout para saídas de ferramenta grandes demais. [Detalhes](../guides/reference.md) | | Memória persistente | Duplo escopo (usuário + projeto), 4 tipos, metadados de confiança/origem, detecção de conflitos, busca ponderada por recência, `/memory consolidate`. Obsolescência ancorada em verificação — a atualidade rastreia uma data `last_verified` (não o mtime do arquivo), de modo que ler uma memória não pode falsamente atualizá-la; apenas `MemoryVerify` reinicia o relógio. [Detalhes](../guides/features.md) | | Multiagente | Cria subagentes tipados (coder/reviewer/researcher/…), isolamento por git-worktree, modo em segundo plano | -| Sistema de permissões | Modos `auto` / `accept-edits` / `accept-all` / `manual` / `plan` (`accept-edits` = executa edições automaticamente, ainda pergunta para outros comandos Bash; uma denylist rígida bloqueia comandos destrutivos ao host em todos os modos) | +| Sistema de permissões | Só pergunta em ações que possam alterar seus arquivos, executar código arbitrário ou sair da sessão. Todas as ferramentas somente-leitura, pipelines de shell somente-leitura (`git log \| head`) e a criação de um arquivo **novo** dentro do workspace rodam sem perguntar. Responder `s` concede **aquele comando ou aquele arquivo** pelo resto da sessão — a alternativa delimitada ao accept-all (`/permissions clear` descarta). Modos: `auto` / `accept-edits` / `accept-all` / `manual` / `plan`; uma lista de negação rígida bloqueia comandos destrutivos em todos os modos. [Detalhes](../guides/security.md#what-runs-without-asking-auto-mode) | | Checkpoints e modo plano | Snapshot automático da conversa + arquivos a cada turno (`/checkpoint`, `/rewind`); modo de análise somente leitura `/plan` | | Comandos slash e temas | 50+ comandos slash com autocompletar por Tab; `/theme` oferece 15 paletas selecionadas | | Brainstorm → Worker | `/brainstorm` executa um debate de N personas → `todo_list.txt`; `/worker` implementa automaticamente as tarefas pendentes | @@ -208,8 +208,11 @@ O Claude Code é um assistente de IA para codificação poderoso e de nível de | **Zhipu (GLM)** | `glm-4-plus` · `glm-4` · `glm-4-flash` (nível gratuito) | 128k | `ZHIPU_API_KEY` | | **DeepSeek** | `deepseek-chat` · `deepseek-reasoner` | 64k | `DEEPSEEK_API_KEY` | | **MiniMax** | `MiniMax-Text-01` · `MiniMax-VL-01` · `abab6.5s-chat` | 256k–1M | `MINIMAX_API_KEY` | +| **OpenRouter** _(400+ modelos, uma única chave)_ | `openrouter/deepseek/deepseek-v4-flash` · `openrouter/anthropic/claude-sonnet-4-6` · `openrouter/openai/gpt-5` | varia | `OPENROUTER_API_KEY` | | **AWS Bedrock / Azure / Vertex** _(via litellm)_ | `litellm//` | varia | específico do provedor | +> **Gateway `openrouter/`:** uma única chave para mais de 400 modelos de vários fornecedores. O ID do modelo mantém o caminho upstream `/` do OpenRouter, então a chamada tem prefixo duplo: `openrouter/deepseek/deepseek-v4-flash`. Para fixar qual fornecedor upstream (e qual quantização) atende a requisição, acrescente `@[/]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8` — que é enviado como o objeto `provider` no corpo da requisição, em vez de ser colado ao ID do modelo. Veja [usage.md](../guides/usage.md#openrouter-400-models-one-key). + > **Adaptador `litellm/`:** roteia para mais de 100 provedores por trás de um único SDK — principalmente para upstreams com autenticação complicada (SigV4 do Bedrock, roteamento de deployment do Azure, JWTs de service-account do Vertex). Para endpoints simples no formato OpenAI, prefira o adaptador `custom/` sem dependências. Instale com `pip install ".[litellm]"`. Veja [recipes.md](../guides/recipes.md#alternative-cloud-providers-with-non-trivial-auth-via-the-litellm-provider). ### Open-Source (local via Ollama) diff --git a/docs/news.md b/docs/news.md index 307620d..ef116e6 100644 --- a/docs/news.md +++ b/docs/news.md @@ -2,6 +2,9 @@ ## 🔥🔥🔥 News (Pacific Time) +- August 16, 2026 (**v3.5.87**): **Permission prompts are now reserved for what actually needs a decision.** *(This release also carries the OpenRouter provider entry + gateway-routing fixes in the entry below.)* A prompt every user answers "yes" to is worse than no prompt — it trains people to stop reading, and pushes them to `accept-all`, which removes *every* gate. The `auto` mode gate was asking for far more than it protected, so it was re-derived from one rule: **ask only when an action can change your files, run arbitrary code, or reach outside the session.** **(1) Read-only tools now come from the registry.** `_check_permission` matched a hardcoded five-name list (`Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`), so the other 18 tools already marked `read_only=True` — `GetDiagnostics`, `TaskList`/`TaskGet`, `MemoryList`/`MemorySearch`, `SkillList`, `ListAgentTypes`, `ReadPDF`/`ReadImage`/`ReadSpreadsheet`, `SummarizeLargeFile`, `WebBrowse`, … — prompted on every call. It now reads `ToolDef.read_only`, so every read-only tool is covered by the same rule and a new one is covered the day it is registered. A small curated set of session-state tools (`TaskCreate`, `TaskUpdate`, `MemorySave`, `Skill`, `SleepTimer`) joins them: they touch no repo file, no shell, and no network. Unclassified tools (MCP, third-party plugins) still prompt. **(2) The Bash check is a parser, not a prefix match.** The old `_is_safe_bash` tested `cmd.startswith(...)` against a 30-entry prefix list and rejected *any* command containing `|`, so the single most common inspection idiom in a terminal — `git log | head -20`, `ls -la | grep test`, `ps aux | grep python` — needed approval. The command is now parsed with `shlex`, split on `|`/`&&`/`||`/`;`, and auto-approved only when **every** segment is a known read-only invocation, classified by program name and guarded per command. Redirection (`>`), backgrounding (`&`), subshells and command substitution are refused outright. The vocabulary grew from ~30 prefixes to a proper set — `stat`, `file`, `tree`, `realpath`, `jq`, `diff`, `sha256sum`, `sort`, `cut`, `sed -n`, `git blame`/`rev-parse`/`ls-files`/`config --get`, `docker ps`, `kubectl get`, `systemctl status`, `tar -t`, `unzip -l`, plus any `--version`/`--help` invocation of any program. **This also closed a hole:** the old list auto-approved anything starting with `python `, `node `, `ruby `, `perl ` or `find `, i.e. arbitrary code execution and `find … -delete`. Interpreters and program-runners (`xargs`, `env VAR=…`, `make`, `pytest`, `npm run`) now prompt — their `--version` forms still don't. **(3) Creating a new file no longer prompts.** A `Write` to a path that does not exist yet, inside the working directory (or `allowed_root`), destroys nothing and runs. Overwriting existing content, writing outside the workspace, and any dot-prefixed path (`.git/hooks/*`, `.github/workflows/*`, `.env` — locations other tools execute or trust) still ask. Turn the rule off with `/config auto_create_files=false`. **(4) A scoped alternative to accept-all.** The prompt gained `s`: approve *and stop asking for this one thing* for the session. The grant is a signature — `Bash:git commit` covers any `git commit …` but no other subcommand, `Edit:/repo/app.py` covers repeat edits to that one file but no other file — held in memory on the `RuntimeContext`, never persisted, ignored in `manual` mode, listed by `/permissions` and dropped by `/permissions clear`. A task that edits one file forty times now asks once instead of forty times, without handing over the whole tool surface. **(5) Tuning.** `/config bash_safe_extra=["your-query-tool"]` adds project-specific read-only programs to the shell vocabulary. **Tests:** `tests/test_permission_auto_approve.py`, 110 cases — the read-only vocabulary and pipelines, ~35 mutating commands that must still prompt (chained deletes, redirection, substitution, interpreters, `find -delete`, `sed -i`, `git push`, `npm install`), registry-driven tool classification, all five mode semantics unchanged, new-file rules, and session-grant scoping/isolation. Docs: [security.md](guides/security.md#what-runs-without-asking-auto-mode) (the policy and its boundaries), [reference.md](guides/reference.md#permission-system) (what runs silently, what prompts, how to tune). +- August 16, 2026: **OpenRouter is a first-class provider — one key for 400+ models, with the upstream provider pinnable per call (PR #179), plus four gateway-routing fixes it surfaced.** **(1) The provider (PR #179, [@albertcheng](https://github.com/albertcheng)).** A new `openrouter` entry in [`providers.py`](../cheetahclaws/providers.py) points at `https://openrouter.ai/api/v1`, reads `OPENROUTER_API_KEY` (or `/config openrouter_api_key=sk-or-...`), and ships a curated model list so OpenRouter shows up in the `/model` **Tab picker** and the Web UI picker with no extra wiring — both enumerate `PROVIDERS`. Model IDs keep OpenRouter's upstream `/` path, so calls are double-prefixed — `/model openrouter/deepseek/deepseek-v4-flash` — exactly like the existing `nim//` form: `detect_provider()` takes the first segment, `bare_model()` strips only that one, and the rest is passed through verbatim. **(2) Pinning the secondary provider.** OpenRouter serves each model from a rotating pool of upstreams; `parse_openrouter_routing()` lets you pin one by appending `@[/]` — `openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8`, or `@fp8` for quantization alone (`fp4` · `fp8` · `int4` · `int8`). The suffix never reaches the model field: it is split off and sent as OpenRouter's `provider` request-body object (`order` + `allow_fallbacks: false`, plus `quantizations`), because provider selection glued into a model ID makes OpenRouter reject the call as an unknown model. Note the trade-off — a pinned provider means the request *fails* rather than rerouting around an outage; drop the suffix if you want fallbacks. **(3) Four routing fixes.** Multi-level model IDs broke four things that only bit gateway routes, all now fixed and regression-tested. **(a) Provider identity survived the prefix strip.** `stream_openai_compat()` re-derived the provider from a model string that had *already* been stripped, so the OpenRouter route `deepseek/deepseek-v4-flash` read as the **DeepSeek API**: it injected DeepSeek-only request fields (`extra_body.thinking` when `/thinking` is off, `reasoning_effort`) into OpenRouter calls and applied DeepSeek's output cap instead of OpenRouter's, while `openrouter/openai/gpt-5` sent OpenAI's `max_completion_tokens` in place of the `max_tokens` OpenRouter documents. `stream()` now passes the resolved provider in `config["_provider_name"]`; `detect_provider()` remains the fallback for direct callers. Side effect worth knowing: `PROVIDERS["nim"]["max_completion_tokens"]` and the live `/v1/models` lookup for slashed `custom//` IDs were dead code for the same reason and now take effect. **(b) Cost estimates were $0.00.** `COSTS` is keyed by plain model name, so every `openrouter/*` route missed and priced at zero — meaning the **dollar budget in `quota.record_usage` never fired** for a gateway that bills real money (token budgets were unaffected). A new `lookup_model_key()` drops both the vendor path and the `@…` suffix, so `openrouter/deepseek/deepseek-v4-flash` is now priced identically to the direct route. Models with no price entry at all still record $0 — set a token budget if you need a hard cap on those. **(c) Context windows were a flat 128 K.** Same key mismatch: every OpenRouter model inherited the provider-level default regardless of its real window — compaction fired far too late on a 32 K model (upstream then rejects the prompt) and needlessly early on a 1 M one. `get_model_context_window()` now retries the per-model registry with the bare name and, failing that, falls back to the *vendor's* own window when the vendor names a provider we know natively. `openrouter/qwen/qwen2.5-coder-32b-instruct` → 32 K, `openrouter/meta-llama/llama-3.3-70b-instruct` → 131 K, `openrouter/anthropic/claude-sonnet-4-6` → 200 K, matching each model's direct route. **(d) The routing suffix ate the prompt overlay.** [`prompts/select.py`](../cheetahclaws/prompts/select.py) routes model-family overlays on the last path segment, so `…/claude-sonnet-4-6@gmicloud/fp8` tailed to `"fp8"` and silently lost `claude.md`; the suffix is now stripped before matching. **Tests:** `tests/test_openrouter_provider.py` grew from 11 to 23 cases — provider registration, multi-level routing, `@suffix` parsing, request-body forwarding, and one case per fix above including a reverse guard that the *real* DeepSeek provider still gets its `thinking` toggle. **2652 pass, 5 skipped, zero regressions.** Docs: [usage.md](guides/usage.md#openrouter-400-models-one-key) (setup + pinning), [reference.md](guides/reference.md) (env var / `/config` / `/model`), [recipes.md](guides/recipes.md) (prefer `openrouter/` over `custom/` + `litellm/openrouter/…`), [architecture.md](architecture.md#provider-abstraction) (why gateway IDs need `_provider_name` and `lookup_model_key`). + - July 30, 2026 (**v3.5.86**): **Next-prompt ghost text — the REPL predicts the line you'd type next, Tab accepts it.** Claude Code leaves a dim suggestion sitting in the empty input box after each reply; CheetahClaws now does the same. **(1) Where the text comes from.** At the end of every *foreground* turn, `run_query` calls the new [`ui/suggest.py`](../cheetahclaws/ui/suggest.py)`::schedule()`, which flattens the last ~4 user/assistant messages (tool-use blocks dropped, each turn truncated to 1500 chars) and asks the **auxiliary** cheap/fast model — the same router compaction uses, `auxiliary.py` — for the single most probable next *user* message: one line, under 12 words, imperative and first-person, in whichever language the user has been writing, or the literal `NONE` when nothing is plausibly next. It runs on a background daemon thread, so the prompt is never delayed and the draft lands whenever it lands; every failure path is silent (no auxiliary model, no API key, provider down → simply no ghost). A generation counter drops a slow draft from turn N once turn N+1 has started, and each new turn clears the previous ghost immediately, so a stale prediction is never left on screen. The reply is cleaned before it can be displayed: first line only, surrounding quotes/backticks/bullets stripped, rejected outright if it exceeds 90 chars or if the model starts explaining itself (`Sure, …`, `The user …`) instead of impersonating the user. Background turns (Telegram/WeChat/Slack/QQ, proactive events) never draft one — they don't own the prompt. **(2) How it renders, and how you accept it.** [`ui/input.py`](../cheetahclaws/ui/input.py) gained a thread-safe pending-suggestion store (written by the drafting thread, read by the prompt_toolkit event loop) and `PredictiveAutoSuggest`: the whole prediction is offered on an empty buffer, the remainder keeps being offered while what you typed still prefixes it, and anything else falls back to the existing shell-history suggestion. It reuses the dim-italic `auto-suggestion` style already in the session, and the **Tab** binding that already accepted history ghosts now accepts these too (**→** works natively). Two prompt_toolkit details drove the design: the auto-suggester is consulted only on text *insert*, so an empty prompt is never asked at all — the prediction is therefore applied synchronously at `pre_run` and on every `on_text_changed`, which additionally makes the ghost exact when a fast Tab would otherwise beat the async pass, and brings it back when you erase your line to empty. The prediction is one-shot: `read_line()` consumes it on return, so it can never leak into a later prompt. Slash completion is untouched — an active completion menu still suppresses ghost acceptance, so `/cmd` + Tab behaves exactly as before. **(3) Control and cost.** New `input_suggest` config key (default `true`): `/config input_suggest=false` disables it persistently, `CHEETAH_SUGGEST=0` for a single run. It costs one small auxiliary call per turn — point `auxiliary_model` at a cheap model (or disable the feature) if that matters on your setup. **(4) Tests.** New `tests/test_input_suggest.py` — 29 cases covering the pending store, prediction-vs-history precedence, cursor/multi-line suppression, draft cleaning (including CJK), transcript flattening, disable switches, auxiliary failure, and superseded-draft staleness, plus **end-to-end tests that drive a real `prompt_toolkit` session over a pipe** and assert the ghost actually renders, Enter alone never submits it, Tab accepts it whole, a typed prefix still completes, erasing brings it back, and `/cmd` Tab is not hijacked. Full suite: **2610 passed, 5 skipped**. Version bumped `3.5.85` → **`3.5.86`** in `pyproject.toml`; this is also the first tagged release to carry the July 11 (terminal tab title + Anthropic prompt-cache) and July 20 (`tool_profile` + bounded-I/O) changes, which landed untagged after v3.5.85. **Not a breaking change** — with no auxiliary model reachable the REPL behaves exactly as it did before. diff --git a/pyproject.toml b/pyproject.toml index eb4cd36..19196db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cheetahclaws" -version = "3.5.86" +version = "3.5.87" description = "CheetahClaws: Agent Harness Infrastructure for Long-Horizon, Multi-Model, and Tool-Using AI Systems" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_openrouter_provider.py b/tests/test_openrouter_provider.py index eb98b38..4f89f1e 100644 --- a/tests/test_openrouter_provider.py +++ b/tests/test_openrouter_provider.py @@ -17,8 +17,8 @@ from cheetahclaws.providers import ( PROVIDERS, AssistantTurn, TextChunk, - bare_model, detect_provider, parse_openrouter_routing, - stream, stream_openai_compat, + bare_model, calc_cost, detect_provider, lookup_model_key, + parse_openrouter_routing, stream, stream_openai_compat, ) @@ -163,3 +163,125 @@ def __init__(self, *args, **kwargs): assert captured["kwargs"]["model"] == "deepseek/deepseek-v4-flash" assert captured["kwargs"]["extra_body"]["provider"] == routing assert any(isinstance(ev, AssistantTurn) for ev in events) + + +# ── Provider identity must survive the prefix strip ────────────────────── +# +# `stream()` hands `stream_openai_compat` a model string with the provider +# prefix already removed, so an OpenRouter route arrives as a plain upstream +# path ("deepseek/deepseek-v4-flash"). Re-deriving the provider from that +# string reads it as the *DeepSeek* provider — the tests below pin the +# behaviour that must not regress. + + +def _capture_request(monkeypatch): + """Patch openai.OpenAI and return the dict that receives create()'s kwargs.""" + captured: dict = {} + + class FakeCompletions: + def create(self, **kwargs): + captured["kwargs"] = kwargs + return [] + + class FakeChat: + completions = FakeCompletions() + + class FakeOpenAI: + def __init__(self, *args, **kwargs): + self.chat = FakeChat + + monkeypatch.setattr("openai.OpenAI", FakeOpenAI) + return captured + + +def test_openrouter_deepseek_route_omits_deepseek_only_fields(monkeypatch): + """`extra_body.thinking` / `reasoning_effort` are DeepSeek-API fields. + An openrouter/deepseek/... route must not pick them up just because the + upstream vendor segment reads "deepseek".""" + captured = _capture_request(monkeypatch) + + list(stream( + "openrouter/deepseek/deepseek-v4-flash", "sys", [], [], + {"openrouter_api_key": "sk-x", "thinking": False, + "reasoning_effort": "high"}, + )) + + kwargs = captured["kwargs"] + assert "thinking" not in (kwargs.get("extra_body") or {}) + assert "reasoning_effort" not in kwargs + + +def test_deepseek_provider_still_gets_its_thinking_toggle(monkeypatch): + """Guard the other direction: the real DeepSeek provider keeps the field.""" + captured = _capture_request(monkeypatch) + + list(stream("deepseek/deepseek-v4-flash", "sys", [], [], + {"deepseek_api_key": "sk-x", "thinking": False})) + + assert captured["kwargs"]["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_openrouter_uses_max_tokens_and_its_own_cap(monkeypatch): + """OpenRouter's documented output field is `max_tokens`. A route whose + vendor segment is "openai" must not switch to the OpenAI-only + `max_completion_tokens`, and the cap must come from the openrouter entry.""" + captured = _capture_request(monkeypatch) + + list(stream("openrouter/openai/gpt-5", "sys", [], [], + {"openrouter_api_key": "sk-x", "max_tokens": 64000})) + + kwargs = captured["kwargs"] + assert "max_completion_tokens" not in kwargs + assert kwargs["max_tokens"] <= PROVIDERS["openrouter"]["max_completion_tokens"] + + +# ── Per-model registry lookups (cost, context window) ──────────────────── + + +@pytest.mark.parametrize("model_id,expected_key", [ + ("openrouter/deepseek/deepseek-v4-flash", "deepseek-v4-flash"), + ("openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8", "deepseek-v4-flash"), + ("openrouter/anthropic/claude-sonnet-4-6", "claude-sonnet-4-6"), + ("gpt-4o", "gpt-4o"), +]) +def test_lookup_model_key_strips_prefixes_and_routing(model_id, expected_key): + assert lookup_model_key(model_id) == expected_key + + +def test_openrouter_usage_is_priced(): + """OpenRouter bills real money — a $0.00 estimate would let a session sail + past the cost budget in quota.record_usage.""" + direct = calc_cost("deepseek-v4-flash", 1_000_000, 1_000_000) + gateway = calc_cost("openrouter/deepseek/deepseek-v4-flash", 1_000_000, 1_000_000) + assert direct > 0 + assert gateway == direct + # The routing suffix must not knock the lookup out either. + assert calc_cost("openrouter/deepseek/deepseek-v4-flash@gmicloud/fp8", + 1_000_000, 1_000_000) == direct + + +def test_openrouter_context_window_resolves_per_model(): + """A gateway route must resolve the real model's window, not the + provider-level default.""" + from cheetahclaws.compaction import get_context_limit + model = "openrouter/meta-llama/llama-3.3-70b-instruct" + assert (get_context_limit(model, {"model": model}) + == get_context_limit("llama-3.3-70b-instruct", {})) + + +def test_routing_suffix_keeps_model_family_overlay(): + """The prompt overlay routes on the model-name tail; the `@provider/quant` + suffix must not make "claude-sonnet-4-6@gmicloud/fp8" tail to "fp8".""" + from cheetahclaws.prompts.select import _family_overlay_for_model + assert (_family_overlay_for_model( + "openrouter/anthropic/claude-sonnet-4-6@gmicloud/fp8") == "claude.md") + + +def test_openrouter_context_window_falls_back_to_vendor_provider(): + """When the per-model registry has no entry, a gateway route should still + beat the gateway's generic default by reading the vendor's own window + (openrouter/anthropic/… → Anthropic's 200k, not OpenRouter's 128k).""" + from cheetahclaws.compaction import get_context_limit + model = "openrouter/anthropic/claude-sonnet-4-6" + assert (get_context_limit(model, {"model": model}) + == PROVIDERS["anthropic"]["context_limit"]) diff --git a/tests/test_permission_auto_approve.py b/tests/test_permission_auto_approve.py new file mode 100644 index 0000000..326b18d --- /dev/null +++ b/tests/test_permission_auto_approve.py @@ -0,0 +1,379 @@ +"""Tests for the permission gate: what runs silently vs what asks. + +The rule under test: in the default ``auto`` mode a tool call is prompted +only when it can change the user's files, execute arbitrary code, or reach +outside the session. Reads, read-only shell pipelines, and CheetahClaws' +own session state (tasks, memories, skills) run straight through — and a +"session grant" ("s" at a prompt) suppresses repeats of one specific +command or file without going all the way to accept-all. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest + +import cheetahclaws.tools # noqa: F401 (registers the built-in tools) +from cheetahclaws import runtime +from cheetahclaws.agent import ( + _check_permission, _permission_signature, _tool_is_read_only, +) +from cheetahclaws.tools.security import _is_safe_bash + + +def _tc(name: str, **inp) -> dict: + return {"id": "t1", "name": name, "input": inp} + + +def _cfg(session_id: str, **extra) -> dict: + cfg = {"permission_mode": "auto", "_session_id": session_id} + cfg.update(extra) + return cfg + + +# ── Read-only shell vocabulary ─────────────────────────────────────────── + +@pytest.mark.parametrize("cmd", [ + "ls -la", + "cat README.md", + "git log --oneline -20", + "git status --porcelain", + "git diff --stat", + "git config --get user.email", + "git stash list", + "wc -l setup.py", + "find . -name '*.py'", + "stat pyproject.toml", + "jq '.name' package.json", + "docker ps -a", + "kubectl get pods", + "pip list", + "cargo tree", + "sed -n '1,20p' README.md", + "curl -I https://example.com", + "tar -tzf pkg.tar.gz", + "cat < input.txt", +]) +def test_read_only_commands_run_without_asking(cmd): + assert _is_safe_bash(cmd) is True + + +@pytest.mark.parametrize("cmd", [ + "git log | head -20", + "ls -la | grep test", + "ps aux | grep python", + "df -h | sort -k5 -r | head -5", + "grep -rn TODO src/ | wc -l", +]) +def test_read_only_pipelines_run_without_asking(cmd): + """A pipeline of read-only stages is still read-only — the old check + rejected every `|`, which prompted for the most common inspection idiom + in the codebase.""" + assert _is_safe_bash(cmd) is True + + +@pytest.mark.parametrize("cmd", [ + # a safe-looking prefix followed by a destructive command + "ls && rm -rf build", + "ls; rm -rf /tmp/x", + "cat f | tee out.txt", + "echo hi > file.txt", + "echo hi >> file.txt", + # command substitution / backgrounding / subshells + "echo `whoami`", + "echo $(rm -rf /)", + "ls &", + # arbitrary code execution + "python script.py", + "python -c 'import os; os.system(\"rm -rf /\")'", + "node server.js", + "bash script.sh", + "awk '{print $1}' f", + "xargs rm < list.txt", + "env FOO=1 rm -rf /", + "sudo ls", + "make test", + "pytest -q", + # read-only programs turned mutating by a flag + "find . -name '*.log' -delete", + "sed -i 's/a/b/' f.txt", + "sort -o out.txt in.txt", + "curl -o out.bin https://example.com", + "tar -xzf pkg.tar.gz", + # write subcommands of otherwise-readable tools + "git push origin main", + "git commit -am wip", + "git config user.email me@example.com", + "npm install", + "pip install requests", + "docker run -it ubuntu", + "kubectl delete pod x", + # nothing to classify + "", + " ", +]) +def test_mutating_commands_still_ask(cmd): + assert _is_safe_bash(cmd) is False + + +def test_interpreters_are_safe_only_for_info_flags(): + """`python --version` executes nothing; `python foo.py` executes anything.""" + assert _is_safe_bash("python --version") is True + assert _is_safe_bash("python -V") is True + assert _is_safe_bash("node --version") is True + assert _is_safe_bash("python -c 'print(1)'") is False + + +@pytest.mark.parametrize("cmd", ["shutdown -h", "rm -v", "kill -9 1234", + "systemctl restart nginx", "git clean -v"]) +def test_short_flags_are_not_mistaken_for_info_flags(cmd): + """`-h` halts on `shutdown` and `-v` is verbose almost everywhere, so an + unknown program invoked with only short flags is not a version query.""" + assert _is_safe_bash(cmd) is False + + +def test_bash_safe_extra_extends_the_vocabulary(): + """Projects can add their own read-only commands via config.""" + assert _is_safe_bash("bazel-query //...") is False + assert _is_safe_bash("bazel-query //...", + {"bash_safe_extra": ["bazel-query"]}) is True + + +def test_unbalanced_quotes_are_not_guessed_at(): + assert _is_safe_bash("ls 'unterminated") is False + + +# ── Tool-level classification ──────────────────────────────────────────── + +@pytest.mark.parametrize("name", [ + "Read", "Glob", "Grep", "WebFetch", "WebSearch", # always were auto + "GetDiagnostics", "TaskList", "TaskGet", "MemoryList", # newly auto + "MemorySearch", "SkillList", "ListAgentTypes", "SummarizeLargeFile", +]) +def test_read_only_tools_are_auto_approved(name): + """Auto-approval reads ToolDef.read_only from the registry instead of a + hardcoded five-name list, so every read-only tool is covered.""" + assert _tool_is_read_only(name) is True + assert _check_permission(_tc(name), _cfg("ro")) is True + + +@pytest.mark.parametrize("name", ["TaskCreate", "TaskUpdate", "MemorySave", + "Skill", "SleepTimer"]) +def test_self_state_tools_are_auto_approved(name): + """These mutate only CheetahClaws' own session state — no repo file, no + shell, no network.""" + assert _check_permission(_tc(name), _cfg("self")) is True + + +@pytest.mark.parametrize("tc", [ + _tc("Write", file_path="/tmp/x.py"), + _tc("Edit", file_path="/tmp/x.py"), + _tc("NotebookEdit", notebook_path="/tmp/x.ipynb"), + _tc("Bash", command="rm -rf build"), + _tc("Agent", prompt="do a thing"), # a sub-agent runs its own loop + _tc("MemoryDelete", memory_id="x"), # destroys user data +]) +def test_state_changing_tools_still_ask(tc): + assert _check_permission(tc, _cfg("ask")) is False + + +def test_unknown_tools_still_ask(): + """MCP / third-party tools are unclassified — they keep prompting.""" + assert _check_permission(_tc("mcp__someserver__do_thing"), _cfg("mcp")) is False + + +# ── Creating new files in the workspace ────────────────────────────────── + +def test_creating_a_new_file_in_the_workspace_is_auto_approved(tmp_path, monkeypatch): + """Nothing is overwritten and nothing outside the workspace is touched, + so there is no decision for the user to make.""" + monkeypatch.chdir(tmp_path) + cfg = _cfg("new-file") + assert _check_permission(_tc("Write", file_path="src/new_module.py"), cfg) is True + assert _check_permission(_tc("Write", file_path=str(tmp_path / "notes.md")), cfg) is True + + +def test_overwriting_an_existing_file_still_asks(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + existing = tmp_path / "app.py" + existing.write_text("real content\n") + assert _check_permission(_tc("Write", file_path=str(existing)), + _cfg("overwrite")) is False + + +def test_writing_outside_the_workspace_still_asks(tmp_path, monkeypatch): + workspace = tmp_path / "repo" + workspace.mkdir() + monkeypatch.chdir(workspace) + outside = tmp_path / "elsewhere" / "x.py" + assert _check_permission(_tc("Write", file_path=str(outside)), + _cfg("outside")) is False + + +@pytest.mark.parametrize("path", [ + ".github/workflows/ci.yml", # runs in CI + ".git/hooks/pre-commit", # runs on every commit + ".env", # trusted by other tools + ".vscode/tasks.json", +]) +def test_creating_dot_paths_still_asks(tmp_path, monkeypatch, path): + """Hook/config locations are executed or trusted by other tools — creating + one is a decision, not a formality.""" + monkeypatch.chdir(tmp_path) + assert _check_permission(_tc("Write", file_path=path), _cfg("dotpath")) is False + + +def test_auto_create_files_can_be_turned_off(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + cfg = _cfg("no-auto-create", auto_create_files=False) + assert _check_permission(_tc("Write", file_path="new.py"), cfg) is False + + +def test_allowed_root_bounds_the_auto_create_rule(tmp_path, monkeypatch): + """When a sandbox root is configured it — not the cwd — is the boundary.""" + root = tmp_path / "sandbox" + (root / "sub").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + cfg = _cfg("rooted", allowed_root=str(root)) + assert _check_permission(_tc("Write", file_path=str(root / "sub" / "a.py")), cfg) is True + assert _check_permission(_tc("Write", file_path=str(tmp_path / "b.py")), cfg) is False + + +# ── Mode semantics are unchanged ───────────────────────────────────────── + +def test_manual_mode_asks_even_for_reads(): + assert _check_permission(_tc("Read", file_path="x"), + _cfg("m", permission_mode="manual")) is False + + +def test_accept_all_never_asks(): + assert _check_permission(_tc("Bash", command="rm -rf build"), + _cfg("aa", permission_mode="accept-all")) is True + + +def test_accept_edits_preapproves_edits_only(): + cfg = _cfg("ae", permission_mode="accept-edits") + assert _check_permission(_tc("Edit", file_path="/tmp/x.py"), cfg) is True + assert _check_permission(_tc("Bash", command="rm -rf build"), cfg) is False + + +def test_plan_mode_refuses_writes_outside_the_plan_file(): + cfg = _cfg("plan", permission_mode="plan") + runtime.get_ctx(cfg).plan_file = "/tmp/plan.md" + assert _check_permission(_tc("Write", file_path="/tmp/plan.md"), cfg) is True + assert _check_permission(_tc("Write", file_path="/tmp/other.py"), cfg) is False + assert _check_permission(_tc("Bash", command="git log"), cfg) is True + assert _check_permission(_tc("Bash", command="git push"), cfg) is False + + +# ── Session-scoped grants ──────────────────────────────────────────────── + +@pytest.mark.parametrize("tc,expected", [ + (_tc("Bash", command="git push origin main"), "Bash:git push"), + (_tc("Bash", command="git push"), "Bash:git push"), + (_tc("Bash", command="pytest -q tests/"), "Bash:pytest"), + (_tc("Bash", command="/usr/bin/make test"), "Bash:make test"), + (_tc("Edit", file_path="/repo/app.py"), "Edit:/repo/app.py"), + (_tc("Agent", prompt="x"), "Agent"), +]) +def test_permission_signature_is_coarse_but_not_sloppy(tc, expected): + assert _permission_signature(tc) == expected + + +def test_session_grant_suppresses_repeats_of_the_same_command(): + cfg = _cfg("grant-cmd") + tc = _tc("Bash", command="pytest -q") + assert _check_permission(tc, cfg) is False + runtime.get_ctx(cfg).approved_sigs.add(_permission_signature(tc)) + assert _check_permission(tc, cfg) is True + # …and covers the same command with different arguments + assert _check_permission(_tc("Bash", command="pytest tests/x.py -k foo"), cfg) is True + # …but not a different program + assert _check_permission(_tc("Bash", command="rm -rf build"), cfg) is False + + +def test_session_grant_for_one_file_does_not_cover_another(): + cfg = _cfg("grant-file") + runtime.get_ctx(cfg).approved_sigs.add("Edit:/repo/app.py") + assert _check_permission(_tc("Edit", file_path="/repo/app.py"), cfg) is True + assert _check_permission(_tc("Edit", file_path="/repo/secrets.py"), cfg) is False + + +def test_session_grants_are_per_session(): + """A grant lives on the RuntimeContext, so another session never inherits it.""" + cfg_a, cfg_b = _cfg("sess-a"), _cfg("sess-b") + runtime.get_ctx(cfg_a).approved_sigs.add("Bash:pytest") + assert _check_permission(_tc("Bash", command="pytest -q"), cfg_a) is True + assert _check_permission(_tc("Bash", command="pytest -q"), cfg_b) is False + + +def test_manual_mode_ignores_session_grants(): + """Explicitly asking for every call must not be overridden by a grant + made earlier under a laxer mode.""" + cfg = _cfg("sess-manual", permission_mode="manual") + runtime.get_ctx(cfg).approved_sigs.add("Bash:pytest") + assert _check_permission(_tc("Bash", command="pytest -q"), cfg) is False + + +def test_answering_s_records_the_grant(monkeypatch): + """The prompt's "s" option stores the signature on the session context.""" + from cheetahclaws import cli + monkeypatch.setattr(cli, "ask_input_interactive", lambda *a, **k: "s") + cfg = _cfg("prompt-s") + granted = cli.ask_permission_interactive("Run: pytest -q", cfg, "Bash:pytest") + assert granted is True + assert "Bash:pytest" in runtime.get_ctx(cfg).approved_sigs + # and the blunt instrument is untouched — this is not accept-all + assert cfg["permission_mode"] == "auto" + + +def test_two_arg_permission_handlers_still_work(monkeypatch): + """A bridge/plugin override that predates the `signature` argument must + still be *called* — passing it three positionals raises a TypeError the + callers swallow, which silently denies the tool instead of asking.""" + from cheetahclaws import cli + from cheetahclaws.agent import PermissionRequest + calls = [] + + def legacy_handler(desc, cfg): # no `signature` parameter + calls.append(desc) + return True + + monkeypatch.setattr(cli, "ask_permission_interactive", legacy_handler) + ev = PermissionRequest(description="Run: pytest -q", signature="Bash:pytest") + assert cli._ask_permission_event(ev, _cfg("legacy")) is True + assert calls == ["Run: pytest -q"] + + +def test_signature_reaches_handlers_that_accept_it(monkeypatch): + from cheetahclaws import cli + from cheetahclaws.agent import PermissionRequest + seen = {} + + def new_handler(desc, cfg, signature=""): + seen["signature"] = signature + return True + + monkeypatch.setattr(cli, "ask_permission_interactive", new_handler) + ev = PermissionRequest(description="Run: pytest -q", signature="Bash:pytest") + assert cli._ask_permission_event(ev, _cfg("modern")) is True + assert seen["signature"] == "Bash:pytest" + + +def test_answering_a_still_flips_to_accept_all(monkeypatch): + from cheetahclaws import cli + monkeypatch.setattr(cli, "ask_input_interactive", lambda *a, **k: "a") + cfg = _cfg("prompt-a") + assert cli.ask_permission_interactive("Run: pytest -q", cfg, "Bash:pytest") is True + assert cfg["permission_mode"] == "accept-all" + + +def test_permissions_clear_drops_session_grants(): + from cheetahclaws.commands.config_cmd import cmd_permissions + cfg = _cfg("clear-me") + runtime.get_ctx(cfg).approved_sigs.update({"Bash:pytest", "Edit:/repo/app.py"}) + cmd_permissions("clear", None, cfg) + assert runtime.get_ctx(cfg).approved_sigs == set()