code-context-control 2.32.2__py3-none-any.whl → 2.34.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.
- cli/c3.py +21 -4
- cli/hook_ghost_files.py +12 -2
- cli/hub_server.py +31 -107
- cli/mcp_server.py +3 -1
- cli/server.py +31 -114
- cli/tools/filter.py +2 -2
- cli/tools/read.py +37 -0
- cli/tools/shell.py +81 -5
- {code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/METADATA +2 -2
- {code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/RECORD +20 -18
- core/mcp_toml.py +128 -0
- core/web_security.py +174 -0
- oracle/mcp_oracle.py +56 -11
- oracle/oracle_server.py +16 -6
- oracle/services/tool_registry.py +17 -2
- services/claude_md.py +1 -1
- {code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
|
|
|
85
85
|
# Config
|
|
86
86
|
CONFIG_DIR = ".c3"
|
|
87
87
|
CONFIG_FILE = ".c3/config.json"
|
|
88
|
-
__version__ = "2.
|
|
88
|
+
__version__ = "2.34.0"
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def _command_deps() -> CommandDeps:
|
|
@@ -4921,8 +4921,14 @@ def cmd_install_mcp(args):
|
|
|
4921
4921
|
# Build hook commands using the Python executable that runs c3.
|
|
4922
4922
|
# On Windows, Claude Code executes hooks via /usr/bin/bash (Git Bash), which cannot
|
|
4923
4923
|
# parse Windows absolute paths containing parentheses (e.g. "(C3)"). Prefix with
|
|
4924
|
-
#
|
|
4925
|
-
|
|
4924
|
+
# cmd.exe so it handles path resolution instead of bash.
|
|
4925
|
+
#
|
|
4926
|
+
# Use "cmd.exe" WITH the extension, not bare "cmd": Git Bash does not resolve bare
|
|
4927
|
+
# "cmd" on PATH, so the old "cmd /c …" prefix silently failed to launch any hook
|
|
4928
|
+
# (verified: under bash, "cmd.exe /c '<py>' '<hook>'" runs and writes the signal
|
|
4929
|
+
# file; "cmd /c …" returns "cmd: command not found"). The single-quoted paths are
|
|
4930
|
+
# correct — bash strips them and re-quotes for cmd.exe, preserving spaces/parens.
|
|
4931
|
+
_hook_prefix = "cmd.exe /c " if sys.platform == "win32" else ""
|
|
4926
4932
|
hook_filter_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_filter.py'))}"
|
|
4927
4933
|
hook_read_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_read.py'))}"
|
|
4928
4934
|
hook_c3read_cmd = f"{_hook_prefix}{shlex.quote(sys.executable)} {shlex.quote(str(cli_dir / 'hook_c3read.py'))}"
|
|
@@ -4962,13 +4968,24 @@ def cmd_install_mcp(args):
|
|
|
4962
4968
|
},
|
|
4963
4969
|
{
|
|
4964
4970
|
"matcher": read_matcher,
|
|
4965
|
-
"hooks": [
|
|
4971
|
+
"hooks": [
|
|
4972
|
+
{"type": "command", "command": hook_read_cmd},
|
|
4973
|
+
{"type": "command", "command": hook_ghost_files_cmd},
|
|
4974
|
+
]
|
|
4966
4975
|
},
|
|
4967
4976
|
{
|
|
4968
4977
|
"matcher": "mcp__c3__c3_read",
|
|
4969
4978
|
"hooks": [
|
|
4970
4979
|
{"type": "command", "command": hook_c3read_cmd},
|
|
4971
4980
|
{"type": "command", "command": hook_c3_signal_cmd},
|
|
4981
|
+
{"type": "command", "command": hook_ghost_files_cmd},
|
|
4982
|
+
]
|
|
4983
|
+
},
|
|
4984
|
+
{
|
|
4985
|
+
"matcher": "mcp__c3__c3_shell",
|
|
4986
|
+
"hooks": [
|
|
4987
|
+
{"type": "command", "command": hook_c3_signal_cmd},
|
|
4988
|
+
{"type": "command", "command": hook_ghost_files_cmd},
|
|
4972
4989
|
]
|
|
4973
4990
|
},
|
|
4974
4991
|
{
|
cli/hook_ghost_files.py
CHANGED
|
@@ -212,6 +212,17 @@ def cleanup_ghost_files(ghosts: list[dict]) -> list[str]:
|
|
|
212
212
|
return deleted
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
# Tools whose output can carry shell-meta text that leaks into 0-byte files:
|
|
216
|
+
# native shells, c3_shell (its `N->Mtok` filter header), and file reads whose
|
|
217
|
+
# content has `-> Type` hints. A downstream shell sees `> word` and creates an
|
|
218
|
+
# empty file named `word`.
|
|
219
|
+
_GHOST_TRIGGER_TOOLS = (
|
|
220
|
+
"Bash", "run_shell_command",
|
|
221
|
+
"mcp__c3__c3_shell",
|
|
222
|
+
"mcp__c3__c3_read", "Read", "read_file",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
215
226
|
def main():
|
|
216
227
|
try:
|
|
217
228
|
raw = sys.stdin.read()
|
|
@@ -221,8 +232,7 @@ def main():
|
|
|
221
232
|
data = json.loads(raw)
|
|
222
233
|
tool_name = data.get("tool_name", "")
|
|
223
234
|
|
|
224
|
-
|
|
225
|
-
if tool_name not in ("Bash", "run_shell_command"):
|
|
235
|
+
if tool_name not in _GHOST_TRIGGER_TOOLS:
|
|
226
236
|
return
|
|
227
237
|
|
|
228
238
|
is_gemini = isinstance(data.get("tool_response", ""), dict)
|
cli/hub_server.py
CHANGED
|
@@ -35,6 +35,26 @@ from services.tool_classifier import CATEGORIES
|
|
|
35
35
|
|
|
36
36
|
app = Flask(__name__, static_folder=str(Path(__file__).parent))
|
|
37
37
|
|
|
38
|
+
# Localhost-only security: Host-header allowlist + Origin/Referer CSRF guard +
|
|
39
|
+
# scoped CORS. The hub manages MANY projects and exposes command-executing
|
|
40
|
+
# endpoints (launch-ide, mcp-server-add, permissions), so cross-origin CSRF /
|
|
41
|
+
# DNS-rebinding protection matters even though it binds loopback by default.
|
|
42
|
+
# Reads bind host + optional allowed_hosts per-request from hub_config.json.
|
|
43
|
+
from core.web_security import (
|
|
44
|
+
allowed_hostnames as _allowed_hostnames,
|
|
45
|
+
)
|
|
46
|
+
from core.web_security import (
|
|
47
|
+
install_guard as _install_web_guard,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _hub_allowed_hosts():
|
|
52
|
+
_c = _read_hub_config()
|
|
53
|
+
return _allowed_hostnames(_c.get("host"), _c.get("allowed_hosts"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_install_web_guard(app, _hub_allowed_hosts)
|
|
57
|
+
|
|
38
58
|
# ─── Hub config ───────────────────────────────────────────────────────────────
|
|
39
59
|
|
|
40
60
|
_GLOBAL_C3_DIR = Path.home() / ".c3"
|
|
@@ -160,46 +180,12 @@ def _project_mcp_config_path(project_root: Path, profile) -> Path:
|
|
|
160
180
|
return (Path.home() / profile.config_path) if profile.config_path_global else (project_root / profile.config_path)
|
|
161
181
|
|
|
162
182
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if not line:
|
|
170
|
-
continue
|
|
171
|
-
|
|
172
|
-
if line.startswith("[") and line.endswith("]"):
|
|
173
|
-
section = line[1:-1].strip()
|
|
174
|
-
if section.startswith("mcp_servers."):
|
|
175
|
-
current_server = section.split(".", 1)[1]
|
|
176
|
-
servers.setdefault(current_server, {})
|
|
177
|
-
else:
|
|
178
|
-
current_server = None
|
|
179
|
-
continue
|
|
180
|
-
|
|
181
|
-
if not current_server or "=" not in line:
|
|
182
|
-
continue
|
|
183
|
-
|
|
184
|
-
key, value = line.split("=", 1)
|
|
185
|
-
key = key.strip().strip('"')
|
|
186
|
-
value = value.strip()
|
|
187
|
-
|
|
188
|
-
if key == "args":
|
|
189
|
-
servers[current_server]["args"] = re.findall(r"[\"']([^\"']*)[\"']", value)
|
|
190
|
-
elif key in ("command", "type"):
|
|
191
|
-
match = re.match(r"^[\"'](.*)[\"']$", value)
|
|
192
|
-
servers[current_server][key] = match.group(1) if match else value
|
|
193
|
-
elif key == "enabled":
|
|
194
|
-
low = value.lower()
|
|
195
|
-
if low.startswith("true"):
|
|
196
|
-
servers[current_server]["enabled"] = True
|
|
197
|
-
elif low.startswith("false"):
|
|
198
|
-
servers[current_server]["enabled"] = False
|
|
199
|
-
else:
|
|
200
|
-
servers[current_server][key] = value
|
|
201
|
-
|
|
202
|
-
return servers
|
|
183
|
+
from core.mcp_toml import (
|
|
184
|
+
parse_toml_mcp_servers as _parse_toml_mcp_servers,
|
|
185
|
+
)
|
|
186
|
+
from core.mcp_toml import (
|
|
187
|
+
upsert_toml_section as _upsert_toml_section,
|
|
188
|
+
)
|
|
203
189
|
|
|
204
190
|
|
|
205
191
|
def _read_project_mcp_servers_for_profile(profile, mcp_file: Path) -> tuple[dict, dict]:
|
|
@@ -218,73 +204,6 @@ def _read_project_mcp_servers_for_profile(profile, mcp_file: Path) -> tuple[dict
|
|
|
218
204
|
return servers, raw_config
|
|
219
205
|
|
|
220
206
|
|
|
221
|
-
def _toml_escape_str(value: str) -> str:
|
|
222
|
-
return value.replace("\\", "/")
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
226
|
-
content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
|
|
227
|
-
header = f"[{section}]"
|
|
228
|
-
|
|
229
|
-
lines = content.splitlines()
|
|
230
|
-
new_lines = []
|
|
231
|
-
skip = False
|
|
232
|
-
for line in lines:
|
|
233
|
-
stripped = line.strip()
|
|
234
|
-
if stripped == header:
|
|
235
|
-
skip = True
|
|
236
|
-
continue
|
|
237
|
-
if skip and stripped.startswith("["):
|
|
238
|
-
skip = False
|
|
239
|
-
if not skip:
|
|
240
|
-
new_lines.append(line)
|
|
241
|
-
|
|
242
|
-
content = "\n".join(new_lines).rstrip()
|
|
243
|
-
section_lines = [f"\n\n{header}"]
|
|
244
|
-
for key, value in entries.items():
|
|
245
|
-
if isinstance(value, list):
|
|
246
|
-
items = ", ".join(f'"{_toml_escape_str(str(item))}"' for item in value)
|
|
247
|
-
section_lines.append(f'{key} = [{items}]')
|
|
248
|
-
elif isinstance(value, bool):
|
|
249
|
-
section_lines.append(f'{key} = {"true" if value else "false"}')
|
|
250
|
-
else:
|
|
251
|
-
section_lines.append(f'{key} = "{_toml_escape_str(str(value))}"')
|
|
252
|
-
section_lines.append("")
|
|
253
|
-
|
|
254
|
-
toml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
-
toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _remove_toml_section(toml_path: Path, section: str) -> bool:
|
|
259
|
-
if not toml_path.exists():
|
|
260
|
-
return False
|
|
261
|
-
content = toml_path.read_text(encoding="utf-8")
|
|
262
|
-
header = f"[{section}]"
|
|
263
|
-
|
|
264
|
-
lines = content.splitlines()
|
|
265
|
-
new_lines = []
|
|
266
|
-
skip = False
|
|
267
|
-
removed = False
|
|
268
|
-
for line in lines:
|
|
269
|
-
stripped = line.strip()
|
|
270
|
-
if stripped == header:
|
|
271
|
-
skip = True
|
|
272
|
-
removed = True
|
|
273
|
-
continue
|
|
274
|
-
if skip and stripped.startswith("["):
|
|
275
|
-
skip = False
|
|
276
|
-
if not skip:
|
|
277
|
-
new_lines.append(line)
|
|
278
|
-
|
|
279
|
-
if removed:
|
|
280
|
-
remaining = "\n".join(new_lines).rstrip()
|
|
281
|
-
if remaining:
|
|
282
|
-
toml_path.write_text(remaining + "\n", encoding="utf-8")
|
|
283
|
-
else:
|
|
284
|
-
toml_path.unlink()
|
|
285
|
-
return removed
|
|
286
|
-
|
|
287
|
-
|
|
288
207
|
def _build_mcp_cli_capabilities() -> dict:
|
|
289
208
|
return {
|
|
290
209
|
"commands": [
|
|
@@ -519,6 +438,11 @@ def api_projects_open():
|
|
|
519
438
|
path = Path(path_str).resolve()
|
|
520
439
|
if not path.exists():
|
|
521
440
|
return jsonify({"error": f"Path does not exist: {path_str}"}), 404
|
|
441
|
+
# Only ever open directories. Opening a *file* via os.startfile would
|
|
442
|
+
# invoke its default handler (e.g. run an .exe/.bat/.lnk), so refuse
|
|
443
|
+
# anything that is not a folder.
|
|
444
|
+
if not path.is_dir():
|
|
445
|
+
return jsonify({"error": "Only directories can be opened"}), 400
|
|
522
446
|
|
|
523
447
|
if sys.platform == "win32":
|
|
524
448
|
os.startfile(str(path))
|
cli/mcp_server.py
CHANGED
|
@@ -639,7 +639,9 @@ async def c3_shell(cmd: str, cwd: str = "", timeout: int = 60,
|
|
|
639
639
|
"""EXECUTE shell command — structured returns, auto-filter, ledger-aware.
|
|
640
640
|
Use for tests, git, build, scripts. Returns exit_code/stdout/stderr/duration_ms.
|
|
641
641
|
Auto-filters stdout >30 lines; auto-logs git mutations to the edit ledger.
|
|
642
|
-
|
|
642
|
+
Best-effort block of catastrophic commands (rm -rf of /, a top-level system dir, or
|
|
643
|
+
$HOME/~; fork bombs; whole-drive wipes) — a guard, NOT a sandbox. Soft-warns on
|
|
644
|
+
--force, --no-verify, reset --hard.
|
|
643
645
|
Native Bash remains the fallback for interactive/TTY commands."""
|
|
644
646
|
svc = _svc(ctx)
|
|
645
647
|
|
cli/server.py
CHANGED
|
@@ -10,7 +10,6 @@ import csv
|
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
-
import re
|
|
14
13
|
import signal
|
|
15
14
|
import subprocess
|
|
16
15
|
import sys
|
|
@@ -167,12 +166,21 @@ atexit.register(_cleanup_runtime)
|
|
|
167
166
|
|
|
168
167
|
|
|
169
168
|
# ─── CORS middleware ──────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
# Localhost-only security: Host-header allowlist + Origin/Referer CSRF guard +
|
|
170
|
+
# scoped CORS (no wildcard). This UI server always binds 127.0.0.1, so only
|
|
171
|
+
# loopback origins are accepted. A loopback bind alone does NOT stop a web page
|
|
172
|
+
# in the user's browser from driving these endpoints — see core/web_security.py.
|
|
173
|
+
from core.web_security import (
|
|
174
|
+
allowed_hostnames as _allowed_hostnames,
|
|
175
|
+
)
|
|
176
|
+
from core.web_security import (
|
|
177
|
+
guard_summary as _guard_summary,
|
|
178
|
+
)
|
|
179
|
+
from core.web_security import (
|
|
180
|
+
install_guard as _install_web_guard,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
_install_web_guard(app, lambda: _allowed_hostnames(None))
|
|
176
184
|
|
|
177
185
|
|
|
178
186
|
# ─── Serve the UI ─────────────────────────────────────────
|
|
@@ -283,6 +291,11 @@ def api_projects_open():
|
|
|
283
291
|
path = Path(path_str).resolve()
|
|
284
292
|
if not path.exists():
|
|
285
293
|
return jsonify({"error": f"Path does not exist: {path_str}"}), 404
|
|
294
|
+
# Only ever open directories. Opening a *file* via os.startfile would
|
|
295
|
+
# invoke its default handler (e.g. run an .exe/.bat/.lnk), so refuse
|
|
296
|
+
# anything that is not a folder.
|
|
297
|
+
if not path.is_dir():
|
|
298
|
+
return jsonify({"error": "Only directories can be opened"}), 400
|
|
286
299
|
|
|
287
300
|
if sys.platform == "win32":
|
|
288
301
|
os.startfile(str(path))
|
|
@@ -365,7 +378,8 @@ def api_health():
|
|
|
365
378
|
except Exception:
|
|
366
379
|
pass
|
|
367
380
|
|
|
368
|
-
return jsonify({"service": "c3-ui", "sources": sources, "session": session_info
|
|
381
|
+
return jsonify({"service": "c3-ui", "sources": sources, "session": session_info,
|
|
382
|
+
"web_guard": _guard_summary()})
|
|
369
383
|
|
|
370
384
|
|
|
371
385
|
# ─── API: Session Registry ───────────────────────────────
|
|
@@ -2410,47 +2424,15 @@ def api_proxy_tools():
|
|
|
2410
2424
|
|
|
2411
2425
|
|
|
2412
2426
|
# ─── API: MCP Status ─────────────────────────────────────
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
if line.startswith("[") and line.endswith("]"):
|
|
2424
|
-
section = line[1:-1].strip()
|
|
2425
|
-
if section.startswith("mcp_servers."):
|
|
2426
|
-
current_server = section.split(".", 1)[1]
|
|
2427
|
-
servers.setdefault(current_server, {})
|
|
2428
|
-
else:
|
|
2429
|
-
current_server = None
|
|
2430
|
-
continue
|
|
2431
|
-
|
|
2432
|
-
if not current_server or "=" not in line:
|
|
2433
|
-
continue
|
|
2434
|
-
|
|
2435
|
-
key, value = line.split("=", 1)
|
|
2436
|
-
key = key.strip()
|
|
2437
|
-
value = value.strip()
|
|
2438
|
-
|
|
2439
|
-
if key == "args":
|
|
2440
|
-
servers[current_server]["args"] = re.findall(r"[\"']([^\"']*)[\"']", value)
|
|
2441
|
-
elif key in ("command", "type"):
|
|
2442
|
-
match = re.match(r"^[\"'](.*)[\"']$", value)
|
|
2443
|
-
servers[current_server][key] = match.group(1) if match else value
|
|
2444
|
-
elif key == "enabled":
|
|
2445
|
-
low = value.lower()
|
|
2446
|
-
if low.startswith("true"):
|
|
2447
|
-
servers[current_server]["enabled"] = True
|
|
2448
|
-
elif low.startswith("false"):
|
|
2449
|
-
servers[current_server]["enabled"] = False
|
|
2450
|
-
else:
|
|
2451
|
-
servers[current_server][key] = value
|
|
2452
|
-
|
|
2453
|
-
return servers
|
|
2427
|
+
from core.mcp_toml import (
|
|
2428
|
+
parse_toml_mcp_servers as _parse_toml_mcp_servers,
|
|
2429
|
+
)
|
|
2430
|
+
from core.mcp_toml import (
|
|
2431
|
+
remove_toml_section as _remove_toml_section,
|
|
2432
|
+
)
|
|
2433
|
+
from core.mcp_toml import (
|
|
2434
|
+
upsert_toml_section as _upsert_toml_section,
|
|
2435
|
+
)
|
|
2454
2436
|
|
|
2455
2437
|
|
|
2456
2438
|
def _find_server_script(servers: dict) -> bool:
|
|
@@ -2463,71 +2445,6 @@ def _find_server_script(servers: dict) -> bool:
|
|
|
2463
2445
|
return False
|
|
2464
2446
|
|
|
2465
2447
|
|
|
2466
|
-
def _toml_escape_str(value: str) -> str:
|
|
2467
|
-
return value.replace("\\", "/")
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
2471
|
-
"""Add or replace a dotted TOML section in-place."""
|
|
2472
|
-
content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
|
|
2473
|
-
header = f"[{section}]"
|
|
2474
|
-
|
|
2475
|
-
lines = content.splitlines()
|
|
2476
|
-
new_lines = []
|
|
2477
|
-
skip = False
|
|
2478
|
-
for line in lines:
|
|
2479
|
-
stripped = line.strip()
|
|
2480
|
-
if stripped == header:
|
|
2481
|
-
skip = True
|
|
2482
|
-
continue
|
|
2483
|
-
if skip and stripped.startswith("["):
|
|
2484
|
-
skip = False
|
|
2485
|
-
if not skip:
|
|
2486
|
-
new_lines.append(line)
|
|
2487
|
-
|
|
2488
|
-
content = "\n".join(new_lines).rstrip()
|
|
2489
|
-
section_lines = [f"\n\n{header}"]
|
|
2490
|
-
for k, v in entries.items():
|
|
2491
|
-
if isinstance(v, list):
|
|
2492
|
-
items = ", ".join(f'"{_toml_escape_str(str(x))}"' for x in v)
|
|
2493
|
-
section_lines.append(f'{k} = [{items}]')
|
|
2494
|
-
elif isinstance(v, bool):
|
|
2495
|
-
section_lines.append(f'{k} = {"true" if v else "false"}')
|
|
2496
|
-
else:
|
|
2497
|
-
section_lines.append(f'{k} = "{_toml_escape_str(str(v))}"')
|
|
2498
|
-
section_lines.append("")
|
|
2499
|
-
|
|
2500
|
-
toml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2501
|
-
toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
def _remove_toml_section(toml_path: Path, section: str) -> bool:
|
|
2505
|
-
"""Remove a dotted TOML section. Returns True if removed."""
|
|
2506
|
-
if not toml_path.exists():
|
|
2507
|
-
return False
|
|
2508
|
-
content = toml_path.read_text(encoding="utf-8")
|
|
2509
|
-
header = f"[{section}]"
|
|
2510
|
-
|
|
2511
|
-
lines = content.splitlines()
|
|
2512
|
-
new_lines = []
|
|
2513
|
-
skip = False
|
|
2514
|
-
removed = False
|
|
2515
|
-
for line in lines:
|
|
2516
|
-
stripped = line.strip()
|
|
2517
|
-
if stripped == header:
|
|
2518
|
-
skip = True
|
|
2519
|
-
removed = True
|
|
2520
|
-
continue
|
|
2521
|
-
if skip and stripped.startswith("["):
|
|
2522
|
-
skip = False
|
|
2523
|
-
if not skip:
|
|
2524
|
-
new_lines.append(line)
|
|
2525
|
-
|
|
2526
|
-
if removed:
|
|
2527
|
-
toml_path.write_text("\n".join(new_lines).rstrip() + "\n", encoding="utf-8")
|
|
2528
|
-
return removed
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
2448
|
def _resolve_mcp_profile(ide_name: str | None):
|
|
2532
2449
|
requested = (ide_name or "").strip().lower()
|
|
2533
2450
|
if requested and requested != "auto":
|
cli/tools/filter.py
CHANGED
|
@@ -64,10 +64,10 @@ def _filter_text(text: str, depth: str, svc, finalize) -> str:
|
|
|
64
64
|
raw_tokens = res['raw_tokens']
|
|
65
65
|
savings_pct = round((1 - filtered_tokens / raw_tokens) * 100, 1) if raw_tokens > 0 else 0
|
|
66
66
|
|
|
67
|
-
header = f"[filter:{method}] {raw_tokens}
|
|
67
|
+
header = f"[filter:{method}] {raw_tokens}→{filtered_tokens}tok ({savings_pct}%saved)"
|
|
68
68
|
resp = f"{header}\n{result_text}"
|
|
69
69
|
return finalize("c3_filter", {"depth": depth},
|
|
70
|
-
resp, f"{raw_tokens}
|
|
70
|
+
resp, f"{raw_tokens}→{filtered_tokens}tok",
|
|
71
71
|
response_tokens=filtered_tokens)
|
|
72
72
|
|
|
73
73
|
|
cli/tools/read.py
CHANGED
|
@@ -25,13 +25,50 @@ def _coerce_list(val: Any) -> list[str] | None:
|
|
|
25
25
|
except (json.JSONDecodeError, ValueError):
|
|
26
26
|
pass
|
|
27
27
|
if val:
|
|
28
|
+
# Comma-separated symbols ("a,b,c") -> multiple targets. Function/class
|
|
29
|
+
# names never contain commas, and regex anchors (^foo$) have none either.
|
|
30
|
+
if "," in val:
|
|
31
|
+
return [s.strip() for s in val.split(",") if s.strip()]
|
|
28
32
|
return [val]
|
|
29
33
|
return None
|
|
30
34
|
|
|
31
35
|
|
|
36
|
+
def _coerce_lines(val: Any):
|
|
37
|
+
"""Coerce `lines` from MCP's string serialization into an int or list.
|
|
38
|
+
|
|
39
|
+
MCP clients sometimes serialize numbers/lists as strings (the same reason
|
|
40
|
+
`_coerce_list` exists for `symbols`). Without this, a JSON-string such as
|
|
41
|
+
"[22, 193]" or "22" falls through handle_read's range logic and the tool
|
|
42
|
+
silently returns the file *map* instead of the requested source lines.
|
|
43
|
+
"""
|
|
44
|
+
if val is None or isinstance(val, (int, list, tuple)):
|
|
45
|
+
return val
|
|
46
|
+
if isinstance(val, str):
|
|
47
|
+
val = val.strip()
|
|
48
|
+
if not val:
|
|
49
|
+
return None
|
|
50
|
+
if val.startswith("["):
|
|
51
|
+
try:
|
|
52
|
+
parsed = json.loads(val)
|
|
53
|
+
except (json.JSONDecodeError, ValueError):
|
|
54
|
+
return None
|
|
55
|
+
return parsed if isinstance(parsed, list) else None
|
|
56
|
+
try:
|
|
57
|
+
return int(val)
|
|
58
|
+
except ValueError:
|
|
59
|
+
if "-" in val: # "start-end" like "22-40"
|
|
60
|
+
a, _, b = val.partition("-")
|
|
61
|
+
try:
|
|
62
|
+
return [int(a.strip()), int(b.strip())]
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
32
68
|
def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
|
|
33
69
|
include_docstrings: bool = True, svc=None, finalize=None) -> str:
|
|
34
70
|
symbols = _coerce_list(symbols)
|
|
71
|
+
lines = _coerce_lines(lines)
|
|
35
72
|
# Multi-file dispatch (parallel)
|
|
36
73
|
if "," in file_path:
|
|
37
74
|
paths = [p.strip() for p in file_path.split(",") if p.strip()]
|
cli/tools/shell.py
CHANGED
|
@@ -22,9 +22,29 @@ from core import count_tokens
|
|
|
22
22
|
_GIT_MUTATING = re.compile(
|
|
23
23
|
r"^\s*git\s+(commit|add|mv|rm|merge|rebase|cherry-pick|revert|reset|restore|checkout)\b"
|
|
24
24
|
)
|
|
25
|
-
# Hard deny —
|
|
25
|
+
# Hard deny — the handful of genuinely catastrophic, irreversible commands.
|
|
26
|
+
# This is a BEST-EFFORT guard, NOT a sandbox: c3_shell runs arbitrary commands
|
|
27
|
+
# by design and a determined caller can trivially reword around these patterns.
|
|
28
|
+
# The escape hatch for an intentional dangerous command is native Bash.
|
|
29
|
+
# Covered: rm -rf of the filesystem root / a top-level system dir / $HOME / ~,
|
|
30
|
+
# the classic fork bomb, and Windows whole-drive-root wipes (del/rd/format C:\).
|
|
31
|
+
# A top-level system dir only matches when it is the *whole* target, so deleting
|
|
32
|
+
# a nested path like /home/me/project/build is intentionally NOT blocked.
|
|
26
33
|
_BLOCKED = re.compile(
|
|
27
|
-
r"
|
|
34
|
+
r"""
|
|
35
|
+
(?<!git\ )\brm\b (?:\s+-\S+)* \s+ # rm + any flags, then a target:
|
|
36
|
+
(?:
|
|
37
|
+
/(?=\s|$|\*) # filesystem root: / /*
|
|
38
|
+
| ~(?=/|\s|$) # home dir: ~ ~/
|
|
39
|
+
| \$HOME\b # $HOME
|
|
40
|
+
| /(?:etc|usr|bin|sbin|lib|lib64|var|boot|root|home|srv|sys|proc|dev|opt)
|
|
41
|
+
(?=/?(?:\s|$|\*)) # a whole top-level system dir
|
|
42
|
+
)
|
|
43
|
+
| :\(\)\s*\{\s*:\s*\|\s*:\s*\}? # fork bomb :(){ :|: };:
|
|
44
|
+
| \b(?:format|rd|rmdir|del)\b [^\n]*? # windows whole-drive-root wipe
|
|
45
|
+
\b[a-zA-Z]:\\?(?=\s|\*|$|["'])
|
|
46
|
+
""",
|
|
47
|
+
re.IGNORECASE | re.VERBOSE,
|
|
28
48
|
)
|
|
29
49
|
# Soft warn — run but prepend a caveat to the response.
|
|
30
50
|
# `(?<!\w)` / `(?!\w)` anchor against word chars, so `--force` (which starts
|
|
@@ -40,7 +60,13 @@ _FILTER_THRESHOLD_LINES = 30
|
|
|
40
60
|
|
|
41
61
|
|
|
42
62
|
def _popen_kwargs() -> dict:
|
|
43
|
-
|
|
63
|
+
# Force UTF-8 in child processes so Unicode output (→, box-drawing, emoji)
|
|
64
|
+
# doesn't crash on Windows' legacy cp1252 console encoding. setdefault so an
|
|
65
|
+
# intentional caller-set encoding still wins.
|
|
66
|
+
env = dict(os.environ)
|
|
67
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
68
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
69
|
+
kw: dict = {"stdin": subprocess.DEVNULL, "env": env}
|
|
44
70
|
if sys.platform == "win32":
|
|
45
71
|
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
46
72
|
return kw
|
|
@@ -66,7 +92,7 @@ def _run_sync(cmd: str, cwd: str, timeout: int) -> dict:
|
|
|
66
92
|
proc = subprocess.Popen(
|
|
67
93
|
cmd, shell=True, cwd=cwd,
|
|
68
94
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
69
|
-
text=True, errors="replace",
|
|
95
|
+
text=True, encoding="utf-8", errors="replace",
|
|
70
96
|
**_popen_kwargs(),
|
|
71
97
|
)
|
|
72
98
|
timed_out = False
|
|
@@ -113,6 +139,45 @@ def _maybe_refresh_ledger(cmd: str, result: dict, svc) -> list[str]:
|
|
|
113
139
|
return []
|
|
114
140
|
|
|
115
141
|
|
|
142
|
+
# git diagnostics whose output the caller almost always needs verbatim — never
|
|
143
|
+
# auto-filter these, even past the line threshold.
|
|
144
|
+
_GIT_DIAGNOSTIC = re.compile(
|
|
145
|
+
r"^\s*git\s+(status|diff|log|show|branch|stash\s+list)\b", re.IGNORECASE
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _list_root_files(root: Path) -> set[str]:
|
|
150
|
+
try:
|
|
151
|
+
return {e.name for e in root.iterdir() if e.is_file()}
|
|
152
|
+
except OSError:
|
|
153
|
+
return set()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _sweep_new_ghost_files(root: Path, before: set[str]) -> list[str]:
|
|
157
|
+
"""Delete 0-byte 'ghost' files (shell-redirect / metacharacter artifacts —
|
|
158
|
+
e.g. a `>Lnnn` marker or `2>$null` leaking a filename) that appeared in
|
|
159
|
+
*root* during this command. Only files absent from *before* are removed, so
|
|
160
|
+
pre-existing files are never touched. Detection is reused from
|
|
161
|
+
hook_ghost_files so the rules live in one place; this makes c3_shell
|
|
162
|
+
self-clean regardless of whether the external PostToolUse ghost hook is
|
|
163
|
+
wired for this tool."""
|
|
164
|
+
try:
|
|
165
|
+
from cli.hook_ghost_files import scan_ghost_files
|
|
166
|
+
except Exception:
|
|
167
|
+
return []
|
|
168
|
+
swept: list[str] = []
|
|
169
|
+
for g in scan_ghost_files(root):
|
|
170
|
+
name = g.get("name", "")
|
|
171
|
+
if not name or name in before:
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
Path(g["path"]).unlink()
|
|
175
|
+
swept.append(name)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
return swept
|
|
179
|
+
|
|
180
|
+
|
|
116
181
|
async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool,
|
|
117
182
|
log: bool, svc, finalize) -> str:
|
|
118
183
|
if not cmd or not cmd.strip():
|
|
@@ -127,11 +192,17 @@ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool,
|
|
|
127
192
|
work_cwd = cwd or svc.project_path
|
|
128
193
|
work_cwd = str(Path(work_cwd).resolve())
|
|
129
194
|
|
|
195
|
+
ghost_root = Path(work_cwd)
|
|
196
|
+
_ghosts_before = _list_root_files(ghost_root)
|
|
197
|
+
|
|
130
198
|
result = await asyncio.to_thread(_run_sync, cmd, work_cwd, timeout)
|
|
131
199
|
|
|
200
|
+
swept_ghosts = _sweep_new_ghost_files(ghost_root, _ghosts_before)
|
|
201
|
+
|
|
132
202
|
raw_stdout = result["stdout"]
|
|
133
203
|
filtered_note = ""
|
|
134
|
-
if filter_output and raw_stdout.count("\n") > _FILTER_THRESHOLD_LINES
|
|
204
|
+
if (filter_output and raw_stdout.count("\n") > _FILTER_THRESHOLD_LINES
|
|
205
|
+
and not _GIT_DIAGNOSTIC.search(cmd)):
|
|
135
206
|
try:
|
|
136
207
|
filtered = await asyncio.to_thread(
|
|
137
208
|
handle_filter,
|
|
@@ -181,6 +252,11 @@ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool,
|
|
|
181
252
|
body += f"--- stderr ---\n{result['stderr'].rstrip()}\n"
|
|
182
253
|
if touched_files:
|
|
183
254
|
body += f"--- ledger ---\nlogged {len(touched_files)} file(s)\n"
|
|
255
|
+
if swept_ghosts:
|
|
256
|
+
body += (
|
|
257
|
+
f"--- ghost-sweep ---\nremoved {len(swept_ghosts)} stray 0-byte "
|
|
258
|
+
f"file(s): {', '.join(swept_ghosts)}\n"
|
|
259
|
+
)
|
|
184
260
|
|
|
185
261
|
summary = f"shell {status} in {result['duration_ms']}ms"
|
|
186
262
|
resp_tokens = count_tokens(body) if body else 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.34.0
|
|
4
4
|
Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
|
|
5
5
|
Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -363,7 +363,7 @@ Real-world A/B tests: same task, with and without C3 mounted. Reports include to
|
|
|
363
363
|
|
|
364
364
|
## Security & privacy
|
|
365
365
|
|
|
366
|
-
- **Hub
|
|
366
|
+
- **All web servers (Hub, per-project UI, Oracle) bind to `127.0.0.1` by default and are guarded against browser-based attacks even on loopback** — a Host-header allowlist (defeats DNS rebinding) plus an Origin/Referer check on every request (defeats cross-origin CSRF), with scoped, non-wildcard CORS. A malicious web page you visit therefore cannot drive C3's local endpoints. There is still **no user authentication**, so do not expose these servers to an untrusted network without auth/TLS in front. Binding to a non-loopback interface in `~/.c3/hub_config.json` (`host`) or Oracle's config (`bind_host`) is opt-in and warned at startup; add externally-facing hostnames/IPs to an `allowed_hosts` list there so the guard permits them. _(Cross-origin/CSRF + DNS-rebinding hardening added in v2.33.0.)_
|
|
367
367
|
- **No telemetry by default.** The OSS package collects nothing. Opt-in Sentry crash reporting requires the `[telemetry]` extra plus both `SENTRY_DSN` and `C3_TELEMETRY_OPT_IN=1`. Even when enabled, request bodies, local variables, and prompts are stripped before sending.
|
|
368
368
|
- **API keys** for third-party model providers are read from environment variables and never persisted by C3.
|
|
369
369
|
- See [`SECURITY.md`](SECURITY.md) for the full hardening guide and disclosure policy.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
|
|
2
2
|
cli/_hook_utils.py,sha256=1_hTA-Wz62xB8jnSAH4C5TfCkrwEP0g2kq_-oRfQLm4,3724
|
|
3
|
-
cli/c3.py,sha256=
|
|
3
|
+
cli/c3.py,sha256=HjB3HjT9iFIy9WHLZbJx6cYruhxUs2iiWFw9T2Y1ECQ,289101
|
|
4
4
|
cli/docs.html,sha256=JgtBFUuUkvmYowPREYiGhhcRbB5e2UjkRc00MIF0hsU,143653
|
|
5
5
|
cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
|
|
6
6
|
cli/hook_auto_snapshot.py,sha256=amtliVDzKUQr6KBR0pdBA8vXghAV-gKr19jBaJVnP_w,5006
|
|
@@ -9,16 +9,16 @@ cli/hook_c3read.py,sha256=B8WzSf94uG4WSNBSdVFwee10HM7tW-y960ptNid_Jjs,3860
|
|
|
9
9
|
cli/hook_edit_ledger.py,sha256=F7QsIRF4Y3oug6FpWDuDloU_Ord288VnBntfD9CL1CM,7428
|
|
10
10
|
cli/hook_edit_unlock.py,sha256=uvTpirt0GbqPtfuTawhT3Kqh3bL-r7JM948yxctfQCs,5920
|
|
11
11
|
cli/hook_filter.py,sha256=dXk3kaHs87O63WUuc9mNRCxSsLybuJnXlhXFqWAi7h8,4505
|
|
12
|
-
cli/hook_ghost_files.py,sha256=
|
|
12
|
+
cli/hook_ghost_files.py,sha256=jpSUUA59_kveSfS3BwRxryu2ehIiCW4HaT2IMvxzqDA,9356
|
|
13
13
|
cli/hook_pretool_enforce.py,sha256=Mo9b6SyjlCuwPnkbNSX0RDfNkmt5u_YeEfdY9Q79RKc,12465
|
|
14
14
|
cli/hook_read.py,sha256=M5l_SU899O72tZe3j4YQJJKNb1-xulvKOj8XZjJzwYU,8021
|
|
15
15
|
cli/hook_session_stats.py,sha256=a1OKi9kmiXRI2qieY_Uq14xRxdXQTQu9WVzDTUlI0GQ,1897
|
|
16
16
|
cli/hook_terse_advisor.py,sha256=pD7Bap7OYOKqtYz7cX8nWSRLH7ook-tSD2Ov2MNp_sA,5907
|
|
17
17
|
cli/hub.html,sha256=Hl-XPZGT1mMiKrbX9c5OsEw6mXEumwIB3vp1WlWaplM,183966
|
|
18
|
-
cli/hub_server.py,sha256=
|
|
18
|
+
cli/hub_server.py,sha256=L7M3PAQNiRFyqdgL0COXIzIo9lyJTaCZSk-K1I2kZtM,59725
|
|
19
19
|
cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
|
|
20
|
-
cli/mcp_server.py,sha256=
|
|
21
|
-
cli/server.py,sha256=
|
|
20
|
+
cli/mcp_server.py,sha256=5cyXOhH_CxJFCpG13AsOEvtdLZAM8_OiI9ZH1xXtZLs,32454
|
|
21
|
+
cli/server.py,sha256=n8CNh444AGuYMnVSSiRK9pirGh84Ap7ZTI8wuRrJCX8,119970
|
|
22
22
|
cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
|
|
23
23
|
cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
|
|
24
24
|
cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
|
|
@@ -33,14 +33,14 @@ cli/tools/compress.py,sha256=hgBQ6jUwvfGRmcC53vJBz_bbGD0E7T85IxD4q53rj4E,8941
|
|
|
33
33
|
cli/tools/delegate.py,sha256=zOpa03znY27_y1u7T7wbfgQXPwCDA409z9dJ2ki4esU,46947
|
|
34
34
|
cli/tools/edit.py,sha256=fVIZzBPe3ixKBxcZFU03ur2XKu9rAlBihga2-tmIuWw,13791
|
|
35
35
|
cli/tools/edits.py,sha256=-Tv5eqw_X-dYc9l4kFWr_8vX1TAUb9QNMv0fpy0rXjQ,5304
|
|
36
|
-
cli/tools/filter.py,sha256=
|
|
36
|
+
cli/tools/filter.py,sha256=IEjBdKrHxYVCm4cP0Ao6WZkKhbIBi1uI-mLY627zEUw,11503
|
|
37
37
|
cli/tools/impact.py,sha256=jjWkFTxHu-gBpZZNd2HTdBl22itA6-wwwOZXxk_qBl8,6257
|
|
38
38
|
cli/tools/memory.py,sha256=OdYBcIEFo4sr5aCG0_uO48uyJ-Kzof7LC2Ou1THGFuc,23317
|
|
39
39
|
cli/tools/project.py,sha256=PKHLGRFZdwBBEgryyV440mybTeQMqnlLgHdfXDnPahw,11868
|
|
40
|
-
cli/tools/read.py,sha256=
|
|
40
|
+
cli/tools/read.py,sha256=2UT2ICjkyUqMWujOHidEhsGuOTORLTt7na2CVdVZwpw,10868
|
|
41
41
|
cli/tools/search.py,sha256=qB8C3w8yuu59aepvnuJNlzbsirtSEZA8zz4neKlE7Xs,13246
|
|
42
42
|
cli/tools/session.py,sha256=LIZbmEhNdh6rAsT6Dbpb21UY8xF9oubvpjGwfnXxQK4,4573
|
|
43
|
-
cli/tools/shell.py,sha256=
|
|
43
|
+
cli/tools/shell.py,sha256=lTGMpT_YWnAyJzcT-WHMLjuenqq6m-p3v6uCHKCsHWM,9752
|
|
44
44
|
cli/tools/status.py,sha256=yCHXskXgKzaDhJT6vHYlp3ZU5j89vhVYzf5dQOAR9yQ,12625
|
|
45
45
|
cli/tools/validate.py,sha256=KJr_YKLHiThgKdPnv-7Uucv2rS3DIoKISNxSy2icw8I,11965
|
|
46
46
|
cli/ui/api.js,sha256=wI9-lxGitIgZ5R6lqi1PVc2edD0b3TsYwMUGgcNWgfU,1342
|
|
@@ -57,15 +57,17 @@ cli/ui/components/memory.js,sha256=v5IsHTxLHpXX4xCsUaZ_UPprZEabdgP4jiWc298iV2U,2
|
|
|
57
57
|
cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE,30837
|
|
58
58
|
cli/ui/components/settings.js,sha256=8LVTV2TQl9tcRXhXbtBEJOCBdiyk-x2QASoVYZUAuEA,71442
|
|
59
59
|
cli/ui/components/sidebar.js,sha256=cAY_jwYB-o1X_wWn__VXlG4IegVObuE3NmVsuFWqxtg,7417
|
|
60
|
-
code_context_control-2.
|
|
60
|
+
code_context_control-2.34.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
|
|
61
61
|
core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
|
|
62
62
|
core/config.py,sha256=0RBVni99wqJIxAYU6uweWVOmdI-FJvQ8d3IV5Mp1Muc,12818
|
|
63
63
|
core/ide.py,sha256=9LzsDVK2LL8RVpL40l6oNGiasZ3D8OCU_9i9A0gJKBo,6876
|
|
64
|
+
core/mcp_toml.py,sha256=nFsgDm9j0W1v-o4tBTeEYFuHyxkBNuuMjPhJX-lvfYE,4422
|
|
65
|
+
core/web_security.py,sha256=WkNmoCsSWepV3XduibuQYalEJSIR_cpmumoK9bce9FM,7412
|
|
64
66
|
oracle/__init__.py,sha256=-OTD7Jh4mUMA4QgPGthPLWXttgZLpkIPhGQ87ZfHBx0,63
|
|
65
67
|
oracle/config.py,sha256=ErjH6Y_F51jGXpYo_4boGhdIk-AIo3rDH3xDwUGs7B8,3193
|
|
66
|
-
oracle/mcp_oracle.py,sha256=
|
|
68
|
+
oracle/mcp_oracle.py,sha256=Vts1ugITgvZP_BzKHgQN1nxHNVQNuNAEPdKgVIpRjPU,8133
|
|
67
69
|
oracle/oracle.html,sha256=KW1jeqmUwvAH2mDlhCLo05nrHW8PfmCYv0iQhL5d74s,185219
|
|
68
|
-
oracle/oracle_server.py,sha256=
|
|
70
|
+
oracle/oracle_server.py,sha256=qMKLzLoV5cp-cK-l5tc0w24jis2QudNVB7S74eKijGQ,33778
|
|
69
71
|
oracle/services/__init__.py,sha256=Nb4POd1_YIwLVYsGfr-DiK-iKTelkU0fh9m7wjeLQHA,23
|
|
70
72
|
oracle/services/api_auth.py,sha256=1PW3pG--1DJb_F6qMhP3gBTYHxxPE2bsHmVmIhC81Y8,3566
|
|
71
73
|
oracle/services/c3_bridge.py,sha256=Khj2jao2oENe4yFA31Ny0yI3fcV8XBerjsLslt4ns3U,9652
|
|
@@ -81,7 +83,7 @@ oracle/services/ollama_bridge.py,sha256=d0458HTaQO9m-Ur4bRIt9izxbJFDj1Dbea9sfo7M
|
|
|
81
83
|
oracle/services/project_scanner.py,sha256=SGHYKU00fm57L5VyDuet-CAACfrhUZY2xvIlZR27aj4,3142
|
|
82
84
|
oracle/services/review_agent.py,sha256=99PQ1oKxRDo_4COMOy3CJOawqk08MRJ4cS66_w7SWCo,7720
|
|
83
85
|
oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
|
|
84
|
-
oracle/services/tool_registry.py,sha256=
|
|
86
|
+
oracle/services/tool_registry.py,sha256=V7eP-UeacN3T4We_zbJ8NdFCFCKJVSKJa4zfH02LvuA,18722
|
|
85
87
|
services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
|
|
86
88
|
services/activity_log.py,sha256=YsW8-HBQEFh2vYTlvnzK7doNsR-XEtBbWXJ-324XigU,3370
|
|
87
89
|
services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
|
|
@@ -90,7 +92,7 @@ services/auto_memory.py,sha256=v__ZS1e68533_Yv491mZtvuZnheC63q6_uTvWhBw3Lw,14290
|
|
|
90
92
|
services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
|
|
91
93
|
services/bitbucket_client.py,sha256=v8xGEcnIEmURvcg38XwmiCGh7-_QnjhAJEb0te_yZzQ,16107
|
|
92
94
|
services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
|
|
93
|
-
services/claude_md.py,sha256=
|
|
95
|
+
services/claude_md.py,sha256=iL0vUQw-5lxSQehNPvhlkUmcGPeMSCcZqP4OYG_qoYk,35092
|
|
94
96
|
services/compressor.py,sha256=uSVyTYfvxFrRYupzyKj-HzkBP0RwARrGYFz_DnMSEaM,25169
|
|
95
97
|
services/context_snapshot.py,sha256=upxrxcBUPX7MrOlgUo7oD9rvm2H1SJLK8FI1tgHrAjg,14045
|
|
96
98
|
services/conversation_store.py,sha256=vPiMiKAE22RCBSSphgGH9Vx-lPV45SmttOwgVVWahL4,33398
|
|
@@ -153,8 +155,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
|
|
|
153
155
|
tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
|
|
154
156
|
tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
|
|
155
157
|
tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
|
|
156
|
-
code_context_control-2.
|
|
157
|
-
code_context_control-2.
|
|
158
|
-
code_context_control-2.
|
|
159
|
-
code_context_control-2.
|
|
160
|
-
code_context_control-2.
|
|
158
|
+
code_context_control-2.34.0.dist-info/METADATA,sha256=jxMVmOACpAqMpGaXua2LZPZdLxHrkefk02mqcaRMNKI,19802
|
|
159
|
+
code_context_control-2.34.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
160
|
+
code_context_control-2.34.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
|
|
161
|
+
code_context_control-2.34.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
|
|
162
|
+
code_context_control-2.34.0.dist-info/RECORD,,
|
core/mcp_toml.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Shared TOML helpers for the MCP-server sections of IDE config files
|
|
2
|
+
(Codex's ``config.toml``, etc.).
|
|
3
|
+
|
|
4
|
+
These were duplicated — and had quietly drifted — between ``cli/server.py`` and
|
|
5
|
+
``cli/hub_server.py``. Consolidating them keeps parse/write behaviour in one
|
|
6
|
+
place (the same triplication pattern that once let a CORS bug live in three
|
|
7
|
+
servers). The reconciled versions adopt the more robust behaviour from each
|
|
8
|
+
copy: ``parse`` strips surrounding quotes from keys, and ``remove`` deletes a
|
|
9
|
+
file that becomes empty instead of leaving an empty stub.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_toml_mcp_servers(content: str) -> dict:
|
|
18
|
+
"""Parse ``[mcp_servers.<name>]`` sections from TOML content into a dict."""
|
|
19
|
+
servers: dict = {}
|
|
20
|
+
current_server = None
|
|
21
|
+
|
|
22
|
+
for raw in content.splitlines():
|
|
23
|
+
line = raw.split("#", 1)[0].strip()
|
|
24
|
+
if not line:
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
if line.startswith("[") and line.endswith("]"):
|
|
28
|
+
section = line[1:-1].strip()
|
|
29
|
+
if section.startswith("mcp_servers."):
|
|
30
|
+
current_server = section.split(".", 1)[1]
|
|
31
|
+
servers.setdefault(current_server, {})
|
|
32
|
+
else:
|
|
33
|
+
current_server = None
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
if not current_server or "=" not in line:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
key, value = line.split("=", 1)
|
|
40
|
+
key = key.strip().strip('"')
|
|
41
|
+
value = value.strip()
|
|
42
|
+
|
|
43
|
+
if key == "args":
|
|
44
|
+
servers[current_server]["args"] = re.findall(r"[\"']([^\"']*)[\"']", value)
|
|
45
|
+
elif key in ("command", "type"):
|
|
46
|
+
match = re.match(r"^[\"'](.*)[\"']$", value)
|
|
47
|
+
servers[current_server][key] = match.group(1) if match else value
|
|
48
|
+
elif key == "enabled":
|
|
49
|
+
low = value.lower()
|
|
50
|
+
if low.startswith("true"):
|
|
51
|
+
servers[current_server]["enabled"] = True
|
|
52
|
+
elif low.startswith("false"):
|
|
53
|
+
servers[current_server]["enabled"] = False
|
|
54
|
+
else:
|
|
55
|
+
servers[current_server][key] = value
|
|
56
|
+
|
|
57
|
+
return servers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def toml_escape_str(value: str) -> str:
|
|
61
|
+
"""Escape a string for a double-quoted TOML value (Windows ``\\`` → ``/``)."""
|
|
62
|
+
return value.replace("\\", "/")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
66
|
+
"""Add or replace a dotted TOML section in-place."""
|
|
67
|
+
content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
|
|
68
|
+
header = f"[{section}]"
|
|
69
|
+
|
|
70
|
+
lines = content.splitlines()
|
|
71
|
+
new_lines = []
|
|
72
|
+
skip = False
|
|
73
|
+
for line in lines:
|
|
74
|
+
stripped = line.strip()
|
|
75
|
+
if stripped == header:
|
|
76
|
+
skip = True
|
|
77
|
+
continue
|
|
78
|
+
if skip and stripped.startswith("["):
|
|
79
|
+
skip = False
|
|
80
|
+
if not skip:
|
|
81
|
+
new_lines.append(line)
|
|
82
|
+
|
|
83
|
+
content = "\n".join(new_lines).rstrip()
|
|
84
|
+
section_lines = [f"\n\n{header}"]
|
|
85
|
+
for key, value in entries.items():
|
|
86
|
+
if isinstance(value, list):
|
|
87
|
+
items = ", ".join(f'"{toml_escape_str(str(item))}"' for item in value)
|
|
88
|
+
section_lines.append(f"{key} = [{items}]")
|
|
89
|
+
elif isinstance(value, bool):
|
|
90
|
+
section_lines.append(f'{key} = {"true" if value else "false"}')
|
|
91
|
+
else:
|
|
92
|
+
section_lines.append(f'{key} = "{toml_escape_str(str(value))}"')
|
|
93
|
+
section_lines.append("")
|
|
94
|
+
|
|
95
|
+
toml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def remove_toml_section(toml_path: Path, section: str) -> bool:
|
|
100
|
+
"""Remove a dotted TOML section. Deletes the file if it becomes empty.
|
|
101
|
+
Returns True if the section was found and removed."""
|
|
102
|
+
if not toml_path.exists():
|
|
103
|
+
return False
|
|
104
|
+
content = toml_path.read_text(encoding="utf-8")
|
|
105
|
+
header = f"[{section}]"
|
|
106
|
+
|
|
107
|
+
lines = content.splitlines()
|
|
108
|
+
new_lines = []
|
|
109
|
+
skip = False
|
|
110
|
+
removed = False
|
|
111
|
+
for line in lines:
|
|
112
|
+
stripped = line.strip()
|
|
113
|
+
if stripped == header:
|
|
114
|
+
skip = True
|
|
115
|
+
removed = True
|
|
116
|
+
continue
|
|
117
|
+
if skip and stripped.startswith("["):
|
|
118
|
+
skip = False
|
|
119
|
+
if not skip:
|
|
120
|
+
new_lines.append(line)
|
|
121
|
+
|
|
122
|
+
if removed:
|
|
123
|
+
remaining = "\n".join(new_lines).rstrip()
|
|
124
|
+
if remaining:
|
|
125
|
+
toml_path.write_text(remaining + "\n", encoding="utf-8")
|
|
126
|
+
else:
|
|
127
|
+
toml_path.unlink()
|
|
128
|
+
return removed
|
core/web_security.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Localhost-only security guard for C3's Flask dashboards (UI, Hub, Oracle).
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
C3's web servers bind to loopback (``127.0.0.1``) by default. A loopback bind
|
|
6
|
+
keeps the server off the LAN, but it does **not** protect against requests made
|
|
7
|
+
by a web page running in the user's own browser. Two classic attacks defeat a
|
|
8
|
+
"loopback is safe" assumption:
|
|
9
|
+
|
|
10
|
+
* **Cross-origin / CSRF** — any website the user visits can ``fetch()`` against
|
|
11
|
+
``http://localhost:<port>/...``. With no auth and a permissive CORS policy,
|
|
12
|
+
that page could drive state-changing endpoints (launch an IDE command, add a
|
|
13
|
+
malicious MCP server, downgrade permissions, wipe data).
|
|
14
|
+
* **DNS rebinding** — an attacker domain re-resolves to ``127.0.0.1`` after the
|
|
15
|
+
page loads, so the browser sends requests to the local server with the
|
|
16
|
+
*attacker's* hostname in the ``Host`` header.
|
|
17
|
+
|
|
18
|
+
This module adds two cheap, standard defenses that together close that gap
|
|
19
|
+
without requiring the dashboard JavaScript to change (same-origin requests pass
|
|
20
|
+
naturally):
|
|
21
|
+
|
|
22
|
+
1. **Host-header allowlist** — defeats DNS rebinding. The rebound request
|
|
23
|
+
carries the attacker's hostname in ``Host``; anything not in the allowlist is
|
|
24
|
+
rejected.
|
|
25
|
+
2. **Origin/Referer check on state-changing requests** — defeats cross-origin
|
|
26
|
+
CSRF. Browsers always attach ``Origin`` to ``POST``/``PUT``/``DELETE``/
|
|
27
|
+
``PATCH`` (cross-origin *and* same-origin), so a mismatched origin is a
|
|
28
|
+
reliable CSRF signal.
|
|
29
|
+
|
|
30
|
+
Non-browser clients (curl, the Oracle Discovery REST/MCP consumers) send no
|
|
31
|
+
``Origin`` and a loopback ``Host``, so they are unaffected. Bearer-token auth
|
|
32
|
+
(Oracle discovery) still applies on top of this guard.
|
|
33
|
+
|
|
34
|
+
For an intentional non-loopback bind (an explicit, already-warned opt-in), pass
|
|
35
|
+
the configured bind host so the user can still reach their own dashboard; remote
|
|
36
|
+
hosts that need access can be added via the optional ``extra`` argument
|
|
37
|
+
(wired from an ``allowed_hosts`` config list by the caller).
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from collections.abc import Callable, Iterable
|
|
42
|
+
from urllib.parse import urlsplit
|
|
43
|
+
|
|
44
|
+
# Hostnames that always denote "this machine". Note: a literal ``0.0.0.0`` never
|
|
45
|
+
# appears as a browser Host header, so it is intentionally excluded — binding to
|
|
46
|
+
# 0.0.0.0 means the client connects by some concrete IP/name, which must be added
|
|
47
|
+
# via ``extra`` (``allowed_hosts`` config) by the operator.
|
|
48
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
49
|
+
_MUTATING_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _hostname(value: str | None) -> str:
|
|
53
|
+
"""Extract a bare, lowercased hostname from a Host header or Origin/Referer URL.
|
|
54
|
+
|
|
55
|
+
Handles values with or without a scheme and with or without a port, including
|
|
56
|
+
IPv6 literals like ``[::1]:3333``.
|
|
57
|
+
"""
|
|
58
|
+
if not value:
|
|
59
|
+
return ""
|
|
60
|
+
value = value.strip()
|
|
61
|
+
# A bare Host header ("localhost:3333") has no scheme; prefix "//" so urlsplit
|
|
62
|
+
# parses it as a netloc rather than a path.
|
|
63
|
+
if "://" not in value:
|
|
64
|
+
value = "//" + value
|
|
65
|
+
try:
|
|
66
|
+
host = urlsplit(value).hostname or ""
|
|
67
|
+
except ValueError:
|
|
68
|
+
return ""
|
|
69
|
+
return host.lower()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def allowed_hostnames(bind_host: str | None = None,
|
|
73
|
+
extra: Iterable[str] | None = None) -> set[str]:
|
|
74
|
+
"""Build the set of acceptable hostnames for this server.
|
|
75
|
+
|
|
76
|
+
Always includes loopback names. ``bind_host`` (unless it is the wildcard
|
|
77
|
+
``0.0.0.0`` or empty) and any ``extra`` hosts are added so an intentional
|
|
78
|
+
non-loopback deployment remains reachable.
|
|
79
|
+
"""
|
|
80
|
+
hosts = set(_LOOPBACK_HOSTS)
|
|
81
|
+
bh = (bind_host or "").strip().lower()
|
|
82
|
+
if bh and bh not in ("0.0.0.0", "::", "*"):
|
|
83
|
+
hosts.add(bh)
|
|
84
|
+
if extra:
|
|
85
|
+
for h in extra:
|
|
86
|
+
h = (h or "").strip().lower()
|
|
87
|
+
if h:
|
|
88
|
+
hosts.add(h)
|
|
89
|
+
return hosts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_request(request, allowed: set[str]) -> tuple[bool, str]:
|
|
93
|
+
"""Return ``(ok, reason)``. ``ok == False`` means the request must be 403'd.
|
|
94
|
+
|
|
95
|
+
``request`` is a Flask request (anything exposing ``.host``, ``.method`` and
|
|
96
|
+
``.headers.get``).
|
|
97
|
+
"""
|
|
98
|
+
# 1) Host-header allowlist — anti DNS-rebinding.
|
|
99
|
+
host = _hostname(getattr(request, "host", "") or "")
|
|
100
|
+
if host and host not in allowed:
|
|
101
|
+
return False, f"host '{host}' is not allowlisted"
|
|
102
|
+
|
|
103
|
+
# 2) Origin check — anti cross-origin CSRF. Browsers always send Origin on
|
|
104
|
+
# state-changing requests, so a mismatch is a reliable CSRF signal. When
|
|
105
|
+
# Origin is absent (typical for curl / API clients), fall back to Referer
|
|
106
|
+
# only for mutating methods; a fully header-less request is treated as a
|
|
107
|
+
# non-browser caller and allowed (it cannot be a CSRF from a page).
|
|
108
|
+
origin = request.headers.get("Origin")
|
|
109
|
+
if origin:
|
|
110
|
+
if _hostname(origin) not in allowed:
|
|
111
|
+
return False, "cross-origin request blocked (Origin)"
|
|
112
|
+
elif request.method in _MUTATING_METHODS:
|
|
113
|
+
referer = request.headers.get("Referer")
|
|
114
|
+
if referer and _hostname(referer) not in allowed:
|
|
115
|
+
return False, "cross-origin request blocked (Referer)"
|
|
116
|
+
return True, ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cors_origin(request, allowed: set[str]) -> str | None:
|
|
120
|
+
"""Echo the request Origin in Access-Control-Allow-Origin only if it is
|
|
121
|
+
same-origin/allowlisted. The wildcard ``*`` is never used.
|
|
122
|
+
"""
|
|
123
|
+
origin = request.headers.get("Origin")
|
|
124
|
+
if origin and _hostname(origin) in allowed:
|
|
125
|
+
return origin
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def guard_summary() -> dict:
|
|
130
|
+
"""Compact, serializable status for health endpoints — confirms to operators
|
|
131
|
+
that the localhost guard is active (it otherwise enforces silently)."""
|
|
132
|
+
return {
|
|
133
|
+
"active": True,
|
|
134
|
+
"host_allowlist": True,
|
|
135
|
+
"csrf": "origin+referer",
|
|
136
|
+
"cors": "scoped",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def install_guard(app, get_allowed: Callable[[], set[str]]) -> None:
|
|
141
|
+
"""Register the Host/Origin guard and a tightened CORS policy on a Flask app.
|
|
142
|
+
|
|
143
|
+
``get_allowed`` is called per-request so live config changes (e.g. a hub
|
|
144
|
+
``host`` edit or an ``allowed_hosts`` list) are honoured without a restart.
|
|
145
|
+
Registering this BEFORE any other ``before_request`` (e.g. a bearer-token
|
|
146
|
+
guard) ensures cross-origin requests are rejected first.
|
|
147
|
+
"""
|
|
148
|
+
from flask import jsonify, request
|
|
149
|
+
|
|
150
|
+
@app.before_request
|
|
151
|
+
def _c3_security_guard(): # noqa: ANN202 - Flask hook
|
|
152
|
+
# Let CORS preflight through; the after_request handler answers it and
|
|
153
|
+
# only reflects an allowlisted Origin, so disallowed origins still fail.
|
|
154
|
+
if request.method == "OPTIONS":
|
|
155
|
+
return None
|
|
156
|
+
ok, reason = check_request(request, get_allowed())
|
|
157
|
+
if not ok:
|
|
158
|
+
return jsonify({"error": f"blocked: {reason}"}), 403
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
@app.after_request
|
|
162
|
+
def _c3_cors(response): # noqa: ANN202 - Flask hook
|
|
163
|
+
origin = cors_origin(request, get_allowed())
|
|
164
|
+
if origin:
|
|
165
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
166
|
+
response.headers["Vary"] = "Origin"
|
|
167
|
+
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
|
168
|
+
response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS"
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
import logging
|
|
172
|
+
logging.getLogger("c3.web_security").info(
|
|
173
|
+
"localhost web guard active — Host allowlist + Origin/Referer CSRF + scoped CORS"
|
|
174
|
+
)
|
oracle/mcp_oracle.py
CHANGED
|
@@ -23,12 +23,19 @@ from oracle.services import api_auth
|
|
|
23
23
|
logger = logging.getLogger("oracle.mcp")
|
|
24
24
|
|
|
25
25
|
_INSTRUCTIONS = (
|
|
26
|
-
"C3 Oracle Discovery — cross-project code & memory intelligence as tools
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
26
|
+
"C3 Oracle Discovery — use C3's cross-project code & memory intelligence as tools.\n"
|
|
27
|
+
"\n"
|
|
28
|
+
"Recommended workflow:\n"
|
|
29
|
+
"1. list_projects — see which C3 projects exist (names + absolute paths).\n"
|
|
30
|
+
"2. Discover across ALL projects: search_facts (memory) or c3_search_cross (code).\n"
|
|
31
|
+
"3. Narrow to one project using its path: c3_search to find code; c3_compress "
|
|
32
|
+
"(mode='map') to see a file's shape before reading; c3_read for exact content; "
|
|
33
|
+
"query_memory / read_graph / cross_insights for that project's memory.\n"
|
|
34
|
+
"\n"
|
|
35
|
+
"Notes: per-project tools REQUIRE a `project_path` taken from list_projects. Every tool "
|
|
36
|
+
"returns JSON. suggest_action creates a PENDING suggestion for a human to approve (not a "
|
|
37
|
+
"direct write); delegate_task runs a configured Oracle agent. Read + safe-action tiers "
|
|
38
|
+
"only — no code-editing tools are exposed."
|
|
32
39
|
)
|
|
33
40
|
|
|
34
41
|
|
|
@@ -99,21 +106,59 @@ class _BearerAuthMiddleware:
|
|
|
99
106
|
await self.app(scope, receive, send)
|
|
100
107
|
|
|
101
108
|
|
|
102
|
-
|
|
109
|
+
class _HostGuardMiddleware:
|
|
110
|
+
"""Pure-ASGI Host-header allowlist.
|
|
111
|
+
|
|
112
|
+
Rejects requests whose ``Host`` header is not loopback or the configured
|
|
113
|
+
bind host — defeating DNS-rebinding against the MCP transport. Defense in
|
|
114
|
+
depth on top of the Bearer gate (a rebound request would still need a valid
|
|
115
|
+
token, but this stops it reaching the app at all).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, app, allowed: set):
|
|
119
|
+
self.app = app
|
|
120
|
+
self.allowed = allowed
|
|
121
|
+
|
|
122
|
+
async def __call__(self, scope, receive, send):
|
|
123
|
+
if scope.get("type") == "http":
|
|
124
|
+
from core.web_security import _hostname
|
|
125
|
+
headers = dict(scope.get("headers") or [])
|
|
126
|
+
host = headers.get(b"host", b"").decode("latin-1")
|
|
127
|
+
if _hostname(host) not in self.allowed:
|
|
128
|
+
body = b'{"error": "forbidden host"}'
|
|
129
|
+
await send({
|
|
130
|
+
"type": "http.response.start",
|
|
131
|
+
"status": 403,
|
|
132
|
+
"headers": [(b"content-type", b"application/json"),
|
|
133
|
+
(b"content-length", str(len(body)).encode())],
|
|
134
|
+
})
|
|
135
|
+
await send({"type": "http.response.body", "body": body})
|
|
136
|
+
return
|
|
137
|
+
await self.app(scope, receive, send)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_app(registry, version: str = "", require_auth: bool = True, path: str = "/mcp",
|
|
141
|
+
host: str = "127.0.0.1", allowed_hosts=None):
|
|
103
142
|
"""Build the Starlette ASGI app for the MCP server (auth middleware attached)."""
|
|
104
143
|
mcp = build_mcp(registry, version)
|
|
105
144
|
app = mcp.http_app(path=path)
|
|
106
145
|
if require_auth:
|
|
107
146
|
app.add_middleware(_BearerAuthMiddleware)
|
|
147
|
+
# Host-header allowlist (defense-in-depth vs DNS rebinding). Added last so it
|
|
148
|
+
# is the outermost middleware and runs before the bearer check.
|
|
149
|
+
from core.web_security import allowed_hostnames
|
|
150
|
+
app.add_middleware(_HostGuardMiddleware, allowed=allowed_hostnames(host, allowed_hosts))
|
|
108
151
|
return app
|
|
109
152
|
|
|
110
153
|
|
|
111
154
|
def serve_mcp(registry, host: str = "127.0.0.1", port: int = 3332,
|
|
112
|
-
version: str = "", require_auth: bool = True, path: str = "/mcp"
|
|
155
|
+
version: str = "", require_auth: bool = True, path: str = "/mcp",
|
|
156
|
+
allowed_hosts=None) -> None:
|
|
113
157
|
"""Blocking: serve the MCP app with uvicorn. Safe to run off the main thread."""
|
|
114
158
|
import uvicorn
|
|
115
159
|
|
|
116
|
-
app = build_app(registry, version=version, require_auth=require_auth, path=path
|
|
160
|
+
app = build_app(registry, version=version, require_auth=require_auth, path=path,
|
|
161
|
+
host=host, allowed_hosts=allowed_hosts)
|
|
117
162
|
config = uvicorn.Config(app, host=host, port=port, log_level="warning", access_log=False)
|
|
118
163
|
server = uvicorn.Server(config)
|
|
119
164
|
# uvicorn only installs signal handlers on the main thread; disable so this
|
|
@@ -124,13 +169,13 @@ def serve_mcp(registry, host: str = "127.0.0.1", port: int = 3332,
|
|
|
124
169
|
|
|
125
170
|
def start_mcp_thread(registry, host: str = "127.0.0.1", port: int = 3332,
|
|
126
171
|
version: str = "", require_auth: bool = True,
|
|
127
|
-
path: str = "/mcp") -> threading.Thread:
|
|
172
|
+
path: str = "/mcp", allowed_hosts=None) -> threading.Thread:
|
|
128
173
|
"""Start :func:`serve_mcp` in a daemon thread and return the thread."""
|
|
129
174
|
|
|
130
175
|
def _run():
|
|
131
176
|
try:
|
|
132
177
|
serve_mcp(registry, host=host, port=port, version=version,
|
|
133
|
-
require_auth=require_auth, path=path)
|
|
178
|
+
require_auth=require_auth, path=path, allowed_hosts=allowed_hosts)
|
|
134
179
|
except Exception:
|
|
135
180
|
logger.exception("Oracle MCP server crashed")
|
|
136
181
|
|
oracle/oracle_server.py
CHANGED
|
@@ -116,12 +116,21 @@ def _init_services():
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
# ── CORS ──────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
# Localhost security guard + scoped CORS (replaces the previous wildcard CORS).
|
|
120
|
+
# Host-header allowlist + Origin/Referer CSRF guard. Bearer auth on
|
|
121
|
+
# /api/discovery/* (see _discovery_auth_guard below) still applies on top; this
|
|
122
|
+
# guard also protects the ungated endpoints (config, chat, /api/apikey) from
|
|
123
|
+
# cross-origin reads — notably the raw Discovery token returned by api_apikey_get.
|
|
124
|
+
from core.web_security import (
|
|
125
|
+
allowed_hostnames as _allowed_hostnames,
|
|
126
|
+
)
|
|
127
|
+
from core.web_security import (
|
|
128
|
+
install_guard as _install_web_guard,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
_install_web_guard(
|
|
132
|
+
app, lambda: _allowed_hostnames(_cfg.get("bind_host"), _cfg.get("allowed_hosts"))
|
|
133
|
+
)
|
|
125
134
|
|
|
126
135
|
|
|
127
136
|
# ── Discovery API auth guard ──────────────────────────────
|
|
@@ -849,6 +858,7 @@ def run_oracle(port: int = None, open_browser: bool = None):
|
|
|
849
858
|
port=mcp_p,
|
|
850
859
|
version=_c3_version(),
|
|
851
860
|
require_auth=cfg.get("api_require_auth", True),
|
|
861
|
+
allowed_hosts=cfg.get("allowed_hosts"),
|
|
852
862
|
)
|
|
853
863
|
print(f"Oracle Discovery MCP → {mcp_url(mcp_host, mcp_p)} (auth: bearer)")
|
|
854
864
|
except Exception as e:
|
oracle/services/tool_registry.py
CHANGED
|
@@ -398,8 +398,23 @@ class ToolRegistry:
|
|
|
398
398
|
"info": {
|
|
399
399
|
"title": "C3 Oracle Discovery API",
|
|
400
400
|
"version": _c3_version(),
|
|
401
|
-
"description":
|
|
402
|
-
|
|
401
|
+
"description": (
|
|
402
|
+
"Use C3's cross-project code & memory intelligence as tools, for external LLMs.\n\n"
|
|
403
|
+
"**Workflow:** call `list_projects` first to get project names + absolute paths; "
|
|
404
|
+
"discover across all projects with `search_facts` (memory) or `c3_search_cross` "
|
|
405
|
+
"(code); then narrow to one project (pass its `project_path`) using `c3_search`, "
|
|
406
|
+
"`c3_compress` (mode `map` to see a file's shape before reading), `c3_read` (exact "
|
|
407
|
+
"content), `query_memory`, `read_graph`, or `cross_insights`.\n\n"
|
|
408
|
+
"**Auth:** every request requires an `Authorization: Bearer <token>` header "
|
|
409
|
+
"(get it from `/api/discovery/mcp-info` or by running `c3 oracle api info`).\n\n"
|
|
410
|
+
"**Capability tiers:** `read` tools are pure discovery; `action` tools are safe and "
|
|
411
|
+
"non-destructive — `suggest_action` creates a PENDING suggestion for human approval "
|
|
412
|
+
"(not a direct write) and `delegate_task` runs a configured Oracle agent. No "
|
|
413
|
+
"code-editing tools are exposed.\n\n"
|
|
414
|
+
"**Invoke:** POST the arguments object to `/api/discovery/tools/{name}`, or POST "
|
|
415
|
+
"`{\"tool\": \"<name>\", \"args\": {...}}` to `/api/discovery/call`. Per-project "
|
|
416
|
+
"tools require a `project_path` from `list_projects`."
|
|
417
|
+
),
|
|
403
418
|
},
|
|
404
419
|
"security": [{"bearerAuth": []}],
|
|
405
420
|
"components": {
|
services/claude_md.py
CHANGED
|
@@ -36,7 +36,7 @@ When falling back, state which c3_* tool was attempted and why it was insufficie
|
|
|
36
36
|
4. **IMPACT** (shared symbols): `c3_impact(target='symbol')` — blast-radius check before editing any function/class used across files
|
|
37
37
|
5. **EDIT via C3**: `c3_edit(file_path, old_string, new_string, summary)` — for ALL edits. Parallel across files; `edits=[]` batch for same file
|
|
38
38
|
6. **FILTER**: `c3_filter(text=...)` — for terminal output >10 lines or log files
|
|
39
|
-
6.5. **SHELL via C3**: `c3_shell(cmd, cwd='', timeout=60)` — for tests, git, build, scripts. Returns structured `{exit_code, stdout, stderr, duration_ms}`. Auto-filters stdout >30 lines; auto-logs git-mutating commands (commit/add/merge/rebase/reset/restore/checkout) to the edit ledger.
|
|
39
|
+
6.5. **SHELL via C3**: `c3_shell(cmd, cwd='', timeout=60)` — for tests, git, build, scripts. Returns structured `{exit_code, stdout, stderr, duration_ms}`. Auto-filters stdout >30 lines; auto-logs git-mutating commands (commit/add/merge/rebase/reset/restore/checkout) to the edit ledger. Best-effort blocks the most catastrophic commands (`rm -rf` of `/`, a top-level system dir, or `$HOME`/`~`; fork bombs; whole-drive wipes) — a guard, not a sandbox; soft-warns on `--force`, `--no-verify`, `reset --hard`. Native Bash remains the fallback for interactive/TTY commands
|
|
40
40
|
7. **VALIDATE**: `c3_validate(file_path)` — after edits or before reporting done. Runs deep type check (pyright/tsc) automatically if installed
|
|
41
41
|
8. **LOG**: `c3_session(action='log')` for decisions. `c3_session(action='snapshot')` before /clear
|
|
42
42
|
9. **DELEGATE**: `c3_delegate(task, backend='ollama|codex|gemini|claude|auto')` or `c3_agent(workflow=...)` for multi-model pipelines
|
|
File without changes
|
{code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.32.2.dist-info → code_context_control-2.34.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|