oct
Loading...
Searching...
No Matches
policy_loader.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/policy_loader.py
4
5"""
6Purpose
7-------
8Centralised policy loader for the ``oct-mcp`` server (Phase 5C, M-C7).
9
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.
15
16Responsibilities
17----------------
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.
25
26Configuration
27-------------
28Set ``policy_source`` in ``~/.oct-mcp/config.json``::
29
30 {
31 "policy_source": "https://internal.company.com/oct-policy.json"
32 }
33
34Or provide a local path::
35
36 {
37 "policy_source": "/etc/oct/policy.json"
38 }
39
40Policy JSON schema (all fields optional)::
41
42 {
43 "profile": "strict",
44 "rate_limit_per_minute": 20,
45 "timeout_default_seconds": 30,
46 "max_output_bytes": 524288,
47 "dry_run_default_for_writes": true
48 }
49
50Only the keys listed in :data:`_ALLOWED_OVERRIDE_KEYS` are honoured.
51Unknown keys are silently dropped. Type mismatches are silently ignored.
52
53Security
54--------
55- HTTPS URLs only. HTTP URLs are rejected to prevent cleartext policy
56 transmission.
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.
61
62Diagnostics
63-----------
64Domain: MCP
65Levels:
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
70
71Contracts
72---------
73- :func:`load_policy_source` never raises - returns ``{}`` on any error.
74- :func:`apply_policy_overrides` never raises - returns *config* unchanged
75 on any error.
76- No ``http://`` URLs are accepted - HTTPS only.
77
78Dependencies
79------------
80- oct.core.diagnostics (_dbg structured logger)
81- oct.mcp.config (lazy; VALID_MCP_PROFILES, to accept only a known profile override)
82"""
83
84from __future__ import annotations
85
86import dataclasses
87import json
88from pathlib import Path
89
90from oct.core.diagnostics import _dbg
91
92
93# ---------------------------------------------------------------------
94# Constants
95# ---------------------------------------------------------------------
96
97#: Maximum bytes read from a policy source (matches config.json guard).
98_POLICY_MAX_BYTES: int = 64 * 1024 # 64 KB
99
100#: Only these McpConfig fields may be overridden via a policy source.
101#: This is an allowlist - unknown keys are silently dropped.
102_ALLOWED_OVERRIDE_KEYS: frozenset[str] = frozenset({
103 "profile",
104 "rate_limit_per_minute",
105 "timeout_default_seconds",
106 "max_output_bytes",
107 "dry_run_default_for_writes",
108})
109
110
111# ---------------------------------------------------------------------
112# Public API
113# ---------------------------------------------------------------------
114
115
116def load_policy_source(source: str, timeout_s: int = 5) -> dict:
117 """Load policy override JSON from *source*.
118
119 Parameters
120 ----------
121 source
122 A local filesystem path or an ``https://`` URL. ``http://`` URLs
123 are rejected. Local paths may be absolute or relative.
124 timeout_s
125 Network timeout in seconds for HTTPS requests (default 5).
126
127 Returns
128 -------
129 A ``dict`` containing only the keys in :data:`_ALLOWED_OVERRIDE_KEYS`
130 with validated values. Returns ``{}`` on any failure.
131 """
132 func = "load_policy_source"
133
134 if not source:
135 return {}
136
137 try:
138 if source.startswith("http://"):
139 _dbg(
140 "MCP", func,
141 "SYSTEM_ERROR: HTTP policy source rejected; use HTTPS",
142 1,
143 )
144 return {}
145
146 if source.startswith("https://"):
147 raw = _fetch_url(source, timeout_s)
148 else:
149 raw = _read_file(source)
150
151 if raw is None:
152 return {}
153
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)
157 return {}
158
159 overrides = _validate_overrides(data)
160 _dbg("MCP", func, f"loaded {len(overrides)} override(s) from {source}", 2)
161 return overrides
162
163 except Exception as exc:
164 _dbg("MCP", func, f"SYSTEM_ERROR: failed to load policy source: {exc}", 1)
165 return {}
166
167
168def apply_policy_overrides(config: object, overrides: dict) -> object:
169 """Return a new :class:`~oct.mcp.config.McpConfig` with *overrides* applied.
170
171 Parameters
172 ----------
173 config
174 The base :class:`~oct.mcp.config.McpConfig` instance.
175 overrides
176 Dict of validated overrides from :func:`load_policy_source`.
177
178 Returns
179 -------
180 A new ``McpConfig`` with the override values merged in. Returns
181 *config* unchanged if *overrides* is empty or an error occurs.
182 """
183 func = "apply_policy_overrides"
184 if not overrides:
185 return config
186 try:
187 result = dataclasses.replace(config, **overrides)
188 _dbg("MCP", func, f"applied overrides: {list(overrides.keys())}", 2)
189 return result
190 except Exception as exc: # pragma: no cover
191 _dbg("MCP", func, f"SYSTEM_ERROR: failed to apply overrides: {exc}", 1)
192 return config
193
194
195# ---------------------------------------------------------------------
196# Internal helpers
197# ---------------------------------------------------------------------
198
199
200def _fetch_url(url: str, timeout_s: int) -> bytes | None:
201 """Fetch *url* via HTTPS and return raw bytes, or ``None`` on error."""
202 func = "_fetch_url"
203 try:
204 import urllib.request
205 req = urllib.request.Request(
206 url,
207 headers={"User-Agent": "oct-mcp/policy-loader"},
208 )
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:
212 # OCT-LINT: disable=no_hardcoded_secrets reason="Diagnostics message for an oversized policy source, not a secret"
213 _dbg("MCP", func, "SYSTEM_ERROR: policy source too large (>64 KB)", 1)
214 return None
215 return raw
216 except Exception as exc:
217 _dbg("MCP", func, f"SYSTEM_ERROR: failed to fetch {url}: {exc}", 1)
218 return None
219
220
221def _read_file(path: str) -> bytes | None:
222 """Read *path* from the filesystem and return raw bytes, or ``None``."""
223 func = "_read_file"
224 try:
225 p = Path(path)
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)
229 return None
230 return p.read_bytes()
231 except Exception as exc:
232 _dbg("MCP", func, f"SYSTEM_ERROR: failed to read {path}: {exc}", 1)
233 return None
234
235
236def _validate_overrides(data: dict) -> dict:
237 """Return a sanitised override dict with only allowed, well-typed values."""
238 from oct.mcp.config import VALID_MCP_PROFILES
239
240 result: dict = {}
241
242 if "profile" in data and data["profile"] in VALID_MCP_PROFILES:
243 result["profile"] = data["profile"]
244
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]
248
249 if "dry_run_default_for_writes" in data and isinstance(
250 data["dry_run_default_for_writes"], bool
251 ):
252 result["dry_run_default_for_writes"] = data["dry_run_default_for_writes"]
253
254 return result
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)