98 """Runtime configuration for the ``oct-mcp`` server.
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.
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``.
110 profile: str =
"default"
111 """Active security profile: ``"default"``, ``"strict"``, or ``"airgapped"``."""
113 rate_limit_per_minute: int = 30
114 """Maximum tool calls per session per minute (Gateway rate limiter)."""
116 timeout_default_seconds: int = 60
117 """Per-tool subprocess timeout in seconds (except ``oct test``)."""
119 timeout_test_seconds: int = 300
120 """Subprocess timeout for ``oct test`` (pytest may need longer)."""
122 max_output_bytes: int = 1_048_576
123 """Hard output-size cap per tool call. Output exceeding this is truncated."""
126 """Maximum ``--jobs`` value forwarded to tools that support parallelism."""
128 auto_approve_read_tools: bool =
True
129 """If True, read-only tools execute without confirmation."""
131 dry_run_default_for_writes: bool =
True
132 """If True, write tools default to dry-run mode (Phase 5B guard)."""
134 redaction_always_on: bool =
False
135 """Force redaction even when not in production mode."""
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."""
140 audit_log_max_files: int = 20
141 """Maximum number of rotated audit log files to keep."""
143 audit_log_max_size_mb: float = 5.0
144 """Rotation threshold per audit file, in megabytes."""
146 trust_repo_config: bool =
False
147 """Whether to load and trust repo-level config (``.octrc.json``)."""
149 safe_mode: bool =
False
150 """Hard-override: all tools return error; set via ``OCT_MCP_SAFE_MODE=1``."""
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."""
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."""
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."""
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."""
181 """Load and return a :class:`McpConfig`.
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``.
193 Never raises. Any read or parse failure falls back to the default
194 config and logs an error at level 1.
197 cfg_path = path
or _DEFAULT_CONFIG_PATH
200 if cfg_path.exists():
202 size = cfg_path.stat().st_size
203 if size > _CONFIG_MAX_BYTES:
206 f
"SYSTEM_ERROR: config file too large ({size} bytes > "
207 f
"{_CONFIG_MAX_BYTES}); using defaults",
211 raw = json.loads(cfg_path.read_text(encoding=
"utf-8"))
212 if isinstance(raw, dict):
214 _dbg(
"MCP", func, f
"loaded config from {cfg_path}", 2)
218 "SYSTEM_ERROR: config file is not a JSON object; using defaults",
221 except (OSError, json.JSONDecodeError, ValueError)
as exc:
222 _dbg(
"MCP", func, f
"SYSTEM_ERROR: cannot read config: {exc}", 1)
224 _dbg(
"MCP", func, f
"no config file at {cfg_path}; using defaults", 4)
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)
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)
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)
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)
249 """Return a new :class:`McpConfig` with fields from *data* merged in.
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.
256 def _clamp(val: int | float, lo: int | float, hi: int | float):
257 return max(lo, min(hi, val))
261 if "profile" in data
and data[
"profile"]
in VALID_MCP_PROFILES:
262 fields[
"profile"] = data[
"profile"]
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)
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)
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)
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)
284 if "max_jobs" in data:
286 if isinstance(v, int):
287 fields[
"max_jobs"] = _clamp(v, _MAX_JOBS_MIN, _MAX_JOBS_MAX)
290 "auto_approve_read_tools",
291 "dry_run_default_for_writes",
292 "redaction_always_on",
295 if bool_field
in data
and isinstance(data[bool_field], bool):
296 fields[bool_field] = data[bool_field]
298 if "audit_log_path" in data
and isinstance(data[
"audit_log_path"], str):
299 fields[
"audit_log_path"] = data[
"audit_log_path"]
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)
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)
311 if "sandbox_backend" in data
and data[
"sandbox_backend"]
in VALID_SANDBOX_BACKENDS:
312 fields[
"sandbox_backend"] = data[
"sandbox_backend"]
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)
319 if "metrics_port" in data:
320 v = data[
"metrics_port"]
322 fields[
"metrics_port"] =
None
323 elif isinstance(v, int):
324 fields[
"metrics_port"] = _clamp(v, _METRICS_PORT_MIN, _METRICS_PORT_MAX)
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
331 from dataclasses
import replace
as _replace
332 return _replace(base, **fields)