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
coderio/config/loader.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from coderio.config.models import (
|
|
9
|
+
CliConfig,
|
|
10
|
+
Config,
|
|
11
|
+
ContextConfig,
|
|
12
|
+
ModelConfig,
|
|
13
|
+
Profile,
|
|
14
|
+
SandboxFsConfig,
|
|
15
|
+
SessionConfig,
|
|
16
|
+
SkillsConfig,
|
|
17
|
+
ToolsConfig,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_toml(path: Path) -> dict:
|
|
22
|
+
if path.is_file():
|
|
23
|
+
with open(path, "rb") as f:
|
|
24
|
+
return tomllib.load(f)
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _merge(base: dict, override: dict) -> dict:
|
|
29
|
+
"""Shallow-merge per section; override wins at the key level."""
|
|
30
|
+
out = {k: dict(v) if isinstance(v, dict) else v for k, v in base.items()}
|
|
31
|
+
for k, v in override.items():
|
|
32
|
+
if isinstance(v, dict):
|
|
33
|
+
out.setdefault(k, {})
|
|
34
|
+
out[k].update(v)
|
|
35
|
+
else:
|
|
36
|
+
out[k] = v
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_project_dir(search_from: Path) -> Path:
|
|
41
|
+
"""Walk upward from search_from looking for a project's .coderio/config.toml.
|
|
42
|
+
|
|
43
|
+
Never ascends into the user's home directory (the home ~/.coderio is the USER
|
|
44
|
+
layer, handled separately), so a temp dir nested under home won't accidentally
|
|
45
|
+
pick up the real user config as if it were a project config.
|
|
46
|
+
"""
|
|
47
|
+
cur = search_from.resolve()
|
|
48
|
+
home = Path(os.path.expanduser("~")).resolve()
|
|
49
|
+
for parent in [cur, *cur.parents]:
|
|
50
|
+
if parent == home:
|
|
51
|
+
break
|
|
52
|
+
if (parent / ".coderio" / "config.toml").is_file():
|
|
53
|
+
return parent
|
|
54
|
+
return search_from.resolve()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _default_user_dir() -> Path:
|
|
58
|
+
return Path(os.path.expanduser("~")) / ".coderio"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_profiles(data: dict) -> list:
|
|
62
|
+
"""Parse the [[profiles]] array into Profile objects.
|
|
63
|
+
|
|
64
|
+
Each table element must have at least name + provider_id + model. base_url
|
|
65
|
+
and kind fall back to "" and "openai_compatible". Malformed entries (missing
|
|
66
|
+
required fields) are skipped rather than crashing — a typo in one profile
|
|
67
|
+
shouldn't prevent the whole config from loading.
|
|
68
|
+
"""
|
|
69
|
+
raw = data.get("profiles")
|
|
70
|
+
if not isinstance(raw, list):
|
|
71
|
+
return []
|
|
72
|
+
out = []
|
|
73
|
+
for entry in raw:
|
|
74
|
+
if not isinstance(entry, dict):
|
|
75
|
+
continue
|
|
76
|
+
name = entry.get("name")
|
|
77
|
+
pid = entry.get("provider_id")
|
|
78
|
+
model = entry.get("model")
|
|
79
|
+
if not (name and pid and model):
|
|
80
|
+
continue # incomplete profile — skip silently
|
|
81
|
+
# context_limit is optional and best-effort: a missing/non-int value
|
|
82
|
+
# just means "not probed yet" (0), don't raise — the runtime falls back
|
|
83
|
+
# to ContextConfig.model_context_limit.
|
|
84
|
+
cl_raw = entry.get("context_limit", 0)
|
|
85
|
+
context_limit = cl_raw if isinstance(cl_raw, int) and not isinstance(cl_raw, bool) else 0
|
|
86
|
+
out.append(
|
|
87
|
+
Profile(
|
|
88
|
+
name=name,
|
|
89
|
+
provider_id=pid,
|
|
90
|
+
model=model,
|
|
91
|
+
base_url=entry.get("base_url", ""),
|
|
92
|
+
kind=entry.get("kind", "openai_compatible"),
|
|
93
|
+
context_limit=context_limit,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_active_profile(data: dict) -> str:
|
|
100
|
+
"""Resolve the active profile name from config, with a sane default.
|
|
101
|
+
|
|
102
|
+
Returns the explicit `active_profile` value if set and non-empty. Otherwise
|
|
103
|
+
empty string — build_chat_model treats empty active_profile as "use the
|
|
104
|
+
legacy [model] path", so old users with no profiles are unaffected. (The
|
|
105
|
+
"default to first profile when active is unset" lives in build_chat_model,
|
|
106
|
+
not here, because the loader shouldn't mutate user intent on disk.)
|
|
107
|
+
"""
|
|
108
|
+
active = data.get("active_profile", "")
|
|
109
|
+
if isinstance(active, str):
|
|
110
|
+
return active.strip()
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _read_fs_list(data: dict, key: str, default: list[str]) -> list[str]:
|
|
115
|
+
"""Read a list-of-strings field from a config subtable (inline copy of
|
|
116
|
+
_from_dict._str_list, which is a closure-local helper and not visible here).
|
|
117
|
+
|
|
118
|
+
Returns the default on missing/malformed values — a bad config shouldn't
|
|
119
|
+
block startup.
|
|
120
|
+
"""
|
|
121
|
+
v = data.get(key, default)
|
|
122
|
+
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
|
123
|
+
return v
|
|
124
|
+
return default
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _read_sandbox_fs(data, default: SandboxFsConfig | None) -> SandboxFsConfig | None:
|
|
128
|
+
"""Read the [tools.sandbox_filesystem] subtable into a SandboxFsConfig.
|
|
129
|
+
|
|
130
|
+
Returns the `default` unchanged when the subtable is empty/missing, so
|
|
131
|
+
users who don't configure filesystem isolation get None (bubblewrap uses
|
|
132
|
+
its built-in workspace-only layout).
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(data, dict) or not data:
|
|
135
|
+
return default
|
|
136
|
+
return SandboxFsConfig(
|
|
137
|
+
allow_write=_read_fs_list(data, "allow_write", default.allow_write if default else []),
|
|
138
|
+
deny_write=_read_fs_list(data, "deny_write", default.deny_write if default else []),
|
|
139
|
+
deny_read=_read_fs_list(data, "deny_read", default.deny_read if default else []),
|
|
140
|
+
allow_read=_read_fs_list(data, "allow_read", default.allow_read if default else []),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _from_dict(data: dict) -> Config:
|
|
145
|
+
cfg = Config()
|
|
146
|
+
m = data.get("model", {})
|
|
147
|
+
t = data.get("tools", {})
|
|
148
|
+
s = data.get("skills", {})
|
|
149
|
+
se = data.get("session", {})
|
|
150
|
+
cl = data.get("cli", {})
|
|
151
|
+
cx = data.get("context", {})
|
|
152
|
+
|
|
153
|
+
# Validate int fields — TOML may give strings/bools if the user mis-types.
|
|
154
|
+
def _int(section: dict, key: str, default: int, section_name: str) -> int:
|
|
155
|
+
v = section.get(key, default)
|
|
156
|
+
if isinstance(v, bool) or not isinstance(v, int):
|
|
157
|
+
raise ValueError(f"config.toml [{section_name}] {key} 必须是整数,但得到 {type(v).__name__}: {v!r}")
|
|
158
|
+
return v
|
|
159
|
+
|
|
160
|
+
def _bool(section: dict, key: str, default: bool) -> bool:
|
|
161
|
+
v = section.get(key, default)
|
|
162
|
+
if not isinstance(v, bool):
|
|
163
|
+
# TOML's true/false parse to Python bool; anything else is a typo.
|
|
164
|
+
# Don't raise — fall back to default so a bad value doesn't block startup.
|
|
165
|
+
return default
|
|
166
|
+
return v
|
|
167
|
+
|
|
168
|
+
def _str_list(section: dict, key: str, default: list[str]) -> list[str]:
|
|
169
|
+
v = section.get(key, default)
|
|
170
|
+
if isinstance(v, list) and all(isinstance(x, str) for x in v):
|
|
171
|
+
return v
|
|
172
|
+
# Not a list of strings — fall back to default rather than crashing.
|
|
173
|
+
# A bad config value shouldn't block startup; the user will notice when
|
|
174
|
+
# their extra blocklist doesn't take effect.
|
|
175
|
+
return default
|
|
176
|
+
|
|
177
|
+
# Validate permission_mode against the known enum values.
|
|
178
|
+
perm = t.get("permission_mode", cfg.tools.permission_mode)
|
|
179
|
+
if isinstance(perm, str):
|
|
180
|
+
perm_lower = perm.lower()
|
|
181
|
+
valid = ("confirm", "plan", "auto", "full", "auto_edit")
|
|
182
|
+
if perm_lower not in valid:
|
|
183
|
+
raise ValueError(f"config.toml [tools] permission_mode='{perm}' 无效。可选值: {', '.join(valid)}")
|
|
184
|
+
perm = perm_lower
|
|
185
|
+
|
|
186
|
+
# context_limit is optional in [model]; a missing/non-int value falls back
|
|
187
|
+
# to 0 (not probed). Don't use the strict _int() helper — context_limit is
|
|
188
|
+
# a best-effort optimization, not a required config field.
|
|
189
|
+
m_cl_raw = m.get("context_limit", 0)
|
|
190
|
+
m_context_limit = m_cl_raw if isinstance(m_cl_raw, int) and not isinstance(m_cl_raw, bool) else 0
|
|
191
|
+
return Config(
|
|
192
|
+
model=ModelConfig(
|
|
193
|
+
default=m.get("default", cfg.model.default),
|
|
194
|
+
provider=m.get("provider", cfg.model.provider),
|
|
195
|
+
base_url=m.get("base_url", cfg.model.base_url),
|
|
196
|
+
provider_id=m.get("provider_id", ""),
|
|
197
|
+
max_output_tokens=_int(m, "max_output_tokens", cfg.model.max_output_tokens, "model"),
|
|
198
|
+
context_limit=m_context_limit,
|
|
199
|
+
),
|
|
200
|
+
tools=ToolsConfig(
|
|
201
|
+
bash_shell=t.get("bash_shell", cfg.tools.bash_shell),
|
|
202
|
+
permission_mode=perm,
|
|
203
|
+
workspace_root=t.get("workspace_root", cfg.tools.workspace_root),
|
|
204
|
+
blocked_commands=_str_list(t, "blocked_commands", cfg.tools.blocked_commands),
|
|
205
|
+
network_allowed=_bool(t, "network_allowed", cfg.tools.network_allowed),
|
|
206
|
+
whitelist_mode=_bool(t, "whitelist_mode", cfg.tools.whitelist_mode),
|
|
207
|
+
allowed_commands=_str_list(t, "allowed_commands", cfg.tools.allowed_commands),
|
|
208
|
+
sandbox_mode=t.get("sandbox_mode", cfg.tools.sandbox_mode),
|
|
209
|
+
auto_allow_if_sandboxed=_bool(t, "auto_allow_if_sandboxed", cfg.tools.auto_allow_if_sandboxed),
|
|
210
|
+
sandbox_fs=_read_sandbox_fs(t.get("sandbox_filesystem", {}), cfg.tools.sandbox_fs),
|
|
211
|
+
),
|
|
212
|
+
skills=SkillsConfig(
|
|
213
|
+
auto_load=s.get("auto_load", cfg.skills.auto_load),
|
|
214
|
+
harness=s.get("harness", cfg.skills.harness),
|
|
215
|
+
repo_url=s.get("repo_url", cfg.skills.repo_url),
|
|
216
|
+
),
|
|
217
|
+
session=SessionConfig(
|
|
218
|
+
save_dir=se.get("save_dir", cfg.session.save_dir),
|
|
219
|
+
),
|
|
220
|
+
cli=CliConfig(
|
|
221
|
+
theme=cl.get("theme", cfg.cli.theme),
|
|
222
|
+
show_tool_output=cl.get("show_tool_output", cfg.cli.show_tool_output),
|
|
223
|
+
),
|
|
224
|
+
context=ContextConfig(
|
|
225
|
+
enabled=cx.get("enabled", cfg.context.enabled),
|
|
226
|
+
trigger_ratio=cx.get("trigger_ratio", cfg.context.trigger_ratio),
|
|
227
|
+
keep_recent=_int(cx, "keep_recent", cfg.context.keep_recent, "context"),
|
|
228
|
+
model_context_limit=_int(cx, "model_context_limit", cfg.context.model_context_limit, "context"),
|
|
229
|
+
),
|
|
230
|
+
profiles=_parse_profiles(data),
|
|
231
|
+
active_profile=_resolve_active_profile(data),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _apply_env(cfg: Config) -> Config:
|
|
236
|
+
model = cfg.model
|
|
237
|
+
v = os.environ.get("CODERIO_MODEL")
|
|
238
|
+
if v:
|
|
239
|
+
model = replace(model, default=v)
|
|
240
|
+
v = os.environ.get("CODERIO_PROVIDER")
|
|
241
|
+
if v:
|
|
242
|
+
model = replace(model, provider=v)
|
|
243
|
+
tools = cfg.tools
|
|
244
|
+
v = os.environ.get("CODERIO_BASH_SHELL")
|
|
245
|
+
if v:
|
|
246
|
+
tools = replace(tools, bash_shell=v)
|
|
247
|
+
# Preserve profiles/active_profile/context — env overrides only touch model/tools.
|
|
248
|
+
return Config(
|
|
249
|
+
model=model,
|
|
250
|
+
tools=tools,
|
|
251
|
+
skills=cfg.skills,
|
|
252
|
+
session=cfg.session,
|
|
253
|
+
cli=cfg.cli,
|
|
254
|
+
context=cfg.context,
|
|
255
|
+
profiles=cfg.profiles,
|
|
256
|
+
active_profile=cfg.active_profile,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_config(search_from: Path | str = ".", user_dir: Path | str | None = None) -> Config:
|
|
261
|
+
"""Load config merging: defaults < user < project < env.
|
|
262
|
+
|
|
263
|
+
Layers (low->high): built-in defaults, user ~/.coderio, project ./.coderio, env vars.
|
|
264
|
+
"""
|
|
265
|
+
search_from = Path(search_from)
|
|
266
|
+
if user_dir is None:
|
|
267
|
+
user_dir = _default_user_dir()
|
|
268
|
+
data = _merge(
|
|
269
|
+
_read_toml(Path(user_dir) / "config.toml"),
|
|
270
|
+
_read_toml(_find_project_dir(search_from) / ".coderio" / "config.toml"),
|
|
271
|
+
)
|
|
272
|
+
cfg = _from_dict(data)
|
|
273
|
+
return _apply_env(cfg)
|
coderio/config/models.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ModelConfig:
|
|
8
|
+
default: str = "glm-4.5"
|
|
9
|
+
provider: str = "openai_compatible"
|
|
10
|
+
base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
|
11
|
+
provider_id: str = ""
|
|
12
|
+
max_output_tokens: int = 16384
|
|
13
|
+
# Context window size (tokens) for [model].default, probed at setup time.
|
|
14
|
+
# 0 = not probed (fall back to ContextConfig.model_context_limit). Mirrors
|
|
15
|
+
# Profile.context_limit for the legacy single-config path. Kept on ModelConfig
|
|
16
|
+
# (not ContextConfig) because it's per-model, not per-compaction-policy.
|
|
17
|
+
context_limit: int = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Profile:
|
|
22
|
+
"""A named, self-contained model configuration (provider + model + endpoint).
|
|
23
|
+
|
|
24
|
+
Bundles everything build_chat_model needs to construct a chat client, so the
|
|
25
|
+
user can keep several providers side by side (e.g. a Coding Plan subscription
|
|
26
|
+
and a personal OpenAI key) and switch between them with /profile without
|
|
27
|
+
re-running onboarding. The API key itself lives in the credentials file
|
|
28
|
+
(keyed by provider_id), not here — same as the legacy [model] path.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
provider_id: str
|
|
33
|
+
model: str
|
|
34
|
+
base_url: str = ""
|
|
35
|
+
kind: str = "openai_compatible"
|
|
36
|
+
# Context window size (tokens) for THIS model, discovered at setup time by
|
|
37
|
+
# probing the provider's /v1/models/{id} endpoint. 0 = not probed (fall back
|
|
38
|
+
# to ContextConfig.model_context_limit). Stored per-profile because different
|
|
39
|
+
# providers/models have different windows — without this, a 256K model like
|
|
40
|
+
# step-3.7-flash gets mis-treated as the global default (200K), triggering
|
|
41
|
+
# compaction at 120K instead of 153K.
|
|
42
|
+
context_limit: int = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ToolsConfig:
|
|
47
|
+
bash_shell: str = ""
|
|
48
|
+
permission_mode: str = "confirm"
|
|
49
|
+
# Workspace root for the shell backend's CWD. Empty = use the process CWD
|
|
50
|
+
# (the directory coderio was launched from). File-path isolation is handled
|
|
51
|
+
# by deepagents' backend virtual_mode (not by a coderio-side policy) — this
|
|
52
|
+
# value only affects where shell commands run.
|
|
53
|
+
workspace_root: str = ""
|
|
54
|
+
# Extra regex patterns appended to the built-in command blacklist. The
|
|
55
|
+
# built-in defaults (rm -rf /, mkfs, fork bomb, etc.) are always active;
|
|
56
|
+
# these are ADDITIONAL blocks for project-specific commands you never want
|
|
57
|
+
# run (e.g. ["git push --force", "npm publish"]). See command_policy.py.
|
|
58
|
+
blocked_commands: list[str] = field(default_factory=list)
|
|
59
|
+
# Master switch for web_fetch / web_search. False = offline mode (both web
|
|
60
|
+
# tools blocked). True (default) = web tools available subject to the
|
|
61
|
+
# normal permission gate.
|
|
62
|
+
network_allowed: bool = True
|
|
63
|
+
# Whitelist mode: when True, commands whose first token isn't in the
|
|
64
|
+
# allowed set (built-in + allowed_commands) are flagged for confirmation
|
|
65
|
+
# (NOT hard-blocked — FULL mode still allows them). Defaults to False
|
|
66
|
+
# (blacklist-only, backward compatible). See command_policy.py.
|
|
67
|
+
whitelist_mode: bool = False
|
|
68
|
+
# User-supplied additions to the built-in command whitelist. Applied only
|
|
69
|
+
# when whitelist_mode is True. E.g. ["docker", "kubectl"] to allow those
|
|
70
|
+
# without confirmation in whitelist mode.
|
|
71
|
+
allowed_commands: list[str] = field(default_factory=list)
|
|
72
|
+
# Sandbox mode: OS-level isolation for the execute tool.
|
|
73
|
+
# "off" = no OS sandbox (regex blacklist + whitelist only) [default]
|
|
74
|
+
# "job" = Job Object resource limits + reliable process-tree kill
|
|
75
|
+
# (prevents fork bombs, OOM, orphaned grandchildren)
|
|
76
|
+
# "write" = Job Object + Windows Restricted Token write isolation
|
|
77
|
+
# (reads system-wide, writes gated — needs Win10+, no admin)
|
|
78
|
+
# On Linux, "write" uses bubblewrap if available (see linux_sandbox.py).
|
|
79
|
+
sandbox_mode: str = "off"
|
|
80
|
+
# When True + sandbox_mode != "off", the execute (shell) tool auto-approves
|
|
81
|
+
# without a confirmation prompt (Claude Code's "autoAllowBashIfSandboxed"
|
|
82
|
+
# design: the sandbox provides the real isolation boundary, so per-command
|
|
83
|
+
# prompts become noise). Defaults False (backward compat). The blacklist
|
|
84
|
+
# still applies — rm -rf / is blocked even in auto-allow mode.
|
|
85
|
+
auto_allow_if_sandboxed: bool = False
|
|
86
|
+
# Filesystem isolation 4-tuple for the sandbox (Linux bubblewrap only;
|
|
87
|
+
# Windows ignores it — token is no-op). See SandboxFsConfig.
|
|
88
|
+
sandbox_fs: "SandboxFsConfig | None" = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class SandboxFsConfig:
|
|
93
|
+
"""Filesystem isolation config for the sandbox (Claude-Code-compatible 4-tuple).
|
|
94
|
+
|
|
95
|
+
Applied on Linux via bubblewrap mounts. Windows ignores these (the token
|
|
96
|
+
is currently a no-op — see win_sandbox.py docstring). All paths support:
|
|
97
|
+
- ``~/foo`` → home-relative
|
|
98
|
+
- ``./foo`` → workspace-relative
|
|
99
|
+
- ``foo`` → workspace-relative (same as ./)
|
|
100
|
+
- ``/abs`` → absolute
|
|
101
|
+
|
|
102
|
+
Semantics (bwrap mount order matters — later mounts override earlier):
|
|
103
|
+
- workspace is ALWAYS read-write (built-in, no need to list it)
|
|
104
|
+
- ``allow_write`` → extra read-write mounts (e.g. /tmp/build, ~/.cache)
|
|
105
|
+
- ``deny_write`` → read-only override (e.g. .git/hooks inside workspace)
|
|
106
|
+
- ``deny_read`` → tmpfs blackhole (path exists but contents invisible)
|
|
107
|
+
- ``allow_read`` → read-only re-mount punching through a deny_read
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
allow_write: list[str] = field(default_factory=list)
|
|
111
|
+
deny_write: list[str] = field(default_factory=list)
|
|
112
|
+
deny_read: list[str] = field(default_factory=list)
|
|
113
|
+
allow_read: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class SkillsConfig:
|
|
118
|
+
auto_load: bool = True
|
|
119
|
+
harness: bool = True
|
|
120
|
+
repo_url: str = "https://github.com/Lion-1209/Lion-Skills"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class CliConfig:
|
|
125
|
+
theme: str = "dark"
|
|
126
|
+
show_tool_output: bool = True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class SessionConfig:
|
|
131
|
+
save_dir: str = "~/.coderio/sessions"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class ContextConfig:
|
|
136
|
+
"""Context-window compaction settings (spec: harness phase 2).
|
|
137
|
+
|
|
138
|
+
When the provider-reported input_tokens exceeds ``trigger_ratio`` of
|
|
139
|
+
``model_context_limit``, old messages are summarized into a single system
|
|
140
|
+
message and the most recent ``keep_recent`` are kept verbatim. Disabled
|
|
141
|
+
when ``enabled=False`` (no compaction attempts, original behavior).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
enabled: bool = True
|
|
145
|
+
trigger_ratio: float = 0.6 # compact at 60% of the context window (lowered
|
|
146
|
+
# from 0.75 — a 30-read_file analysis session
|
|
147
|
+
# hit 61k tokens without triggering; 60% gives
|
|
148
|
+
# earlier, healthier compaction)
|
|
149
|
+
keep_recent: int = 8 # messages preserved verbatim at the tail
|
|
150
|
+
model_context_limit: int = 200_000 # assumed window size when the active
|
|
151
|
+
# profile has no probed context_limit.
|
|
152
|
+
# Raised from 128K (too aggressive — a
|
|
153
|
+
# 256K model was compacting at 76K) to
|
|
154
|
+
# 200K, the floor for modern models
|
|
155
|
+
# (Claude, GPT-4o, step-3.7). The real
|
|
156
|
+
# value comes from probe_context_limit
|
|
157
|
+
# at setup time, stored per-profile.
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class Config:
|
|
162
|
+
# All sub-config fields default to None and are populated in __post_init__.
|
|
163
|
+
# The `Type = None` default is a dataclass idiom: the field IS declared as
|
|
164
|
+
# the concrete type, but defaults to None before __post_init__ runs. mypy
|
|
165
|
+
# accepts this for dataclasses (the None is the "not yet initialized"
|
|
166
|
+
# sentinel, replaced in __post_init__ which the loader always calls).
|
|
167
|
+
model: ModelConfig = None # type: ignore[assignment]
|
|
168
|
+
tools: ToolsConfig = None # type: ignore[assignment]
|
|
169
|
+
skills: SkillsConfig = None # type: ignore[assignment]
|
|
170
|
+
session: SessionConfig = None # type: ignore[assignment]
|
|
171
|
+
cli: CliConfig = None # type: ignore[assignment]
|
|
172
|
+
context: ContextConfig = None # type: ignore[assignment]
|
|
173
|
+
# Named profiles (multi-config). Empty list = legacy single-config mode:
|
|
174
|
+
# build_chat_model falls through to the [model] section's 3-layer path,
|
|
175
|
+
# so existing users with no profiles are unaffected.
|
|
176
|
+
profiles: list = None # type: ignore[assignment]
|
|
177
|
+
active_profile: str = ""
|
|
178
|
+
|
|
179
|
+
def __post_init__(self):
|
|
180
|
+
if self.model is None:
|
|
181
|
+
object.__setattr__(self, "model", ModelConfig())
|
|
182
|
+
if self.tools is None:
|
|
183
|
+
object.__setattr__(self, "tools", ToolsConfig())
|
|
184
|
+
if self.skills is None:
|
|
185
|
+
object.__setattr__(self, "skills", SkillsConfig())
|
|
186
|
+
if self.session is None:
|
|
187
|
+
object.__setattr__(self, "session", SessionConfig())
|
|
188
|
+
if self.cli is None:
|
|
189
|
+
object.__setattr__(self, "cli", CliConfig())
|
|
190
|
+
if self.context is None:
|
|
191
|
+
object.__setattr__(self, "context", ContextConfig())
|
|
192
|
+
if self.profiles is None:
|
|
193
|
+
object.__setattr__(self, "profiles", [])
|
coderio/config/trust.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""First-use trust confirmation for repository-level config files.
|
|
2
|
+
|
|
3
|
+
SECURITY (2026-08-14 v2 audit, biggest remaining hole): the loader placed the
|
|
4
|
+
REPOSITORY's ``.coderio/config.toml`` ABOVE the user config — a cloned malicious
|
|
5
|
+
repo could set ``permission_mode = "full"``, point ``model.base_url`` at an
|
|
6
|
+
attacker's endpoint (session exfiltration), and ``.mcp.json``'s ``command``
|
|
7
|
+
entries are spawned directly at startup. Cloning a hostile repo + starting
|
|
8
|
+
coderio ≈ arbitrary code execution, with zero prompt.
|
|
9
|
+
|
|
10
|
+
Mitigation (same approach as Claude Code / Codex / ZCode): on first detection
|
|
11
|
+
of repo-level config, SHOW the user what it contains and require explicit
|
|
12
|
+
confirmation. The confirmation is recorded per-repo in the USER's trust store
|
|
13
|
+
(``~/.coderio/trusted-repos.json``), keyed by a content hash — a later commit
|
|
14
|
+
that changes the config re-triggers the prompt (trust the content, not just
|
|
15
|
+
the path).
|
|
16
|
+
|
|
17
|
+
Non-interactive contexts (tests, CI) use :func:`is_repo_trusted` /
|
|
18
|
+
:func:`mark_repo_trusted` directly, or pass ``assume_trusted=True``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
_log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Files that constitute "repo-level config" and therefore require trust.
|
|
31
|
+
# A repo with none of these starts silently (nothing to confirm).
|
|
32
|
+
REPO_CONFIG_FILES = (".coderio/config.toml", ".mcp.json")
|
|
33
|
+
|
|
34
|
+
# Safety-relevant keys called out in the confirmation summary — the user should
|
|
35
|
+
# see these even if they don't read the full file.
|
|
36
|
+
_SENSITIVE_KEYS = ("permission_mode", "sandbox_mode", "base_url", "auto_allow_if_sandboxed", "network_allowed")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _trust_store(user_dir: Path | str) -> Path:
|
|
40
|
+
return Path(user_dir) / "trusted-repos.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _repo_fingerprint(project_dir: Path) -> str:
|
|
44
|
+
"""Hash the CONTENT of every repo config file (not the path alone).
|
|
45
|
+
|
|
46
|
+
Content-keyed trust: editing the config after confirmation re-triggers the
|
|
47
|
+
prompt — a malicious upstream commit can't ride on a previously-granted
|
|
48
|
+
trust.
|
|
49
|
+
"""
|
|
50
|
+
h = hashlib.sha256()
|
|
51
|
+
for rel in REPO_CONFIG_FILES:
|
|
52
|
+
p = project_dir / rel
|
|
53
|
+
h.update(rel.encode("utf-8"))
|
|
54
|
+
if p.is_file():
|
|
55
|
+
h.update(p.read_bytes())
|
|
56
|
+
return h.hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def existing_repo_configs(project_dir: Path | str) -> list[Path]:
|
|
60
|
+
"""Return the repo-level config files that exist (possibly empty list)."""
|
|
61
|
+
project_dir = Path(project_dir)
|
|
62
|
+
return [project_dir / rel for rel in REPO_CONFIG_FILES if (project_dir / rel).is_file()]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_repo_trusted(project_dir: Path | str, user_dir: Path | str) -> bool:
|
|
66
|
+
"""True when the repo's CURRENT config content matches a stored trust entry.
|
|
67
|
+
|
|
68
|
+
A repo with no repo-level config is trusted by definition (nothing to
|
|
69
|
+
confirm). A config change after confirmation invalidates the trust.
|
|
70
|
+
"""
|
|
71
|
+
project_dir = Path(project_dir)
|
|
72
|
+
if not existing_repo_configs(project_dir):
|
|
73
|
+
return True
|
|
74
|
+
store = _trust_store(user_dir)
|
|
75
|
+
if not store.is_file():
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
entries = json.loads(store.read_text(encoding="utf-8"))
|
|
79
|
+
except (json.JSONDecodeError, OSError):
|
|
80
|
+
return False
|
|
81
|
+
trusted = entries.get(str(project_dir.resolve()))
|
|
82
|
+
return trusted == _repo_fingerprint(project_dir)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def mark_repo_trusted(project_dir: Path | str, user_dir: Path | str) -> None:
|
|
86
|
+
"""Record the repo's current config content as trusted (after the user
|
|
87
|
+
confirms). Best-effort: a failure to write the store logs a warning and
|
|
88
|
+
leaves the repo untrusted (safe direction — the prompt reappears)."""
|
|
89
|
+
project_dir = Path(project_dir)
|
|
90
|
+
store = _trust_store(user_dir)
|
|
91
|
+
try:
|
|
92
|
+
entries: dict = {}
|
|
93
|
+
if store.is_file():
|
|
94
|
+
entries = json.loads(store.read_text(encoding="utf-8"))
|
|
95
|
+
if not isinstance(entries, dict):
|
|
96
|
+
entries = {}
|
|
97
|
+
entries[str(project_dir.resolve())] = _repo_fingerprint(project_dir)
|
|
98
|
+
store.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
store.write_text(json.dumps(entries, indent=2), encoding="utf-8")
|
|
100
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
101
|
+
_log.warning("could not persist repo trust for %s: %s", project_dir, e)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def summarize_repo_configs(project_dir: Path | str) -> str:
|
|
105
|
+
"""Build the human-readable summary shown in the confirmation prompt.
|
|
106
|
+
|
|
107
|
+
Lists each file, its size, and any safety-relevant settings found in it —
|
|
108
|
+
so ``permission_mode = "full"`` can't hide inside a long TOML file the user
|
|
109
|
+
didn't read. MCP ``command``/``url`` entries are listed by server name
|
|
110
|
+
(those spawn processes / make network calls at startup).
|
|
111
|
+
"""
|
|
112
|
+
project_dir = Path(project_dir)
|
|
113
|
+
lines = []
|
|
114
|
+
for path in existing_repo_configs(project_dir):
|
|
115
|
+
rel = path.relative_to(project_dir).as_posix()
|
|
116
|
+
lines.append(f"{rel} ({path.stat().st_size} bytes)")
|
|
117
|
+
try:
|
|
118
|
+
text = path.read_text(encoding="utf-8")
|
|
119
|
+
except OSError:
|
|
120
|
+
lines.append(" <unreadable>")
|
|
121
|
+
continue
|
|
122
|
+
if path.name == "config.toml":
|
|
123
|
+
for ln in text.splitlines():
|
|
124
|
+
s = ln.strip()
|
|
125
|
+
if any(k in s for k in _SENSITIVE_KEYS) and "=" in s:
|
|
126
|
+
lines.append(f" {s}")
|
|
127
|
+
else: # .mcp.json — list servers (they spawn/connect at startup)
|
|
128
|
+
try:
|
|
129
|
+
servers = json.loads(text).get("mcpServers", {})
|
|
130
|
+
for name in servers:
|
|
131
|
+
cfg = servers[name] or {}
|
|
132
|
+
what = cfg.get("command") or cfg.get("url") or "?"
|
|
133
|
+
lines.append(f" server {name!r}: {what}")
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
lines.append(" <invalid JSON>")
|
|
136
|
+
return "\n".join(lines)
|
coderio/llm/__init__.py
ADDED