6Sandboxed shell execution with a whole-argv command allowlist (M8.0 WU-3).
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).
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
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.
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.
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
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.
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.
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)
102oc_chat/docs/conventions/CONTRACTS.md §3 Tool registry contract; §4 Tool return-
103value conventions (run_command → §4.1; confirmation gating → §4.5).
111from collections.abc
import Callable, Iterator
112from dataclasses
import dataclass
113from datetime
import datetime
114from pathlib
import Path
116from oc_diagnostics
import _dbg
120 ConfirmationRequired,
124 LineRedactor, redact_secrets, scrubbed_env)
126 PIP_INDEX_REACHING_SUBCOMMANDS,
144 """Return ``'safe'``, ``'confirm'``, or ``'denied'`` for *command*.
146 Back-compat wrapper over the whole-argv policy (M8.0 WU-3): Appendix A
147 ``allow`` → ``'safe'``, ``confirm`` → ``'confirm'``, ``deny`` → ``'denied'``.
149 from .filesystem
import _sandbox_root
150 decision = classify_command(command, _sandbox_root()).decision
151 return {
"allow":
"safe",
"confirm":
"confirm",
"deny":
"denied"}[decision]
155 """Return ``True`` if *command* is admitted by the whole-argv policy
156 (Tier-1 ``allow`` or Tier-2 ``confirm``); ``False`` if denied."""
172_human_approved_command: contextvars.ContextVar[str |
None] = contextvars.ContextVar(
173 "oc_chat_human_approved_command", default=
None
177@contextlib.contextmanager
179 """Authorize exactly *command* for one Tier-2 :func:`run_command` execution.
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.
186 token = _human_approved_command.set(command)
190 _human_approved_command.reset(token)
208@dataclass(frozen=True)
210 """How long the approving human will wait, and who to ask for more."""
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."""
218_human_budget: contextvars.ContextVar[
"HumanBudget | None"] = contextvars.ContextVar(
219 "oc_chat_human_budget", default=
None
223@contextlib.contextmanager
225 seconds:
"int | None", *, on_expired:
"Callable[[], int | None] | None" =
None,
227 """Set the approving human's wait for one confirmed :func:`run_command`.
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.
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.
240 token = _human_budget.set(
HumanBudget(seconds, on_expired))
244 _human_budget.reset(token)
248 """Fail-closed converter for contexts with no interactive human.
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).
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."
259 return {
"error": (msg +
" " + note).strip()}
278_PYTHON_PROGRAMS: frozenset[str] = frozenset({
"python",
"python3",
"py"})
286_PY_SHORT_VALUE_OPTIONS: frozenset[str] = frozenset({
"c",
"m",
"W",
"X"})
294_PY_SHORT_FLAG_OPTIONS: frozenset[str] = frozenset(
"bdhiqstuvxBEIOPRSV?")
298_PY_UNKNOWN_OPTION =
"\0"
302 """The first value-taking option letter in a single-dash CPython *token*,
303 and the value attached to it inside that same token.
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.
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,
""
320 """Index of the token after ``-m pip`` in a *python* argv, or ``-1``.
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.
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
340 if not token.startswith(
"-")
or token
in (
"-",
"--"):
342 if token.startswith(
"--"):
346 if option == _PY_UNKNOWN_OPTION
or option ==
"c":
352 module = attached
or (argv[i + 1]
if i + 1 < len(argv)
else "")
353 if not module
or Path(module).name.lower() !=
"pip":
355 return i + 1
if attached
else i + 2
356 i += 1
if attached
else 2
361 """Position of an index-reaching pip subcommand in *argv*, or ``-1``.
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`).
370 elif program
in _PYTHON_PROGRAMS:
376 return at
if at < len(argv)
and argv[at]
in PIP_INDEX_REACHING_SUBCOMMANDS
else -1
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).
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.
395 named = pip_index_option(argv[at + 1:])
397 return None, pip_index_denial(named)
401 pinned[at + 1:at + 1] = [
"--isolated",
"--index-url", authority(
"pip_index_url")]
410 confirmed: bool =
False,
411) -> CommandResult | ConfirmationRequired | ToolError:
412 """Execute a whitelisted shell command and return its output.
417 Shell command string.
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".
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).
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`).
439 timeout = clamp_timeout(timeout)
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"}
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
455 on_budget_expired:
"Callable[[], int | None] | None" =
None
456 reason = decision.reason
457 if decision.decision ==
"confirm":
462 pinned_argv, pin_denial =
_pip_index_pin(decision.argv, decision.program)
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)}"
475 if _human_approved_command.get() != command:
477 "requires_confirmation":
True,
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)."
487 _dbg(
"OC_CHAT.TOOLS", func,
488 f
"Tier-2 authorized by out-of-band human confirm: {command!r}", 2)
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"
509 env = scrubbed_env({
"PYTHONIOENCODING":
"utf-8"})
511 from ._proc
import run_cancellable
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:
520 exec_argv = [
"cmd",
"/v:off",
"/c", *decision.argv]
533 def _note_lines_of_one_stream() -> "Callable[[str], None]":
536 def _note_line(line: str) ->
None:
537 TOOL_ACTIVITY.note_line(redactor.redact_line(line))
543 result = run_cancellable(
549 on_line_factory=_note_lines_of_one_stream,
550 on_budget_expired=on_budget_expired,
554 stdout_content = redact_secrets(result.stdout
or "")
555 stderr_content = redact_secrets(result.stderr
or "")
558 "stdout": stdout_content,
559 "stderr": stderr_content,
560 "returncode": result.returncode,
562 "base_command": decision.program,
564 "full_command": executed_command,
566 "executed_at": datetime.now().isoformat(),
571 if not stdout_content.strip()
and stderr_content.strip():
573 "Empty stdout but stderr has content - check for output buffering issues"
575 if result.returncode != 0
and not stderr_content.strip():
577 f
"Non-zero return code ({result.returncode}) but no stderr output"
580 response[
"warnings"] = warnings
584 except subprocess.TimeoutExpired
as timed_out:
590 waited = timed_out.timeout
if timed_out.timeout
is not None else timeout
593 "stderr": redact_secrets(f
"Command timed out after {waited:.0f}s: {command}"),
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}
606 search_dir: str =
".",
608 file_path: str |
None =
None,
609 case_sensitive: bool =
False,
610 max_results: int = 100,
612 """Search for a text pattern in files under *search_dir*.
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.
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.
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).
633 Dict with ``matches`` list of ``{file, line_numbers, match_count}``,
634 plus ``total_matches``, ``searched_files``, ``pattern``,
635 and ``case_sensitive``.
638 from .filesystem
import _outside_sandbox_error, _safe_path, _sandbox_root
639 from ._sandbox
import is_protected_read_path
644 if file_path
is not None:
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 "
653 search_dir = file_path
656 max_results = clamp_results(max_results)
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}"}
677 return {
"error": f
"Search path is neither a file nor a directory: {search_dir}"}
679 flags = 0
if case_sensitive
else re.IGNORECASE
681 pattern_re = re.compile(pattern, flags)
682 except re.error
as e:
683 return {
"error": f
"Invalid regex pattern: {e}"}
693 for fp
in search_candidates:
694 if tool_cancel_requested():
697 "total_matches": len(matches),
698 "searched_files": files_searched,
700 "case_sensitive": case_sensitive,
705 if is_protected_read_path(fp):
709 with open(fp,
"r", encoding=
"utf-8", errors=
"replace")
as fh:
711 ln
for ln, line
in enumerate(fh, start=1)
712 if pattern_re.search(line)
716 "file": str(fp.relative_to(_sandbox_root())),
717 "line_numbers": line_matches,
718 "match_count": len(line_matches),
720 if len(matches) >= max_results:
722 except (UnicodeDecodeError, OSError):
727 "total_matches": len(matches),
728 "searched_files": files_searched,
730 "case_sensitive": case_sensitive,
732 except Exception
as e:
733 return {
"error": f
"Search error: {e}"}
737 "run_command": run_command,
738 "text_search": text_search,
745 "name":
"run_command",
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."
758 "description":
"The shell command to run.",
762 "description":
"Timeout in seconds (default 30).",
765 "required": [
"command"],
772 "name":
"text_search",
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."
786 "description":
"Regular-expression pattern to search for.",
790 "description":
"File or directory to search (default: project root '.').",
794 "description":
"Match case when true (default false).",
798 "description":
"Maximum number of matching files to return (default 100).",
801 "required": [
"pattern"],