coderio 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,94 @@
1
+ """coderio tools."""
2
+
3
+ from coderio.tools.base import (
4
+ DESTRUCTIVE_TOOLS,
5
+ Tool,
6
+ to_langchain_tool,
7
+ )
8
+ from coderio.tools.bash import BashArgs, BashTool
9
+ from coderio.tools.edit_file import EditFileArgs, EditFileTool
10
+ from coderio.tools.glob_tool import GlobArgs, GlobTool
11
+ from coderio.tools.grep_tool import GrepArgs, GrepTool
12
+ from coderio.tools.list_dir import ListDirArgs, ListDirTool
13
+ from coderio.tools.multi_edit import MultiEditArgs, MultiEditTool
14
+ from coderio.tools.note import NoteArgs, NoteTool
15
+ from coderio.tools.permission import (
16
+ AutoPermissionGate,
17
+ PermissionGate,
18
+ PermissionMode,
19
+ RichPromptPermissionGate,
20
+ )
21
+ from coderio.tools.read_file import ReadFileArgs, ReadFileTool
22
+ from coderio.tools.todo import TodoArgs, TodoStore, TodoTool
23
+ from coderio.tools.web_fetch import WebFetchArgs, WebFetchTool
24
+ from coderio.tools.web_search import WebSearchArgs, WebSearchTool
25
+ from coderio.tools.write_file import WriteFileArgs, WriteFileTool
26
+
27
+ __all__ = [
28
+ "Tool",
29
+ "to_langchain_tool",
30
+ "DESTRUCTIVE_TOOLS",
31
+ "PermissionGate",
32
+ "PermissionMode",
33
+ "RichPromptPermissionGate",
34
+ "AutoPermissionGate",
35
+ "TodoStore",
36
+ "build_default_tools",
37
+ "to_langchain_tools",
38
+ ]
39
+
40
+
41
+ def build_default_tools(bash_shell: str = "", **_) -> list:
42
+ """Return the default tool set (12 tools)."""
43
+ return [
44
+ ReadFileTool(),
45
+ WriteFileTool(),
46
+ EditFileTool(),
47
+ MultiEditTool(),
48
+ ListDirTool(),
49
+ BashTool(shell=bash_shell),
50
+ GlobTool(),
51
+ GrepTool(),
52
+ TodoTool(TodoStore()),
53
+ WebSearchTool(),
54
+ WebFetchTool(),
55
+ NoteTool(),
56
+ ]
57
+
58
+
59
+ _ARGS_SCHEMAS: dict = {
60
+ "read_file": ReadFileArgs,
61
+ "write_file": WriteFileArgs,
62
+ "edit_file": EditFileArgs,
63
+ "multi_edit": MultiEditArgs,
64
+ "list_dir": ListDirArgs,
65
+ "bash": BashArgs,
66
+ "glob": GlobArgs,
67
+ "grep": GrepArgs,
68
+ "todo": TodoArgs,
69
+ "web_search": WebSearchArgs,
70
+ "web_fetch": WebFetchArgs,
71
+ "note": NoteArgs,
72
+ }
73
+
74
+
75
+ def to_langchain_tools(tools: list, extra: dict | None = None) -> list:
76
+ """Adapt coderio tools (+ optional extras like activate_skill) to langchain tools.
77
+
78
+ `extra` maps tool name -> args_schema for tools not in the default set.
79
+ Skill-carried tools (loaded from a skill's tools.py) carry their own
80
+ ``args_schema`` attribute and are picked up automatically here — they don't
81
+ need to be pre-registered in _ARGS_SCHEMAS.
82
+ """
83
+ schemas = dict(_ARGS_SCHEMAS)
84
+ if extra:
85
+ schemas.update(extra)
86
+ out = []
87
+ for t in tools:
88
+ # Prefer the explicit schema registry; fall back to the tool's own
89
+ # args_schema (skill-carried tools define their schema in tools.py).
90
+ schema = schemas.get(t.name) or getattr(t, "args_schema", None)
91
+ if schema is None:
92
+ continue
93
+ out.append(to_langchain_tool(t, schema))
94
+ return out
coderio/tools/base.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+ from langchain_core.tools import StructuredTool
6
+ from pydantic import BaseModel
7
+
8
+
9
+ @runtime_checkable
10
+ class Tool(Protocol):
11
+ """Unified tool interface. Each tool declares a pydantic args_schema and a run()."""
12
+
13
+ name: str
14
+ description: str
15
+
16
+ def run(self, **kwargs: Any) -> str: ...
17
+
18
+
19
+ def to_langchain_tool(tool: "Tool", args_schema: type[BaseModel]) -> StructuredTool:
20
+ """Adapt a coderio Tool to a langchain StructuredTool (spec §3.1).
21
+
22
+ The bound model then exposes the tool's JSON schema to the LLM and returns
23
+ tool_calls whose args are validated against args_schema.
24
+ """
25
+
26
+ def _invoke(**kwargs: Any) -> str:
27
+ return tool.run(**kwargs)
28
+
29
+ return StructuredTool.from_function(
30
+ _invoke,
31
+ name=tool.name,
32
+ description=tool.description,
33
+ args_schema=args_schema,
34
+ )
35
+
36
+
37
+ # Tool classification by permission risk level. Uses deepagents native tool
38
+ # names (execute/write_todos/ls) since the production engine is deepagents.
39
+ # The old ReAct engine names (bash/todo/list_dir) are no longer in use.
40
+
41
+ # Destructive tools: require permission in CONFIRM/AUTO_EDIT/PLAN modes.
42
+ # multi_edit is destructive (atomic multi-edit of one file) and must NOT be
43
+ # omitted — without it, CONFIRM/AUTO_EDIT modes treat multi_edit as read-only
44
+ # and let it through without asking (permission.py: check() returns True for
45
+ # any tool not in DESTRUCTIVE_TOOLS). The deepagents backend doesn't expose
46
+ # multi_edit, but this list is also the permission model's source of truth.
47
+ # task is the subagent-delegation tool (2026-08-14 report P0-2): delegating
48
+ # spawns a subagent with write/execute tools — without gating task itself,
49
+ # PLAN mode (nominally read-only) could delegate destructive work even after
50
+ # the general-purpose subagent got coderio middleware (defense in depth).
51
+ DESTRUCTIVE_TOOLS = {"write_file", "edit_file", "multi_edit", "execute", "web_fetch", "note", "task"}
52
+
53
+ # Auto Edit mode: file edits auto-allowed (fast + reversible via git), but
54
+ # shell/network/note-writes still need confirmation (harder to undo side effects).
55
+ FILE_EDIT_TOOLS = {"write_file", "edit_file"}
coderio/tools/bash.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from coderio.tools.win_job import kill_process_tree as _kill_process_tree
12
+
13
+ _WIN_CANDIDATES = (
14
+ "C:\\Program Files\\Git\\bin\\bash.exe",
15
+ "C:\\Program Files\\Git\\usr\\bin\\bash.exe",
16
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
17
+ )
18
+
19
+
20
+ class BashArgs(BaseModel):
21
+ command: str = Field(description="The shell command to execute.")
22
+ timeout: int = Field(default=120, description="Timeout in seconds.")
23
+ run_in_background: bool = Field(default=False, description="Run detached; returns a pid.")
24
+ cwd: str = Field(default="", description="Working directory for the command.")
25
+
26
+
27
+ def detect_shell(configured: str) -> str:
28
+ """Resolve the bash executable path. Spec §3.3.
29
+
30
+ Order: explicit config > Windows candidates > PATH `bash`.
31
+ """
32
+ if configured:
33
+ p = Path(configured)
34
+ if p.is_file():
35
+ return str(p)
36
+ if sys.platform == "win32":
37
+ for cand in _WIN_CANDIDATES:
38
+ if Path(cand).is_file():
39
+ return cand
40
+ found = shutil.which("bash")
41
+ if found:
42
+ return found
43
+ if sys.platform == "win32":
44
+ raise FileNotFoundError(
45
+ "bash not found. Install Git Bash (https://git-scm.com) and/or set [tools].bash_shell in config.toml."
46
+ )
47
+ raise FileNotFoundError(
48
+ "bash not found. Install it via your package manager "
49
+ "(e.g. apt install bash / brew install bash) "
50
+ "and/or set [tools].bash_shell in config.toml."
51
+ )
52
+
53
+
54
+ class BashTool:
55
+ name = "bash"
56
+ description = (
57
+ "Execute a shell command via bash (Git Bash on Windows, native bash on Linux/macOS). "
58
+ "Returns combined stdout+stderr with an [exit_code: N] marker. "
59
+ "Supports timeout (default 120s) and run_in_background.\n\n"
60
+ "ENVIRONMENT DISCOVERY (important on Windows): the bash shell inherits the system "
61
+ "PATH, which may differ from the environment coderio itself runs in. Before assuming "
62
+ "'the environment is broken', discover what's actually available:\n"
63
+ " - `which python python3 py 2>/dev/null` — find which Python interpreters exist\n"
64
+ " - Check for a project virtual environment: `ls .venv/Scripts/python.exe` "
65
+ "(Windows) or `ls venv/bin/python` (Linux/macOS). If found, use that path directly: "
66
+ "`.venv/Scripts/python.exe -m pytest` instead of bare `python -m pytest`.\n"
67
+ " - `py -3 --version` — Windows Python launcher may find a Python 3 even when "
68
+ "`python` resolves to Python 2.\n"
69
+ "A bare `python` that fails does NOT mean dependencies are missing. Always verify "
70
+ "with the correct interpreter before concluding the environment is broken."
71
+ )
72
+ args_schema = BashArgs
73
+
74
+ def __init__(self, shell: str = ""):
75
+ self._shell = shell
76
+
77
+ def _resolve(self):
78
+ if self._shell and Path(self._shell).is_file():
79
+ return self._shell
80
+ return detect_shell(self._shell)
81
+
82
+ @staticmethod
83
+ def _prepend_venv_activate(command: str, cwd: str) -> str:
84
+ """Prepend 'source .venv/bin/activate' (or Scripts path on Windows) if
85
+ a virtual environment exists in the working directory.
86
+
87
+ This ensures `python` / `pip` in the bash command resolve to the
88
+ project's venv interpreter, not a random system Python. The check is
89
+ purely additive — if no .venv exists, the command runs unchanged.
90
+ """
91
+ if not command or not cwd:
92
+ return command
93
+ work = Path(cwd)
94
+ if sys.platform == "win32":
95
+ activate = work / ".venv" / "Scripts" / "activate"
96
+ else:
97
+ activate = work / ".venv" / "bin" / "activate"
98
+ if not activate.is_file():
99
+ return command
100
+ # Use 'source' (bash builtin) to activate in the same shell session.
101
+ # 'command' runs after activation, so `python` now points to .venv.
102
+ return f"source '{activate}' 2>/dev/null; {command}"
103
+
104
+ def run(
105
+ self,
106
+ command: str,
107
+ timeout: int = 120,
108
+ run_in_background: bool = False,
109
+ cwd: str = "",
110
+ ) -> str:
111
+ shell = self._resolve()
112
+ work = cwd or os.getcwd()
113
+ # Auto-activate a project virtual environment if one exists in the
114
+ # working directory. Without this, bash's login shell inherits the
115
+ # system PATH, where `python` may resolve to a wrong interpreter (e.g.
116
+ # Python 2.7 on Windows, or no python at all on macOS). The agent then
117
+ # sees 'No module named pytest' even though coderio itself runs fine
118
+ # with all deps installed in .venv.
119
+ command = self._prepend_venv_activate(command, work)
120
+ if run_in_background:
121
+ proc = subprocess.Popen(
122
+ [shell, "-l", "-c", command],
123
+ cwd=work,
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.PIPE,
126
+ text=True,
127
+ encoding="utf-8",
128
+ errors="replace",
129
+ )
130
+ return f"Started background task (pid={proc.pid})"
131
+ # Use Popen + manual timeout instead of subprocess.run(timeout=...).
132
+ # subprocess.run's timeout on Windows only kills the direct child —
133
+ # grandchildren (pytest workers, hanging test threads) survive and
134
+ # hold the pipes open, so the call NEVER returns. The manual timeout
135
+ # below uses _kill_process_tree (Windows Job Objects) to kill the
136
+ # entire process tree, then closes the pipes explicitly.
137
+ try:
138
+ proc = subprocess.Popen(
139
+ [shell, "-l", "-c", command],
140
+ cwd=work,
141
+ stdout=subprocess.PIPE,
142
+ stderr=subprocess.PIPE,
143
+ text=True,
144
+ encoding="utf-8",
145
+ errors="replace",
146
+ # On Linux/macOS, start a new process group so os.killpg works.
147
+ start_new_session=(sys.platform != "win32"),
148
+ )
149
+ except FileNotFoundError as e:
150
+ return f"Error: {e}"
151
+ try:
152
+ stdout, stderr = proc.communicate(timeout=timeout)
153
+ out = (stdout or "") + (stderr or "")
154
+ # Append exit code so the model can tell success (0) from failure
155
+ # without guessing from the output text. Critical for the harness
156
+ # VerifyGate: a non-zero exit means the verification attempt failed,
157
+ # which the model should read and fix — not just "ran = verified".
158
+ return f"{out}\n[exit_code: {proc.returncode}]"
159
+ except subprocess.TimeoutExpired:
160
+ # Kill the ENTIRE process tree (not just the direct child). Without
161
+ # this, grandchildren hold the pipes open and communicate() never
162
+ # returns — the TUI freezes indefinitely.
163
+ _kill_process_tree(proc)
164
+ try:
165
+ proc.communicate(timeout=5)
166
+ except Exception:
167
+ pass
168
+ return f"Error: command timed out after {timeout}s"
@@ -0,0 +1,364 @@
1
+ """Command-content review policy for the shell (execute) tool.
2
+
3
+ This is the cheapest layer of defense against destructive shell commands — it
4
+ catches the obvious catastrophe patterns (``rm -rf /``, ``mkfs``, fork bombs)
5
+ before they reach ``subprocess.run(shell=True)``. It is NOT a real OS sandbox:
6
+ a determined model can obfuscate commands (base64 decode, variable expansion,
7
+ ``$(...)`` substitution) to bypass regex matching. The goal is to stop the
8
+ *accidental* and *careless* cases, not a adversarial one.
9
+
10
+ Two complementary modes (both can be active at once):
11
+
12
+ **Blacklist (always on, even in FULL mode)**: built-in patterns that block the
13
+ catastrophic commands. Users can append via ``[tools].blocked_commands``. A
14
+ blacklist match → hard block (the command never runs).
15
+
16
+ **Whitelist (opt-in via ``[tools].whitelist_mode = true``)**: when enabled,
17
+ commands whose first token isn't in the allowed set are flagged for confirmation
18
+ (NOT hard-blocked — they degrade to the tier's confirm path, so FULL still
19
+ allows them, CONFIRM prompts, PLAN blocks). This is stricter than the blacklist
20
+ alone but less disruptive than hard-denying unknown commands. Users extend the
21
+ allowlist via ``[tools].allowed_commands``.
22
+
23
+ The whitelist, like the blacklist, is name-matching only — ``python -c
24
+ "import os; os.system('rm -rf /')"`` passes because the first token is
25
+ ``python``. It raises the bar for accidental damage (a typo'd command name, a
26
+ wrong tool), not for adversarial input. True isolation needs OS-level sandboxing
27
+ (see win_sandbox.py / linux_sandbox.py).
28
+
29
+ For true isolation, run coderio inside a container/VM — see the architecture
30
+ doc's security section. This policy is the "at least do this" floor that the
31
+ 2026-08-07 analysis report (P0-1) asked for.
32
+
33
+ Design:
34
+ - Default blacklist is built-in and always active (even in FULL mode). Users
35
+ can append their own patterns via config.toml [tools].blocked_commands.
36
+ - Matching is regex on the raw command string (no shell AST parsing). This
37
+ will false-positive on ``echo "rm -rf /"`` — acceptable, since safety > the
38
+ rare case of quoting a destructive pattern in a string literal.
39
+ - ``network_allowed=False`` disables web_fetch/web_search entirely (offline mode).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+ from dataclasses import dataclass, field
46
+
47
+ # Built-in blacklist: (pattern, reason). Compiled once at import.
48
+ #
49
+ # Each pattern matches the destructive form but NOT safe variants:
50
+ # - `rm -rf /` blocks recursive root deletion, but `rm -rf ./build` is fine
51
+ # - `rm -rf ~` / `rm -rf $HOME` blocks home-dir nuking
52
+ # - `mkfs` blocks filesystem formatting
53
+ # - `dd ... of=/dev/` blocks writing to raw devices
54
+ # - fork bomb `:(){:|:&};:` (and the spaced variant)
55
+ # - `chmod -R 777 /` blocks global permission corruption
56
+ # - `> /dev/sda` blocks redirect-to-device
57
+ # - `shutdown` / `reboot` / `halt` / `poweroff` block system power control
58
+ # - `:(){` is the canonical fork-bomb opener (catches variants before the full form)
59
+ _DEFAULT_BLOCKED: list[tuple[str, str]] = [
60
+ # Recursive deletion of root or any system directory (/home, /etc, /usr, ...).
61
+ # `rm -rf /home` is just as catastrophic as `rm -rf /`. Any absolute path
62
+ # under root with -rf is blocked; `rm -rf ./build` (relative) is fine.
63
+ #
64
+ # REGRESSION HARDENING (2026-08-14 report): the original single-flag regex
65
+ # `-[rRfF]*[rR][fF]*` missed several ACTUALLY-DESTRUCTIVE forms while the
66
+ # harmless bare `rm -rf /` (coreutils refuses it without
67
+ # --no-preserve-root!) was the only one blocked:
68
+ # - `rm -rf / --no-preserve-root` ← the only form that truly deletes /
69
+ # - `rm -r -f /` ← flags written separately
70
+ # - `rm -rf "/"` ← quoted root
71
+ # - `chmod -R 0777 /` ← leading-zero mode
72
+ # Fixed below with dedicated patterns; each verified by tests in
73
+ # test_command_policy.py ("blacklist hardening" section).
74
+ # Flags may be split across multiple dash-groups: rm -r -f, rm -rf -v, ...
75
+ (r"\brm\s+(?:-[rRfFvIi]+\s+)+/(?:\S|$)", "recursive delete of system directory (absolute path under /)"),
76
+ # Bare `rm -rf /` at end-of-line (trailing space or EOL).
77
+ (r"\brm\s+(?:-[rRfFvIi]+\s+)+/\s*$", "recursive delete of root directory"),
78
+ # Quoted root: rm -rf "/" or rm -rf '/'.
79
+ (r"\brm\s+(?:-[rRfFvIi]+\s+)+[\"']/[\"'](?:\s|$)", "recursive delete of root directory (quoted)"),
80
+ # --no-preserve-root anywhere after rm: its ONLY purpose is defeating the
81
+ # coreutils root protection — no legitimate coding task uses it.
82
+ (r"\brm\b[^|;&]*--no-preserve-root", "rm with --no-preserve-root (removes root-delete protection)"),
83
+ (r"\brm\s+(?:-[rRfFvIi]+\s+)+~(?:\s|$|/)", "recursive delete of home directory"),
84
+ (r"\brm\s+(?:-[rRfFvIi]+\s+)+\$HOME(?:\s|$|/)", "recursive delete of home directory"),
85
+ (r"\brm\s+(?:-[rRfFvIi]+\s+)+\*", "recursive delete of all files (rm -rf *)"),
86
+ # find starting at / with -delete: deletes every match under root.
87
+ # `find / -name x -delete` etc. — any find rooted at / ending in -delete.
88
+ (r"\bfind\s+/(?:\s|$).*?-delete\b", "find -delete starting at filesystem root"),
89
+ # Filesystem format — destroys all data on a device.
90
+ (r"\bmkfs(?:\.\w+)?\s", "filesystem format (destroys all data on target)"),
91
+ # Writing to raw block devices.
92
+ (r"\bdd\b.*\bof\s*=\s*/dev/", "dd writing to a raw device"),
93
+ (r">\s*/dev/(?:sd|nvme|hd)", "redirect to a raw block device"),
94
+ # Fork bomb — the bash classic `:(){:|:&};:` and spaced variants.
95
+ # Allow optional space between `:` and `()` (some shells/users space it).
96
+ (r":\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;", "fork bomb"),
97
+ # Global permission corruption. Accept 777 with or without leading 0
98
+ # (chmod accepts both `777` and `0777`; the old pattern missed 0777).
99
+ (r"\bchmod\s+-R\s+0?777\s+/(?:\S|$)", "recursive world-writable on system directory"),
100
+ # System power control — no coding task needs these.
101
+ (r"\b(?:shutdown|reboot|halt|poweroff)\b", "system power control"),
102
+ # Kernel module manipulation — loading/unloading kernel code.
103
+ (r"\brmmod\b", "kernel module unload"),
104
+ (r"\binsmod\b", "kernel module load"),
105
+ (r"\bmodprobe\b", "kernel module manipulation"),
106
+ ]
107
+
108
+ # Built-in whitelist: commands a coding agent legitimately needs. When
109
+ # whitelist_mode is enabled, any command whose first token isn't here (or in
110
+ # the user's allowed_commands) is flagged for confirmation.
111
+ #
112
+ # Keep this conservative — adding a command here means "the agent can run this
113
+ # without asking, even in whitelist mode". Destructive commands (rm, dd, mkfs)
114
+ # are intentionally absent: even in whitelist mode, they should prompt.
115
+ _DEFAULT_ALLOWED = frozenset(
116
+ {
117
+ # Language runtimes & package managers
118
+ "python",
119
+ "python3",
120
+ "py",
121
+ "pip",
122
+ "pip3",
123
+ "pipx",
124
+ "uv",
125
+ "poetry",
126
+ "node",
127
+ "npm",
128
+ "npx",
129
+ "yarn",
130
+ "pnpm",
131
+ "bun",
132
+ "deno",
133
+ "cargo",
134
+ "rustc",
135
+ "go",
136
+ "java",
137
+ "javac",
138
+ "gradle",
139
+ "mvn",
140
+ "ruby",
141
+ "gem",
142
+ "bundle",
143
+ # Build / task runners
144
+ "make",
145
+ "cmake",
146
+ "ninja",
147
+ "just",
148
+ # Dev tools
149
+ "git",
150
+ "gh",
151
+ "ruff",
152
+ "mypy",
153
+ "pyright",
154
+ "black",
155
+ "isort",
156
+ "pytest",
157
+ "tox",
158
+ "nox",
159
+ "jest",
160
+ "vitest",
161
+ "test",
162
+ "eslint",
163
+ "tsc",
164
+ # Read-only inspection (safe, no side effects)
165
+ "ls",
166
+ "ll",
167
+ "dir",
168
+ "cat",
169
+ "head",
170
+ "tail",
171
+ "less",
172
+ "more",
173
+ "grep",
174
+ "rg",
175
+ "find",
176
+ "fd",
177
+ "which",
178
+ "where",
179
+ "whereis",
180
+ "wc",
181
+ "sort",
182
+ "uniq",
183
+ "diff",
184
+ "comm",
185
+ "cut",
186
+ "tr",
187
+ "awk",
188
+ "sed",
189
+ "stat",
190
+ "file",
191
+ "du",
192
+ "df",
193
+ "env",
194
+ "printenv",
195
+ "echo",
196
+ "printf",
197
+ "date",
198
+ "uname",
199
+ "whoami",
200
+ "hostname",
201
+ # File ops (non-destructive)
202
+ "mkdir",
203
+ "touch",
204
+ "cp",
205
+ "mv",
206
+ "ln",
207
+ "chmod",
208
+ "chown",
209
+ "tree",
210
+ "exa",
211
+ "bat",
212
+ # Shell builtins / misc
213
+ "cd",
214
+ "pwd",
215
+ "export",
216
+ "set",
217
+ "source",
218
+ "eval",
219
+ "true",
220
+ "false",
221
+ "test",
222
+ # Compression (common in build flows)
223
+ "tar",
224
+ "zip",
225
+ "unzip",
226
+ "gzip",
227
+ "gunzip",
228
+ }
229
+ )
230
+
231
+
232
+ def _extract_command_name(command: str) -> str:
233
+ """Extract the first token of a command (the executable name).
234
+
235
+ Handles a few common prefixes that wrap the real command:
236
+ - ``source venv/bin/activate && python ...`` → ``python``
237
+ - ``sudo apt install ...`` → ``apt``
238
+ - ``env VAR=x python ...`` → ``python``
239
+
240
+ Returns the bare name (no path prefix): ``/usr/bin/python`` → ``python``.
241
+ Returns "" for empty/whitespace input.
242
+ """
243
+ if not command or not command.strip():
244
+ return ""
245
+ # Strip leading env-var assignments (``VAR=x foo``) and common wrappers.
246
+ tokens = command.strip().split()
247
+ i = 0
248
+ # Skip ``env`` + its VAR=val args.
249
+ if tokens and tokens[0] in ("env", "sudo", "command"):
250
+ i = 1
251
+ while i < len(tokens) and "=" in tokens[i] and not tokens[i].startswith("-"):
252
+ i += 1
253
+ # Skip ``source x && ...`` / ``. x && ...`` prefix.
254
+ if i < len(tokens) and tokens[i] in ("source", "."):
255
+ # Skip the script arg, then any && / ; separator.
256
+ i += 2
257
+ while i < len(tokens) and tokens[i] in ("&&", "||", ";", "&", "|"):
258
+ i += 1
259
+ if i >= len(tokens):
260
+ return ""
261
+ name = tokens[i]
262
+ # Strip path prefix: /usr/bin/python → python, ./foo → foo.
263
+ if "/" in name or "\\" in name:
264
+ name = name.replace("\\", "/").rsplit("/", 1)[-1]
265
+ return name
266
+
267
+
268
+ @dataclass
269
+ class CommandPolicy:
270
+ """Decides whether a shell command or network call may proceed.
271
+
272
+ Attributes:
273
+ extra_blocked: user-supplied patterns (from config.toml
274
+ [tools].blocked_commands). Appended to the built-in defaults, so
275
+ both layers apply. Each entry is a regex string.
276
+ network_allowed: if False, web_fetch and web_search are blocked
277
+ entirely (offline mode). Defaults to True.
278
+ whitelist_mode: if True, commands whose first token isn't in the
279
+ allowed set are flagged for confirmation (see check_whitelist).
280
+ Defaults to False (blacklist-only, backward compatible).
281
+ allowed_commands: user-supplied additions to the built-in whitelist
282
+ (from config.toml [tools].allowed_commands). Applied only when
283
+ whitelist_mode is True.
284
+ """
285
+
286
+ extra_blocked: list[str] = field(default_factory=list)
287
+ network_allowed: bool = True
288
+ whitelist_mode: bool = False
289
+ allowed_commands: list[str] = field(default_factory=list)
290
+ # Compiled patterns, lazily built. Cached on first check_command call.
291
+ _compiled: list[tuple[re.Pattern, str]] = field(default_factory=list, repr=False)
292
+ _initialized: bool = field(default=False, repr=False)
293
+
294
+ def _ensure_compiled(self) -> None:
295
+ """Compile regexes once, on first use (avoids import-time work if the
296
+ policy is constructed but never checked — e.g. in tests)."""
297
+ if self._initialized:
298
+ return
299
+ for pattern, reason in _DEFAULT_BLOCKED:
300
+ self._compiled.append((re.compile(pattern), reason))
301
+ for user_pattern in self.extra_blocked:
302
+ # User patterns get a generic reason; we don't know their intent.
303
+ try:
304
+ self._compiled.append((re.compile(user_pattern), f"matches user blocklist: {user_pattern}"))
305
+ except re.error:
306
+ # Skip invalid regex rather than crashing — log via the reason
307
+ # so a future check could surface it. Silently dropping is
308
+ # safer than blocking all commands because one pattern is bad.
309
+ continue
310
+ self._initialized = True
311
+
312
+ def check_command(self, command: str) -> str | None:
313
+ """Check a shell command against the blacklist.
314
+
315
+ Returns a human-readable reason string if the command is blocked, or
316
+ None if it may proceed. The reason is surfaced to the model via a
317
+ ToolMessage so it can understand WHY and try a different approach.
318
+
319
+ The blacklist is ALWAYS active (even in FULL mode) — safety takes
320
+ priority. The whitelist (check_whitelist) is a separate, softer layer.
321
+ """
322
+ if not command:
323
+ return None
324
+ self._ensure_compiled()
325
+ for pattern, reason in self._compiled:
326
+ if pattern.search(command):
327
+ return reason
328
+ return None
329
+
330
+ def check_whitelist(self, command: str) -> str | None:
331
+ """Check if a command is outside the whitelist (whitelist mode only).
332
+
333
+ Returns a reason string if the command's first token is NOT in the
334
+ allowed set (the command should be flagged for confirmation), or None
335
+ if the command is whitelisted (or whitelist_mode is disabled).
336
+
337
+ Unlike check_command (hard block), a whitelist miss is NOT a hard
338
+ block — the caller (CommandReviewMiddleware) degrades it to the tier's
339
+ confirm path. This means FULL mode still allows whitelist-miss
340
+ commands, PLAN mode still blocks them (PLAN blocks all shell anyway),
341
+ and CONFIRM/AUTO_EDIT prompt the user.
342
+ """
343
+ if not self.whitelist_mode or not command:
344
+ return None
345
+ name = _extract_command_name(command)
346
+ if not name:
347
+ return None # empty after extraction — let it through, blacklist will catch real issues
348
+ allowed = _DEFAULT_ALLOWED | frozenset(self.allowed_commands)
349
+ if name not in allowed:
350
+ return (
351
+ f"command {name!r} is not in the whitelist "
352
+ f"(whitelist_mode=true). This is not a hard block — the "
353
+ f"permission tier decides whether to confirm (CONFIRM/AUTO_EDIT) "
354
+ f"or allow (FULL). Add {name!r} to [tools].allowed_commands to "
355
+ f"silence this."
356
+ )
357
+ return None
358
+
359
+ @classmethod
360
+ def default(cls) -> CommandPolicy:
361
+ """The policy used when config doesn't specify one: built-in blacklist
362
+ active, network allowed. Safe default that adds protection without
363
+ breaking any normal coding workflow."""
364
+ return cls()