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