oc_chat
Loading...
Searching...
No Matches
shell.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/tools/shell.py
4
5"""
6Sandboxed shell execution with a whole-argv command allowlist (M8.0 WU-3).
7
8Purpose
9-------
10Provides :func:`run_command`, which parses a command string to argv,
11rejects shell metacharacters outside quotes, and validates the WHOLE argv against the
12locked Appendix A allowlist (:mod:`oc_chat.tools._argv_policy`) before
13executing it ``shell=False`` with a scrubbed environment. There is no
14``shell=True`` lane and no Unix→PowerShell conversion (F55).
15
16**Tier 1 — allowed (read-only / no side-effects):** ``pip list``/``show``/
17``freeze``, ``where``/``which``, ``dir``/``ls`` (a small option set + ≤1
18in-sandbox path arg), ``echo`` (argv-only). Reads go through the filesystem
19tools and git through ``oct_git_*`` — so ``git`` (freeform), ``cat``/``type``/
20``grep``/``findstr``, ``curl``/``wget``, the shells, and the cmd.exe mutators
21are **denied**.
22
23**Tier 2 — human-confirm / grant-gated (argv-only):** ``python``/``pytest``/
24``pip install``|``uninstall``/``oct``. M8.0-A (LD-4): a Tier-2 command is **not
25executable by the agent** — a *model-supplied* flag never authorizes it. It
26returns a ``requires_confirmation`` dict that no model-reachable path can satisfy
27(fail closed). M8.0-C: the react-loop relays that signal to an interactive human
28and, on 'y', re-authorizes the exact command via :func:`human_confirmed_command`
29(a ContextVar the model cannot set); headless / sub-loop / batch fail closed.
30One-use signed grants for headless Tier-2 are M8.1.
31
32**The two timeouts (plan 283).** ``clamp_timeout`` bounds the ``timeout`` the
33MODEL supplies — the M8 Appendix B ceiling, 300 s, with no out-of-band raise
34path — and applies to every command. :func:`human_budget` is a second, separate
35value carried the same way the approval is, read ONLY after the exact-string
36approval matched, and it is the approving human's own patience: a number of
37seconds, or ``None`` for "until it finishes". The ceiling is what the agent may
38decide; the budget is what the human decided.
39
40Responsibilities
41----------------
42- :func:`_classify` / :func:`_is_allowed` — back-compat wrappers over the
43 whole-argv policy decision (``_argv_policy.classify_command``).
44- :func:`run_command` — orchestrate validation, optional confirmation
45 prompt, and ``subprocess.run`` execution with timeout and captured
46 output.
47- :func:`human_confirmed_command` / :func:`human_budget` — the two ContextVar
48 seams the interactive react-loop sets after a human answers the Tier-2 ask:
49 what was approved, and how long they will wait for it.
50- :func:`_pip_index_pin` — the F78 index pin: an index-reaching ``pip``
51 subcommand executes with ``--isolated --index-url <operator-trusted>``
52 appended after the subcommand, and the ``python -m pip`` spelling of a
53 command that names its own index is refused on the argv policy's terms.
54- :func:`_python_m_pip_at` / :func:`_first_value_option` — CPython's own
55 ``-m`` spellings, so the attached and clustered forms (``-mpip``, ``-qmpip``)
56 reach the pin and ``-Xmpip`` (X's value) correctly does not.
57- ``TOOLS`` and ``SCHEMAS`` for auto-registration.
58
59Diagnostics
60-----------
61Domain: OC_CHAT.TOOLS
62Levels:
63 L2 — lifecycle events
64 L3 — semantic details
65 L4 — deep tracing
66
67Contracts
68---------
69- Commands not matching any whitelist are *blocked* — they return an
70 error dict, never silently execute.
71- Mutating ``git`` (add/commit/push) is intentionally *not* in either
72 whitelist; agents must use ``oct_git_*`` instead.
73- Subprocess calls always run with a finite timeout and captured
74 stdout/stderr — no streaming output, no PTY.
75- Tier-2 (code-exec / mutation) commands are not agent-runnable (M8.0-A,
76 LD-4): a model-supplied flag never authorizes them; they return
77 ``requires_confirmation`` and fail closed. Only the out-of-band human-confirm
78 channel (M8.0-C, via :func:`human_confirmed_command`) or a one-use grant
79 (M8.1) authorizes them.
80- A granted ``pip install`` / ``pip download`` runs against the operator-trusted
81 index and nothing else (M8 F78, SUPPLY-02 / plan 253). The pin is computed
82 BEFORE the confirmation is offered and carried in the reason the human reads,
83 so what is approved is what runs; it changes what EXECUTES and never what
84 AUTHORIZES — the exact approved command string remains the only key.
85
86Dependencies
87------------
88- oc_diagnostics (structured debug logging via _dbg)
89- oc_chat.core.tool_results (CommandResult, ConfirmationRequired, ToolError - the run_command return types)
90- oc_chat.tools._sandbox (scrubbed child environment, output secret redaction and the protected-read check)
91- oc_chat.tools._argv_policy (classify_command, the pip index helpers and WINDOWS_BUILTINS - the whole-argv allowlist, the F78 index pin and refusal, and the built-ins run through cmd)
92- oc_chat.tools.filesystem (lazy; _sandbox_root, _safe_path, _outside_sandbox_error - the sandbox root and search path checks)
93- oc_chat.core.defaults (lazy; authority - the operator-trusted pip index URL for the F78 pin)
94- oc_chat.core.resource_caps (lazy; clamp_timeout, clamp_results - the M8 ceilings on model-supplied arguments)
95- oc_chat.tools._proc (lazy; run_cancellable - the cancellable subprocess runner)
96- oc_chat.core.tool_activity (lazy; TOOL_ACTIVITY - the terminal activity record fed redacted output lines)
97- oc_chat.core.cancellation (lazy; tool_cancel_requested - cooperative Ctrl+C polling in text_search)
98- cmd (not imported; the Windows command interpreter, run as cmd /v:off /c for the built-ins dir and echo)
99
100See Also
101--------
102oc_chat/docs/conventions/CONTRACTS.md §3 Tool registry contract; §4 Tool return-
103value conventions (run_command → §4.1; confirmation gating → §4.5).
104"""
105import contextlib
106import contextvars
107import shlex
108import subprocess
109import os
110import platform
111from collections.abc import Callable, Iterator
112from dataclasses import dataclass
113from datetime import datetime
114from pathlib import Path
115
116from oc_diagnostics import _dbg
117
118from oc_chat.core.tool_results import (
119 CommandResult,
120 ConfirmationRequired,
121 ToolError,
122)
123from oc_chat.tools._sandbox import (
124 LineRedactor, redact_secrets, scrubbed_env)
125from oc_chat.tools._argv_policy import (
126 PIP_INDEX_REACHING_SUBCOMMANDS,
127 WINDOWS_BUILTINS,
128 classify_command,
129 pip_index_denial,
130 pip_index_option,
131)
132
133# ── Whole-argv command policy (M8.0 WU-3; Appendix A / LD-4) ─────────────────
134#
135# The legacy longest-prefix ``startswith`` whitelist + the Unix→PowerShell
136# ``_get_cross_platform_command`` conversion (F55) are gone. Classification is
137# now a deterministic WHOLE-argv match against the Appendix A table
138# (:func:`oc_chat.tools._argv_policy.classify_command`); :func:`run_command`
139# parses to argv and executes ``shell=False``. ``_classify`` / ``_is_allowed``
140# are retained as back-compat wrappers (tests / analytics) over the new decision.
141
142
143def _classify(command: str) -> str:
144 """Return ``'safe'``, ``'confirm'``, or ``'denied'`` for *command*.
145
146 Back-compat wrapper over the whole-argv policy (M8.0 WU-3): Appendix A
147 ``allow`` → ``'safe'``, ``confirm`` → ``'confirm'``, ``deny`` → ``'denied'``.
148 """
149 from .filesystem import _sandbox_root
150 decision = classify_command(command, _sandbox_root()).decision
151 return {"allow": "safe", "confirm": "confirm", "deny": "denied"}[decision]
152
153
154def _is_allowed(command: str) -> bool:
155 """Return ``True`` if *command* is admitted by the whole-argv policy
156 (Tier-1 ``allow`` or Tier-2 ``confirm``); ``False`` if denied."""
157 return _classify(command) != "denied"
158
159
160# ── Tier-2 out-of-band human-confirm channel (M8.0-C; LD-4) ──────────────────
161#
162# M8.0-A made a Tier-2 command non-executable by the agent: a *model-supplied*
163# flag never authorizes it. M8.0-C adds the ONLY authorization path -- an
164# interactive human 'y'. The authority is carried by a ``ContextVar`` rather than
165# a parameter or an env var precisely because the model cannot reach it: the agent
166# supplies JSON tool arguments, never Python execution context. The interactive
167# react-loop sets it (via :func:`human_confirmed_command`) to the exact command a
168# human approved, around the single re-dispatch of that command; ``run_command``
169# honors it ONLY on an exact-string match and the token is reset immediately after,
170# so the authorization never leaks to another command or outlives the one call.
171# Headless / sub-loop / batch / piped stdin never set it -> Tier-2 fails closed.
172_human_approved_command: contextvars.ContextVar[str | None] = contextvars.ContextVar(
173 "oc_chat_human_approved_command", default=None
174)
175
176
177@contextlib.contextmanager
178def human_confirmed_command(command: str) -> Iterator[None]:
179 """Authorize exactly *command* for one Tier-2 :func:`run_command` execution.
180
181 For use ONLY by the interactive react-loop after an out-of-band human 'y'
182 (M8.0-C). Not authority the model can forge: it sets no env var and is not a
183 tool argument. The token is reset on exit so the grant is scoped to the single
184 re-dispatch this brackets.
185 """
186 token = _human_approved_command.set(command)
187 try:
188 yield
189 finally:
190 _human_approved_command.reset(token)
191
192
193# ── The human's budget (plan 283 P1; SHELL-04) ──────────────────────────────
194#
195# ``clamp_timeout`` bounds what the MODEL may ask for and is untouched by this:
196# the 300 s ceiling is the answer to "how long may the agent decide to wait".
197# The budget below answers a different question — "how long is the HUMAN who
198# just approved this command willing to wait" — and it is therefore read in
199# exactly one place: inside ``run_command``, on the branch where
200# ``_human_approved_command`` already matched. It travels by ContextVar for the
201# same reason the approval does: the model supplies JSON tool arguments, never
202# Python execution context, so there is no spelling of a tool call that reaches
203# it. ``None`` is a real value here and means "no ceiling at all" — the operator
204# answered "run until it finishes" — which is why the ContextVar holds a record
205# rather than a bare int: "unset" and "unlimited" must not collapse into one.
206
207
208@dataclass(frozen=True)
210 """How long the approving human will wait, and who to ask for more."""
211
212 seconds: "int | None"
213 """Whole seconds to allow, or ``None`` for no ceiling at all."""
214 on_expired: "Callable[[], int | None] | None" = None
215 """Called once at expiry to ask for an extension; ``None`` never asks."""
216
217
218_human_budget: contextvars.ContextVar["HumanBudget | None"] = contextvars.ContextVar(
219 "oc_chat_human_budget", default=None
220)
221
222
223@contextlib.contextmanager
225 seconds: "int | None", *, on_expired: "Callable[[], int | None] | None" = None,
226) -> Iterator[None]:
227 """Set the approving human's wait for one confirmed :func:`run_command`.
228
229 For use ONLY by the interactive react-loop, alongside
230 :func:`human_confirmed_command`, after a human answered the Tier-2 ask
231 (plan 283). *seconds* is the budget they chose and ``None`` means they chose
232 "until it finishes"; *on_expired* is the callable that will ask them for one
233 extension when the budget runs out. Like the approval token, this is reset on
234 exit, so a budget never leaks to another command or outlives the one call.
235
236 It authorises NOTHING on its own: a budget without a matching
237 :func:`human_confirmed_command` is never read, because the branch that reads
238 it is only reached once the exact-string approval has already matched.
239 """
240 token = _human_budget.set(HumanBudget(seconds, on_expired))
241 try:
242 yield
243 finally:
244 _human_budget.reset(token)
245
246
247def deny_unconfirmed_tier2(result: dict, *, note: str = "") -> dict:
248 """Fail-closed converter for contexts with no interactive human.
249
250 A Tier-2 ``requires_confirmation`` cannot be satisfied without a human, so in a
251 sub-loop / batch / headless run it is converted to a terminal ``error`` the model
252 can act on (stop asking) instead of being handed back the un-satisfiable signal.
253 Any other result passes through unchanged (identity).
254 """
255 if isinstance(result, dict) and result.get("requires_confirmation"):
256 msg = result.get("message") or (
257 "Tier-2 command requires out-of-band human approval."
258 )
259 return {"error": (msg + " " + note).strip()}
260 return result
261
262
263# ── The F78 package-index pin (SUPPLY-02; plan 253) ─────────────────────────
264#
265# WU-3c closed the ENV half of F78: ``scrubbed_env`` drops ``PIP_*``, so a
266# planted ``PIP_INDEX_URL`` cannot redirect a granted install. Two halves were
267# left open. pip still reads its CONFIGURATION FILES -- the user profile's
268# ``pip.ini`` and the virtual environment's own -- and an index could still be
269# named in the approved command itself. ``--isolated`` closes the environment
270# and the user profile; it does NOT close the site (venv) configuration file,
271# which pip consults only when no index is given explicitly. That is why both
272# the flag and an explicit ``--index-url`` are appended: either alone leaves one
273# of the two paths open.
274
275#: Interpreters that can run pip as a module (``python -m pip install ...``).
276#: That spelling classifies as a ``python`` Tier-2 command, so ``_classify_pip``
277#: never sees it and the seam has to recognise it here.
278_PYTHON_PROGRAMS: frozenset[str] = frozenset({"python", "python3", "py"})
279
280#: CPython short options that TAKE A VALUE. Each takes it from the rest of its
281#: own token when there is one (``-Xutf8``) and from the next argv element when
282#: the token ends at the letter (``-X utf8``) — which is exactly why ``-Xmpip``
283#: is X's value ``mpip`` and NOT ``-m pip``. ``-c`` and ``-m`` additionally
284#: TERMINATE the option list: everything after them belongs to the code or the
285#: module, never to the interpreter.
286_PY_SHORT_VALUE_OPTIONS: frozenset[str] = frozenset({"c", "m", "W", "X"})
287
288#: CPython short options that are pure flags, so an option cluster continues
289#: past them: ``-qmpip`` is ``--quiet -m pip``. Probed one letter at a time
290#: against CPython 3.14 rather than transcribed from ``--help``, which omits the
291#: still-accepted legacy no-ops ``-t`` and ``-R``. ``-h`` / ``-?`` / ``-V`` are
292#: flags too (``-Vzz`` answers "Unknown option: -z", so ``-V`` consumes nothing);
293#: they make the interpreter print and exit, so nothing installs either way.
294_PY_SHORT_FLAG_OPTIONS: frozenset[str] = frozenset("bdhiqstuvxBEIOPRSV?")
295
296#: Sentinel for an option letter CPython does not accept. NUL can never be one
297#: (the metacharacter gate denies it outright), so it cannot collide with ``-?``.
298_PY_UNKNOWN_OPTION = "\0"
299
300
301def _first_value_option(token: str) -> "tuple[str, str]":
302 """The first value-taking option letter in a single-dash CPython *token*,
303 and the value attached to it inside that same token.
304
305 Returns ``("", "")`` for a cluster of pure flags and
306 ``(_PY_UNKNOWN_OPTION, "")`` for a letter the interpreter refuses. A flag
307 continues the cluster; the first value-taking letter ends it and takes the
308 remainder of the token as its value — empty when the token ends there, in
309 which case the value is the next argv element.
310 """
311 for pos, ch in enumerate(token[1:], start=1):
312 if ch in _PY_SHORT_VALUE_OPTIONS:
313 return ch, token[pos + 1:]
314 if ch not in _PY_SHORT_FLAG_OPTIONS:
315 return _PY_UNKNOWN_OPTION, ""
316 return "", ""
317
318
319def _python_m_pip_at(argv: list[str]) -> int:
320 """Index of the token after ``-m pip`` in a *python* argv, or ``-1``.
321
322 ``-m`` takes a value, and CPython's own parser accepts that value ATTACHED
323 to the option token as readily as separated: ``-mpip``, and inside a flag
324 cluster ``-qmpip`` / ``-Impip`` / ``-SEmpip``, are all ``-m pip``. Matching
325 only a bare ``-m`` token let the attached spelling walk past both the
326 refusal and the pin (gate finding, 2026-09-09) — the same class of gap the
327 optparse abbreviations were for pip's own options.
328
329 The scan is deliberately no WIDER than the interpreter. ``-W`` and ``-X``
330 take their values the same way, so ``-Xmpip`` is X's value and not a module;
331 ``-c`` terminates the option list, so ``-cmpip`` is code; and a token that
332 is not an option at all is the script path, after which no module can
333 follow. Each of those was verified by running CPython 3.14, because a scan
334 that matched more than the interpreter does would deny commands that never
335 reach pip at all.
336 """
337 i = 1
338 while i < len(argv):
339 token = argv[i]
340 if not token.startswith("-") or token in ("-", "--"):
341 return -1 # the script path, or the end of options
342 if token.startswith("--"):
343 i += 1 # a long option (--check-hash-based-pycs)
344 continue
345 option, attached = _first_value_option(token)
346 if option == _PY_UNKNOWN_OPTION or option == "c":
347 return -1 # refused by the interpreter, or code
348 if option == "":
349 i += 1 # a cluster of pure flags
350 continue
351 if option == "m":
352 module = attached or (argv[i + 1] if i + 1 < len(argv) else "")
353 if not module or Path(module).name.lower() != "pip":
354 return -1
355 return i + 1 if attached else i + 2
356 i += 1 if attached else 2 # -W / -X consumed their value
357 return -1
358
359
360def _pip_subcommand_at(argv: list[str], program: str) -> int:
361 """Position of an index-reaching pip subcommand in *argv*, or ``-1``.
362
363 Both Tier-2 spellings are matched: a direct ``pip install ...`` and
364 ``python -m pip install ...`` — the pin applies after ``pip`` wherever it
365 sits in the argv, in whichever spelling the interpreter accepts
366 (:func:`_python_m_pip_at`).
367 """
368 if program == "pip":
369 at = 1
370 elif program in _PYTHON_PROGRAMS:
371 at = _python_m_pip_at(argv)
372 if at < 0:
373 return -1
374 else:
375 return -1
376 return at if at < len(argv) and argv[at] in PIP_INDEX_REACHING_SUBCOMMANDS else -1
377
378
379def _pip_index_pin(argv: list[str], program: str) -> "tuple[list[str] | None, str]":
380 """Pin an index-reaching pip *argv* to the operator-trusted index (F78).
381
382 Returns ``(executed_argv, denial)``. *executed_argv* is ``None`` when *argv*
383 is not an index-reaching pip command, and the caller executes it unchanged.
384 *denial* is non-empty when the command names an index or a trusted host
385 itself: the argv policy already refuses that for the bare ``pip`` spelling,
386 and this closes the ``python -m pip`` spelling on the same terms. Refusing
387 rather than overriding is deliberate — appending a pin to a command that
388 already carries ``--index-url`` would leave which index wins to pip's own
389 last-one-wins parsing, and would leave the human approving a string whose
390 index quietly did not apply.
391 """
392 at = _pip_subcommand_at(argv, program)
393 if at < 0:
394 return None, ""
395 named = pip_index_option(argv[at + 1:])
396 if named:
397 return None, pip_index_denial(named)
398 from oc_chat.core.defaults import authority
399
400 pinned = list(argv)
401 pinned[at + 1:at + 1] = ["--isolated", "--index-url", authority("pip_index_url")]
402 return pinned, ""
403
404
405# ── Public API ──────────────────────────────────────────────────────────────
406
408 command: str,
409 timeout: int = 30,
410 confirmed: bool = False,
411) -> CommandResult | ConfirmationRequired | ToolError:
412 """Execute a whitelisted shell command and return its output.
413
414 Parameters
415 ----------
416 command : str
417 Shell command string.
418 timeout : int
419 Maximum seconds to wait (default 30), clamped to the M8 ceiling of
420 300 s. On the human-confirmed Tier-2 path an out-of-band
421 :func:`human_budget` replaces it with the approving human's own wait —
422 see the module docstring's "The two timeouts".
423 confirmed : bool
424 Retained for back-compat and **ignored** (M8.0-A): a model-supplied flag
425 never authorizes a Tier-2 command. The only authorization is the out-of-band
426 :func:`human_confirmed_command` ContextVar (interactive human 'y', M8.0-C) —
427 unreachable by the model — or a one-use grant (M8.1).
428
429 Returns
430 -------
431 dict
432 ``{"stdout": str, "stderr": str, "returncode": int}`` on success, or
433 ``{"requires_confirmation": True, "command": str, "reason": str, "message":
434 str}`` for a Tier-2 command the agent cannot self-authorize (the react-loop
435 relays it to a human via :func:`_resolve_tier2_confirmation`).
436 """
437 func = "run_command"
438 from oc_chat.core.resource_caps import clamp_timeout
439 timeout = clamp_timeout(timeout) # M8.0 WU-9a: model arg cannot exceed 300 s
440 _dbg("OC_CHAT.TOOLS", func, f"cmd={command!r} timeout={timeout} confirmed={confirmed}", 2)
441 if not command or not command.strip():
442 return {"error": "Empty command provided"}
443
444 # M8.0 WU-3: deterministic whole-argv allowlist (Appendix A / LD-4). The
445 # command is parsed to argv and validated as a whole; there is no shell=True
446 # lane and no Unix→PowerShell conversion (F55).
447 from .filesystem import _sandbox_root
448 decision = classify_command(command, _sandbox_root())
449 if decision.decision == "deny":
450 _dbg("OC_CHAT.TOOLS", func, f"denied: {decision.reason}", 3)
451 return {"error": f"Command denied: {decision.reason}"}
452 pinned_argv: "list[str] | None" = None
453 #: Only a human-set budget ever fills this in (plan 283); ``None`` keeps the
454 #: timeout a hard verdict, which is what every Tier-1 command gets.
455 on_budget_expired: "Callable[[], int | None] | None" = None
456 reason = decision.reason
457 if decision.decision == "confirm":
458 # M8 F78 (plan 253): an index-reaching pip subcommand runs against the
459 # operator-trusted index and nothing else. The pin is computed BEFORE
460 # the confirmation is offered and carried in the reason, so the human
461 # approves the argv that will actually run rather than a shorter string.
462 pinned_argv, pin_denial = _pip_index_pin(decision.argv, decision.program)
463 if pin_denial:
464 _dbg("OC_CHAT.TOOLS", func, f"denied: {pin_denial}", 3)
465 return {"error": f"Command denied: {pin_denial}"}
466 if pinned_argv is not None:
467 reason = f"{decision.reason}; runs as: {shlex.join(pinned_argv)}"
468
469 # M8.0-A (LD-4): a model-supplied `confirmed=True` does NOT authorize a
470 # Tier-2 (code-exec / package-mutation) command. M8.0-C: the ONLY
471 # authorization is an out-of-band human 'y' relayed via the
472 # `_human_approved_command` ContextVar (set by the interactive react-loop,
473 # unreachable by the model), and only for the EXACT command approved.
474 # Absent that, fail closed with a signal the react-loop can relay to a human.
475 if _human_approved_command.get() != command:
476 return {
477 "requires_confirmation": True,
478 "command": command,
479 "reason": reason,
480 "message": (
481 f"'{command}' is a Tier-2 command ({reason}) and cannot be "
482 f"run by the agent: Tier-2 requires out-of-band human approval that "
483 f"the agent cannot supply. Ask the user to run it, or use the "
484 f"dedicated tools (oct_* for git, the filesystem tools for files)."
485 ),
486 }
487 _dbg("OC_CHAT.TOOLS", func,
488 f"Tier-2 authorized by out-of-band human confirm: {command!r}", 2)
489 # Plan 283 P1: and ONLY here, past the exact-string match, does the
490 # approving human's own budget replace the model's clamped timeout.
491 # Tier-1 never reaches this branch, so an allowed command keeps the
492 # clamp whatever a stray budget in the context might say.
493 _budget = _human_budget.get()
494 if _budget is not None:
495 timeout = _budget.seconds
496 on_budget_expired = _budget.on_expired
497 _dbg("OC_CHAT.TOOLS", func,
498 f"human budget applies: "
499 f"{'no ceiling' if timeout is None else str(timeout) + 's'} "
500 f"(extension {'offered' if on_budget_expired else 'not offered'})", 2)
501 tier = "safe" if decision.decision == "allow" else "confirm"
502
503 # Execute argv-only (shell=False). cmd.exe built-ins (dir/echo) have no
504 # standalone executable on Windows, so they run via `cmd /c` — still
505 # shell=False (the argv is passed as a list, never a shell string).
506 try:
507 # M8.0 WU-3c: minimal scrubbed env -- drop poisonable GIT_*/PIP_*/proxy/
508 # cert/PYTHONPATH/... families that could redirect the child (F25/F72/F78/F79).
509 env = scrubbed_env({"PYTHONIOENCODING": "utf-8"})
510
511 from ._proc import run_cancellable
512 from oc_chat.core.tool_activity import TOOL_ACTIVITY
513
514 # The pinned argv when F78 applies, the classifier's argv otherwise.
515 exec_argv = decision.argv if pinned_argv is None else pinned_argv
516 executed_command = command if pinned_argv is None else shlex.join(pinned_argv)
517 if platform.system() == "Windows" and decision.program in WINDOWS_BUILTINS:
518 # /v:off disables cmd.exe delayed expansion (!VAR!); %VAR% expansion
519 # is blocked upstream by the `%` metacharacter gate (WU-3a fold).
520 exec_argv = ["cmd", "/v:off", "/c", *decision.argv]
521
522 # Plan 279: each line goes to the terminal's activity record as the child
523 # writes it, through the SAME redaction the result below goes through --
524 # the status bar must never show what the result would have hidden. The
525 # record is terminal-only: nothing here reaches the result, the events,
526 # the session or the model.
527 #
528 # Plan 287: one LineRedactor per stream, built here and handed to each
529 # reader by the runner. The filter remembers that it is inside a PEM
530 # private-key block, which a per-line redaction of a whole-block pattern
531 # otherwise cannot see; stdout and stderr are drained on separate threads,
532 # so sharing one instance would interleave the two streams' armor states.
533 def _note_lines_of_one_stream() -> "Callable[[str], None]":
534 redactor = LineRedactor()
535
536 def _note_line(line: str) -> None:
537 TOOL_ACTIVITY.note_line(redactor.redact_line(line))
538
539 return _note_line
540
541 # Cancellable: a Ctrl+C terminates the child (a subprocess can be killed,
542 # unlike a pure-Python worker thread). Returncode -2 == cancelled.
543 result = run_cancellable(
544 exec_argv,
545 shell=False,
546 timeout=timeout,
547 env=env,
548 cwd=_sandbox_root(),
549 on_line_factory=_note_lines_of_one_stream,
550 on_budget_expired=on_budget_expired,
551 )
552
553 # Secret-pattern redaction of all command output (M8 F46/F51; WU-2).
554 stdout_content = redact_secrets(result.stdout or "")
555 stderr_content = redact_secrets(result.stderr or "")
556
557 response = {
558 "stdout": stdout_content,
559 "stderr": stderr_content,
560 "returncode": result.returncode,
561 "command_info": {
562 "base_command": decision.program,
563 # What RAN, which is the pinned argv when F78 applied.
564 "full_command": executed_command,
565 "tier": tier,
566 "executed_at": datetime.now().isoformat(),
567 },
568 }
569
570 warnings = []
571 if not stdout_content.strip() and stderr_content.strip():
572 warnings.append(
573 "Empty stdout but stderr has content - check for output buffering issues"
574 )
575 if result.returncode != 0 and not stderr_content.strip():
576 warnings.append(
577 f"Non-zero return code ({result.returncode}) but no stderr output"
578 )
579 if warnings:
580 response["warnings"] = warnings
581
582 return response
583
584 except subprocess.TimeoutExpired as timed_out:
585 # Error output is redacted too -- it echoes the command, which may carry
586 # a secret (e.g. an inline auth header) (M8 F46/F51; WU-2).
587 # The exception's own timeout is quoted rather than the local, because a
588 # human who extended the budget (plan 283) waited longer than the number
589 # this function started with, and the line must say what actually elapsed.
590 waited = timed_out.timeout if timed_out.timeout is not None else timeout
591 return {
592 "stdout": "",
593 "stderr": redact_secrets(f"Command timed out after {waited:.0f}s: {command}"),
594 "returncode": -1,
595 }
596 except FileNotFoundError as e:
597 return {"stdout": "", "stderr": redact_secrets(str(e)), "returncode": -1}
598 except Exception as e:
599 return {"stdout": "", "stderr": redact_secrets(f"Execution error: {type(e).__name__}: {e}"), "returncode": -1}
600
601
602# ── Tool registry entries ───────────────────────────────────────────────────
603
605 pattern: str,
606 search_dir: str = ".",
607 *,
608 file_path: str | None = None,
609 case_sensitive: bool = False,
610 max_results: int = 100,
611) -> dict:
612 """Search for a text pattern in files under *search_dir*.
613
614 Phase P.10a: the historical parameter name was ``file_path``. That
615 name was misleading (the argument is a *directory*, not a file path).
616 The canonical name is now ``search_dir``. Callers passing
617 ``file_path=`` still work for now but get a one-time
618 :class:`DeprecationWarning`; on conflict (both keywords supplied),
619 ``file_path`` wins for backwards compatibility.
620
621 A *single file* is also a valid target (F26): ``search_dir`` may name a
622 file, in which case only that file is searched; a directory walks the tree.
623
624 Args:
625 pattern: Regular-expression pattern to search for.
626 search_dir: File or directory to search (default: project root).
627 file_path: **Deprecated** alias for ``search_dir``. Keyword-only.
628 Will be removed in a future release.
629 case_sensitive: Match case when True (default False).
630 max_results: Maximum number of matching files to return (default 100).
631
632 Returns:
633 Dict with ``matches`` list of ``{file, line_numbers, match_count}``,
634 plus ``total_matches``, ``searched_files``, ``pattern``,
635 and ``case_sensitive``.
636 """
637 import warnings
638 from .filesystem import _outside_sandbox_error, _safe_path, _sandbox_root
639 from ._sandbox import is_protected_read_path
640 import re
641
642 # P.10a deprecation alias: file_path wins on conflict for backward
643 # compatibility. Emit a one-line DeprecationWarning per call.
644 if file_path is not None:
645 warnings.warn(
646 "text_search(file_path=...) is deprecated; "
647 "use text_search(search_dir=...) instead. The argument is a "
648 "directory to search, not a file path — search_dir is the "
649 "accurate name.",
650 DeprecationWarning,
651 stacklevel=2,
652 )
653 search_dir = file_path
654
655 from oc_chat.core.resource_caps import clamp_results
656 max_results = clamp_results(max_results) # M8.0 WU-9a: cannot exceed 100
657
658 # F26: accept a single FILE target as well as a directory. The 166 stream's
659 # three failures were all `search_dir=<a file>` (with a `|` alternation
660 # pattern, which is a red herring — the regex compiles fine); the historical
661 # `is_dir()`-only guard rejected any file target as "not a directory". A file
662 # target searches just that file; a directory target walks the tree.
663 # F25: an out-of-sandbox search_dir is unreachable, not absent — name the
664 # boundary (root + resolved path + existence) instead of raising or reading
665 # as "not found".
666 try:
667 search_root = _safe_path(search_dir)
668 except PermissionError:
669 return {"error": _outside_sandbox_error(search_dir) or f"Search path not found: {search_dir}"}
670 if search_root.is_file():
671 search_candidates: "Iterator[object] | list" = [search_root]
672 elif search_root.is_dir():
673 search_candidates = search_root.rglob("*")
674 elif not search_root.exists():
675 return {"error": f"Search path not found: {search_dir}"}
676 else:
677 return {"error": f"Search path is neither a file nor a directory: {search_dir}"}
678
679 flags = 0 if case_sensitive else re.IGNORECASE
680 try:
681 pattern_re = re.compile(pattern, flags)
682 except re.error as e:
683 return {"error": f"Invalid regex pattern: {e}"}
684
685 matches = []
686 files_searched = 0
687
688 # Cooperative cancellation: a Ctrl+C sets a process-wide flag (a worker
689 # thread running this search cannot be force-killed), polled once per file.
690 from oc_chat.core.cancellation import tool_cancel_requested
691
692 try:
693 for fp in search_candidates:
694 if tool_cancel_requested():
695 return {
696 "matches": matches,
697 "total_matches": len(matches),
698 "searched_files": files_searched,
699 "pattern": pattern,
700 "case_sensitive": case_sensitive,
701 "cancelled": True,
702 }
703 if not fp.is_file():
704 continue
705 if is_protected_read_path(fp):
706 continue # do not search protected-read (secret) files (M8 F46b; WU-2)
707 files_searched += 1
708 try:
709 with open(fp, "r", encoding="utf-8", errors="replace") as fh:
710 line_matches = [
711 ln for ln, line in enumerate(fh, start=1)
712 if pattern_re.search(line)
713 ]
714 if line_matches:
715 matches.append({
716 "file": str(fp.relative_to(_sandbox_root())),
717 "line_numbers": line_matches,
718 "match_count": len(line_matches),
719 })
720 if len(matches) >= max_results:
721 break
722 except (UnicodeDecodeError, OSError):
723 continue
724
725 return {
726 "matches": matches,
727 "total_matches": len(matches),
728 "searched_files": files_searched,
729 "pattern": pattern,
730 "case_sensitive": case_sensitive,
731 }
732 except Exception as e:
733 return {"error": f"Search error: {e}"}
734
735
736TOOLS = {
737 "run_command": run_command,
738 "text_search": text_search,
739}
740
741SCHEMAS = [
742 {
743 "type": "function",
744 "function": {
745 "name": "run_command",
746 "description": (
747 "Run an allowlisted, read-only shell command (pip list/show/freeze, "
748 "where/which, dir/ls, echo) and return stdout, stderr, and return "
749 "code. Code-execution / mutation commands (python, pytest, pip "
750 "install, ...) are NOT runnable here — use the dedicated tools "
751 "(oct_* for git, the filesystem tools for files), or ask the user."
752 ),
753 "parameters": {
754 "type": "object",
755 "properties": {
756 "command": {
757 "type": "string",
758 "description": "The shell command to run.",
759 },
760 "timeout": {
761 "type": "integer",
762 "description": "Timeout in seconds (default 30).",
763 },
764 },
765 "required": ["command"],
766 },
767 },
768 },
769 {
770 "type": "function",
771 "function": {
772 "name": "text_search",
773 "description": (
774 "Search for a regex pattern across files. `search_dir` may be a "
775 "directory (walks the tree) OR a single file (searches just that "
776 "file). Returns matching file paths, line numbers, and match "
777 "counts. NOTE: the search-target parameter is named `search_dir`. "
778 "The legacy alias `file_path` still works but emits a deprecation "
779 "warning — use `search_dir` in new code."
780 ),
781 "parameters": {
782 "type": "object",
783 "properties": {
784 "pattern": {
785 "type": "string",
786 "description": "Regular-expression pattern to search for.",
787 },
788 "search_dir": {
789 "type": "string",
790 "description": "File or directory to search (default: project root '.').",
791 },
792 "case_sensitive": {
793 "type": "boolean",
794 "description": "Match case when true (default false).",
795 },
796 "max_results": {
797 "type": "integer",
798 "description": "Maximum number of matching files to return (default 100).",
799 },
800 },
801 "required": ["pattern"],
802 },
803 },
804 },
805]
"tuple[str, str]" _first_value_option(str token)
Definition shell.py:301
dict text_search(str pattern, str search_dir=".", *, str|None file_path=None, bool case_sensitive=False, int max_results=100)
Definition shell.py:611
str _classify(str command)
Definition shell.py:143
int _python_m_pip_at(list[str] argv)
Definition shell.py:319
bool _is_allowed(str command)
Definition shell.py:154
Iterator[None] human_budget("int | None" seconds, *, "Callable[[], int | None] | None" on_expired=None)
Definition shell.py:226
"tuple[list[str] | None, str]" _pip_index_pin(list[str] argv, str program)
Definition shell.py:379
CommandResult|ConfirmationRequired|ToolError run_command(str command, int timeout=30, bool confirmed=False)
Definition shell.py:411
Iterator[None] human_confirmed_command(str command)
Definition shell.py:178
dict deny_unconfirmed_tier2(dict result, *, str note="")
Definition shell.py:247
int _pip_subcommand_at(list[str] argv, str program)
Definition shell.py:360