8Centralised policy loader for the ``oct-mcp`` server (Phase 5C, M-C7).
10Loads policy override JSON from a local filesystem path or an HTTPS URL
11at server startup. The loaded overrides are applied on top of the base
12:class:`~oct.mcp.config.McpConfig` before the policy engine is
13instantiated, enabling enterprise deployments to distribute a shared
14security policy from a central location.
18- Read the policy document named by ``policy_source``, from a local path
19 or an HTTPS URL, within the 64 KB cap.
20- Reject HTTP URLs, oversized sources and malformed JSON.
21- Filter the parsed document to :data:`_ALLOWED_OVERRIDE_KEYS`, dropping
22 unknown keys and type mismatches.
23- Apply the surviving overrides on top of the base ``McpConfig``.
24- Fall back to built-in defaults on any failure, without raising.
28Set ``policy_source`` in ``~/.oct-mcp/config.json``::
31 "policy_source": "https://internal.company.com/oct-policy.json"
34Or provide a local path::
37 "policy_source": "/etc/oct/policy.json"
40Policy JSON schema (all fields optional)::
44 "rate_limit_per_minute": 20,
45 "timeout_default_seconds": 30,
46 "max_output_bytes": 524288,
47 "dry_run_default_for_writes": true
50Only the keys listed in :data:`_ALLOWED_OVERRIDE_KEYS` are honoured.
51Unknown keys are silently dropped. Type mismatches are silently ignored.
55- HTTPS URLs only. HTTP URLs are rejected to prevent cleartext policy
57- File size capped at 64 KB (same as ``config.json``).
58- Schema is an allowlist - no arbitrary key injection possible.
59- Any failure (network error, parse error, schema violation) falls back
60 to built-in defaults silently without crashing the server.
66 L1 - errors: load failures, schema violations
67 L2 - lifecycle: policy loaded, overrides applied
68 L3 - (none): resolved field detail is reported at L4
69 L4 - deep trace: resolved field values
73- :func:`load_policy_source` never raises - returns ``{}`` on any error.
74- :func:`apply_policy_overrides` never raises - returns *config* unchanged
76- No ``http://`` URLs are accepted - HTTPS only.
80- oct.core.diagnostics (_dbg structured logger)
81- oct.mcp.config (lazy; VALID_MCP_PROFILES, to accept only a known profile override)
84from __future__
import annotations
88from pathlib
import Path
90from oct.core.diagnostics
import _dbg
98_POLICY_MAX_BYTES: int = 64 * 1024
102_ALLOWED_OVERRIDE_KEYS: frozenset[str] = frozenset({
104 "rate_limit_per_minute",
105 "timeout_default_seconds",
107 "dry_run_default_for_writes",
117 """Load policy override JSON from *source*.
122 A local filesystem path or an ``https://`` URL. ``http://`` URLs
123 are rejected. Local paths may be absolute or relative.
125 Network timeout in seconds for HTTPS requests (default 5).
129 A ``dict`` containing only the keys in :data:`_ALLOWED_OVERRIDE_KEYS`
130 with validated values. Returns ``{}`` on any failure.
132 func =
"load_policy_source"
138 if source.startswith(
"http://"):
141 "SYSTEM_ERROR: HTTP policy source rejected; use HTTPS",
146 if source.startswith(
"https://"):
154 data = json.loads(raw)
155 if not isinstance(data, dict):
156 _dbg(
"MCP", func,
"SYSTEM_ERROR: policy source is not a JSON object", 1)
160 _dbg(
"MCP", func, f
"loaded {len(overrides)} override(s) from {source}", 2)
163 except Exception
as exc:
164 _dbg(
"MCP", func, f
"SYSTEM_ERROR: failed to load policy source: {exc}", 1)
169 """Return a new :class:`~oct.mcp.config.McpConfig` with *overrides* applied.
174 The base :class:`~oct.mcp.config.McpConfig` instance.
176 Dict of validated overrides from :func:`load_policy_source`.
180 A new ``McpConfig`` with the override values merged in. Returns
181 *config* unchanged if *overrides* is empty or an error occurs.
183 func =
"apply_policy_overrides"
187 result = dataclasses.replace(config, **overrides)
188 _dbg(
"MCP", func, f
"applied overrides: {list(overrides.keys())}", 2)
190 except Exception
as exc:
191 _dbg(
"MCP", func, f
"SYSTEM_ERROR: failed to apply overrides: {exc}", 1)
201 """Fetch *url* via HTTPS and return raw bytes, or ``None`` on error."""
204 import urllib.request
205 req = urllib.request.Request(
207 headers={
"User-Agent":
"oct-mcp/policy-loader"},
209 with urllib.request.urlopen(req, timeout=timeout_s)
as resp:
210 raw = resp.read(_POLICY_MAX_BYTES + 1)
211 if len(raw) > _POLICY_MAX_BYTES:
213 _dbg(
"MCP", func,
"SYSTEM_ERROR: policy source too large (>64 KB)", 1)
216 except Exception
as exc:
217 _dbg(
"MCP", func, f
"SYSTEM_ERROR: failed to fetch {url}: {exc}", 1)
222 """Read *path* from the filesystem and return raw bytes, or ``None``."""
226 size = p.stat().st_size
227 if size > _POLICY_MAX_BYTES:
228 _dbg(
"MCP", func, f
"SYSTEM_ERROR: policy file too large ({size} bytes)", 1)
230 return p.read_bytes()
231 except Exception
as exc:
232 _dbg(
"MCP", func, f
"SYSTEM_ERROR: failed to read {path}: {exc}", 1)
237 """Return a sanitised override dict with only allowed, well-typed values."""
242 if "profile" in data
and data[
"profile"]
in VALID_MCP_PROFILES:
243 result[
"profile"] = data[
"profile"]
245 for int_field
in (
"rate_limit_per_minute",
"timeout_default_seconds",
"max_output_bytes"):
246 if int_field
in data
and isinstance(data[int_field], int)
and data[int_field] > 0:
247 result[int_field] = data[int_field]
249 if "dry_run_default_for_writes" in data
and isinstance(
250 data[
"dry_run_default_for_writes"], bool
252 result[
"dry_run_default_for_writes"] = data[
"dry_run_default_for_writes"]
bytes|None _read_file(str path)
dict _validate_overrides(dict data)
object apply_policy_overrides(object config, dict overrides)
bytes|None _fetch_url(str url, int timeout_s)
dict load_policy_source(str source, int timeout_s=5)