oc_chat
Loading...
Searching...
No Matches
permission_validator.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/permission_validator.py
4
5"""
6M8.1 WU-0a pre-dispatch decision fields + the M8.1 safety validator (§7.2; F7/F31).
7
8Purpose
9-------
10The react-loop decides a tool call's fate **before** it dispatches (the classify →
11decide → emit-started-for-allowed lifecycle). This module owns the two things that
12decision needs and the loop must not: the **audit field set** each pre-dispatch
13verdict carries onto its ``permission_decision`` event, and the **validator** that
14says whether an M8.1-emitted decision is complete.
15
16Two verdicts are pre-dispatch-decidable in M8.1 (§A.9(b), ratified):
17
18- **cap-deny** — the WU-9a per-turn tool-call budget is spent. A budget/DoS verdict,
19 so it carries the fail-closed ``risk_class="critical"`` sentinel directly and is
20 **never** routed through :func:`~oc_chat.core.risk_class.assign_risk_class` (that
21 function judges capability x target, which is not what a cap-deny is about).
22- **auto-allow** — everything else. ``risk_class`` is derived from the tool's
23 **capability alone**; the target-bearing derivation (protected-write / importable /
24 human-confirm rungs) needs the resolved target and belongs to WU-0b.
25
26Tier-2 confirmation, protected-write and external-content (F67) verdicts are computed
27inside the tool bodies and stay **post-dispatch** until WU-0b. Their plan-173 relay
28emission carries none of these fields, so :func:`is_m8_1_decision` discriminates the
29two populations on ``policy_version`` presence and the validator only ever speaks for
30the M8.1-emitted set.
31
32Responsibilities
33----------------
34- :func:`tool_capability` — first-party tool name -> ``CapabilityClass`` verb.
35- :func:`input_hash` — the argument-bound ``sha256`` hex digest.
36- :func:`auto_allow_decision_fields` / :func:`cap_deny_decision_fields` — the kwargs
37 for :func:`~oc_chat.core.events.make_permission_decision` (envelope ids excluded).
38- :func:`is_m8_1_decision` — M8.1-emitted vs. legacy plan-173 relay decision.
39- :func:`validate_m8_1_permission_decision` — the M8.1 safety validator.
40- :func:`link_started_to_decision` — set ``permission_decision_id`` on an already-built
41 ``tool_call_started`` envelope (the contract field exists; the factory does not take
42 it and the WU-0a isolation forbids reshaping it).
43
44Contracts
45---------
46- The validator is **pure**: it raises :class:`PermissionDecisionValidationError` or
47 returns ``None``, and never mutates the payload.
48- The grant / envelope discriminator is keyed on **fingerprint presence, NEVER on
49 ``authorization_mode``** — the validator must not depend on that field's resolution.
50- ``grant_fingerprint`` and ``envelope_fingerprint`` are **mutually exclusive**; a
51 decision authorized by neither (auto-allow, cap-deny) is valid with both absent.
52- Fail-closed: a tool with no declared capability yields no capability verb, and
53 ``assign_risk_class`` maps that to its own ``"critical"`` sentinel.
54- The contracts package is FROZEN here: this module calls the factory and reads the
55 payload; it never reshapes either.
56
57Dependencies
58------------
59- oc_diagnostics (structured debug logging via _dbg)
60- option_c_agent_contracts (canonical_json — the shared serialization scheme)
61- oc_chat.core.risk_class (assign_risk_class, the M8.0 WU-9b scorer)
62
63Diagnostics
64-----------
65Domain: OC_CHAT.CORE
66Levels:
67 L2 — a validation failure
68 L3 — per-decision verdict detail
69 L4 — module load / deep tracing
70"""
71from __future__ import annotations
72
73import hashlib
74from typing import Any, Mapping, Optional
75
76from oc_diagnostics import _dbg
77from option_c_agent_contracts import canonical_json
78
79from oc_chat.core.risk_class import assign_risk_class
80
81_dbg("OC_CHAT.CORE", "module", "loaded", 4)
82
83
84#: The active policy bundle identifier stamped on every M8.1-emitted decision.
85#: M8.1 ships a single uniform bundle (§ "uniform rollout"), so this is one constant
86#: rather than a resolver; WU-3's ``policy_version_mismatch`` compares against it, and
87#: WU-6 replaces the constant with the signed bundle's own version when one exists.
88POLICY_VERSION: str = "m8.1.0"
89
90#: First-party tool name -> ``CapabilityClass`` verb. The registry is a deterministic
91#: module allowlist (M8.0 WU-6 / G-2), so this map is closed over the first-party
92#: surface; anything else (an entry-point plugin, a sub-registry, a test double) has no
93#: declared capability and fails closed to ``"critical"`` via ``assign_risk_class``.
94TOOL_CAPABILITIES: dict[str, str] = {
95 # filesystem
96 "list_dir": "list",
97 "search_files": "list",
98 "read_file": "read",
99 "file_info": "read",
100 "read_chunks": "read",
101 "write_file": "write",
102 "edit_file": "write",
103 # memory
104 "remember": "persist",
105 "recall": "read",
106 # planner
107 "create_plan": "persist",
108 "mark_step": "persist",
109 "stop_plan": "persist",
110 "get_status": "read",
111 # shell
112 "run_command": "exec",
113 "text_search": "read",
114 # web
115 "fetch_url": "egress_fetch",
116 "web_search": "egress_fetch",
117 # operator channel
118 "ask_user": "read",
119 # oct_* — each spawns the Option C toolchain as a subprocess
120 "oct_lint": "exec",
121 "oct_health": "exec",
122 "oct_typecheck": "exec",
123 "oct_new": "exec",
124 "oct_test": "exec",
125 "oct_git_status": "exec",
126 "oct_git_check": "exec",
127 "oct_git_init": "exec",
128 "oct_git_add": "exec",
129 "oct_git_commit": "exec",
130 "oct_git_branch": "exec",
131 "oct_git_restore": "exec",
132 "oct_git_ignore": "exec",
133 "oct_git_commit_msg": "exec",
134 "oct_git_changelog": "exec",
135 "oct_git_hooks": "exec",
136}
137
138#: Exec tools whose argv is a fixed template (LD-1 ``templated``): the executable and
139#: subcommand are module constants, only operands come from the model. ``run_command``
140#: is deliberately absent — it takes a raw command string, so it scores the higher rung.
141_TEMPLATED_EXEC_TOOLS: frozenset[str] = frozenset(
142 name for name, cap in TOOL_CAPABILITIES.items() if cap == "exec" and name != "run_command"
143)
144
145#: The fail-closed ``RiskClass`` sentinel (§5.6 / DESIGN_FOUNDATION:25) a cap-deny
146#: carries. A valid ``RiskClass`` member — not an invented string, and not ``None``.
147CAP_DENY_RISK_CLASS: str = "critical"
148
149#: The base set every M8.1-emitted decision must carry. ``risk_class`` is conditional
150#: (see :func:`validate_m8_1_permission_decision`) and ``denial_reason_code`` is
151#: required only on a denial, so both are handled separately.
152_BASE_FIELDS: tuple[str, ...] = (
153 "tool_call_id", "policy_version", "final_outcome", "input_hash",
154)
155
156#: Required iff ``grant_fingerprint`` is present.
157_GRANT_FIELDS: tuple[str, ...] = (
158 "permission_request_id", "canonical_request_hash", "grant_key_fingerprint",
159)
160
161#: Required iff ``envelope_fingerprint`` is present. An envelope is not bound to a
162#: single request, so the request-scoped fields stay optional on that branch.
163_ENVELOPE_FIELDS: tuple[str, ...] = ("grant_key_fingerprint",)
164
165
167 """An M8.1-emitted ``permission_decision`` is missing a required field."""
168
169
170def tool_capability(tool_name: str) -> str:
171 """Return the ``CapabilityClass`` verb for *tool_name*, or ``""`` when undeclared.
172
173 ``""`` is deliberately out-of-enum: :func:`assign_risk_class` maps any
174 non-``CapabilityClass`` value to its fail-closed ``"critical"`` sentinel, so an
175 unknown tool is scored as maximally risky rather than silently as ``"low"``.
176 """
177 func = "tool_capability"
178 cap = TOOL_CAPABILITIES.get(tool_name, "")
179 if not cap:
180 _dbg("OC_CHAT.CORE", func, f"no declared capability for tool {tool_name!r}", 3)
181 return cap
182
183
184def input_hash(args: Mapping[str, Any]) -> str:
185 """Return the ``sha256`` hex digest binding a decision to the call's arguments.
186
187 Uses the shared canonical-JSON scheme (sorted keys, no insignificant whitespace,
188 UTF-8) so the same arguments always hash the same. A value the scheme cannot
189 serialize falls back to its ``repr`` rather than raising — an audit field must
190 never be the reason a turn dies.
191 """
192 func = "input_hash"
193 try:
194 serialized = canonical_json(dict(args))
195 except (TypeError, ValueError) as exc:
196 _dbg("OC_CHAT.CORE", func, f"non-canonical args ({type(exc).__name__}); repr fallback", 2)
197 serialized = repr(sorted(dict(args).items(), key=lambda kv: kv[0]))
198 return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
199
200
201def auto_allow_decision_fields(*, tool_call_id: str, tool_name: str,
202 args: Mapping[str, Any]) -> dict[str, Any]:
203 """Return the ``make_permission_decision`` kwargs for a pre-dispatch auto-allow.
204
205 ``risk_class`` is derived from the tool's capability **alone** (no target): the
206 target-bearing rungs need the resolved path/host/argv and land with WU-0b.
207 """
208 func = "auto_allow_decision_fields"
209 capability = tool_capability(tool_name)
210 risk_class = assign_risk_class(
211 capability,
212 templated=tool_name in _TEMPLATED_EXEC_TOOLS,
213 )
214 _dbg("OC_CHAT.CORE", func,
215 f"allow tool={tool_name} capability={capability or '<undeclared>'} "
216 f"risk_class={risk_class}", 3)
217 return {
218 "decision": "allow",
219 "decided_by": "rule",
220 "tool_call_id": tool_call_id,
221 "policy_version": POLICY_VERSION,
222 "risk_class": risk_class,
223 "final_outcome": "allow",
224 "input_hash": input_hash(args),
225 "authorization_mode": "auto_allow",
226 }
227
228
229def cap_deny_decision_fields(*, tool_call_id: str, tool_name: str,
230 args: Mapping[str, Any]) -> dict[str, Any]:
231 """Return the ``make_permission_decision`` kwargs for a per-turn-cap denial.
232
233 A cap-deny is a budget/DoS verdict, **not** a capability x target judgement, so it
234 carries :data:`CAP_DENY_RISK_CLASS` directly and never calls ``assign_risk_class``.
235 """
236 func = "cap_deny_decision_fields"
237 _dbg("OC_CHAT.CORE", func,
238 f"deny tool={tool_name} reason=tool_call_cap_exceeded "
239 f"risk_class={CAP_DENY_RISK_CLASS}", 2)
240 return {
241 "decision": "deny",
242 "decided_by": "rule",
243 "tool_call_id": tool_call_id,
244 "policy_version": POLICY_VERSION,
245 "risk_class": CAP_DENY_RISK_CLASS,
246 "final_outcome": "deny",
247 "input_hash": input_hash(args),
248 "authorization_mode": "deny",
249 "denial_reason_code": "tool_call_cap_exceeded",
250 }
251
252
253def stall_deny_decision_fields(*, tool_call_id: str, tool_name: str,
254 args: Mapping[str, Any]) -> dict[str, Any]:
255 """Return the ``make_permission_decision`` kwargs for a detected stall.
256
257 A stall is a bounded tool-call DoS verdict and carries
258 :data:`CAP_DENY_RISK_CLASS` for the same reason a cap-deny does, but it is
259 NOT a cap: plan 248 gave the contracts leaf ``"stall_detected"`` so the two
260 stop sharing one code. The operator-facing tool result and alert still carry
261 the specific stall evidence; this field is what an audit reader counts.
262 """
263 func = "stall_deny_decision_fields"
264 _dbg("OC_CHAT.CORE", func,
265 f"deny tool={tool_name} reason=stall_detected "
266 f"risk_class={CAP_DENY_RISK_CLASS}", 2)
267 return {
268 "decision": "deny",
269 "decided_by": "rule",
270 "tool_call_id": tool_call_id,
271 "policy_version": POLICY_VERSION,
272 "risk_class": CAP_DENY_RISK_CLASS,
273 "final_outcome": "deny",
274 "input_hash": input_hash(args),
275 "authorization_mode": "deny",
276 "denial_reason_code": "stall_detected",
277 }
278
279
280def is_m8_1_decision(payload: Any) -> bool:
281 """True when *payload* is an M8.1-emitted decision (not a legacy plan-173 relay one).
282
283 ``policy_version`` is the discriminator: every M8.1 emission stamps it, and the
284 3-field relay emission at the post-dispatch confirmation point does not.
285 """
286 return getattr(payload, "policy_version", None) is not None
287
288
290 has_capability_target: bool = True) -> None:
291 """Raise unless *payload* satisfies the M8.1 decision contract (WU-0a item 3).
292
293 Parameters
294 ----------
295 payload
296 A ``permission_decision`` payload (or any object exposing its fields).
297 has_capability_target : bool
298 ``False`` only for a decision with no capability x target to score, which is
299 the single documented waiver for ``risk_class``. Every runtime emission today
300 has one, so the default requires it.
301
302 Raises
303 ------
304 PermissionDecisionValidationError
305 With every missing/conflicting field named, so an audit failure is actionable.
306 """
307 func = "validate_m8_1_permission_decision"
308 problems: list[str] = []
309
310 for field in _BASE_FIELDS:
311 if getattr(payload, field, None) is None:
312 problems.append(f"missing required field {field!r}")
313
314 if has_capability_target and getattr(payload, "risk_class", None) is None:
315 problems.append("missing required field 'risk_class' (a capability x target exists)")
316
317 denied = (getattr(payload, "final_outcome", None) == "deny"
318 or getattr(payload, "decision", None) == "deny")
319 if denied and getattr(payload, "denial_reason_code", None) is None:
320 problems.append("missing required field 'denial_reason_code' (the decision denied)")
321
322 # The discriminator is fingerprint PRESENCE, never authorization_mode.
323 grant_fp = getattr(payload, "grant_fingerprint", None)
324 envelope_fp = getattr(payload, "envelope_fingerprint", None)
325 if grant_fp is not None and envelope_fp is not None:
326 problems.append(
327 "grant_fingerprint XOR envelope_fingerprint violated: a decision is "
328 "authorized by a grant or by an envelope, never both")
329 if grant_fp is not None:
330 problems.extend(f"missing required field {f!r} (grant_fingerprint is present)"
331 for f in _GRANT_FIELDS if getattr(payload, f, None) is None)
332 if envelope_fp is not None:
333 problems.extend(f"missing required field {f!r} (envelope_fingerprint is present)"
334 for f in _ENVELOPE_FIELDS if getattr(payload, f, None) is None)
335
336 if problems:
337 detail = "; ".join(problems)
338 _dbg("OC_CHAT.CORE", func, f"invalid permission_decision: {detail}", 2, override=True)
340 f"M8.1 permission_decision is incomplete: {detail}")
341 _dbg("OC_CHAT.CORE", func, "permission_decision valid", 4)
342
343
344def link_started_to_decision(event: Any, permission_decision_id: Optional[str]) -> Any:
345 """Return *event* with the envelope's ``permission_decision_id`` set.
346
347 ``TurnEvent.permission_decision_id`` is an existing contract field, but
348 ``make_tool_call_started`` does not expose it and the WU-0a isolation forbids
349 reshaping the ``core.events`` factory signatures — so the linkage is applied to the
350 built (frozen) envelope via pydantic's ``model_copy``. Returns *event* unchanged
351 when there is no decision to link (a call whose verdict is not pre-dispatch
352 decidable), so the caller needs no branch.
353 """
354 if permission_decision_id is None:
355 return event
356 return event.model_copy(update={"permission_decision_id": permission_decision_id})
357
358
359__all__ = [
360 "CAP_DENY_RISK_CLASS",
361 "POLICY_VERSION",
362 "TOOL_CAPABILITIES",
363 "PermissionDecisionValidationError",
364 "auto_allow_decision_fields",
365 "cap_deny_decision_fields",
366 "stall_deny_decision_fields",
367 "input_hash",
368 "is_m8_1_decision",
369 "link_started_to_decision",
370 "tool_capability",
371 "validate_m8_1_permission_decision",
372]
Any link_started_to_decision(Any event, Optional[str] permission_decision_id)
None validate_m8_1_permission_decision(Any payload, *, bool has_capability_target=True)
dict[str, Any] stall_deny_decision_fields(*, str tool_call_id, str tool_name, Mapping[str, Any] args)
str input_hash(Mapping[str, Any] args)
dict[str, Any] auto_allow_decision_fields(*, str tool_call_id, str tool_name, Mapping[str, Any] args)
dict[str, Any] cap_deny_decision_fields(*, str tool_call_id, str tool_name, Mapping[str, Any] args)