oc_chat
Loading...
Searching...
No Matches
tier2_session_grant.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/tier2_session_grant.py
4
5"""
6Session-long Tier-2 grant for python scripts under the work directory (SHELL-02).
7
8Purpose
9-------
10A Tier-2 ``run_command`` is authorised by an out-of-band human ``y``, and that
11``y`` covers ONE exact command string ONCE (``shell.human_confirmed_command``).
12An agent iterating on a probe script it just wrote therefore raises the same
13prompt on every run. The operator, as the M8.1 lock authority, ruled on
142026-09-04 for a DIRECTORY-scoped, session-long grant and explicitly AGAINST a
15path-bound one: the agent authors the file it runs, so a path grant is a grant on
16any content, and agents rename probe files anyway.
17
18Stated plainly, as the operator accepted it: for the rest of the session the
19agent can run any Python it writes under ONE directory without a keystroke. Under
20``permissive`` that sits beside the already-accepted in-process residual; it is
21refused outright under ``guarded``/``isolated``. Everything in this module exists
22to keep that reach exactly that wide, visible, and revocable.
23
24What a grant does NOT do
25------------------------
26It never becomes authority of its own. A grant-satisfied call is re-dispatched
27under :func:`oc_chat.tools.shell.human_confirmed_command` exactly as an approved
28``y`` is, so the ContextVar exact-string contract stays the single authorisation
29seam. This module only decides whether the human is ASKED.
30
31Responsibilities
32----------------
33- :func:`active_session_grant` / :func:`grant_work_dir_for_session` /
34 :func:`revoke_session_grants` — the process-lifetime grant state.
35- :func:`resolve_work_dir` — the grantable directory for a sandbox root
36 (``<sandbox_root>/<project_tree.work_dir>``, resolved strictly).
37- :func:`session_grant_satisfies` — whether an ACTIVE grant covers this argv.
38- :func:`grantable_work_dir` — the directory a grant WOULD cover for this argv,
39 i.e. whether the prompt may offer the third key at all.
40
41Contracts
42---------
43- The grant is a resolved DIRECTORY, never a path and never a pattern. It is
44 stored resolved and compared with ``Path.relative_to`` on resolved paths, so a
45 symlink or junction pointing out of the directory is not covered.
46- A command qualifies only when ALL hold: ``argv[0]`` is python/python3/py;
47 ``argv[1]`` is a path (never an option) resolving strictly to a regular ``.py``
48 file inside the granted directory; the granted directory is still inside the
49 effective sandbox root; and the resolved risk profile is ``permissive``.
50 Trailing tokens are the script's own argv and add no reach.
51- The TTY precondition (§P2) is enforced by the CALLER: the react-loop relay
52 fails closed on a missing TTY before it ever consults this module.
53- State is process-lifetime and is NEVER persisted to disk or session metadata;
54 a fresh process starts with no grant. It is cleared at both seams where F67's
55 ``reset_session_trust`` is called.
56
57Dependencies
58------------
59- oc_diagnostics (structured debug logging via _dbg)
60- oc_chat.core.risk_profile (lazy; the permissive-only rule)
61- oc_chat.core.settings (lazy; the ``work_dir`` name)
62- oc_chat.tools._sandbox (lazy; the sandbox containment check)
63
64Diagnostics
65-----------
66Domain: OC_CHAT.CORE
67Levels:
68 L1 — never (a grant is an operator decision, not a fault)
69 L2 — grant / revoke lifecycle events
70 L3 — per-decision detail
71 L4 — deep tracing
72"""
73from __future__ import annotations
74
75from collections.abc import Sequence
76from pathlib import Path
77
78from oc_diagnostics import _dbg
79
80#: The interpreter basenames a grant may cover. ``pytest``/``oct``/``pip`` are
81#: Tier-2 too and are deliberately absent: the grant covers code the agent WROTE
82#: in the granted directory, not arbitrary tooling.
83PYTHON_PROGRAMS: frozenset[str] = frozenset({"python", "python3", "py"})
84
85#: Fallback when settings cannot be read; matches ``DEFAULT_SETTINGS``.
86DEFAULT_WORK_DIR = "_work"
87
88#: The directory granted for this process, or ``None``. Never persisted.
89_SESSION_GRANT_DIR: Path | None = None
90
91
92def active_session_grant() -> "Path | None":
93 """The resolved directory granted for this session, or ``None``."""
94 return _SESSION_GRANT_DIR
95
96
97def grant_work_dir_for_session(resolved_dir: Path) -> None:
98 """Grant python-on-*resolved_dir* for the rest of this session.
99
100 *resolved_dir* must already be resolved and validated by the caller (the
101 react-loop offers the third key only for a directory :func:`resolve_work_dir`
102 produced), so this is a bare assignment plus its lifecycle diagnostic.
103 """
104 func = "grant_work_dir_for_session"
105 global _SESSION_GRANT_DIR
106 _SESSION_GRANT_DIR = resolved_dir
107 _dbg("OC_CHAT.CORE", func,
108 f"session Tier-2 python grant ACTIVE on {resolved_dir} — scripts there run "
109 f"without a keystroke until /grants revoke or the session ends", 2, override=True)
110
111
113 """Drop any active grant (``/grants revoke``, and both session-reset seams).
114
115 The state is cleared BEFORE the diagnostic, never after: ``_dbg`` raises under
116 warnings-as-errors if diagnostics are not initialised yet, and a grant that
117 survived its own revocation because a log line failed would be exactly the
118 kind of silent authority this module exists to prevent.
119 """
120 func = "revoke_session_grants"
121 global _SESSION_GRANT_DIR
122 revoked, _SESSION_GRANT_DIR = _SESSION_GRANT_DIR, None
123 if revoked is not None:
124 _dbg("OC_CHAT.CORE", func,
125 f"session Tier-2 python grant REVOKED (was {revoked})", 2, override=True)
126
127
128def _work_dir_name() -> str:
129 """The configured scratch-directory name (``project_tree.work_dir``)."""
130 func = "_work_dir_name"
131 try:
132 from oc_chat.core.settings import runtime_settings
133 name = str(runtime_settings().project_tree.get("work_dir") or "").strip()
134 except Exception as exc: # pragma: no cover - defensive
135 # EXTERNAL_ERROR (L2): settings are operator data; an unreadable file must
136 # narrow the grant to the documented default, never widen it.
137 _dbg("OC_CHAT.CORE", func,
138 f"EXTERNAL_ERROR: settings unreadable ({exc}); using {DEFAULT_WORK_DIR!r}", 2)
139 return DEFAULT_WORK_DIR
140 return name or DEFAULT_WORK_DIR
141
142
143def resolve_work_dir(sandbox_root: "str | Path") -> "Path | None":
144 """The grantable directory under *sandbox_root*, or ``None``.
145
146 ``<sandbox_root>/<project_tree.work_dir>`` resolved with ``strict=True`` and
147 checked with :func:`~oc_chat.tools._sandbox.require_inside_sandbox`. Returns
148 ``None`` when it does not exist, is not a directory, or resolves outside the
149 sandbox — in which case the third prompt key is never offered.
150 """
151 func = "resolve_work_dir"
152 from oc_chat.tools._sandbox import SandboxViolation, require_inside_sandbox
153
154 root = Path(sandbox_root)
155 try:
156 candidate = (root / _work_dir_name()).resolve(strict=True)
157 except (OSError, RuntimeError, ValueError) as exc:
158 _dbg("OC_CHAT.CORE", func, f"no grantable work dir under {root}: {exc}", 3)
159 return None
160 if not candidate.is_dir():
161 _dbg("OC_CHAT.CORE", func, f"work dir is not a directory: {candidate}", 3)
162 return None
163 try:
164 return require_inside_sandbox(candidate, root)
165 except SandboxViolation as exc:
166 _dbg("OC_CHAT.CORE", func, f"work dir outside the sandbox: {exc}", 3)
167 return None
168
169
170def _python_program(arg0: str) -> bool:
171 """True when *arg0*'s basename is a python interpreter (``.exe`` stripped)."""
172 name = Path(arg0).name.lower()
173 if name.endswith(".exe"):
174 name = name[:-4]
175 return name in PYTHON_PROGRAMS
176
177
178def _resolved_script(arg: str, sandbox_root: "str | Path") -> "Path | None":
179 """*arg* resolved strictly to a regular ``.py`` file, or ``None``.
180
181 An OPTION (anything starting with ``-``) is never a script: ``-c`` and ``-m``
182 are the two shapes that would turn a directory grant into arbitrary code
183 execution, so they are rejected on their leading dash rather than by name.
184 A relative path is joined to *sandbox_root*, which is the cwd
185 :func:`~oc_chat.tools.shell.run_command` executes in.
186 """
187 if not arg or arg.startswith("-"):
188 return None
189 candidate = Path(arg)
190 if not candidate.is_absolute():
191 candidate = Path(sandbox_root) / candidate
192 try:
193 resolved = candidate.resolve(strict=True)
194 except (OSError, RuntimeError, ValueError):
195 return None # missing / unresolvable
196 if resolved.suffix.lower() != ".py" or not resolved.is_file():
197 return None
198 return resolved
199
200
201def _qualifies(argv: "Sequence[str]", sandbox_root: "str | Path",
202 grant_dir: "Path | None") -> bool:
203 """Whether *argv* is ``python <script inside grant_dir>`` under ``permissive``.
204
205 The single predicate behind both :func:`session_grant_satisfies` (with the
206 ACTIVE grant) and :func:`grantable_work_dir` (with the candidate directory),
207 so what a grant would cover and what it does cover cannot drift apart.
208 """
209 func = "_qualifies"
210 if grant_dir is None or len(argv) < 2 or not _python_program(argv[0]):
211 return False
212
213 from oc_chat.core.risk_profile import resolve_risk_profile
214 if resolve_risk_profile() != "permissive":
215 _dbg("OC_CHAT.CORE", func, "not permissive — session grants are not consulted", 3)
216 return False
217
218 # The grant is pinned to the sandbox it was taken in: a later `/sandbox set`
219 # leaves the granted directory outside the new root, and it stops applying.
220 from oc_chat.tools._sandbox import SandboxViolation, require_inside_sandbox
221 try:
222 require_inside_sandbox(grant_dir, Path(sandbox_root))
223 except SandboxViolation:
224 _dbg("OC_CHAT.CORE", func, f"granted dir {grant_dir} is outside the active sandbox", 3)
225 return False
226
227 script = _resolved_script(argv[1], sandbox_root)
228 if script is None:
229 return False
230 try:
231 script.relative_to(grant_dir)
232 except ValueError:
233 _dbg("OC_CHAT.CORE", func, f"{script} resolves outside {grant_dir}", 3)
234 return False
235 return True
236
237
238def session_grant_satisfies(argv: "Sequence[str]", sandbox_root: "str | Path") -> bool:
239 """True when the ACTIVE grant covers *argv* (§P2).
240
241 Trailing tokens after the script path are the script's own argv and are not
242 examined: the agent authored the script, so its arguments add no reach.
243 """
244 func = "session_grant_satisfies"
245 satisfied = _qualifies(argv, sandbox_root, _SESSION_GRANT_DIR)
246 if satisfied:
247 _dbg("OC_CHAT.CORE", func,
248 f"session grant satisfies {list(argv)[:2]} under {_SESSION_GRANT_DIR}", 3)
249 return satisfied
250
251
252def grantable_work_dir(argv: "Sequence[str]", sandbox_root: "str | Path") -> "Path | None":
253 """The directory the third prompt key would grant for *argv*, or ``None``.
254
255 Returns a directory ONLY when *argv* would qualify under a grant on it, so
256 the prompt gains its third key exactly when pressing it would help.
257 """
258 candidate = resolve_work_dir(sandbox_root)
259 return candidate if _qualifies(argv, sandbox_root, candidate) else None
260
261
262__all__ = [
263 "PYTHON_PROGRAMS",
264 "DEFAULT_WORK_DIR",
265 "active_session_grant",
266 "grant_work_dir_for_session",
267 "revoke_session_grants",
268 "resolve_work_dir",
269 "session_grant_satisfies",
270 "grantable_work_dir",
271]
"Path | None" _resolved_script(str arg, "str | Path" sandbox_root)
"Path | None" grantable_work_dir("Sequence[str]" argv, "str | Path" sandbox_root)
None grant_work_dir_for_session(Path resolved_dir)
bool session_grant_satisfies("Sequence[str]" argv, "str | Path" sandbox_root)
"Path | None" resolve_work_dir("str | Path" sandbox_root)
bool _qualifies("Sequence[str]" argv, "str | Path" sandbox_root, "Path | None" grant_dir)