96 """Runtime configuration for the ``oct-mcp`` server.
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.
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``.
108 profile: str =
"default"
109 """Active security profile: ``"default"``, ``"strict"``, or ``"airgapped"``."""
111 rate_limit_per_minute: int = 30
112 """Maximum tool calls per session per minute (Gateway rate limiter)."""
114 timeout_default_seconds: int = 60
115 """Per-tool subprocess timeout in seconds (except ``oct test``)."""
117 timeout_test_seconds: int = 300
118 """Subprocess timeout for ``oct test`` (pytest may need longer)."""
120 max_output_bytes: int = 1_048_576
121 """Hard output-size cap per tool call. Output exceeding this is truncated."""
124 """Maximum ``--jobs`` value forwarded to tools that support parallelism."""
126 auto_approve_read_tools: bool =
True
127 """If True, read-only tools execute without confirmation."""
129 dry_run_default_for_writes: bool =
True
130 """If True, write tools default to dry-run mode (Phase 5B guard)."""
132 redaction_always_on: bool =
False
133 """Force redaction even when not in production mode."""
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."""
138 audit_log_max_files: int = 20
139 """Maximum number of rotated audit log files to keep."""
141 audit_log_max_size_mb: float = 5.0
142 """Rotation threshold per audit file, in megabytes."""
144 trust_repo_config: bool =
False
145 """Whether to load and trust repo-level config (``.octrc.json``)."""
147 safe_mode: bool =
False
149 """Hard-override: all tools return error; set via ``OCT_MCP_SAFE_MODE=1``."""
151 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."""
158 memory_limit_mb: int = 512
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."""
164 metrics_port: int |
None =
None
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."""
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."""
183 """Load and return a :class:`McpConfig`.
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``.
195 Never raises. Any read or parse failure falls back to the default
196 config and logs an error at level 1.
199 cfg_path = path
or _DEFAULT_CONFIG_PATH
202 if cfg_path.exists():
204 size = cfg_path.stat().st_size
205 if size > _CONFIG_MAX_BYTES:
208 f
"SYSTEM_ERROR: config file too large ({size} bytes > "
209 f
"{_CONFIG_MAX_BYTES}); using defaults",
213 raw = json.loads(cfg_path.read_text(encoding=
"utf-8"))
214 if isinstance(raw, dict):
216 _dbg(
"MCP", func, f
"loaded config from {cfg_path}", 2)
221 "SYSTEM_ERROR: config file is not a JSON object; using defaults",
224 except (OSError, json.JSONDecodeError, ValueError)
as exc:
225 _dbg(
"MCP", func, f
"SYSTEM_ERROR: cannot read config: {exc}", 1)
227 _dbg(
"MCP", func, f
"no config file at {cfg_path}; using defaults", 4)
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)
234 if os.environ.get(
"OCT_TRUST_REPO_CONFIG",
"").strip() ==
"1":
235 config.trust_repo_config =
True
237 _dbg(
"MCP", func,
"trust_repo_config=True (OCT_TRUST_REPO_CONFIG=1)", 2)
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)
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)
253 """Return a new :class:`McpConfig` with fields from *data* merged in.
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.
260 def _clamp(val: int | float, lo: int | float, hi: int | float):
261 return max(lo, min(hi, val))
265 if "profile" in data
and data[
"profile"]
in VALID_MCP_PROFILES:
266 fields[
"profile"] = data[
"profile"]
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)
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)
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)
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)
288 if "max_jobs" in data:
290 if isinstance(v, int):
291 fields[
"max_jobs"] = _clamp(v, _MAX_JOBS_MIN, _MAX_JOBS_MAX)
294 "auto_approve_read_tools",
295 "dry_run_default_for_writes",
296 "redaction_always_on",
299 if bool_field
in data
and isinstance(data[bool_field], bool):
300 fields[bool_field] = data[bool_field]
302 if "audit_log_path" in data
and isinstance(data[
"audit_log_path"], str):
303 fields[
"audit_log_path"] = data[
"audit_log_path"]
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)
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)
315 if "sandbox_backend" in data
and data[
"sandbox_backend"]
in VALID_SANDBOX_BACKENDS:
316 fields[
"sandbox_backend"] = data[
"sandbox_backend"]
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)
323 if "metrics_port" in data:
324 v = data[
"metrics_port"]
326 fields[
"metrics_port"] =
None
327 elif isinstance(v, int):
328 fields[
"metrics_port"] = _clamp(v, _METRICS_PORT_MIN, _METRICS_PORT_MAX)
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
335 from dataclasses
import replace
as _replace
336 return _replace(base, **fields)