6External-content confirmation gate (M8.0 WU-8; §7.3, F67 / F68).
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.
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.
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.
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``.
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)
57 L1 — gate disablement (a risk signal)
58 L2 — trust grants / lifecycle events
59 L3 — per-decision detail
62from __future__
import annotations
69from collections.abc
import Iterator
70from dataclasses
import dataclass
71from urllib.parse
import urlsplit
73from oc_diagnostics
import _dbg
77GATE_ENV_VAR =
"OC_CHAT_EXTERNAL_CONTENT_GATE"
78_DISABLE_VALUES = frozenset({
"0",
"off",
"false",
"no",
"disabled"})
82_SESSION_TRUSTED_SOURCES: set[str] = set()
86_allow_once_source: contextvars.ContextVar[str |
None] = contextvars.ContextVar(
87 "oc_chat_allow_external_source_once", default=
None
90_disable_logged =
False
96_active_session_key: tuple |
None =
None
103_id_to_locator: dict[str, str] = {}
107 """Bind *session_id* to the record it was activated from.
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
115 if isinstance(session_id, str)
and session_id
and isinstance(locator, str)
and locator:
116 _id_to_locator[session_id] = locator
120 """True if *locator* is the record this process already binds *session_id* to.
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.
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.
132 if not isinstance(session_id, str)
or not session_id:
134 known = _id_to_locator.get(session_id)
137 return isinstance(locator, str)
and locator == known
141 """Test-only: clear the in-process id->locator bindings."""
142 _id_to_locator.clear()
145@dataclass(frozen=True)
147 """A prebuilt ``session_id -> canonical_record_locator`` mapping (plan 152-b D4).
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.
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.
166 """Build the :class:`PreparedRecordBinding` for a load/switch candidate — PREPARE half.
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.
172 if isinstance(session_id, str)
and session_id
and isinstance(locator, str)
and locator:
178 """Publish a prepared record binding — COMMIT half. CANNOT RAISE.
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`.
184 _id_to_locator[binding.session_id] = binding.locator
188 """True iff *raw* is EXACTLY the canonical lowercase RFC form of a uuid4.
190 The pinned predicate (plan 152 D9 / R09-05), with NO pre-parse
193 parsed = uuid.UUID(raw); accept = (parsed.version == 4 and raw == str(parsed))
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.
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.
205 if not isinstance(raw, str):
208 parsed = uuid.UUID(raw)
211 return parsed.version == 4
and raw == str(parsed)
215 """Derive the F67 trust key for *session* (plan 152 D9).
217 Keyed on the validated canonical ``session_id`` so that identity — not
218 object address and not display name — decides trust scope:
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.
228 sid = getattr(session,
"session_id",
None)
231 return (
"legacy", id(session), getattr(session,
"name",
None))
234@dataclass(frozen=True)
236 """A fully-built F67 trust rotation, ready for a no-throw install (R10-01).
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.
252 """Build the :class:`PreparedF67Binding` for *session* — PREPARE half.
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.
262 """Publish a prepared trust rotation — COMMIT half. CANNOT RAISE.
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).
273 The lifecycle diagnostic is deferred to
274 :func:`log_session_binding_lifecycle`, called post-transaction, best-effort.
276 global _active_session_key
277 _active_session_key = binding.key
278 if binding.clears_trust:
279 _SESSION_TRUSTED_SOURCES.clear()
283 """Emit the F67 rotation diagnostic AFTER the transaction (best-effort).
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.
289 func =
"log_session_binding_lifecycle"
290 if binding.clears_trust:
291 _dbg(
"OC_CHAT.CORE", func,
"active session changed -> external trust reset", 2)
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
310 """Canonical trust key for *target* (a URL or a bare host).
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.
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.
330 s = (target
or "").strip()
331 parts = urlsplit(s
if "://" in s
else "//" + s, scheme=
"https")
332 host = (parts.hostname
or "").strip().lower().rstrip(
".")
336 host = ipaddress.ip_address(host).compressed
342 if host
in LOCAL_HOSTS:
348 return f
"[{host}]:{port}" if ":" in host
else f
"{host}:{port}"
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
358 """The gate decision: allowed if the gate is off, or *source* is trusted/granted."""
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)
372 """Clear all session-trusted sources (call at session start / new / clear)."""
373 _SESSION_TRUSTED_SOURCES.clear()
377 """Reset the trust set when the active session changes (F67 session-scope).
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.
388 (An in-place ``/session clear`` keeps the same session;
389 :meth:`ChatSession.clear` resets trust for that case.)
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.
395 func =
"bind_active_session"
396 global _active_session_key
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
405@contextlib.contextmanager
407 """Authorize *source* for exactly one re-dispatch (allow-once).
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.
413 token = _allow_once_source.set(source)
417 _allow_once_source.reset(token)
421 """The ``requires_confirmation`` signal a gated tool returns (relayed by the
422 react-loop; failed closed by the headless sub-loop)."""
424 "requires_confirmation":
True,
425 "gate":
"external_content",
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."
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()}
448 """The F68 ``permissive`` account-compromise banner, or ``None`` (AC-V09-6).
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).
456 if resolve_risk_profile() !=
"permissive":
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."
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",
dict make_external_gate_block(str tool, str source, str detail)
bool is_canonical_session_id(object raw)
None seed_record_binding(object session_id, object locator)
Iterator[None] allow_external_source_once(str source)
None _reset_record_bindings_for_tests()
None bind_active_session(object session)
None log_session_binding_lifecycle(PreparedF67Binding binding)
None reset_session_trust()
bool is_canonical_record(object session_id, object locator)
None trust_source_for_session(str source)
"PreparedRecordBinding | None" prepare_record_binding(object session_id, object locator)
dict deny_external_fetch(dict result, *, str note="")
str|None blast_radius_banner()
PreparedF67Binding prepare_session_binding(object session)
None install_prepared_record_binding("PreparedRecordBinding" binding)
bool external_fetch_allowed(str source)
str normalize_source(str target)
tuple session_trust_key(object session)
bool is_source_trusted(str source)
None install_prepared_session_binding(PreparedF67Binding binding)