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