oct
Loading...
Searching...
No Matches
policy.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/policy.py
4
5"""
6Purpose
7-------
8Policy Engine (Layer 3) for the ``oct-mcp`` six-layer pipeline.
9Maintains the tool manifest and decides whether a given tool call is
10permitted under the active MCP profile.
11
12Responsibilities
13----------------
14- Define :data:`TOOL_MANIFEST` mapping tool names to :class:`ToolSpec`.
15- Define :class:`McpPolicy` which enforces profile-based rules.
16- Return structured :class:`PolicyDecision` objects (never raises for
17 policy rejections - callers check the decision).
18
19Diagnostics
20-----------
21Domain: MCP
22Levels:
23 L1 - errors: unknown tool, manifest inconsistency
24 L2 - lifecycle: policy decision
25 L3 - (none): the decision is reported at L2, enforcement detail at L4
26 L4 - deep trace: profile enforcement details
27
28Contracts
29---------
30- This module does not import ``click``, the MCP SDK, or any other
31 ``oct.mcp.*`` module except ``config.py``.
32- :meth:`McpPolicy.check` never raises for normal policy decisions.
33 Only ``ValueError`` is raised for programming errors (e.g. an unknown
34 profile string passed at construction time).
35- In ``airgapped`` profile, ALL tool calls are denied regardless of
36 the tool manifest.
37- In ``strict`` profile, only tools with ``auto_approve=True`` and
38 ``destructive=False`` may execute, and repo-level config is ignored.
39
40Dependencies
41------------
42- oct.core.diagnostics (_dbg structured logger)
43"""
44
45from __future__ import annotations
46
47from dataclasses import dataclass, field
48from typing import Literal
49
50from oct.core.diagnostics import _dbg
51
52
53# ---------------------------------------------------------------------
54# Types
55# ---------------------------------------------------------------------
56
57#: Valid MCP profile names (independent of git quality-gate profiles).
58McpProfile = Literal["default", "strict", "airgapped"]
59
60
61@dataclass(frozen=True)
63 """Metadata for one entry in the tool manifest.
64
65 Attributes
66 ----------
67 cli
68 The ``oct`` CLI subcommand string (e.g. ``"lint"``, ``"git status"``).
69 permissions
70 Set of permission tokens required by this tool (``"read"``,
71 ``"exec"``). Phase 5A tools are read-only (``{"read"}`` or
72 ``{"read", "exec"}``).
73 destructive
74 True if the tool can mutate the filesystem. All Phase 5A tools
75 are ``False``; write tools will be ``True`` in Phase 5B.
76 auto_approve
77 True if the tool may execute without explicit user confirmation.
78 All Phase 5A tools are ``True``.
79 """
80
81 cli: str
82 permissions: frozenset[str] = field(default_factory=frozenset)
83 destructive: bool = False
84 auto_approve: bool = True
85
86 def __post_init__(self) -> None:
87 # Ensure permissions is always a frozenset even when passed as a plain set.
88 object.__setattr__(self, "permissions", frozenset(self.permissions))
89
90
91# ---------------------------------------------------------------------
92# Tool manifest (Phase 5A - 9 read-only tools)
93# ---------------------------------------------------------------------
94
95TOOL_MANIFEST: dict[str, ToolSpec] = {
96 # ------------------------------------------------------------------
97 # Phase 5A - read-only tools (9)
98 # ------------------------------------------------------------------
99 "oct_lint": ToolSpec(
100 cli="lint",
101 permissions=frozenset({"read"}),
102 destructive=False,
103 auto_approve=True,
104 ),
105 "oct_health": ToolSpec(
106 cli="health",
107 permissions=frozenset({"read"}),
108 destructive=False,
109 auto_approve=True,
110 ),
111 "oct_skeleton": ToolSpec(
112 cli="export-skeleton",
113 permissions=frozenset({"read"}),
114 destructive=False,
115 auto_approve=True,
116 ),
117 "oct_deps": ToolSpec(
118 cli="deps",
119 permissions=frozenset({"read"}),
120 destructive=False,
121 auto_approve=True,
122 ),
123 "oct_test": ToolSpec(
124 cli="test",
125 permissions=frozenset({"read", "exec"}),
126 destructive=False,
127 auto_approve=True,
128 ),
129 "oct_typecheck": ToolSpec(
130 cli="typecheck",
131 permissions=frozenset({"read", "exec"}),
132 destructive=False,
133 auto_approve=True,
134 ),
135 "oct_diag": ToolSpec(
136 cli="diag",
137 permissions=frozenset({"read"}),
138 destructive=False,
139 auto_approve=True,
140 ),
141 "oct_git_status": ToolSpec(
142 cli="git",
143 permissions=frozenset({"read"}),
144 destructive=False,
145 auto_approve=True,
146 ),
147 "oct_git_check": ToolSpec(
148 cli="git",
149 permissions=frozenset({"read"}),
150 destructive=False,
151 auto_approve=True,
152 ),
153 # ------------------------------------------------------------------
154 # Phase 5B - write tools (11) - all destructive=True, auto_approve=False
155 # Blocked in strict + airgapped profiles; require confirm=True via HITL.
156 # ------------------------------------------------------------------
157 "oct_format": ToolSpec(
158 cli="format",
159 permissions=frozenset({"read", "write"}),
160 destructive=True,
161 auto_approve=False,
162 ),
163 "oct_docs": ToolSpec(
164 cli="docs",
165 permissions=frozenset({"read", "write"}),
166 destructive=True,
167 auto_approve=False,
168 ),
169 "oct_export_source": ToolSpec(
170 cli="export-source",
171 permissions=frozenset({"read", "write"}),
172 destructive=True,
173 auto_approve=False,
174 ),
175 "oct_new": ToolSpec(
176 cli="new",
177 permissions=frozenset({"write"}),
178 destructive=True,
179 auto_approve=False,
180 ),
181 "oct_scaffold": ToolSpec(
182 cli="scaffold",
183 permissions=frozenset({"write"}),
184 destructive=True,
185 auto_approve=False,
186 ),
187 "oct_clean": ToolSpec(
188 cli="clean",
189 permissions=frozenset({"write"}),
190 destructive=True,
191 auto_approve=False,
192 ),
193 "oct_install_hooks": ToolSpec(
194 cli="install-hooks",
195 permissions=frozenset({"write"}),
196 destructive=True,
197 auto_approve=False,
198 ),
199 "oct_git_commit": ToolSpec(
200 cli="git",
201 permissions=frozenset({"write"}),
202 destructive=True,
203 auto_approve=False,
204 ),
205 "oct_git_hooks_install": ToolSpec(
206 cli="git",
207 permissions=frozenset({"write"}),
208 destructive=True,
209 auto_approve=False,
210 ),
211 "oct_git_init": ToolSpec(
212 cli="git",
213 permissions=frozenset({"write"}),
214 destructive=True,
215 auto_approve=False,
216 ),
217 "oct_git_changelog": ToolSpec(
218 cli="git",
219 permissions=frozenset({"write"}),
220 destructive=True,
221 auto_approve=False,
222 ),
223}
224
225
226# ---------------------------------------------------------------------
227# Policy decision
228# ---------------------------------------------------------------------
229
230
231@dataclass
233 """The result of a :meth:`McpPolicy.check` call.
234
235 Attributes
236 ----------
237 allowed
238 ``True`` iff the tool call should proceed.
239 decision
240 Human-readable decision string: ``"allowed"``, ``"denied"``,
241 or ``"requires_confirmation"``.
242 policy_rule
243 Short identifier for the rule that produced this decision.
244 reason
245 Human-readable reason (included in audit records and error
246 responses).
247 """
248
249 allowed: bool
250 decision: str
251 policy_rule: str
252 reason: str
253
254
255# ---------------------------------------------------------------------
256# Policy engine
257# ---------------------------------------------------------------------
258
259
261 """Stateless policy engine for the MCP tool manifest.
262
263 Parameters
264 ----------
265 profile
266 Active MCP profile. One of ``"default"``, ``"strict"``, or
267 ``"airgapped"``.
268 safe_mode
269 If ``True``, all tool calls are denied regardless of profile.
270 Mirrors ``McpConfig.safe_mode``.
271 """
272
274 self,
275 profile: McpProfile = "default",
276 safe_mode: bool = False,
277 ) -> None:
278 func = "McpPolicy.__init__"
279 if profile not in ("default", "strict", "airgapped"):
280 raise ValueError(
281 f"Invalid MCP profile: {profile!r}. "
282 f"Must be one of: default, strict, airgapped"
283 )
284 self._profile: McpProfile = profile
285 self._safe_mode: bool = safe_mode
286 _dbg("MCP", func, f"profile={profile} safe_mode={safe_mode}", 2)
287
288 @property
289 def profile(self) -> McpProfile:
290 """Active profile name."""
291 return self._profile
292
293 def check(self, tool_name: str) -> PolicyDecision:
294 """Check whether *tool_name* is permitted under the active policy.
295
296 Parameters
297 ----------
298 tool_name
299 MCP tool name, e.g. ``"oct_lint"``.
300
301 Returns
302 -------
303 :class:`PolicyDecision` with ``allowed=True`` or ``allowed=False``.
304 Never raises.
305 """
306 func = "McpPolicy.check"
307
308 # -- safe mode (hard override) -----------------------------------
309 if self._safe_mode:
310 _dbg("MCP", func, f"denied tool={tool_name} rule=safe_mode", 2)
311 return PolicyDecision(
312 allowed=False,
313 decision="denied",
314 policy_rule="safe_mode",
315 # OCT-LINT: disable=no_hardcoded_secrets reason="Denial reason text shown to the caller, not a secret"
316 reason="Server is running in safe mode (OCT_MCP_SAFE_MODE=1). "
317 "All tool calls are blocked.",
318 )
319
320 # -- airgapped profile (deny everything) -------------------------
321 if self._profile == "airgapped":
322 _dbg("MCP", func, f"denied tool={tool_name} rule=airgapped_profile", 2)
323 return PolicyDecision(
324 allowed=False,
325 decision="denied",
326 policy_rule="airgapped_profile",
327 reason="The airgapped profile denies all tool calls. "
328 "Switch to 'default' or 'strict' to enable tools.",
329 )
330
331 # -- tool not in manifest ----------------------------------------
332 spec = TOOL_MANIFEST.get(tool_name)
333 if spec is None:
334 _dbg("MCP", func, f"denied tool={tool_name} rule=unknown_tool", 1)
335 return PolicyDecision(
336 allowed=False,
337 decision="denied",
338 policy_rule="unknown_tool",
339 reason=f"Tool {tool_name!r} is not in the tool manifest.",
340 )
341
342 # -- destructive tools blocked in strict profile -----------------
343 if self._profile == "strict" and spec.destructive:
344 _dbg("MCP", func, f"denied tool={tool_name} rule=strict_no_destructive", 2)
345 return PolicyDecision(
346 allowed=False,
347 decision="denied",
348 policy_rule="strict_no_destructive",
349 reason=f"Tool {tool_name!r} is destructive and the 'strict' profile "
350 f"only allows read-only tools.",
351 )
352
353 # -- default: all manifest tools with auto_approve=True ----------
354 _dbg("MCP", func, f"allowed tool={tool_name} profile={self._profile}", 4)
355 return PolicyDecision(
356 allowed=True,
357 decision="allowed",
358 policy_rule="manifest_auto_approve",
359 reason=f"Tool {tool_name!r} is in the manifest with auto_approve=True.",
360 )
McpProfile profile(self)
Definition policy.py:289
PolicyDecision check(self, str tool_name)
Definition policy.py:293
None __init__(self, McpProfile profile="default", bool safe_mode=False)
Definition policy.py:277
frozenset permissions
Definition policy.py:82
None __post_init__(self)
Definition policy.py:86