6M8.1 WU-0a pre-dispatch decision fields + the M8.1 safety validator (§7.2; F7/F31).
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.
16Two verdicts are pre-dispatch-decidable in M8.1 (§A.9(b), ratified):
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.
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
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).
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.
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)
67 L2 — a validation failure
68 L3 — per-decision verdict detail
69 L4 — module load / deep tracing
71from __future__
import annotations
74from typing
import Any, Mapping, Optional
76from oc_diagnostics
import _dbg
77from option_c_agent_contracts
import canonical_json
81_dbg(
"OC_CHAT.CORE",
"module",
"loaded", 4)
88POLICY_VERSION: str =
"m8.1.0"
94TOOL_CAPABILITIES: dict[str, str] = {
97 "search_files":
"list",
100 "read_chunks":
"read",
101 "write_file":
"write",
102 "edit_file":
"write",
104 "remember":
"persist",
107 "create_plan":
"persist",
108 "mark_step":
"persist",
109 "stop_plan":
"persist",
110 "get_status":
"read",
112 "run_command":
"exec",
113 "text_search":
"read",
115 "fetch_url":
"egress_fetch",
116 "web_search":
"egress_fetch",
121 "oct_health":
"exec",
122 "oct_typecheck":
"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",
141_TEMPLATED_EXEC_TOOLS: frozenset[str] = frozenset(
142 name
for name, cap
in TOOL_CAPABILITIES.items()
if cap ==
"exec" and name !=
"run_command"
147CAP_DENY_RISK_CLASS: str =
"critical"
152_BASE_FIELDS: tuple[str, ...] = (
153 "tool_call_id",
"policy_version",
"final_outcome",
"input_hash",
157_GRANT_FIELDS: tuple[str, ...] = (
158 "permission_request_id",
"canonical_request_hash",
"grant_key_fingerprint",
163_ENVELOPE_FIELDS: tuple[str, ...] = (
"grant_key_fingerprint",)
167 """An M8.1-emitted ``permission_decision`` is missing a required field."""
171 """Return the ``CapabilityClass`` verb for *tool_name*, or ``""`` when undeclared.
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"``.
177 func =
"tool_capability"
178 cap = TOOL_CAPABILITIES.get(tool_name,
"")
180 _dbg(
"OC_CHAT.CORE", func, f
"no declared capability for tool {tool_name!r}", 3)
185 """Return the ``sha256`` hex digest binding a decision to the call's arguments.
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.
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()
202 args: Mapping[str, Any]) -> dict[str, Any]:
203 """Return the ``make_permission_decision`` kwargs for a pre-dispatch auto-allow.
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.
208 func =
"auto_allow_decision_fields"
210 risk_class = assign_risk_class(
212 templated=tool_name
in _TEMPLATED_EXEC_TOOLS,
214 _dbg(
"OC_CHAT.CORE", func,
215 f
"allow tool={tool_name} capability={capability or '<undeclared>'} "
216 f
"risk_class={risk_class}", 3)
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",
225 "authorization_mode":
"auto_allow",
230 args: Mapping[str, Any]) -> dict[str, Any]:
231 """Return the ``make_permission_decision`` kwargs for a per-turn-cap denial.
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``.
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)
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",
248 "authorization_mode":
"deny",
249 "denial_reason_code":
"tool_call_cap_exceeded",
254 args: Mapping[str, Any]) -> dict[str, Any]:
255 """Return the ``make_permission_decision`` kwargs for a detected stall.
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.
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)
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",
275 "authorization_mode":
"deny",
276 "denial_reason_code":
"stall_detected",
281 """True when *payload* is an M8.1-emitted decision (not a legacy plan-173 relay one).
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.
286 return getattr(payload,
"policy_version",
None)
is not None
290 has_capability_target: bool =
True) ->
None:
291 """Raise unless *payload* satisfies the M8.1 decision contract (WU-0a item 3).
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.
304 PermissionDecisionValidationError
305 With every missing/conflicting field named, so an audit failure is actionable.
307 func =
"validate_m8_1_permission_decision"
308 problems: list[str] = []
310 for field
in _BASE_FIELDS:
311 if getattr(payload, field,
None)
is None:
312 problems.append(f
"missing required field {field!r}")
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)")
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)")
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:
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)
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)
345 """Return *event* with the envelope's ``permission_decision_id`` set.
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.
354 if permission_decision_id
is None:
356 return event.model_copy(update={
"permission_decision_id": permission_decision_id})
360 "CAP_DENY_RISK_CLASS",
363 "PermissionDecisionValidationError",
364 "auto_allow_decision_fields",
365 "cap_deny_decision_fields",
366 "stall_deny_decision_fields",
369 "link_started_to_decision",
371 "validate_m8_1_permission_decision",
Any link_started_to_decision(Any event, Optional[str] permission_decision_id)
str tool_capability(str tool_name)
bool is_m8_1_decision(Any payload)
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)