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,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}"