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.
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.
22HITL Protocol (D-5B-1 — stateless)
23-----------------------------------
24No server-side pending state.
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
30- **Second call** (``confirm=True``): safety gate returns
31 ``proceed=True``; full execution proceeds.
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.
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``,
444. ``oct_format``, ``confirm=True``, ``apply=True`` → ``proceed=True``,
465. Other destructive, ``confirm=True`` → ``proceed=True``,
48 (Tools with their own ``dry_run`` field honour that field directly
49 — the executor reads it from the validated args dict.)
55 L2 — lifecycle: safety decision
56 L4 — deep trace: field values
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.
68from __future__
import annotations
70from dataclasses
import dataclass
73 from oc_diagnostics
import _dbg
as _real_dbg
75 def _dbg(*args, **kwargs) -> None:
76 _real_dbg(*args, **kwargs)
78 def _dbg(*args, **kwargs) -> None:
85HITL_SENTINEL: str =
"[oct-mcp requires_confirmation]"
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",
110 """Result of a :meth:`SafetyGate.check` call.
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.
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
127 Human-readable explanation forwarded to audit logs.
131 requires_confirmation: bool
146 """Return the structured confirmation-required response string.
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.
155 MCP tool name (e.g. ``"oct_format"``).
157 The :class:`~oct.mcp.policy.ToolSpec` for this tool.
159 Validated args dict (``model.model_dump()``). Shown in the
160 confirmation request so the user can review what will happen.
162 risk = _TOOL_RISK_DESCRIPTIONS.get(
164 "modifies the filesystem",
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)"
171 f
"Tool '{tool_name}' {risk}. "
172 f
"Args: {args_text}. "
173 f
"Re-call with confirm=True to proceed."
183 """HITL + dry-run enforcement gate for write tools.
185 One instance is created per MCP server session (in ``server.py``)
186 and stored in :class:`~oct.mcp.server.ServerState`.
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.
195 Active :class:`~oct.mcp.config.McpConfig`. The gate reads
196 ``config.dry_run_default_for_writes`` to decide the fallback
201 func =
"SafetyGate.__init__"
203 _dbg(
"MCP", func, f
"dry_run_default={config.dry_run_default_for_writes}", 2)
213 """Evaluate the HITL gate and dry-run rules for *tool_name*.
218 MCP tool name (e.g. ``"oct_format"``).
220 :class:`~oct.mcp.policy.ToolSpec` for *tool_name*.
222 Validated args dict (``model.model_dump()``).
226 :class:`SafetyDecision` — never raises.
228 func =
"SafetyGate.check"
231 if not spec.destructive:
232 _dbg(
"MCP", func, f
"non-destructive tool={tool_name} → proceed", 4)
235 requires_confirmation=
False,
237 reason=f
"Tool '{tool_name}' is non-destructive; no confirmation required.",
240 confirm = args.get(
"confirm",
False)
244 _dbg(
"MCP", func, f
"destructive tool={tool_name} confirm=False → requires_confirmation", 2)
247 requires_confirmation=
True,
250 f
"Tool '{tool_name}' is destructive and requires explicit confirmation. "
251 f
"Re-call with confirm=True to proceed."
256 if tool_name ==
"oct_format":
257 apply = args.get(
"apply",
False)
259 _dbg(
"MCP", func,
"oct_format confirm=True apply=False → dry_run=True", 2)
262 requires_confirmation=
False,
265 "oct_format: apply=False so --dry-run will be passed. "
266 "Set apply=True together with confirm=True to write changes."
269 _dbg(
"MCP", func,
"oct_format confirm=True apply=True → proceed with --fix", 2)
272 requires_confirmation=
False,
274 reason=
"oct_format: confirm=True and apply=True — will apply formatting changes.",
278 _dbg(
"MCP", func, f
"destructive tool={tool_name} confirm=True → proceed", 2)
281 requires_confirmation=
False,
283 reason=f
"Tool '{tool_name}': confirm=True — proceeding with execution.",
SafetyDecision check(self, str tool_name, ToolSpec spec, dict args)
None __init__(self, McpConfig config)
None _dbg(*args, **kwargs)
str _format_confirmation_request(str tool_name, ToolSpec spec, dict args)