Option C Tools
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
16Configuration
17-------------
18Set ``policy_source`` in ``~/.oct-mcp/config.json``::
19
20 {
21 "policy_source": "https://internal.company.com/oct-policy.json"
22 }
23
24Or provide a local path::
25
26 {
27 "policy_source": "/etc/oct/policy.json"
28 }
29
30Policy JSON schema (all fields optional)::
31
32 {
33 "profile": "strict",
34 "rate_limit_per_minute": 20,
35 "timeout_default_seconds": 30,
36 "max_output_bytes": 524288,
37 "dry_run_default_for_writes": true
38 }
39
40Only the keys listed in :data:`_ALLOWED_OVERRIDE_KEYS` are honoured.
41Unknown keys are silently dropped. Type mismatches are silently ignored.
42
43Security
44--------
45- HTTPS URLs only. HTTP URLs are rejected to prevent cleartext policy
46 transmission.
47- File size capped at 64 KB (same as ``config.json``).
48- Schema is an allowlist — no arbitrary key injection possible.
49- Any failure (network error, parse error, schema violation) falls back
50 to built-in defaults silently without crashing the server.
51
52Diagnostics
53-----------
54Domain: MCP
55Levels:
56 L1 — errors: load failures, schema violations
57 L2 — lifecycle: policy loaded, overrides applied
58 L4 — deep trace: resolved field values
59
60Contracts
61---------
62- :func:`load_policy_source` never raises — returns ``{}`` on any error.
63- :func:`apply_policy_overrides` never raises — returns *config* unchanged
64 on any error.
65- No ``http://`` URLs are accepted — HTTPS only.
66"""
67
68from __future__ import annotations
69
70import dataclasses
71import json
72from pathlib import Path
73
74try:
75 from oc_diagnostics import _dbg as _real_dbg
76
77 def _dbg(*args, **kwargs) -> None:
78 _real_dbg(*args, **kwargs)
79except ImportError: # pragma: no cover
80 def _dbg(*args, **kwargs) -> None:
81 return None
82
83
84# ---------------------------------------------------------------------
85# Constants
86# ---------------------------------------------------------------------
87
88#: Maximum bytes read from a policy source (matches config.json guard).
89_POLICY_MAX_BYTES: int = 64 * 1024 # 64 KB
90
91#: Only these McpConfig fields may be overridden via a policy source.
92#: This is an allowlist — unknown keys are silently dropped.
93_ALLOWED_OVERRIDE_KEYS: frozenset[str] = frozenset({
94 "profile",
95 "rate_limit_per_minute",
96 "timeout_default_seconds",
97 "max_output_bytes",
98 "dry_run_default_for_writes",
99})
100
101
102# ---------------------------------------------------------------------
103# Public API
104# ---------------------------------------------------------------------
105
106
107def load_policy_source(source: str, timeout_s: int = 5) -> dict:
108 """Load policy override JSON from *source*.
109
110 Parameters
111 ----------
112 source
113 A local filesystem path or an ``https://`` URL. ``http://`` URLs
114 are rejected. Local paths may be absolute or relative.
115 timeout_s
116 Network timeout in seconds for HTTPS requests (default 5).
117
118 Returns
119 -------
120 A ``dict`` containing only the keys in :data:`_ALLOWED_OVERRIDE_KEYS`
121 with validated values. Returns ``{}`` on any failure.
122 """
123 func = "load_policy_source"
124
125 if not source:
126 return {}
127
128 try:
129 if source.startswith("http://"):
130 _dbg(
131 "MCP", func,
132 "SYSTEM_ERROR: HTTP policy source rejected; use HTTPS",
133 1,
134 )
135 return {}
136
137 if source.startswith("https://"):
138 raw = _fetch_url(source, timeout_s)
139 else:
140 raw = _read_file(source)
141
142 if raw is None:
143 return {}
144
145 data = json.loads(raw)
146 if not isinstance(data, dict):
147 _dbg("MCP", func, "SYSTEM_ERROR: policy source is not a JSON object", 1)
148 return {}
149
150 overrides = _validate_overrides(data)
151 _dbg("MCP", func, f"loaded {len(overrides)} override(s) from {source}", 2)
152 return overrides
153
154 except Exception as exc:
155 _dbg("MCP", func, f"SYSTEM_ERROR: failed to load policy source: {exc}", 1)
156 return {}
157
158
159def apply_policy_overrides(config: object, overrides: dict) -> object:
160 """Return a new :class:`~oct.mcp.config.McpConfig` with *overrides* applied.
161
162 Parameters
163 ----------
164 config
165 The base :class:`~oct.mcp.config.McpConfig` instance.
166 overrides
167 Dict of validated overrides from :func:`load_policy_source`.
168
169 Returns
170 -------
171 A new ``McpConfig`` with the override values merged in. Returns
172 *config* unchanged if *overrides* is empty or an error occurs.
173 """
174 func = "apply_policy_overrides"
175 if not overrides:
176 return config
177 try:
178 result = dataclasses.replace(config, **overrides)
179 _dbg("MCP", func, f"applied overrides: {list(overrides.keys())}", 2)
180 return result
181 except Exception as exc: # pragma: no cover
182 _dbg("MCP", func, f"SYSTEM_ERROR: failed to apply overrides: {exc}", 1)
183 return config
184
185
186# ---------------------------------------------------------------------
187# Internal helpers
188# ---------------------------------------------------------------------
189
190
191def _fetch_url(url: str, timeout_s: int) -> bytes | None:
192 """Fetch *url* via HTTPS and return raw bytes, or ``None`` on error."""
193 func = "_fetch_url"
194 try:
195 import urllib.request
196 req = urllib.request.Request(
197 url,
198 headers={"User-Agent": "oct-mcp/policy-loader"},
199 )
200 with urllib.request.urlopen(req, timeout=timeout_s) as resp:
201 raw = resp.read(_POLICY_MAX_BYTES + 1)
202 if len(raw) > _POLICY_MAX_BYTES:
203 _dbg("MCP", func, "SYSTEM_ERROR: policy source too large (>64 KB)", 1)
204 return None
205 return raw
206 except Exception as exc:
207 _dbg("MCP", func, f"SYSTEM_ERROR: failed to fetch {url}: {exc}", 1)
208 return None
209
210
211def _read_file(path: str) -> bytes | None:
212 """Read *path* from the filesystem and return raw bytes, or ``None``."""
213 func = "_read_file"
214 try:
215 p = Path(path)
216 size = p.stat().st_size
217 if size > _POLICY_MAX_BYTES:
218 _dbg("MCP", func, f"SYSTEM_ERROR: policy file too large ({size} bytes)", 1)
219 return None
220 return p.read_bytes()
221 except Exception as exc:
222 _dbg("MCP", func, f"SYSTEM_ERROR: failed to read {path}: {exc}", 1)
223 return None
224
225
226def _validate_overrides(data: dict) -> dict:
227 """Return a sanitised override dict with only allowed, well-typed values."""
228 from oct.mcp.config import VALID_MCP_PROFILES
229
230 result: dict = {}
231
232 if "profile" in data and data["profile"] in VALID_MCP_PROFILES:
233 result["profile"] = data["profile"]
234
235 for int_field in ("rate_limit_per_minute", "timeout_default_seconds", "max_output_bytes"):
236 if int_field in data and isinstance(data[int_field], int) and data[int_field] > 0:
237 result[int_field] = data[int_field]
238
239 if "dry_run_default_for_writes" in data and isinstance(
240 data["dry_run_default_for_writes"], bool
241 ):
242 result["dry_run_default_for_writes"] = data["dry_run_default_for_writes"]
243
244 return result
bytes|None _read_file(str path)
None _dbg(*args, **kwargs)
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)