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,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from coderio.tools.base import DESTRUCTIVE_TOOLS, FILE_EDIT_TOOLS
|
|
7
|
+
|
|
8
|
+
# Heuristic keywords for classifying MCP tool risk by NAME. MCP tools arrive
|
|
9
|
+
# with server-prefixed names (e.g. "filesystem_write_file", "github_create_pr")
|
|
10
|
+
# that PermissionGate's DESTRUCTIVE_TOOLS set doesn't recognize — without this
|
|
11
|
+
# classification, a PLAN-mode agent could freely call an MCP server's write/exec
|
|
12
|
+
# tools because they fall through to the "not in DESTRUCTIVE_TOOLS → allow" path.
|
|
13
|
+
#
|
|
14
|
+
# This is a NAME-HEURISTIC, not a capability check: a server could expose a
|
|
15
|
+
# destructive tool under a benign name. The goal is to catch the obvious cases
|
|
16
|
+
# (write/create/delete/execute in the tool name) so the tier gate engages, same
|
|
17
|
+
# as for coderio's built-in tools. False negatives are possible; false positives
|
|
18
|
+
# only trigger a confirm prompt (safe direction).
|
|
19
|
+
_MCP_DESTRUCTIVE_KEYWORDS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"write",
|
|
22
|
+
"create",
|
|
23
|
+
"delete",
|
|
24
|
+
"remove",
|
|
25
|
+
"execute",
|
|
26
|
+
"exec",
|
|
27
|
+
"run",
|
|
28
|
+
"shell",
|
|
29
|
+
"fetch", # network egress
|
|
30
|
+
"request",
|
|
31
|
+
"post", # HTTP POST
|
|
32
|
+
"put", # HTTP PUT
|
|
33
|
+
"patch", # HTTP PATCH
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Built-in tool names that CONTAIN a destructive keyword but must NOT be
|
|
38
|
+
# classified destructive by the heuristic — they're handled explicitly by the
|
|
39
|
+
# mode logic (or are read-only despite the name). Without this exclusion,
|
|
40
|
+
# write_todos (deepagents' planning tool) would match "write" and be blocked in
|
|
41
|
+
# PLAN mode, preventing the agent from ever creating a todo list.
|
|
42
|
+
_HEURISTIC_EXCLUDE = frozenset({"write_todos"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_mcp_destructive(tool_name: str) -> bool:
|
|
46
|
+
"""Heuristic: does this tool name suggest a destructive/network action?
|
|
47
|
+
|
|
48
|
+
Used for MCP tools whose names aren't in the static DESTRUCTIVE_TOOLS set.
|
|
49
|
+
Matches any keyword as a substring (case-insensitive) — "filesystem_write",
|
|
50
|
+
"github_create_pr", "db_delete_row" all match.
|
|
51
|
+
|
|
52
|
+
Excludes built-in names in _HEURISTIC_EXCLUDE that happen to contain a
|
|
53
|
+
keyword but have their own explicit handling (e.g. write_todos is a
|
|
54
|
+
planning tool, not a file write).
|
|
55
|
+
"""
|
|
56
|
+
if tool_name in _HEURISTIC_EXCLUDE:
|
|
57
|
+
return False
|
|
58
|
+
lower = tool_name.lower()
|
|
59
|
+
return any(kw in lower for kw in _MCP_DESTRUCTIVE_KEYWORDS)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PermissionMode(StrEnum):
|
|
63
|
+
"""Four permission levels (industry-standard tiered access).
|
|
64
|
+
|
|
65
|
+
StrEnum so members ARE strings (== works with raw str), but invalid values
|
|
66
|
+
raise ValueError at construction — catching config typos early.
|
|
67
|
+
|
|
68
|
+
Levels (least → most permissive):
|
|
69
|
+
PLAN — read-only, blocks ALL writes/shell (safe exploration)
|
|
70
|
+
CONFIRM — prompts before each destructive action
|
|
71
|
+
AUTO_EDIT — auto-allow file edits, but shell/web/note still need confirm
|
|
72
|
+
FULL — auto-allow everything (no prompts)
|
|
73
|
+
|
|
74
|
+
Backward compat: the old "auto" string maps to FULL via normalize().
|
|
75
|
+
|
|
76
|
+
Security model: file path isolation is handled by deepagents' backend
|
|
77
|
+
virtual_mode (not by WorkspacePolicy — which was deleted because it
|
|
78
|
+
couldn't handle virtual paths). Permission gates only control WHICH tools
|
|
79
|
+
may execute, not WHERE they can write.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
PLAN = "plan"
|
|
83
|
+
CONFIRM = "confirm"
|
|
84
|
+
AUTO_EDIT = "auto_edit"
|
|
85
|
+
FULL = "full"
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def normalize(cls, raw: str) -> "PermissionMode":
|
|
89
|
+
"""Map a raw config string to a PermissionMode, with backward compat.
|
|
90
|
+
|
|
91
|
+
Old configs may have permission_mode = "auto" (the pre-v0.2 name for
|
|
92
|
+
FULL). This silently upgrades it so users don't get a ValueError on
|
|
93
|
+
existing configs.
|
|
94
|
+
"""
|
|
95
|
+
if raw == "auto":
|
|
96
|
+
return cls.FULL
|
|
97
|
+
return cls(raw)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PermissionGate:
|
|
101
|
+
"""Permission gate based on tool type + mode (no path checking).
|
|
102
|
+
|
|
103
|
+
Path isolation is delegated to deepagents' backend virtual_mode, which
|
|
104
|
+
maps virtual paths (e.g. /foo.py) to the workspace root and enforces
|
|
105
|
+
that all file operations stay inside it. This gate only decides whether
|
|
106
|
+
a tool TYPE is allowed in the current MODE — it does NOT inspect paths.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, mode: str, auto_allow_execute: bool = False):
|
|
110
|
+
"""Initialize the gate.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
mode: permission tier (plan/confirm/auto_edit/full).
|
|
114
|
+
auto_allow_execute: when True, the ``execute`` (shell) tool is
|
|
115
|
+
auto-allowed in CONFIRM/AUTO_EDIT modes without prompting —
|
|
116
|
+
this is the "sandbox 消除问题" design from Claude Code
|
|
117
|
+
(``autoAllowBashIfSandboxed``). Intended for use when
|
|
118
|
+
``sandbox_mode != "off"``: the sandbox provides the real
|
|
119
|
+
isolation boundary, so the per-command prompt becomes noise.
|
|
120
|
+
PLAN mode is unaffected (always read-only — sandbox doesn't
|
|
121
|
+
change that semantics). The command blacklist still applies
|
|
122
|
+
(via CommandReviewMiddleware, which runs after this gate).
|
|
123
|
+
"""
|
|
124
|
+
self._mode = PermissionMode.normalize(mode)
|
|
125
|
+
self._auto_allow_execute = auto_allow_execute
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def mode(self) -> str:
|
|
129
|
+
return self._mode
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def auto_allow_execute(self) -> bool:
|
|
133
|
+
return self._auto_allow_execute
|
|
134
|
+
|
|
135
|
+
def check(self, tool_name: str, args: dict[str, Any]) -> bool | str:
|
|
136
|
+
# note tool: only WRITE/APPEND/DELETE are destructive. read/list are
|
|
137
|
+
# read-only and shouldn't prompt (same as read_file/ls).
|
|
138
|
+
if tool_name == "note":
|
|
139
|
+
action = str(args.get("action", "")).lower()
|
|
140
|
+
if action in ("read", "list"):
|
|
141
|
+
return True
|
|
142
|
+
if tool_name in DESTRUCTIVE_TOOLS:
|
|
143
|
+
pass # fall through to mode-based decision below
|
|
144
|
+
elif _is_mcp_destructive(tool_name):
|
|
145
|
+
# MCP tool with a destructive-sounding name (write/create/delete/execute/...).
|
|
146
|
+
# Treat it like a built-in destructive tool so the tier gate engages.
|
|
147
|
+
# This prevents a PLAN-mode agent from calling an MCP server's write
|
|
148
|
+
# tool just because "filesystem_write_file" isn't in DESTRUCTIVE_TOOLS.
|
|
149
|
+
pass
|
|
150
|
+
else:
|
|
151
|
+
# Read-only tool (built-in or MCP) — allow regardless of mode.
|
|
152
|
+
return True
|
|
153
|
+
# FULL: auto-allow everything.
|
|
154
|
+
if self._mode == PermissionMode.FULL:
|
|
155
|
+
return True
|
|
156
|
+
# auto_allow_execute: the "sandbox 消除问题" path. When a sandbox is
|
|
157
|
+
# active, the OS provides the real isolation boundary, so prompting
|
|
158
|
+
# the user for every shell command becomes noise. Skip the prompt for
|
|
159
|
+
# the execute tool specifically. PLAN mode is excluded — PLAN is always
|
|
160
|
+
# read-only, and sandbox doesn't change that contract.
|
|
161
|
+
if tool_name == "execute" and self._auto_allow_execute and self._mode != PermissionMode.PLAN:
|
|
162
|
+
return True
|
|
163
|
+
# AUTO_EDIT: auto-allow file edits, shell/web/note still confirm.
|
|
164
|
+
# MCP destructive tools always confirm in AUTO_EDIT (we can't reliably
|
|
165
|
+
# tell an MCP write from an MCP execute, so be conservative).
|
|
166
|
+
if self._mode == PermissionMode.AUTO_EDIT:
|
|
167
|
+
if tool_name in FILE_EDIT_TOOLS:
|
|
168
|
+
return True
|
|
169
|
+
return self._ask(tool_name, args)
|
|
170
|
+
if self._mode == PermissionMode.PLAN:
|
|
171
|
+
return False
|
|
172
|
+
# CONFIRM: prompt for all destructive tools.
|
|
173
|
+
return self._ask(tool_name, args)
|
|
174
|
+
|
|
175
|
+
def _ask(self, tool_name: str, args: dict[str, Any]) -> bool | str:
|
|
176
|
+
raise NotImplementedError
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _default_prompt(tool_name: str, args) -> bool:
|
|
180
|
+
"""Console prompt used when no custom prompter is supplied."""
|
|
181
|
+
confirm = input(f"Allow {tool_name}({args})? [y/N] ").strip().lower()
|
|
182
|
+
return confirm in {"yes", "y"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RichPromptPermissionGate(PermissionGate):
|
|
186
|
+
"""Concrete confirm-mode gate using a Rich console.
|
|
187
|
+
|
|
188
|
+
`prompt_fn` is injectable so tests can answer without a real TTY.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
console=None,
|
|
194
|
+
prompt_fn: Callable[[str, dict[str, Any]], bool] | None = None,
|
|
195
|
+
auto_allow_execute: bool = False,
|
|
196
|
+
):
|
|
197
|
+
super().__init__(PermissionMode.CONFIRM, auto_allow_execute=auto_allow_execute)
|
|
198
|
+
self._console = console
|
|
199
|
+
self._prompt_fn = prompt_fn or _default_prompt
|
|
200
|
+
|
|
201
|
+
def _ask(self, tool_name: str, args: dict[str, Any]) -> bool:
|
|
202
|
+
if self._console:
|
|
203
|
+
self._console.print(f"[yellow]permission:[/yellow] {tool_name}({args})")
|
|
204
|
+
return self._prompt_fn(tool_name, args)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class AutoPermissionGate(PermissionGate):
|
|
208
|
+
"""Auto-approve everything (FULL mode). For tests / explicit trust."""
|
|
209
|
+
|
|
210
|
+
def __init__(self):
|
|
211
|
+
super().__init__(PermissionMode.FULL)
|
|
212
|
+
|
|
213
|
+
def _ask(self, tool_name: str, args: dict[str, Any]) -> bool:
|
|
214
|
+
return True
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReadFileArgs(BaseModel):
|
|
9
|
+
path: str = Field(
|
|
10
|
+
description="Path to the file. Relative paths resolve from the project root (cwd), "
|
|
11
|
+
"NOT from the file's own directory. If unsure of the exact path, use glob or "
|
|
12
|
+
"grep to locate it first."
|
|
13
|
+
)
|
|
14
|
+
offset: int = Field(default=0, description="1-based first line number to show (0=start).")
|
|
15
|
+
limit: int = Field(default=2000, description="Maximum number of lines to return.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReadFileTool:
|
|
19
|
+
name = "read_file"
|
|
20
|
+
description = (
|
|
21
|
+
"Read a file from the local filesystem. Returns content with line numbers "
|
|
22
|
+
"(cat -n style). Supports offset and limit.\n\n"
|
|
23
|
+
"PATH RESOLUTION: relative paths resolve from the project root (cwd), not from "
|
|
24
|
+
"the file's own directory. For example, if you saw 'store.py' under 'session/' "
|
|
25
|
+
"in list_dir('src/coderio'), the correct path is 'src/coderio/session/store.py', "
|
|
26
|
+
"NOT 'session/store.py'. When a read returns 'file not found', use glob to find "
|
|
27
|
+
"the actual location: glob('**/store.py')."
|
|
28
|
+
)
|
|
29
|
+
args_schema = ReadFileArgs
|
|
30
|
+
|
|
31
|
+
def run(self, path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
32
|
+
p = Path(path)
|
|
33
|
+
if not p.exists():
|
|
34
|
+
return f"Error: file not found: {path}"
|
|
35
|
+
if p.is_dir():
|
|
36
|
+
return f"Error: path is a directory: {path}"
|
|
37
|
+
try:
|
|
38
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
39
|
+
except OSError as e:
|
|
40
|
+
return f"Error reading file: {e}"
|
|
41
|
+
lines = text.splitlines()
|
|
42
|
+
if offset > 0:
|
|
43
|
+
start_idx = max(0, offset - 1)
|
|
44
|
+
else:
|
|
45
|
+
start_idx = 0
|
|
46
|
+
if limit:
|
|
47
|
+
end_idx = start_idx + limit
|
|
48
|
+
else:
|
|
49
|
+
end_idx = len(lines)
|
|
50
|
+
numbered = [f"{i}\t{line}" for i, line in enumerate(lines[start_idx:end_idx], start_idx + 1)]
|
|
51
|
+
return "\n".join(numbered)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Cross-platform sandbox dispatcher.
|
|
2
|
+
|
|
3
|
+
Selects the appropriate OS-level sandbox based on platform + mode:
|
|
4
|
+
- Windows: win_sandbox.py (Restricted Token + Job Object)
|
|
5
|
+
- Linux: linux_sandbox.py (bubblewrap)
|
|
6
|
+
- macOS: linux_sandbox.py fallback (bubblewrap if available, else off)
|
|
7
|
+
|
|
8
|
+
Modes (match config ToolsConfig.sandbox_mode):
|
|
9
|
+
- "off": no sandbox — caller should use plain subprocess (we return -1
|
|
10
|
+
so the caller falls back to its own subprocess path).
|
|
11
|
+
- "job": Windows Job Object (resource limits + process-tree kill only).
|
|
12
|
+
On Linux, equivalent to plain run (POSIX process groups already
|
|
13
|
+
provide reliable kill; resource limits need cgroups — future work).
|
|
14
|
+
- "write": Windows Restricted Token (file-write isolation). On Linux,
|
|
15
|
+
bubblewrap (read-only /, write to workspace only).
|
|
16
|
+
|
|
17
|
+
This module is the single import point for the shell backend — it keeps the
|
|
18
|
+
platform branching out of deep_loop.py so the backend stays readable.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_with_sandbox(
|
|
30
|
+
command: str,
|
|
31
|
+
cwd: str,
|
|
32
|
+
*,
|
|
33
|
+
mode: str = "job",
|
|
34
|
+
timeout: int = 120,
|
|
35
|
+
env: dict | None = None,
|
|
36
|
+
max_output_bytes: int = 100_000,
|
|
37
|
+
network_allowed: bool = True,
|
|
38
|
+
fs_config=None,
|
|
39
|
+
) -> tuple[int, str]:
|
|
40
|
+
"""Run a command with the requested sandbox mode.
|
|
41
|
+
|
|
42
|
+
Returns (exit_code, combined_output). On any sandbox failure, degrades to
|
|
43
|
+
plain subprocess.run (returns -1 only if even the fallback fails — callers
|
|
44
|
+
should treat -1 as "retry without sandbox").
|
|
45
|
+
|
|
46
|
+
The caller (shell backend's execute) passes mode through from config. If
|
|
47
|
+
the sandbox isn't available on this platform (e.g. "write" on a Linux
|
|
48
|
+
without bubblewrap), we log + fall back to subprocess — never block work.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
network_allowed: forwarded to bubblewrap as ``--unshare-net`` when
|
|
52
|
+
False. REGRESSION GUARD: this was previously not forwarded (the
|
|
53
|
+
``run_bwrap`` call omitted it), so ``network_allowed=false`` had
|
|
54
|
+
zero effect on Linux sandbox mode — a silent security gap.
|
|
55
|
+
fs_config: optional SandboxFsConfig for filesystem allow/deny lists
|
|
56
|
+
(bubblewrap only; Windows ignores it — token is a no-op there).
|
|
57
|
+
"""
|
|
58
|
+
if mode == "off":
|
|
59
|
+
# Shouldn't reach here (caller checks mode before calling), but handle
|
|
60
|
+
# it defensively by signalling "use your own subprocess path".
|
|
61
|
+
return (-1, "sandbox mode is off")
|
|
62
|
+
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
from coderio.tools.win_sandbox import run_sandboxed
|
|
65
|
+
|
|
66
|
+
# Windows: fs_config not yet applied (token is no-op; ACL is TODO).
|
|
67
|
+
return run_sandboxed(command, cwd, timeout=timeout, env=env, max_output_bytes=max_output_bytes)
|
|
68
|
+
|
|
69
|
+
# POSIX: try bubblewrap (linux_sandbox module) for "write" mode; for "job"
|
|
70
|
+
# mode there's no POSIX equivalent of Job Object resource limits without
|
|
71
|
+
# cgroups, so we fall through to plain subprocess (with start_new_session
|
|
72
|
+
# for at least reliable process-group kill).
|
|
73
|
+
if mode == "write":
|
|
74
|
+
try:
|
|
75
|
+
from coderio.tools.linux_sandbox import bwrap_available, run_bwrap
|
|
76
|
+
|
|
77
|
+
if bwrap_available():
|
|
78
|
+
return run_bwrap(
|
|
79
|
+
command,
|
|
80
|
+
cwd,
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
env=env,
|
|
83
|
+
max_output_bytes=max_output_bytes,
|
|
84
|
+
network_allowed=network_allowed,
|
|
85
|
+
fs_config=fs_config,
|
|
86
|
+
)
|
|
87
|
+
_log.warning("sandbox_mode=write but bubblewrap not installed — falling back to plain run")
|
|
88
|
+
except ImportError:
|
|
89
|
+
_log.warning("linux_sandbox not available — falling back to plain run")
|
|
90
|
+
|
|
91
|
+
# Fallback: plain subprocess with start_new_session (for POSIX process-group kill).
|
|
92
|
+
import subprocess
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
kwargs: dict = {"shell": True, "capture_output": True, "cwd": cwd, "timeout": timeout, "text": False}
|
|
96
|
+
if sys.platform != "win32":
|
|
97
|
+
kwargs["start_new_session"] = True # own process group → killpg works
|
|
98
|
+
if env is not None:
|
|
99
|
+
kwargs["env"] = env
|
|
100
|
+
kwargs["stdin"] = subprocess.DEVNULL
|
|
101
|
+
proc = subprocess.run(command, **kwargs)
|
|
102
|
+
stdout = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
|
|
103
|
+
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
|
104
|
+
output = stdout
|
|
105
|
+
if stderr:
|
|
106
|
+
output += f"\n[stderr]\n{stderr}"
|
|
107
|
+
if len(output) > max_output_bytes:
|
|
108
|
+
output = output[:max_output_bytes] + f"\n\n... Output truncated at {max_output_bytes} bytes."
|
|
109
|
+
return (proc.returncode, output)
|
|
110
|
+
except subprocess.TimeoutExpired:
|
|
111
|
+
return (124, f"Command timed out after {timeout}s")
|
|
112
|
+
except Exception as e: # noqa: BLE001
|
|
113
|
+
return (1, f"Execution error: {e}")
|
coderio/tools/todo.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TodoArgs(BaseModel):
|
|
9
|
+
action: str = Field(description="One of: list, add, update, delete.")
|
|
10
|
+
content: str = Field(default="", description="Todo text (for 'add').")
|
|
11
|
+
priority: str = Field(default="medium", description="high | medium | low (for 'add').")
|
|
12
|
+
index: int = Field(default=-1, description="Todo index (for 'update'/'delete').")
|
|
13
|
+
status: str = Field(default="", description="New status (for 'update').")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Todo:
|
|
18
|
+
content: str
|
|
19
|
+
status: str = "pending"
|
|
20
|
+
priority: str = "medium"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TodoStore:
|
|
25
|
+
todos: list[Todo] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TodoTool:
|
|
29
|
+
name = "todo"
|
|
30
|
+
description = "Manage a task list. actions: list, add, update, delete."
|
|
31
|
+
args_schema = TodoArgs
|
|
32
|
+
|
|
33
|
+
def __init__(self, store: TodoStore):
|
|
34
|
+
self.store = store
|
|
35
|
+
|
|
36
|
+
def run(
|
|
37
|
+
self,
|
|
38
|
+
action: str,
|
|
39
|
+
content: str = "",
|
|
40
|
+
priority: str = "medium",
|
|
41
|
+
index: int = -1,
|
|
42
|
+
status: str = "",
|
|
43
|
+
) -> str:
|
|
44
|
+
if action == "list":
|
|
45
|
+
if not self.store.todos:
|
|
46
|
+
return "No todos."
|
|
47
|
+
lines = []
|
|
48
|
+
for i, t in enumerate(self.store.todos):
|
|
49
|
+
if t.status == "completed":
|
|
50
|
+
mark = "✓"
|
|
51
|
+
elif t.status == "in_progress":
|
|
52
|
+
mark = "→"
|
|
53
|
+
else:
|
|
54
|
+
mark = " "
|
|
55
|
+
lines.append(f"[{i}] {mark} ({t.priority}) {t.content}")
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
elif action == "add":
|
|
58
|
+
self.store.todos.append(Todo(content=content, priority=priority))
|
|
59
|
+
return f"Added todo: {content}"
|
|
60
|
+
elif action == "update":
|
|
61
|
+
if not (0 <= index < len(self.store.todos)):
|
|
62
|
+
return f"Error: index {index} out of range"
|
|
63
|
+
if status:
|
|
64
|
+
self.store.todos[index].status = status
|
|
65
|
+
return f"Updated todo {index}"
|
|
66
|
+
elif action == "delete":
|
|
67
|
+
if not (0 <= index < len(self.store.todos)):
|
|
68
|
+
return f"Error: index {index} out of range"
|
|
69
|
+
self.store.todos.pop(index)
|
|
70
|
+
return f"Deleted todo {index}"
|
|
71
|
+
return f"Error: unknown action {action!r}"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Web fetch tool with SSRF (Server-Side Request Forgery) protection.
|
|
2
|
+
|
|
3
|
+
SECURITY (2026-08-14 report P0-3): the original implementation did a bare
|
|
4
|
+
``httpx.get(url, follow_redirects=True)`` with no validation. A prompt-injected
|
|
5
|
+
model could point it at:
|
|
6
|
+
|
|
7
|
+
- ``http://127.0.0.1:*`` — local services (databases, admin panels)
|
|
8
|
+
- ``http://169.254.169.254/latest/meta-data/`` — cloud metadata (AWS/GCP/Azure
|
|
9
|
+
credential exfiltration; this is the classic SSRF payout)
|
|
10
|
+
- ``http://[::1]``, ``http://10.x.x.x``, ``http://192.168.x.x`` — internal networks
|
|
11
|
+
|
|
12
|
+
Protections applied here (defense in depth):
|
|
13
|
+
|
|
14
|
+
1. Scheme allowlist: only ``http`` / ``https`` (blocks ``file://``, ``ftp://``...).
|
|
15
|
+
2. IP-based blocking at CONNECT time: the hostname is resolved and every
|
|
16
|
+
resolved address is checked against private / loopback / link-local /
|
|
17
|
+
reserved ranges BEFORE the request is sent. DNS-rebinding is mitigated by
|
|
18
|
+
checking at connect time via a custom transport, not just at parse time.
|
|
19
|
+
3. Redirect policy: redirects are followed MANUALLY (max 3 hops), and each hop's
|
|
20
|
+
URL goes through the same validation. ``follow_redirects=False`` on the
|
|
21
|
+
client — an allowlisted URL redirecting to 169.254.169.254 is still blocked.
|
|
22
|
+
4. Response size cap: stream up to 1 MB, not ``resp.text`` unbounded into memory.
|
|
23
|
+
5. Content-Type sniff: binary payloads are rejected early.
|
|
24
|
+
|
|
25
|
+
This is prompt-injection defense, not a hard boundary — a determined attacker
|
|
26
|
+
with a DNS name that resolves to a public-but-malicious host still gets
|
|
27
|
+
fetched. The goal is to close the internal-network/metadata-exfiltration
|
|
28
|
+
class, which is the realistic damage path for a local coding agent.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ipaddress
|
|
34
|
+
import re
|
|
35
|
+
import socket
|
|
36
|
+
|
|
37
|
+
import httpx
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
|
|
40
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
41
|
+
_WS_RE = re.compile(r"\s+")
|
|
42
|
+
|
|
43
|
+
_MAX_RESPONSE_BYTES = 1_000_000 # 1 MB cap — resp.text unbounded was an OOM vector
|
|
44
|
+
_MAX_REDIRECTS = 3
|
|
45
|
+
|
|
46
|
+
_SSRF_ERROR_TEMPLATE = (
|
|
47
|
+
"Error fetching {url}: blocked by SSRF protection — {reason}. "
|
|
48
|
+
"coderio's web_fetch may only reach public internet hosts over http/https; "
|
|
49
|
+
"private networks, loopback, and link-local addresses (including cloud "
|
|
50
|
+
"metadata services) are not fetchable."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_text(html: str) -> str:
|
|
55
|
+
for tag in ("article", "main", "body"):
|
|
56
|
+
m = re.search(f"<{tag}[^>]*>(.*?)</{tag}>", html, re.DOTALL | re.IGNORECASE)
|
|
57
|
+
if m:
|
|
58
|
+
html = m.group(1)
|
|
59
|
+
break
|
|
60
|
+
text = _TAG_RE.sub(" ", html)
|
|
61
|
+
return _WS_RE.sub(" ", text).strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_blocked_address(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str | None:
|
|
65
|
+
"""Return a human-readable reason if the address must not be fetched, else None."""
|
|
66
|
+
if ip.is_loopback:
|
|
67
|
+
return f"loopback address {ip} is not allowed"
|
|
68
|
+
if ip.is_private:
|
|
69
|
+
return f"private address {ip} is not allowed"
|
|
70
|
+
if ip.is_link_local:
|
|
71
|
+
return f"link-local address {ip} is not allowed (cloud metadata lives here)"
|
|
72
|
+
if ip.is_reserved or ip.is_unspecified or ip.is_multicast:
|
|
73
|
+
return f"reserved address {ip} is not allowed"
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_url_host(url: str) -> str | None:
|
|
78
|
+
"""Validate scheme + resolved host addresses. Returns an error reason or None.
|
|
79
|
+
|
|
80
|
+
Resolution happens HERE (per request / per redirect hop), so a DNS name
|
|
81
|
+
that flips to an internal IP between checks is still caught.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
parsed = httpx.URL(url)
|
|
85
|
+
except Exception as e: # noqa: BLE001 — malformed URL is a user error, not a crash
|
|
86
|
+
return f"invalid URL: {e}"
|
|
87
|
+
|
|
88
|
+
if parsed.scheme not in ("http", "https"):
|
|
89
|
+
return f"scheme {parsed.scheme!r} is not allowed (only http/https)"
|
|
90
|
+
|
|
91
|
+
host = parsed.host
|
|
92
|
+
if not host:
|
|
93
|
+
return "URL has no host"
|
|
94
|
+
|
|
95
|
+
# Bracketed IPv6 literal: httpx.URL.host strips the brackets.
|
|
96
|
+
try:
|
|
97
|
+
# Try to parse the host as a literal IP first (fast path, no DNS).
|
|
98
|
+
addr = ipaddress.ip_address(host)
|
|
99
|
+
return _is_blocked_address(addr)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass # Not an IP literal — resolve as a hostname below.
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
infos = socket.getaddrinfo(host, None)
|
|
105
|
+
except socket.gaierror as e:
|
|
106
|
+
return f"DNS resolution failed: {e}"
|
|
107
|
+
|
|
108
|
+
for info in infos:
|
|
109
|
+
sockaddr = info[4]
|
|
110
|
+
ip_str = sockaddr[0]
|
|
111
|
+
try:
|
|
112
|
+
addr = ipaddress.ip_address(ip_str)
|
|
113
|
+
except ValueError:
|
|
114
|
+
continue
|
|
115
|
+
reason = _is_blocked_address(addr)
|
|
116
|
+
if reason:
|
|
117
|
+
return reason
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WebFetchArgs(BaseModel):
|
|
122
|
+
url: str = Field(description="URL to fetch (public internet, http/https only).")
|
|
123
|
+
timeout: int = Field(default=20, description="Request timeout in seconds.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WebFetchTool:
|
|
127
|
+
name = "web_fetch"
|
|
128
|
+
description = (
|
|
129
|
+
"Fetch a public-internet URL and return extracted text content. "
|
|
130
|
+
"Private/loopback/link-local addresses are blocked (SSRF protection). "
|
|
131
|
+
"Requires permission."
|
|
132
|
+
)
|
|
133
|
+
args_schema = WebFetchArgs
|
|
134
|
+
|
|
135
|
+
def run(self, url: str, timeout: int = 20) -> str:
|
|
136
|
+
# Validate the initial URL before any request is made.
|
|
137
|
+
reason = _validate_url_host(url)
|
|
138
|
+
if reason:
|
|
139
|
+
return _SSRF_ERROR_TEMPLATE.format(url=url, reason=reason)
|
|
140
|
+
|
|
141
|
+
# follow_redirects=False — we hop manually so EACH redirect target
|
|
142
|
+
# passes validation. A public URL redirecting to 169.254.169.254
|
|
143
|
+
# (the metadata-service exfil pattern) is caught at the hop.
|
|
144
|
+
try:
|
|
145
|
+
with httpx.Client(
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
follow_redirects=False,
|
|
148
|
+
headers={"User-Agent": "coderio/0.1"},
|
|
149
|
+
) as client:
|
|
150
|
+
current_url = url
|
|
151
|
+
for _hop in range(_MAX_REDIRECTS + 1):
|
|
152
|
+
resp = client.get(current_url)
|
|
153
|
+
if resp.is_redirect:
|
|
154
|
+
# Validate the redirect target with the same rules.
|
|
155
|
+
next_url = str(resp.headers.get("location", ""))
|
|
156
|
+
if not next_url:
|
|
157
|
+
return f"Error fetching {url}: redirect without Location header"
|
|
158
|
+
# Relative redirect — resolve against the current URL.
|
|
159
|
+
next_url = str(httpx.URL(current_url).join(next_url))
|
|
160
|
+
reason = _validate_url_host(next_url)
|
|
161
|
+
if reason:
|
|
162
|
+
return _SSRF_ERROR_TEMPLATE.format(url=next_url, reason=reason)
|
|
163
|
+
current_url = next_url
|
|
164
|
+
continue
|
|
165
|
+
resp.raise_for_status()
|
|
166
|
+
|
|
167
|
+
# Binary sniff: reading a 1MB of gzip'd binary as text is
|
|
168
|
+
# useless to the model and wastes the context budget.
|
|
169
|
+
ctype = resp.headers.get("content-type", "").lower()
|
|
170
|
+
if any(
|
|
171
|
+
t in ctype
|
|
172
|
+
for t in ("image/", "audio/", "video/", "application/octet-stream", "application/pdf")
|
|
173
|
+
):
|
|
174
|
+
return f"Error fetching {url}: unsupported content type {ctype!r} (text/HTML only)"
|
|
175
|
+
|
|
176
|
+
# Size cap via streamed read (Content-Length may lie/absent).
|
|
177
|
+
chunks: list[bytes] = []
|
|
178
|
+
total = 0
|
|
179
|
+
for chunk in resp.iter_bytes():
|
|
180
|
+
chunks.append(chunk)
|
|
181
|
+
total += len(chunk)
|
|
182
|
+
if total > _MAX_RESPONSE_BYTES:
|
|
183
|
+
break
|
|
184
|
+
body = b"".join(chunks)[:_MAX_RESPONSE_BYTES]
|
|
185
|
+
return _extract_text(body.decode("utf-8", errors="replace"))[:8000]
|
|
186
|
+
return f"Error fetching {url}: too many redirects (max {_MAX_REDIRECTS})"
|
|
187
|
+
except Exception as e: # noqa: BLE001 — surface fetch errors to the model
|
|
188
|
+
return f"Error fetching {url}: {e}"
|