Option C Tools
Loading...
Searching...
No Matches
safety.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/safety.py
4
5"""
6Purpose
7-------
8Safety Gate (Layer 2.5) for the ``oct-mcp`` six-layer pipeline.
9Enforces the Human-in-the-Loop (HITL) consent protocol for all
10write/destructive tool calls, and applies dry-run defaults.
11
12Responsibilities
13----------------
14- Define :class:`SafetyDecision` — the structured result of a safety
15 check (proceed, requires_confirmation, dry_run, reason).
16- Define :class:`SafetyGate` with a single :meth:`SafetyGate.check`
17 entry point consumed by :func:`oct.mcp.tools._run_tool_write`.
18- Define :func:`_format_confirmation_request` which produces the
19 structured ``[oct-mcp requires_confirmation] …`` response string
20 returned to the AI host when confirmation is needed.
21
22HITL Protocol (D-5B-1 — stateless)
23-----------------------------------
24No server-side pending state.
25
26- **First call** (``confirm=False``, default): safety gate returns
27 ``proceed=False``. The caller returns a
28 ``[oct-mcp requires_confirmation] …`` string to the AI host; no
29 execution occurs.
30- **Second call** (``confirm=True``): safety gate returns
31 ``proceed=True``; full execution proceeds.
32
33The AI host re-submits the identical call with ``confirm=True`` after
34the user approves in the host UI. There is no replay window to exploit
35(T-08) because each call is evaluated independently.
36
37Dry-run rules (D-5B-2, D-5B-5)
38--------------------------------
391. Non-destructive tools → always ``proceed=True``, ``dry_run=False``.
402. Destructive, ``confirm=False`` → ``proceed=False``,
41 ``requires_confirmation=True``.
423. ``oct_format``, ``confirm=True``, ``apply=False`` → ``proceed=True``,
43 ``dry_run=True``.
444. ``oct_format``, ``confirm=True``, ``apply=True`` → ``proceed=True``,
45 ``dry_run=False``.
465. Other destructive, ``confirm=True`` → ``proceed=True``,
47 ``dry_run=False``.
48 (Tools with their own ``dry_run`` field honour that field directly
49 — the executor reads it from the validated args dict.)
50
51Diagnostics
52-----------
53Domain: MCP
54Levels:
55 L2 — lifecycle: safety decision
56 L4 — deep trace: field values
57
58Contracts
59---------
60- This module does not import ``click``, the MCP SDK, or any
61 ``oct.mcp.executor`` / ``oct.mcp.tools`` module (no cycles).
62- :meth:`SafetyGate.check` never raises for valid input.
63- The confirmation request string always starts with the sentinel
64 ``[oct-mcp requires_confirmation]`` so callers can detect it via
65 simple prefix matching.
66"""
67
68from __future__ import annotations
69
70from dataclasses import dataclass
71
72try:
73 from oc_diagnostics import _dbg as _real_dbg
74
75 def _dbg(*args, **kwargs) -> None:
76 _real_dbg(*args, **kwargs)
77except ImportError: # pragma: no cover
78 def _dbg(*args, **kwargs) -> None:
79 return None
80
81from oct.mcp.config import McpConfig
82from oct.mcp.policy import ToolSpec
83
84# Sentinel prefix for confirmation-required responses.
85HITL_SENTINEL: str = "[oct-mcp requires_confirmation]"
86
87# Per-tool risk descriptions shown in the confirmation request.
88_TOOL_RISK_DESCRIPTIONS: dict[str, str] = {
89 "oct_format": "auto-fixes Python formatting in project files",
90 "oct_docs": "generates or removes documentation artifacts",
91 "oct_export_source": "writes consolidated source-code export files",
92 "oct_new": "creates a new Python source file",
93 "oct_scaffold": "creates a new Option C project directory tree",
94 "oct_clean": "removes _source_code-*, _skeleton_code-*, and __pycache__ directories",
95 "oct_install_hooks": "installs pre-commit hooks configuration",
96 "oct_git_commit": "commits staged changes to the git history",
97 "oct_git_hooks_install":"installs git pre-commit hooks",
98 "oct_git_init": "initialises or enhances the git repository configuration",
99 "oct_git_changelog": "writes CHANGELOG.md from commit history",
100}
101
102
103# ---------------------------------------------------------------------
104# Safety decision
105# ---------------------------------------------------------------------
106
107
108@dataclass
110 """Result of a :meth:`SafetyGate.check` call.
111
112 Attributes
113 ----------
114 proceed
115 ``True`` → execution may continue; ``False`` → return the
116 confirmation request to the AI host.
117 requires_confirmation
118 ``True`` when the AI host must re-submit the call with
119 ``confirm=True`` before execution proceeds.
120 dry_run
121 ``True`` → executor should pass ``--dry-run`` (or equivalent)
122 to the CLI subprocess. Applies only to ``oct_format`` (where
123 ``apply=False`` triggers dry-run even with ``confirm=True``).
124 Other tools that have their own ``dry_run`` field manage it
125 independently.
126 reason
127 Human-readable explanation forwarded to audit logs.
128 """
129
130 proceed: bool
131 requires_confirmation: bool
132 dry_run: bool
133 reason: str
134
135
136# ---------------------------------------------------------------------
137# Confirmation request formatter
138# ---------------------------------------------------------------------
139
140
142 tool_name: str,
143 spec: ToolSpec,
144 args: dict,
145) -> str:
146 """Return the structured confirmation-required response string.
147
148 The string always starts with :data:`HITL_SENTINEL` so callers can
149 detect it with a prefix check. The body describes the tool, its
150 risk, and the args that would be applied.
151
152 Parameters
153 ----------
154 tool_name
155 MCP tool name (e.g. ``"oct_format"``).
156 spec
157 The :class:`~oct.mcp.policy.ToolSpec` for this tool.
158 args
159 Validated args dict (``model.model_dump()``). Shown in the
160 confirmation request so the user can review what will happen.
161 """
162 risk = _TOOL_RISK_DESCRIPTIONS.get(
163 tool_name,
164 "modifies the filesystem",
165 )
166 # Show args without the confirm key itself (it's always False here).
167 display_args = {k: v for k, v in args.items() if k != "confirm"}
168 args_text = ", ".join(f"{k}={v!r}" for k, v in display_args.items()) or "(defaults)"
169 return (
170 f"{HITL_SENTINEL} "
171 f"Tool '{tool_name}' {risk}. "
172 f"Args: {args_text}. "
173 f"Re-call with confirm=True to proceed."
174 )
175
176
177# ---------------------------------------------------------------------
178# Safety gate
179# ---------------------------------------------------------------------
180
181
183 """HITL + dry-run enforcement gate for write tools.
184
185 One instance is created per MCP server session (in ``server.py``)
186 and stored in :class:`~oct.mcp.server.ServerState`.
187
188 The gate is **stateless** — each :meth:`check` call is independent.
189 There is no in-memory pending-confirmation queue. This eliminates
190 the T-08 replay-window attack surface.
191
192 Parameters
193 ----------
194 config
195 Active :class:`~oct.mcp.config.McpConfig`. The gate reads
196 ``config.dry_run_default_for_writes`` to decide the fallback
197 dry-run behaviour.
198 """
199
200 def __init__(self, config: McpConfig) -> None:
201 func = "SafetyGate.__init__"
202 self._config = config
203 _dbg("MCP", func, f"dry_run_default={config.dry_run_default_for_writes}", 2)
204
205 # -- public API ------------------------------------------------------
206
207 def check(
208 self,
209 tool_name: str,
210 spec: ToolSpec,
211 args: dict,
212 ) -> SafetyDecision:
213 """Evaluate the HITL gate and dry-run rules for *tool_name*.
214
215 Parameters
216 ----------
217 tool_name
218 MCP tool name (e.g. ``"oct_format"``).
219 spec
220 :class:`~oct.mcp.policy.ToolSpec` for *tool_name*.
221 args
222 Validated args dict (``model.model_dump()``).
223
224 Returns
225 -------
226 :class:`SafetyDecision` — never raises.
227 """
228 func = "SafetyGate.check"
229
230 # Rule 1: non-destructive tools always proceed without dry-run.
231 if not spec.destructive:
232 _dbg("MCP", func, f"non-destructive tool={tool_name} → proceed", 4)
233 return SafetyDecision(
234 proceed=True,
235 requires_confirmation=False,
236 dry_run=False,
237 reason=f"Tool '{tool_name}' is non-destructive; no confirmation required.",
238 )
239
240 confirm = args.get("confirm", False)
241
242 # Rule 2: destructive + confirm=False → requires confirmation.
243 if not confirm:
244 _dbg("MCP", func, f"destructive tool={tool_name} confirm=False → requires_confirmation", 2)
245 return SafetyDecision(
246 proceed=False,
247 requires_confirmation=True,
248 dry_run=False,
249 reason=(
250 f"Tool '{tool_name}' is destructive and requires explicit confirmation. "
251 f"Re-call with confirm=True to proceed."
252 ),
253 )
254
255 # Rules 3 & 4: oct_format double guard — apply=False → dry_run.
256 if tool_name == "oct_format":
257 apply = args.get("apply", False)
258 if not apply:
259 _dbg("MCP", func, "oct_format confirm=True apply=False → dry_run=True", 2)
260 return SafetyDecision(
261 proceed=True,
262 requires_confirmation=False,
263 dry_run=True,
264 reason=(
265 "oct_format: apply=False so --dry-run will be passed. "
266 "Set apply=True together with confirm=True to write changes."
267 ),
268 )
269 _dbg("MCP", func, "oct_format confirm=True apply=True → proceed with --fix", 2)
270 return SafetyDecision(
271 proceed=True,
272 requires_confirmation=False,
273 dry_run=False,
274 reason="oct_format: confirm=True and apply=True — will apply formatting changes.",
275 )
276
277 # Rule 5: all other destructive tools with confirm=True → proceed.
278 _dbg("MCP", func, f"destructive tool={tool_name} confirm=True → proceed", 2)
279 return SafetyDecision(
280 proceed=True,
281 requires_confirmation=False,
282 dry_run=False,
283 reason=f"Tool '{tool_name}': confirm=True — proceeding with execution.",
284 )
SafetyDecision check(self, str tool_name, ToolSpec spec, dict args)
Definition safety.py:212
None __init__(self, McpConfig config)
Definition safety.py:200
None _dbg(*args, **kwargs)
Definition safety.py:75
str _format_confirmation_request(str tool_name, ToolSpec spec, dict args)
Definition safety.py:145