Option C Tools
Loading...
Searching...
No Matches
config.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/config.py
4
5"""
6Purpose
7-------
8Configuration loader for the ``oct-mcp`` server. Reads
9``~/.oct-mcp/config.json`` (user-level trusted config), merges with
10environment variable overrides, and exposes a :class:`McpConfig`
11dataclass consumed by every layer of the six-layer pipeline.
12
13Responsibilities
14----------------
15- Define the canonical :class:`McpConfig` schema with safe defaults.
16- Implement :func:`load_config` which locates, validates, and returns a
17 populated ``McpConfig``.
18- Honour ``OCT_MCP_SAFE_MODE`` and ``OCT_TRUST_REPO_CONFIG`` env vars.
19- Never read or trust project-level config (``debug_config.json``,
20 ``.octrc.json``) unless ``trust_repo_config`` is explicitly enabled.
21- Apply a 64 KB file-size guard (matching OI-414) to the user config.
22
23Diagnostics
24-----------
25Domain: MCP
26Levels:
27 L1 — errors: config file read failures, schema errors
28 L2 — lifecycle: config loaded, safe-mode active
29 L4 — deep trace: resolved field values
30
31Contracts
32---------
33- This module does not import ``click``, the MCP SDK, or any other
34 ``oct.mcp.*`` module (no cycles).
35- :func:`load_config` always returns a fully-populated ``McpConfig``
36 even if the file is missing — defaults are safe.
37- Config values coming from JSON are validated and clamped so callers
38 never receive out-of-range integers.
39"""
40
41from __future__ import annotations
42
43import json
44import os
45from dataclasses import dataclass, field
46from pathlib import Path
47
48try:
49 from oc_diagnostics import _dbg as _real_dbg
50
51 def _dbg(*args, **kwargs) -> None:
52 _real_dbg(*args, **kwargs)
53except ImportError: # pragma: no cover
54 def _dbg(*args, **kwargs) -> None:
55 return None
56
57
58# ---------------------------------------------------------------------
59# Constants
60# ---------------------------------------------------------------------
61
62#: Maximum size for ``~/.oct-mcp/config.json``. Matches OI-414 (.octrc.json
63#: cap) applied to the user config as defence-in-depth.
64_CONFIG_MAX_BYTES: int = 64 * 1024 # 64 KB
65
66#: Default location for the user-level MCP server config.
67_DEFAULT_CONFIG_PATH: Path = Path.home() / ".oct-mcp" / "config.json"
68
69#: Valid profile names. See blueprint §5.3.
70VALID_MCP_PROFILES: tuple[str, ...] = ("default", "strict", "airgapped")
71
72#: Minimum / maximum allowed values for integer config fields.
73_RATE_LIMIT_MIN: int = 1
74_RATE_LIMIT_MAX: int = 1000
75_TIMEOUT_MIN: int = 5
76_TIMEOUT_MAX: int = 300
77_TIMEOUT_TEST_MAX: int = 600
78_MAX_OUTPUT_MIN: int = 1024 # 1 KB
79_MAX_OUTPUT_MAX: int = 10 * 1024 * 1024 # 10 MB
80_MAX_JOBS_MIN: int = 1
81_MAX_JOBS_MAX: int = 16
82_MEMORY_LIMIT_MIN: int = 0 # 0 = disabled
83_MEMORY_LIMIT_MAX: int = 8192 # MB
84_METRICS_PORT_MIN: int = 1
85_METRICS_PORT_MAX: int = 65535
86
87#: Valid sandbox backend names. "auto" picks best available for the platform.
88VALID_SANDBOX_BACKENDS: tuple[str, ...] = ("auto", "native", "bubblewrap", "sandbox_exec")
89
90
91# ---------------------------------------------------------------------
92# Data model
93# ---------------------------------------------------------------------
94
95
96@dataclass
98 """Runtime configuration for the ``oct-mcp`` server.
99
100 All fields have safe, conservative defaults that work without any
101 config file present. Fields loaded from JSON are clamped to the
102 valid ranges defined by the module-level constants.
103
104 ``trust_repo_config`` defaults to ``False`` — repo-level config
105 (``debug_config.json``, ``.octrc.json``) is untrusted by default per
106 blueprint §8.1. Set to ``True`` via ``OCT_TRUST_REPO_CONFIG=1`` or
107 the config file field ``trust_repo_config: true``.
108 """
109
110 profile: str = "default"
111 """Active security profile: ``"default"``, ``"strict"``, or ``"airgapped"``."""
112
113 rate_limit_per_minute: int = 30
114 """Maximum tool calls per session per minute (Gateway rate limiter)."""
115
116 timeout_default_seconds: int = 60
117 """Per-tool subprocess timeout in seconds (except ``oct test``)."""
118
119 timeout_test_seconds: int = 300
120 """Subprocess timeout for ``oct test`` (pytest may need longer)."""
121
122 max_output_bytes: int = 1_048_576 # 1 MB
123 """Hard output-size cap per tool call. Output exceeding this is truncated."""
124
125 max_jobs: int = 4
126 """Maximum ``--jobs`` value forwarded to tools that support parallelism."""
127
128 auto_approve_read_tools: bool = True
129 """If True, read-only tools execute without confirmation."""
130
131 dry_run_default_for_writes: bool = True
132 """If True, write tools default to dry-run mode (Phase 5B guard)."""
133
134 redaction_always_on: bool = False
135 """Force redaction even when not in production mode."""
136
137 audit_log_path: str = str(Path.home() / ".oct-mcp" / "audit.log")
138 """Path to the JSONL audit log. Rotation files go in the same directory."""
139
140 audit_log_max_files: int = 20
141 """Maximum number of rotated audit log files to keep."""
142
143 audit_log_max_size_mb: float = 5.0
144 """Rotation threshold per audit file, in megabytes."""
145
146 trust_repo_config: bool = False
147 """Whether to load and trust repo-level config (``.octrc.json``)."""
148
149 safe_mode: bool = False
150 """Hard-override: all tools return error; set via ``OCT_MCP_SAFE_MODE=1``."""
151
152 sandbox_backend: str = "auto"
153 """OS-level sandbox backend: ``"auto"``, ``"native"``, ``"bubblewrap"``,
154 or ``"sandbox_exec"``. ``"auto"`` picks the best available backend for
155 the current platform, falling back to ``"native"`` if no enhanced backend
156 is found. Set via ``OCT_MCP_SANDBOX`` env var."""
157
158 memory_limit_mb: int = 512
159 """Per-subprocess memory limit in megabytes (POSIX only; 0 = disabled).
160 On Windows this field is accepted but has no effect — use Docker for
161 memory enforcement on Windows. Range: 0–8192."""
162
163 metrics_port: int | None = None
164 """If set, starts a Prometheus ``/metrics`` HTTP server on this port
165 in a background thread. Requires ``oct[metrics]`` (prometheus-client).
166 ``None`` (default) disables metrics collection."""
167
168 policy_source: str | None = None
169 """If set, load additional policy overrides from this source at startup.
170 Accepts a local filesystem path or an ``https://`` URL. Overrides are
171 merged on top of the base config. Any load failure falls back to
172 built-in defaults silently."""
173
174
175# ---------------------------------------------------------------------
176# Loader
177# ---------------------------------------------------------------------
178
179
180def load_config(path: Path | None = None) -> McpConfig:
181 """Load and return a :class:`McpConfig`.
182
183 Resolution order:
184
185 1. Read ``~/.oct-mcp/config.json`` (or *path* if supplied).
186 2. Validate and clamp JSON field values.
187 3. Apply environment variable overrides:
188 - ``OCT_MCP_SAFE_MODE=1`` → ``safe_mode = True``
189 - ``OCT_TRUST_REPO_CONFIG=1`` → ``trust_repo_config = True``
190 - ``OCT_MCP_PROFILE=<name>`` → ``profile = <name>``
191 4. Return a fully-populated ``McpConfig``.
192
193 Never raises. Any read or parse failure falls back to the default
194 config and logs an error at level 1.
195 """
196 func = "load_config"
197 cfg_path = path or _DEFAULT_CONFIG_PATH
198 config = McpConfig()
199
200 if cfg_path.exists():
201 try:
202 size = cfg_path.stat().st_size
203 if size > _CONFIG_MAX_BYTES:
204 _dbg(
205 "MCP", func,
206 f"SYSTEM_ERROR: config file too large ({size} bytes > "
207 f"{_CONFIG_MAX_BYTES}); using defaults",
208 1,
209 )
210 else:
211 raw = json.loads(cfg_path.read_text(encoding="utf-8"))
212 if isinstance(raw, dict):
213 config = _apply_json(config, raw)
214 _dbg("MCP", func, f"loaded config from {cfg_path}", 2)
215 else:
216 _dbg(
217 "MCP", func,
218 "SYSTEM_ERROR: config file is not a JSON object; using defaults",
219 1,
220 )
221 except (OSError, json.JSONDecodeError, ValueError) as exc:
222 _dbg("MCP", func, f"SYSTEM_ERROR: cannot read config: {exc}", 1)
223 else:
224 _dbg("MCP", func, f"no config file at {cfg_path}; using defaults", 4)
225
226 # Environment variable overrides (always trusted — set by user/CI).
227 if os.environ.get("OCT_MCP_SAFE_MODE", "").strip() == "1":
228 config.safe_mode = True
229 _dbg("MCP", func, "safe_mode=True (OCT_MCP_SAFE_MODE=1)", 2)
230
231 if os.environ.get("OCT_TRUST_REPO_CONFIG", "").strip() == "1":
232 config.trust_repo_config = True
233 _dbg("MCP", func, "trust_repo_config=True (OCT_TRUST_REPO_CONFIG=1)", 2)
234
235 profile_env = os.environ.get("OCT_MCP_PROFILE", "").strip()
236 if profile_env in VALID_MCP_PROFILES:
237 config.profile = profile_env
238 _dbg("MCP", func, f"profile={config.profile} (OCT_MCP_PROFILE)", 2)
239
240 sandbox_env = os.environ.get("OCT_MCP_SANDBOX", "").strip()
241 if sandbox_env in VALID_SANDBOX_BACKENDS:
242 config.sandbox_backend = sandbox_env
243 _dbg("MCP", func, f"sandbox_backend={config.sandbox_backend} (OCT_MCP_SANDBOX)", 2)
244
245 return config
246
247
248def _apply_json(base: McpConfig, data: dict) -> McpConfig:
249 """Return a new :class:`McpConfig` with fields from *data* merged in.
250
251 Only known fields are applied; unknown keys are silently ignored
252 (the config file is user-controlled but we don't crash on new keys).
253 Field values are clamped to the valid ranges defined by the module
254 constants. Booleans must be actual JSON booleans, not strings.
255 """
256 def _clamp(val: int | float, lo: int | float, hi: int | float):
257 return max(lo, min(hi, val))
258
259 fields: dict = {}
260
261 if "profile" in data and data["profile"] in VALID_MCP_PROFILES:
262 fields["profile"] = data["profile"]
263
264 if "rate_limit_per_minute" in data:
265 v = data["rate_limit_per_minute"]
266 if isinstance(v, int):
267 fields["rate_limit_per_minute"] = _clamp(v, _RATE_LIMIT_MIN, _RATE_LIMIT_MAX)
268
269 if "timeout_default_seconds" in data:
270 v = data["timeout_default_seconds"]
271 if isinstance(v, int):
272 fields["timeout_default_seconds"] = _clamp(v, _TIMEOUT_MIN, _TIMEOUT_MAX)
273
274 if "timeout_test_seconds" in data:
275 v = data["timeout_test_seconds"]
276 if isinstance(v, int):
277 fields["timeout_test_seconds"] = _clamp(v, _TIMEOUT_MIN, _TIMEOUT_TEST_MAX)
278
279 if "max_output_bytes" in data:
280 v = data["max_output_bytes"]
281 if isinstance(v, int):
282 fields["max_output_bytes"] = _clamp(v, _MAX_OUTPUT_MIN, _MAX_OUTPUT_MAX)
283
284 if "max_jobs" in data:
285 v = data["max_jobs"]
286 if isinstance(v, int):
287 fields["max_jobs"] = _clamp(v, _MAX_JOBS_MIN, _MAX_JOBS_MAX)
288
289 for bool_field in (
290 "auto_approve_read_tools",
291 "dry_run_default_for_writes",
292 "redaction_always_on",
293 "trust_repo_config",
294 ):
295 if bool_field in data and isinstance(data[bool_field], bool):
296 fields[bool_field] = data[bool_field]
297
298 if "audit_log_path" in data and isinstance(data["audit_log_path"], str):
299 fields["audit_log_path"] = data["audit_log_path"]
300
301 if "audit_log_max_files" in data:
302 v = data["audit_log_max_files"]
303 if isinstance(v, int) and v >= 1:
304 fields["audit_log_max_files"] = min(v, 100)
305
306 if "audit_log_max_size_mb" in data:
307 v = data["audit_log_max_size_mb"]
308 if isinstance(v, (int, float)) and v > 0:
309 fields["audit_log_max_size_mb"] = min(float(v), 100.0)
310
311 if "sandbox_backend" in data and data["sandbox_backend"] in VALID_SANDBOX_BACKENDS:
312 fields["sandbox_backend"] = data["sandbox_backend"]
313
314 if "memory_limit_mb" in data:
315 v = data["memory_limit_mb"]
316 if isinstance(v, int):
317 fields["memory_limit_mb"] = _clamp(v, _MEMORY_LIMIT_MIN, _MEMORY_LIMIT_MAX)
318
319 if "metrics_port" in data:
320 v = data["metrics_port"]
321 if v is None:
322 fields["metrics_port"] = None
323 elif isinstance(v, int):
324 fields["metrics_port"] = _clamp(v, _METRICS_PORT_MIN, _METRICS_PORT_MAX)
325
326 if "policy_source" in data:
327 v = data["policy_source"]
328 if v is None or isinstance(v, str):
329 fields["policy_source"] = v or None
330
331 from dataclasses import replace as _replace
332 return _replace(base, **fields)
McpConfig load_config(Path|None path=None)
Definition config.py:180
McpConfig _apply_json(McpConfig base, dict data)
Definition config.py:248
None _dbg(*args, **kwargs)
Definition config.py:51