6Session-long Tier-2 grant for python scripts under the work directory (SHELL-02).
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.
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.
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.
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.
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.
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)
68 L1 — never (a grant is an operator decision, not a fault)
69 L2 — grant / revoke lifecycle events
70 L3 — per-decision detail
73from __future__
import annotations
75from collections.abc
import Sequence
76from pathlib
import Path
78from oc_diagnostics
import _dbg
83PYTHON_PROGRAMS: frozenset[str] = frozenset({
"python",
"python3",
"py"})
86DEFAULT_WORK_DIR =
"_work"
89_SESSION_GRANT_DIR: Path |
None =
None
93 """The resolved directory granted for this session, or ``None``."""
94 return _SESSION_GRANT_DIR
98 """Grant python-on-*resolved_dir* for the rest of this session.
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.
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)
113 """Drop any active grant (``/grants revoke``, and both session-reset seams).
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.
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)
129 """The configured scratch-directory name (``project_tree.work_dir``)."""
130 func =
"_work_dir_name"
133 name = str(runtime_settings().project_tree.get(
"work_dir")
or "").strip()
134 except Exception
as exc:
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
144 """The grantable directory under *sandbox_root*, or ``None``.
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.
151 func =
"resolve_work_dir"
154 root = Path(sandbox_root)
157 except (OSError, RuntimeError, ValueError)
as exc:
158 _dbg(
"OC_CHAT.CORE", func, f
"no grantable work dir under {root}: {exc}", 3)
160 if not candidate.is_dir():
161 _dbg(
"OC_CHAT.CORE", func, f
"work dir is not a directory: {candidate}", 3)
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)
171 """True when *arg0*'s basename is a python interpreter (``.exe`` stripped)."""
172 name = Path(arg0).name.lower()
173 if name.endswith(
".exe"):
175 return name
in PYTHON_PROGRAMS
179 """*arg* resolved strictly to a regular ``.py`` file, or ``None``.
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.
187 if not arg
or arg.startswith(
"-"):
189 candidate = Path(arg)
190 if not candidate.is_absolute():
191 candidate = Path(sandbox_root) / candidate
193 resolved = candidate.resolve(strict=
True)
194 except (OSError, RuntimeError, ValueError):
196 if resolved.suffix.lower() !=
".py" or not resolved.is_file():
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``.
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.
214 if resolve_risk_profile() !=
"permissive":
215 _dbg(
"OC_CHAT.CORE", func,
"not permissive — session grants are not consulted", 3)
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)
231 script.relative_to(grant_dir)
233 _dbg(
"OC_CHAT.CORE", func, f
"{script} resolves outside {grant_dir}", 3)
239 """True when the ACTIVE grant covers *argv* (§P2).
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.
244 func =
"session_grant_satisfies"
245 satisfied =
_qualifies(argv, sandbox_root, _SESSION_GRANT_DIR)
247 _dbg(
"OC_CHAT.CORE", func,
248 f
"session grant satisfies {list(argv)[:2]} under {_SESSION_GRANT_DIR}", 3)
253 """The directory the third prompt key would grant for *argv*, or ``None``.
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.
259 return candidate
if _qualifies(argv, sandbox_root, candidate)
else None
265 "active_session_grant",
266 "grant_work_dir_for_session",
267 "revoke_session_grants",
269 "session_grant_satisfies",
270 "grantable_work_dir",
"Path | None" _resolved_script(str arg, "str | Path" sandbox_root)
None revoke_session_grants()
"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)
bool _python_program(str arg0)
"Path | None" active_session_grant()