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 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.32.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
- # "cmd /c" so cmd.exe handles path resolution instead of bash.
4925
- _hook_prefix = "cmd /c " if sys.platform == "win32" else ""
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": [{"type": "command", "command": hook_read_cmd}]
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
- # Only trigger on Bash (Claude Code) or run_shell_command (Gemini)
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
- def _parse_toml_mcp_servers(content: str) -> dict:
164
- servers = {}
165
- current_server = None
166
-
167
- for raw in content.splitlines():
168
- line = raw.split("#", 1)[0].strip()
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
- Blocks: rm -rf / or ~, fork bombs. Soft-warns on --force, --no-verify, reset --hard.
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
- @app.after_request
171
- def add_cors(response):
172
- response.headers['Access-Control-Allow-Origin'] = '*'
173
- response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
174
- response.headers['Access-Control-Allow-Methods'] = 'GET,POST,DELETE,OPTIONS'
175
- return response
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
- def _parse_toml_mcp_servers(content: str) -> dict:
2414
- """Parse [mcp_servers.<name>] sections from TOML content."""
2415
- servers = {}
2416
- current_server = None
2417
-
2418
- for raw in content.splitlines():
2419
- line = raw.split("#", 1)[0].strip()
2420
- if not line:
2421
- continue
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}->{filtered_tokens}tok ({savings_pct}%saved)"
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}->{filtered_tokens}tok",
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 — fork bombs, rm -rf on root/home. Escape hatch: native Bash.
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"(\brm\s+-rf\s+(/|~|\$HOME)(\s|$)|:\(\)\s*\{\s*:\s*\|\s*:)"
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
- kw: dict = {"stdin": subprocess.DEVNULL}
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.32.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 binds to `127.0.0.1` by default.** Setting `host` to a non-loopback interface in `~/.c3/hub_config.json` is opt-in and warned at startup. **Do not expose the Hub to a public network without auth/TLS in front of it.**
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=mv6N7y8teSwK18x0q-XLjoLTZhWzrJYIsyH8rlbwj-o,288183
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=2FT10IlHaXCRaUtjY9oCyttjX0rkihIfA2WohAt0OiQ,9047
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=gnUJdCgX5ZKZHwLKLYW5ki-P_9HH9Zyi_Hb_yoSKwSI,61979
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=FR-FsVsRzU0H_uI504fXqPNVGbTgCukntYtL36sHxrM,32329
21
- cli/server.py,sha256=g9iyi_UhxvyyqIz8ihCsIpDg-fXr88vZXdLllEKsHBU,122525
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=0C37_Cm2YEsXp5pA4w-tWV8V_UHu5xflNp4bLMZqrbo,11501
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=z2A3UWt3MOriv2Z4-YWNvWNB07MrYT5368_ZuulNHIE,9397
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=EZs8VhqpzR8dH83gAhkleDgryuTshF7khw9ke3qeepc,6444
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.32.2.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
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=VN_I-bYvawGTqcpEyVyBz8-GIEZEVJWbM1lBA7Wi1BM,5972
68
+ oracle/mcp_oracle.py,sha256=Vts1ugITgvZP_BzKHgQN1nxHNVQNuNAEPdKgVIpRjPU,8133
67
69
  oracle/oracle.html,sha256=KW1jeqmUwvAH2mDlhCLo05nrHW8PfmCYv0iQhL5d74s,185219
68
- oracle/oracle_server.py,sha256=ehgjXcaea6TUbtD7-yJ0t3Vr6AttC_eyhfL35jPJo7Q,33337
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=y18ZWP1eLkCI3ZSKJDBjK1Wlp_qK7Kr4o_gm_r5gBXI,17396
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=K3iAi6Lhllpf14k-37NzqhM8LepIlXszf0dDyvAnIC4,34966
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.32.2.dist-info/METADATA,sha256=wPXVQQGc2FfMlK5H2UWOq9pPAmRY_MWXXpJjj9ALNE4,19221
157
- code_context_control-2.32.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
158
- code_context_control-2.32.2.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
159
- code_context_control-2.32.2.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
160
- code_context_control-2.32.2.dist-info/RECORD,,
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
- "Start with list_projects to see available projects, then use c3_search_cross "
28
- "or search_facts to discover across all of them, or the per-project tools "
29
- "(c3_search, c3_read, c3_compress, query_memory, read_graph) with a project_path. "
30
- "suggest_action creates a PENDING suggestion for human approval; delegate_task runs "
31
- "a configured Oracle agent. No code-editing tools are exposed."
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
- def build_app(registry, version: str = "", require_auth: bool = True, path: str = "/mcp"):
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") -> None:
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
- @app.after_request
120
- def _cors(resp):
121
- resp.headers["Access-Control-Allow-Origin"] = "*"
122
- resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
123
- resp.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS"
124
- return resp
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:
@@ -398,8 +398,23 @@ class ToolRegistry:
398
398
  "info": {
399
399
  "title": "C3 Oracle Discovery API",
400
400
  "version": _c3_version(),
401
- "description": "Read + safe-action discovery tools over C3's cross-project code and "
402
- "memory intelligence, for use by external LLMs.",
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. Blocks fork bombs and `rm -rf /` or `~`; soft-warns on `--force`, `--no-verify`, `reset --hard`. Native Bash remains the fallback for interactive/TTY commands
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