oc_chat
Loading...
Searching...
No Matches
deployment_shape.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/deployment_shape.py
4
5"""
6The `guarded` deployment-shape invariant (M8.0 WU-7; Appendix C G-1/G-7).
7
8Purpose
9-------
10Under the ``guarded`` / ``isolated`` profiles the harness must **fail closed at
11startup and on every effective-root change** if the agent's write sandbox overlaps
12any of the harness's *importable roots*: a ``write_file`` into an importable tree
13followed by an ``import`` is an in-process code-execution path (F57), and the
14contract packages are additionally the ``canonical_json`` MAC preimage (F74). The
15invariant closes both **by topology** — if the importable tree is outside the write
16sandbox, a write can never become an in-process import. This is what makes the T2
17(untrusted *target* project) posture safe without the cross-process broker; it is
18the keystone of the ``guarded`` profile.
19
20``permissive`` is a no-op: in-tree monorepo dogfooding intentionally keeps the
21harness and the sandbox in one tree, accepting the in-process-ACE residual (§6).
22
23Responsibilities
24----------------
25- :func:`enumerate_importable_roots` — the resolved importable-root set, derived
26 from **loaded** ``sys.modules`` (the harness + contract packages) plus the active
27 site-packages / per-user site and a hardcoded repo-root fallback — never a literal
28 list (AC-G-1).
29- :func:`check_deployment_shape` — under ``guarded`` / ``isolated``, raise
30 :class:`DeploymentShapeError` if any importable root is equal / ancestor /
31 descendant of the sandbox (compared on real ``resolve()``-d paths).
32
33Contracts
34---------
35- ``permissive`` ⇒ no-op; ``guarded`` / ``isolated`` ⇒ enforced.
36- Overlap is symmetric: equal, sandbox-inside-root, and root-inside-sandbox all
37 fail closed. Paths are resolved (``..`` / symlinks / editable finders) first.
38- The importable-root set reflects ``sys.modules`` at call time, so an unloaded
39 package contributes nothing and the set follows the real deployment.
40- G-7 follows by topology: a sandbox disjoint from every importable root is not
41 importable, so a target ``conftest.py`` cannot be collected in this process.
42
43Dependencies
44------------
45- oc_diagnostics (structured debug logging via _dbg)
46
47Diagnostics
48-----------
49Domain: OC_CHAT.CORE
50Levels:
51 L1 — fail-closed refusal (a startup/root-change block)
52 L2 — lifecycle (check entry / outcome)
53 L3 — the enumerated roots / per-root comparison
54 L4 — deep tracing
55"""
56from __future__ import annotations
57
58import site
59import sys
60from pathlib import Path
61
62from oc_diagnostics import _dbg
63
64#: The harness + contract importable packages. Only those actually present in
65#: ``sys.modules`` contribute a root (the set is module-derived, not a literal list).
66_IMPORTABLE_PACKAGES: tuple[str, ...] = (
67 "oc_chat", "oc_ui", "providers", "oc_diagnostics", "oct",
68 "option_c_agent_contracts", "option_c_registry_contracts",
69)
70
71#: Profiles for which the invariant is enforced (permissive is in-tree dogfooding).
72_ENFORCING_PROFILES: frozenset[str] = frozenset({"guarded", "isolated"})
73
74
75class DeploymentShapeError(RuntimeError):
76 """Raised when the write sandbox overlaps a harness importable root (G-1)."""
77
78
79def _module_dirs(module: object) -> list[Path]:
80 """Resolved directories a module is importable from (``__file__`` parent +
81 every ``__path__`` entry, for regular and namespace packages)."""
82 dirs: list[Path] = []
83 f = getattr(module, "__file__", None)
84 if f:
85 try:
86 dirs.append(Path(f).resolve(strict=False).parent)
87 except (OSError, ValueError):
88 pass
89 for entry in getattr(module, "__path__", None) or []:
90 try:
91 dirs.append(Path(entry).resolve(strict=False))
92 except (OSError, ValueError, TypeError):
93 pass
94 return dirs
95
96
98 """Import the importable packages so a namespace/contract package that would
99 otherwise load *lazily during agent construction* is in ``sys.modules`` at
100 enumeration time.
101
102 Without this, a PEP-420 namespace contract package (``option_c_agent_contracts``
103 / ``option_c_registry_contracts`` — in-tree, not under site-packages) is absent
104 from ``sys.modules`` at the startup check, so a sandbox overlapping it would pass
105 and the package would load afterward (AC-V11-3). Already-loaded names are a fast
106 no-op; an unavailable one is ignored (the set still follows the real deployment).
107 """
108 import importlib
109 for name in _IMPORTABLE_PACKAGES:
110 if name in sys.modules:
111 continue
112 try:
113 importlib.import_module(name)
114 except Exception:
115 pass # optional / not present in this deployment
116
117
118def enumerate_importable_roots() -> set[Path]:
119 """Return the resolved importable roots derived from loaded ``sys.modules``.
120
121 For each harness/contract package (force-loaded if not yet imported), its package
122 directory(ies); plus the active ``site-packages`` / per-user site; plus a
123 hardcoded repo-root fallback (``oc_chat``'s parent) in case an editable finder hid
124 the sys.path entry.
125 """
127 roots: set[Path] = set()
128 for name in _IMPORTABLE_PACKAGES:
129 module = sys.modules.get(name)
130 if module is not None:
131 roots.update(_module_dirs(module))
132 # Site dirs: a write landing here would also be importable.
133 site_dirs: list[str] = []
134 try:
135 site_dirs.extend(site.getsitepackages())
136 except Exception: # virtualenv variants may lack getsitepackages
137 pass
138 try:
139 user_site = site.getusersitepackages()
140 if user_site:
141 site_dirs.append(user_site)
142 except (AttributeError, Exception):
143 pass
144 for d in site_dirs:
145 try:
146 roots.add(Path(d).resolve(strict=False))
147 except (OSError, ValueError):
148 pass
149 # Hardcoded fallback: the harness repo root (oc_chat's parent).
150 oc_chat_mod = sys.modules.get("oc_chat")
151 f = getattr(oc_chat_mod, "__file__", None)
152 if f:
153 try:
154 roots.add(Path(f).resolve(strict=False).parent.parent)
155 except (OSError, ValueError):
156 pass
157 return roots
158
159
160def _overlaps(a: Path, b: Path) -> bool:
161 """True if *a* and *b* are equal, or one is an ancestor of the other (resolved)."""
162 a = a.resolve(strict=False)
163 b = b.resolve(strict=False)
164 return a == b or a.is_relative_to(b) or b.is_relative_to(a)
165
166
168 sandbox_root: str | Path,
169 profile: str,
170 *,
171 importable_roots: "set[Path] | None" = None,
172) -> None:
173 """Enforce the G-1 invariant for *profile*; raise on overlap (no-op otherwise).
174
175 Under ``guarded`` / ``isolated``, raise :class:`DeploymentShapeError` if any
176 importable root is equal / ancestor / descendant of *sandbox_root*. Under any
177 other profile (``permissive``), return without checking.
178 """
179 func = "check_deployment_shape"
180 if profile == "permissive":
181 return # in-tree dogfooding is the only no-op (§6)
182 if profile not in _ENFORCING_PROFILES:
183 # An unexpected / unrecognized profile is enforced, never silently skipped
184 # (fail-closed). Real callers pass a resolve_risk_profile() value.
185 _dbg("OC_CHAT.CORE", func,
186 f"unexpected profile {profile!r}; enforcing deployment-shape fail-closed",
187 1, override=True)
188 sandbox = Path(sandbox_root).resolve(strict=False)
189 roots = importable_roots if importable_roots is not None else enumerate_importable_roots()
190 overlapping = sorted({str(r.resolve(strict=False)) for r in roots if _overlaps(r, sandbox)})
191 if overlapping:
192 _dbg("OC_CHAT.CORE", func,
193 f"{profile} deployment-shape REFUSED: sandbox {sandbox} overlaps "
194 f"importable root(s) {overlapping}", 1, override=True)
196 f"the '{profile}' profile refuses this deployment shape: the write "
197 f"sandbox '{sandbox}' overlaps harness importable root(s) "
198 f"{', '.join(overlapping)} — a write-then-import would be an in-process "
199 f"code-execution path. Move the sandbox outside the harness install tree, "
200 f"or use the 'permissive' profile for in-tree dogfooding."
201 )
202 _dbg("OC_CHAT.CORE", func, f"{profile} deployment-shape OK for sandbox {sandbox}", 2)
203
204
205__all__ = [
206 "DeploymentShapeError",
207 "enumerate_importable_roots",
208 "check_deployment_shape",
209]
None check_deployment_shape(str|Path sandbox_root, str profile, *, "set[Path] | None" importable_roots=None)
list[Path] _module_dirs(object module)