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.
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).
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
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
37- In ``strict`` profile, only tools with ``auto_approve=True`` and
38 ``destructive=False`` may execute, and repo-level config is ignored.
42- oct.core.diagnostics (_dbg structured logger)
45from __future__
import annotations
47from dataclasses
import dataclass, field
48from typing
import Literal
50from oct.core.diagnostics
import _dbg
58McpProfile = Literal[
"default",
"strict",
"airgapped"]
61@dataclass(frozen=True)
63 """Metadata for one entry in the tool manifest.
68 The ``oct`` CLI subcommand string (e.g. ``"lint"``, ``"git status"``).
70 Set of permission tokens required by this tool (``"read"``,
71 ``"exec"``). Phase 5A tools are read-only (``{"read"}`` or
72 ``{"read", "exec"}``).
74 True if the tool can mutate the filesystem. All Phase 5A tools
75 are ``False``; write tools will be ``True`` in Phase 5B.
77 True if the tool may execute without explicit user confirmation.
78 All Phase 5A tools are ``True``.
82 permissions: frozenset[str] = field(default_factory=frozenset)
83 destructive: bool =
False
84 auto_approve: bool =
True
88 object.__setattr__(self,
"permissions", frozenset(self.
permissions))
95TOOL_MANIFEST: dict[str, ToolSpec] = {
101 permissions=frozenset({
"read"}),
107 permissions=frozenset({
"read"}),
112 cli=
"export-skeleton",
113 permissions=frozenset({
"read"}),
119 permissions=frozenset({
"read"}),
125 permissions=frozenset({
"read",
"exec"}),
131 permissions=frozenset({
"read",
"exec"}),
137 permissions=frozenset({
"read"}),
143 permissions=frozenset({
"read"}),
149 permissions=frozenset({
"read"}),
159 permissions=frozenset({
"read",
"write"}),
165 permissions=frozenset({
"read",
"write"}),
171 permissions=frozenset({
"read",
"write"}),
177 permissions=frozenset({
"write"}),
183 permissions=frozenset({
"write"}),
189 permissions=frozenset({
"write"}),
195 permissions=frozenset({
"write"}),
201 permissions=frozenset({
"write"}),
207 permissions=frozenset({
"write"}),
213 permissions=frozenset({
"write"}),
219 permissions=frozenset({
"write"}),
233 """The result of a :meth:`McpPolicy.check` call.
238 ``True`` iff the tool call should proceed.
240 Human-readable decision string: ``"allowed"``, ``"denied"``,
241 or ``"requires_confirmation"``.
243 Short identifier for the rule that produced this decision.
245 Human-readable reason (included in audit records and error
261 """Stateless policy engine for the MCP tool manifest.
266 Active MCP profile. One of ``"default"``, ``"strict"``, or
269 If ``True``, all tool calls are denied regardless of profile.
270 Mirrors ``McpConfig.safe_mode``.
275 profile: McpProfile =
"default",
276 safe_mode: bool =
False,
278 func =
"McpPolicy.__init__"
279 if profile
not in (
"default",
"strict",
"airgapped"):
281 f
"Invalid MCP profile: {profile!r}. "
282 f
"Must be one of: default, strict, airgapped"
286 _dbg(
"MCP", func, f
"profile={profile} safe_mode={safe_mode}", 2)
290 """Active profile name."""
293 def check(self, tool_name: str) -> PolicyDecision:
294 """Check whether *tool_name* is permitted under the active policy.
299 MCP tool name, e.g. ``"oct_lint"``.
303 :class:`PolicyDecision` with ``allowed=True`` or ``allowed=False``.
306 func =
"McpPolicy.check"
310 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=safe_mode", 2)
314 policy_rule=
"safe_mode",
316 reason=
"Server is running in safe mode (OCT_MCP_SAFE_MODE=1). "
317 "All tool calls are blocked.",
322 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=airgapped_profile", 2)
326 policy_rule=
"airgapped_profile",
327 reason=
"The airgapped profile denies all tool calls. "
328 "Switch to 'default' or 'strict' to enable tools.",
332 spec = TOOL_MANIFEST.get(tool_name)
334 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=unknown_tool", 1)
338 policy_rule=
"unknown_tool",
339 reason=f
"Tool {tool_name!r} is not in the tool manifest.",
343 if self.
_profile ==
"strict" and spec.destructive:
344 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=strict_no_destructive", 2)
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.",
354 _dbg(
"MCP", func, f
"allowed tool={tool_name} profile={self._profile}", 4)
358 policy_rule=
"manifest_auto_approve",
359 reason=f
"Tool {tool_name!r} is in the manifest with auto_approve=True.",
PolicyDecision check(self, str tool_name)
None __init__(self, McpProfile profile="default", bool safe_mode=False)