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 L4 — deep trace: profile enforcement details
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
36- In ``strict`` profile, only tools with ``auto_approve=True`` and
37 ``destructive=False`` may execute, and repo-level config is ignored.
40from __future__
import annotations
42from dataclasses
import dataclass, field
43from typing
import Literal
46 from oc_diagnostics
import _dbg
as _real_dbg
48 def _dbg(*args, **kwargs) -> None:
49 _real_dbg(*args, **kwargs)
51 def _dbg(*args, **kwargs) -> None:
60McpProfile = Literal[
"default",
"strict",
"airgapped"]
63@dataclass(frozen=True)
65 """Metadata for one entry in the tool manifest.
70 The ``oct`` CLI subcommand string (e.g. ``"lint"``, ``"git status"``).
72 Set of permission tokens required by this tool (``"read"``,
73 ``"exec"``). Phase 5A tools are read-only (``{"read"}`` or
74 ``{"read", "exec"}``).
76 True if the tool can mutate the filesystem. All Phase 5A tools
77 are ``False``; write tools will be ``True`` in Phase 5B.
79 True if the tool may execute without explicit user confirmation.
80 All Phase 5A tools are ``True``.
84 permissions: frozenset[str] = field(default_factory=frozenset)
85 destructive: bool =
False
86 auto_approve: bool =
True
90 object.__setattr__(self,
"permissions", frozenset(self.
permissions))
97TOOL_MANIFEST: dict[str, ToolSpec] = {
103 permissions=frozenset({
"read"}),
109 permissions=frozenset({
"read"}),
114 cli=
"export-skeleton",
115 permissions=frozenset({
"read"}),
121 permissions=frozenset({
"read"}),
127 permissions=frozenset({
"read",
"exec"}),
133 permissions=frozenset({
"read",
"exec"}),
139 permissions=frozenset({
"read"}),
145 permissions=frozenset({
"read"}),
151 permissions=frozenset({
"read"}),
161 permissions=frozenset({
"read",
"write"}),
167 permissions=frozenset({
"read",
"write"}),
173 permissions=frozenset({
"read",
"write"}),
179 permissions=frozenset({
"write"}),
185 permissions=frozenset({
"write"}),
191 permissions=frozenset({
"write"}),
197 permissions=frozenset({
"write"}),
203 permissions=frozenset({
"write"}),
209 permissions=frozenset({
"write"}),
215 permissions=frozenset({
"write"}),
221 permissions=frozenset({
"write"}),
235 """The result of a :meth:`McpPolicy.check` call.
240 ``True`` iff the tool call should proceed.
242 Human-readable decision string: ``"allowed"``, ``"denied"``,
243 or ``"requires_confirmation"``.
245 Short identifier for the rule that produced this decision.
247 Human-readable reason (included in audit records and error
263 """Stateless policy engine for the MCP tool manifest.
268 Active MCP profile. One of ``"default"``, ``"strict"``, or
271 If ``True``, all tool calls are denied regardless of profile.
272 Mirrors ``McpConfig.safe_mode``.
277 profile: McpProfile =
"default",
278 safe_mode: bool =
False,
280 func =
"McpPolicy.__init__"
281 if profile
not in (
"default",
"strict",
"airgapped"):
283 f
"Invalid MCP profile: {profile!r}. "
284 f
"Must be one of: default, strict, airgapped"
288 _dbg(
"MCP", func, f
"profile={profile} safe_mode={safe_mode}", 2)
292 """Active profile name."""
295 def check(self, tool_name: str) -> PolicyDecision:
296 """Check whether *tool_name* is permitted under the active policy.
301 MCP tool name, e.g. ``"oct_lint"``.
305 :class:`PolicyDecision` with ``allowed=True`` or ``allowed=False``.
308 func =
"McpPolicy.check"
312 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=safe_mode", 2)
316 policy_rule=
"safe_mode",
317 reason=
"Server is running in safe mode (OCT_MCP_SAFE_MODE=1). "
318 "All tool calls are blocked.",
323 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=airgapped_profile", 2)
327 policy_rule=
"airgapped_profile",
328 reason=
"The airgapped profile denies all tool calls. "
329 "Switch to 'default' or 'strict' to enable tools.",
333 spec = TOOL_MANIFEST.get(tool_name)
335 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=unknown_tool", 1)
339 policy_rule=
"unknown_tool",
340 reason=f
"Tool {tool_name!r} is not in the tool manifest.",
344 if self.
_profile ==
"strict" and spec.destructive:
345 _dbg(
"MCP", func, f
"denied tool={tool_name} rule=strict_no_destructive", 2)
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.",
355 _dbg(
"MCP", func, f
"allowed tool={tool_name} profile={self._profile}", 4)
359 policy_rule=
"manifest_auto_approve",
360 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)
None _dbg(*args, **kwargs)