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.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- 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()
|