oc_chat
Loading...
Searching...
No Matches
external_content_gate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/external_content_gate.py
4
5"""
6External-content confirmation gate (M8.0 WU-8; §7.3, F67 / F68).
7
8Purpose
9-------
10`fetch_url` and `web_search` pull EXTERNAL, untrusted content into the deciding
11context — the residual T1 risk is a generic injection in fetched content driving
12an in-process ACE and outbound exfil. The T1 "trusted inputs" premise is enforced
13rather than assumed: such a fetch is **confirmed before it happens** with three
14choices — **allow once · trust this source for the session · deny.** A session-
15trusted source skips later prompts; a headless run with no confirmer fails closed
16(the react-loop relay denies it). The gate is **on by default in every profile**;
17it is disabled only out-of-band (``OC_CHAT_EXTERNAL_CONTENT_GATE``), and the
18disablement is logged as a risk.
19
20``recall`` is intentionally NOT gated in M8.0: no stored memory carries a
21non-``trusted`` provenance yet, so there is nothing to gate; recall-gating rides
22on the M8.3 memory-provenance derivation.
23
24Responsibilities
25----------------
26- :func:`gate_enabled` / :func:`normalize_source` — enablement + the canonical
27 host key for a URL/host source.
28- :func:`external_fetch_allowed` — the gate decision (off / trusted / one-shot).
29- :func:`trust_source_for_session` / :func:`reset_session_trust` /
30 :func:`allow_external_source_once` — the session trust set + the one-shot grant.
31- :func:`make_external_gate_block` / :func:`deny_external_fetch` — the
32 ``requires_confirmation`` signal a gated tool returns, and its headless denial.
33- :func:`blast_radius_banner` — the F68 ``permissive`` account-compromise banner.
34
35Contracts
36---------
37- The decision state lives here (a session-scoped trusted-source set + a one-shot
38 ``ContextVar`` grant the model cannot reach); the tools (``web.py``) ask
39 :func:`external_fetch_allowed` and, when blocked, return
40 :func:`make_external_gate_block`; the react-loop relays the three-choice prompt
41 and, on approval, re-runs the tool under :func:`allow_external_source_once` or
42 after :func:`trust_source_for_session`.
43- A one-shot grant is reset on context exit and is never recorded as session trust.
44- :func:`blast_radius_banner` (AC-V09-6) returns the banner ONLY when the active
45 profile is ``permissive`` AND the gate is active; otherwise ``None``.
46
47Dependencies
48------------
49- oc_diagnostics (structured debug logging via _dbg)
50- oc_chat.core.risk_profile (lazy; banner profile resolution)
51- oc_chat.tools._local_fetch (lazy; local host set that makes a local source's trust key carry its port)
52
53Diagnostics
54-----------
55Domain: OC_CHAT.CORE
56Levels:
57 L1 — gate disablement (a risk signal)
58 L2 — trust grants / lifecycle events
59 L3 — per-decision detail
60 L4 — deep tracing
61"""
62from __future__ import annotations
63
64import contextlib
65import contextvars
66import ipaddress
67import os
68import uuid
69from collections.abc import Iterator
70from dataclasses import dataclass
71from urllib.parse import urlsplit
72
73from oc_diagnostics import _dbg
74
75#: Out-of-band disable knob (process env only; stripped from a workspace ``.env``
76#: by :mod:`oc_chat._env_guard`). Any of these values turns the gate OFF.
77GATE_ENV_VAR = "OC_CHAT_EXTERNAL_CONTENT_GATE"
78_DISABLE_VALUES = frozenset({"0", "off", "false", "no", "disabled"})
79
80#: Sources the operator has trusted for THIS session (process-lifetime; reset on a
81#: new/cleared session). Host-granularity, exact match — no implicit subdomain share.
82_SESSION_TRUSTED_SOURCES: set[str] = set()
83
84#: One-shot "allow once" grant the react-loop sets around a single re-dispatch.
85#: A ``ContextVar`` (not a parameter/env) so the model cannot forge it.
86_allow_once_source: contextvars.ContextVar[str | None] = contextvars.ContextVar(
87 "oc_chat_allow_external_source_once", default=None
88)
89
90_disable_logged = False
91
92#: Trust key of the session bound on the most recent turn (F67 session-scope).
93#: ``("sid", <canonical uuid4>)`` for a session carrying a validated canonical
94#: ``session_id``; ``("legacy", id, name)`` for a legacy double that lacks one
95#: (plan 152 D9 — the explicitly-scoped compatibility fallback).
96_active_session_key: tuple | None = None
97
98
99#: In-process ``session_id -> canonical_record_locator`` binding (plan 152 R11-10).
100#: Seeded at EVERY activation (startup, resume, new, load, switch) and updated on
101#: save-as/rename. Presenting an id from a DIFFERENT record than the one that owns
102#: it in this process means the presenting record is not canonical.
103_id_to_locator: dict[str, str] = {}
104
105
106def seed_record_binding(session_id: object, locator: object) -> None:
107 """Bind *session_id* to the record it was activated from.
108
109 Called at every activation. Without seeding, a process that starts on a
110 COPIED record B(X), establishes trust, and then live-loads the original
111 A(X) would accept A as X without rotating trust — the "a fresh process
112 starts empty" argument stops applying the moment trust precedes the first
113 live load.
114 """
115 if isinstance(session_id, str) and session_id and isinstance(locator, str) and locator:
116 _id_to_locator[session_id] = locator
117
118
119def is_canonical_record(session_id: object, locator: object) -> bool:
120 """True if *locator* is the record this process already binds *session_id* to.
121
122 An id never seen before is accepted (and should then be seeded) — the rule
123 detects a SECOND record presenting an id this process already owns, which is
124 the reachable trust-inheritance schedule. A same-record reload is NOT a
125 duplicate, because the locator is the session directory rather than the
126 per-save stamped meta filename.
127
128 Residual, stated honestly: a cross-RUN whole-directory clone with an edited
129 name is indistinguishable without a global uniqueness index. That closes
130 when the sessions store makes ``session_id`` a primary key.
131 """
132 if not isinstance(session_id, str) or not session_id:
133 return False
134 known = _id_to_locator.get(session_id)
135 if known is None:
136 return True # first sighting in this process
137 return isinstance(locator, str) and locator == known
138
139
141 """Test-only: clear the in-process id->locator bindings."""
142 _id_to_locator.clear()
143
144
145@dataclass(frozen=True)
147 """A prebuilt ``session_id -> canonical_record_locator`` mapping (plan 152-b D4).
148
149 :func:`seed_record_binding` mutates the process-global ``_id_to_locator``
150 IMMEDIATELY, which is a live effect a transition's PREPARE phase must not
151 perform: on abort it would leave the D9 record-ownership map polluted, and
152 the landed :class:`PreparedF67Binding` carries no locator to install it with.
153
154 The load/switch seam therefore prepares this value in PREPARE (pure, zero
155 global effect) and installs it via :func:`install_prepared_record_binding`
156 as ONE no-throw step inside the commit critical section, immediately before
157 identity publication — so ``_id_to_locator`` joins the all-or-nothing
158 old/new tuple oracle instead of being written ahead of the commit.
159 """
160
161 session_id: str
162 locator: str
163
164
165def prepare_record_binding(session_id: object, locator: object) -> "PreparedRecordBinding | None":
166 """Build the :class:`PreparedRecordBinding` for a load/switch candidate — PREPARE half.
167
168 Mutates nothing. Returns ``None`` when either input is not a usable string,
169 mirroring :func:`seed_record_binding`'s own guard, so a ``None`` result is a
170 silent no-op at commit exactly as an unusable ``seed_record_binding`` call is.
171 """
172 if isinstance(session_id, str) and session_id and isinstance(locator, str) and locator:
173 return PreparedRecordBinding(session_id=session_id, locator=locator)
174 return None
175
176
177def install_prepared_record_binding(binding: "PreparedRecordBinding") -> None:
178 """Publish a prepared record binding — COMMIT half. CANNOT RAISE.
179
180 A single dict assignment on the process-global ``_id_to_locator`` — no
181 validation, no allocation, no logging — so it is safe inside the no-fail
182 commit block. The validation already happened in :func:`prepare_record_binding`.
183 """
184 _id_to_locator[binding.session_id] = binding.locator
185
186
187def is_canonical_session_id(raw: object) -> bool:
188 """True iff *raw* is EXACTLY the canonical lowercase RFC form of a uuid4.
189
190 The pinned predicate (plan 152 D9 / R09-05), with NO pre-parse
191 normalisation::
192
193 parsed = uuid.UUID(raw); accept = (parsed.version == 4 and raw == str(parsed))
194
195 Because ``str(parsed)`` is the canonical lowercase 8-4-4-4-12 form, this
196 accepts only that exact spelling and REJECTS uppercase, outer whitespace,
197 braces, ``urn:uuid:``, compact hex, and every non-v4 version. A ``.lower()``
198 or ``.strip()`` anywhere in here would silently accept forms the acceptance
199 criteria require to be rejected.
200
201 A trust key derived from an unvalidated string is not a complete gate, so
202 this is the single definition both the gate and the persistence boundary
203 (:func:`oc_chat.core.session.apply_session_metadata_fields`) share.
204 """
205 if not isinstance(raw, str):
206 return False
207 try:
208 parsed = uuid.UUID(raw)
209 except Exception:
210 return False # malformed / non-parseable -> not canonical
211 return parsed.version == 4 and raw == str(parsed)
212
213
214def session_trust_key(session: object) -> tuple:
215 """Derive the F67 trust key for *session* (plan 152 D9).
216
217 Keyed on the validated canonical ``session_id`` so that identity — not
218 object address and not display name — decides trust scope:
219
220 - a RENAME preserves the id, so trust is RETAINED (same identity);
221 - a load/switch to a DIFFERENT id rotates the key even when the object
222 address and name are unchanged (the pre-fix leak);
223 - a session lacking a canonical id falls back to the legacy ``(id, name)``
224 shape. That fallback exists for test doubles only; production sessions
225 always carry a canonical id because the persistence boundary regenerates
226 one on a malformed or missing value.
227 """
228 sid = getattr(session, "session_id", None)
230 return ("sid", sid)
231 return ("legacy", id(session), getattr(session, "name", None))
232
233
234@dataclass(frozen=True)
236 """A fully-built F67 trust rotation, ready for a no-throw install (R10-01).
237
238 Everything fallible or allocating — reading the candidate's id, validating
239 it, deriving the key, deciding whether trust rotates — happens when this
240 value is constructed during a transition's PREPARE phase, so
241 :func:`install_prepared_session_binding` can be a bare pair of in-memory
242 mutations inside the no-fail commit block.
243 """
244
245 #: The key to publish (already validated).
246 key: tuple
247 #: Whether installing this binding clears the session trust set.
248 clears_trust: bool
249
250
251def prepare_session_binding(session: object) -> PreparedF67Binding:
252 """Build the :class:`PreparedF67Binding` for *session* — PREPARE half.
253
254 Does the validation, the key derivation and the rotate-or-retain decision
255 up front. Safe to call outside a transition; it mutates nothing.
256 """
257 key = session_trust_key(session)
258 return PreparedF67Binding(key=key, clears_trust=key != _active_session_key)
259
260
261def install_prepared_session_binding(binding: PreparedF67Binding) -> None:
262 """Publish a prepared trust rotation — COMMIT half. CANNOT RAISE.
263
264 Deliberately contains ONLY in-memory mutations: no validation, no logging,
265 no formatting, no allocation. The previous shape called
266 :func:`bind_active_session`, which branches and calls ``_dbg`` — and ``_dbg``
267 emits a ``warnings.warn`` before diagnostics are initialised, which RAISES
268 under warnings-as-errors. That made trust rotation fallible and bundled it
269 with identity publication; this split is what lets the commit block install
270 trust BEFORE identity with no window where the new identity carries old
271 trust (plan 152 R10-01).
272
273 The lifecycle diagnostic is deferred to
274 :func:`log_session_binding_lifecycle`, called post-transaction, best-effort.
275 """
276 global _active_session_key
277 _active_session_key = binding.key
278 if binding.clears_trust:
279 _SESSION_TRUSTED_SOURCES.clear()
280
281
282def log_session_binding_lifecycle(binding: PreparedF67Binding) -> None:
283 """Emit the F67 rotation diagnostic AFTER the transaction (best-effort).
284
285 Separated from :func:`install_prepared_session_binding` so that nothing
286 inside the transition's critical section can raise. Callers run this in the
287 post-transaction best-effort step and contain its failure.
288 """
289 func = "log_session_binding_lifecycle"
290 if binding.clears_trust:
291 _dbg("OC_CHAT.CORE", func, "active session changed -> external trust reset", 2)
292
293
294def gate_enabled() -> bool:
295 """True unless the gate is disabled out-of-band (logged once as a risk)."""
296 func = "gate_enabled"
297 global _disable_logged
298 raw = os.environ.get(GATE_ENV_VAR, "").strip().lower()
299 if raw in _DISABLE_VALUES:
300 if not _disable_logged:
301 _dbg("OC_CHAT.CORE", func,
302 f"external-content gate DISABLED via {GATE_ENV_VAR}={raw!r} "
303 f"(F67 off — fetched content is no longer confirm-gated)", 1, override=True)
304 _disable_logged = True
305 return False
306 return True
307
308
309def normalize_source(target: str) -> str:
310 """Canonical trust key for *target* (a URL or a bare host).
311
312 Uses ``urlsplit().hostname`` (already lowercased, trailing-dot-stripped,
313 punycode-decoded, IPv6 unbracketed; userinfo excluded) and compresses an IP
314 literal via :mod:`ipaddress`. Exact-host granularity: ``trust example.com``
315 grants neither ``sub.example.com`` nor a parent domain.
316
317 **Plan 232 P7 — a LOCAL source's key carries its port** (``localhost:8000``,
318 ``[::1]:8000``). On the public internet a host IS the trust boundary and the
319 port is noise, which is why every non-local key stays host-only and unchanged.
320 On loopback the opposite holds: one port is a dev server the human chose to
321 expose, while the others are whatever else that developer happens to be
322 running — a database admin console, another project's API, an SSH forward. So
323 "trust this source for the session" must mean one dev server, not the whole
324 loopback surface. The local host set is imported from the local-fetch module
325 rather than restated here, so the set that ADMITS a local fetch and the set
326 that KEYS its trust can never drift apart. A local URL with no explicit port
327 keeps the bare-host key: it is never admitted by the fetch rule anyway, and a
328 bare key can only ever fail to match a port-qualified grant, never widen one.
329 """
330 s = (target or "").strip()
331 parts = urlsplit(s if "://" in s else "//" + s, scheme="https")
332 host = (parts.hostname or "").strip().lower().rstrip(".")
333 if not host:
334 return s.lower() # malformed — egress will reject it anyway
335 try:
336 host = ipaddress.ip_address(host).compressed
337 except ValueError:
338 pass # not an IP literal — keep the DNS name
339 # Lazy import: `_local_fetch` pulls in nothing from oc_chat at module level,
340 # so this cannot cycle back through core.
341 from oc_chat.tools._local_fetch import LOCAL_HOSTS
342 if host in LOCAL_HOSTS:
343 try:
344 port = parts.port
345 except ValueError:
346 port = None # malformed port — fall back to the bare host key
347 if port is not None:
348 return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
349 return host
350
351
352def is_source_trusted(source: str) -> bool:
353 """True if *source* was trusted for the session (or one-shot-granted now)."""
354 return source in _SESSION_TRUSTED_SOURCES or _allow_once_source.get() == source
355
356
357def external_fetch_allowed(source: str) -> bool:
358 """The gate decision: allowed if the gate is off, or *source* is trusted/granted."""
359 if not gate_enabled():
360 return True
361 return is_source_trusted(source)
362
363
364def trust_source_for_session(source: str) -> None:
365 """Trust *source* for the rest of this session (skips later prompts)."""
366 func = "trust_source_for_session"
367 _SESSION_TRUSTED_SOURCES.add(source)
368 _dbg("OC_CHAT.CORE", func, f"session-trusted external source: {source!r}", 2)
369
370
372 """Clear all session-trusted sources (call at session start / new / clear)."""
373 _SESSION_TRUSTED_SOURCES.clear()
374
375
376def bind_active_session(session: object) -> None:
377 """Reset the trust set when the active session changes (F67 session-scope).
378
379 Called once per turn by the react-loop. Keyed on the VALIDATED CANONICAL
380 ``session_id`` (plan 152 D9) via :func:`session_trust_key`, so a new /
381 loaded / switched / freshly-created session starts with an empty trust set —
382 a source trusted in one session never carries into another, no matter which
383 transition path created it. Keying on identity rather than ``(object id,
384 name)`` closes two defects of the old key: a same-object/same-name load
385 carrying a DIFFERENT persisted id used to keep its trust set (a leak), and a
386 RENAME used to reset trust although identity was preserved.
387
388 (An in-place ``/session clear`` keeps the same session;
389 :meth:`ChatSession.clear` resets trust for that case.)
390
391 Replacement transitions no longer depend on this path: they install the
392 rotation explicitly at commit via :func:`install_prepared_session_binding`,
393 before publishing the new identity. This remains the per-turn safety net.
394 """
395 func = "bind_active_session"
396 global _active_session_key
397 key = session_trust_key(session)
398 if key != _active_session_key:
399 if _SESSION_TRUSTED_SOURCES:
400 _dbg("OC_CHAT.CORE", func, "active session changed -> external trust reset", 2)
401 _SESSION_TRUSTED_SOURCES.clear()
402 _active_session_key = key
403
404
405@contextlib.contextmanager
406def allow_external_source_once(source: str) -> Iterator[None]:
407 """Authorize *source* for exactly one re-dispatch (allow-once).
408
409 For use ONLY by the react-loop relay after an interactive 'a'. Not model-
410 reachable (a ``ContextVar``, not a tool argument); reset on exit so the grant
411 never outlives the single re-run it brackets and is not session-trust.
412 """
413 token = _allow_once_source.set(source)
414 try:
415 yield
416 finally:
417 _allow_once_source.reset(token)
418
419
420def make_external_gate_block(tool: str, source: str, detail: str) -> dict:
421 """The ``requires_confirmation`` signal a gated tool returns (relayed by the
422 react-loop; failed closed by the headless sub-loop)."""
423 return {
424 "requires_confirmation": True,
425 "gate": "external_content",
426 "tool": tool,
427 "source": source,
428 "detail": detail,
429 "message": (
430 f"{tool} would pull EXTERNAL, untrusted content from '{source}'"
431 + (f" ({detail})" if detail else "")
432 + ". External content can carry injected instructions. A human must "
433 "approve it: allow once, trust this source for the session, or deny."
434 ),
435 }
436
437
438def deny_external_fetch(result: dict, *, note: str = "") -> dict:
439 """Convert a gate block into a terminal ``error`` for contexts with no human
440 (headless / sub-loop / declined). Any other result passes through unchanged."""
441 if isinstance(result, dict) and result.get("requires_confirmation"):
442 msg = result.get("message") or "External-content fetch requires human approval."
443 return {"error": (msg + " " + note).strip()}
444 return result
445
446
447def blast_radius_banner() -> str | None:
448 """The F68 ``permissive`` account-compromise banner, or ``None`` (AC-V09-6).
449
450 Present ONLY when the active profile is ``permissive`` AND the F67 gate is
451 active (a disabled gate removes the mitigation the banner is conditioned on).
452 """
453 if not gate_enabled():
454 return None
455 from oc_chat.core.risk_profile import resolve_risk_profile
456 if resolve_risk_profile() != "permissive":
457 return None
458 return (
459 "permissive profile: the agent runs in-process with your account's "
460 "authority. A weaponized in-process code-execution (e.g. via injected "
461 "content) can reach your full developer-account authority and exfiltrate "
462 "secrets — including through the provider-egress transcript channel. The "
463 "external-content confirmation gate (F67) is active; deny fetches you did "
464 "not intend. Use the 'guarded' profile for less-trusted projects."
465 )
466
467
468__all__ = [
469 "GATE_ENV_VAR",
470 "gate_enabled",
471 "normalize_source",
472 "is_source_trusted",
473 "external_fetch_allowed",
474 "trust_source_for_session",
475 "reset_session_trust",
476 "bind_active_session",
477 "allow_external_source_once",
478 "make_external_gate_block",
479 "deny_external_fetch",
480 "blast_radius_banner",
481]
dict make_external_gate_block(str tool, str source, str detail)
None seed_record_binding(object session_id, object locator)
Iterator[None] allow_external_source_once(str source)
None log_session_binding_lifecycle(PreparedF67Binding binding)
bool is_canonical_record(object session_id, object locator)
"PreparedRecordBinding | None" prepare_record_binding(object session_id, object locator)
dict deny_external_fetch(dict result, *, str note="")
PreparedF67Binding prepare_session_binding(object session)
None install_prepared_record_binding("PreparedRecordBinding" binding)
None install_prepared_session_binding(PreparedF67Binding binding)