oc_chat
Loading...
Searching...
No Matches
oc_chat.tools.shell Namespace Reference

Classes

class  HumanBudget

Functions

str _classify (str command)
bool _is_allowed (str command)
Iterator[None] human_confirmed_command (str command)
Iterator[None] human_budget ("int | None" seconds, *, "Callable[[], int | None] | None" on_expired=None)
dict deny_unconfirmed_tier2 (dict result, *, str note="")
"tuple[str, str]" _first_value_option (str token)
int _python_m_pip_at (list[str] argv)
int _pip_subcommand_at (list[str] argv, str program)
"tuple[list[str] | None, str]" _pip_index_pin (list[str] argv, str program)
CommandResult|ConfirmationRequired|ToolError run_command (str command, int timeout=30, bool confirmed=False)
dict text_search (str pattern, str search_dir=".", *, str|None file_path=None, bool case_sensitive=False, int max_results=100)

Variables

contextvars _human_approved_command
frozenset _PYTHON_PROGRAMS = frozenset({"python", "python3", "py"})
frozenset _PY_SHORT_VALUE_OPTIONS = frozenset({"c", "m", "W", "X"})
frozenset _PY_SHORT_FLAG_OPTIONS = frozenset("bdhiqstuvxBEIOPRSV?")
str _PY_UNKNOWN_OPTION = "\0"
dict TOOLS
list SCHEMAS

Detailed Description

Sandboxed shell execution with a whole-argv command allowlist (M8.0 WU-3).

Purpose
-------
Provides :func:`run_command`, which parses a command string to argv,
rejects shell metacharacters outside quotes, and validates the WHOLE argv against the
locked Appendix A allowlist (:mod:`oc_chat.tools._argv_policy`) before
executing it ``shell=False`` with a scrubbed environment. There is no
``shell=True`` lane and no Unix→PowerShell conversion (F55).

**Tier 1 — allowed (read-only / no side-effects):** ``pip list``/``show``/
``freeze``, ``where``/``which``, ``dir``/``ls`` (a small option set + ≤1
in-sandbox path arg), ``echo`` (argv-only). Reads go through the filesystem
tools and git through ``oct_git_*`` — so ``git`` (freeform), ``cat``/``type``/
``grep``/``findstr``, ``curl``/``wget``, the shells, and the cmd.exe mutators
are **denied**.

**Tier 2 — human-confirm / grant-gated (argv-only):** ``python``/``pytest``/
``pip install``|``uninstall``/``oct``. M8.0-A (LD-4): a Tier-2 command is **not
executable by the agent** — a *model-supplied* flag never authorizes it. It
returns a ``requires_confirmation`` dict that no model-reachable path can satisfy
(fail closed). M8.0-C: the react-loop relays that signal to an interactive human
and, on 'y', re-authorizes the exact command via :func:`human_confirmed_command`
(a ContextVar the model cannot set); headless / sub-loop / batch fail closed.
One-use signed grants for headless Tier-2 are M8.1.

**The two timeouts (plan 283).** ``clamp_timeout`` bounds the ``timeout`` the
MODEL supplies — the M8 Appendix B ceiling, 300 s, with no out-of-band raise
path — and applies to every command. :func:`human_budget` is a second, separate
value carried the same way the approval is, read ONLY after the exact-string
approval matched, and it is the approving human's own patience: a number of
seconds, or ``None`` for "until it finishes". The ceiling is what the agent may
decide; the budget is what the human decided.

Responsibilities
----------------
- :func:`_classify` / :func:`_is_allowed` — back-compat wrappers over the
  whole-argv policy decision (``_argv_policy.classify_command``).
- :func:`run_command` — orchestrate validation, optional confirmation
  prompt, and ``subprocess.run`` execution with timeout and captured
  output.
- :func:`human_confirmed_command` / :func:`human_budget` — the two ContextVar
  seams the interactive react-loop sets after a human answers the Tier-2 ask:
  what was approved, and how long they will wait for it.
- :func:`_pip_index_pin` — the F78 index pin: an index-reaching ``pip``
  subcommand executes with ``--isolated --index-url <operator-trusted>``
  appended after the subcommand, and the ``python -m pip`` spelling of a
  command that names its own index is refused on the argv policy's terms.
- :func:`_python_m_pip_at` / :func:`_first_value_option` — CPython's own
  ``-m`` spellings, so the attached and clustered forms (``-mpip``, ``-qmpip``)
  reach the pin and ``-Xmpip`` (X's value) correctly does not.
- ``TOOLS`` and ``SCHEMAS`` for auto-registration.

Diagnostics
-----------
Domain: OC_CHAT.TOOLS
Levels:
    L2 — lifecycle events
    L3 — semantic details
    L4 — deep tracing

Contracts
---------
- Commands not matching any whitelist are *blocked* — they return an
  error dict, never silently execute.
- Mutating ``git`` (add/commit/push) is intentionally *not* in either
  whitelist; agents must use ``oct_git_*`` instead.
- Subprocess calls always run with a finite timeout and captured
  stdout/stderr — no streaming output, no PTY.
- Tier-2 (code-exec / mutation) commands are not agent-runnable (M8.0-A,
  LD-4): a model-supplied flag never authorizes them; they return
  ``requires_confirmation`` and fail closed. Only the out-of-band human-confirm
  channel (M8.0-C, via :func:`human_confirmed_command`) or a one-use grant
  (M8.1) authorizes them.
- A granted ``pip install`` / ``pip download`` runs against the operator-trusted
  index and nothing else (M8 F78, SUPPLY-02 / plan 253). The pin is computed
  BEFORE the confirmation is offered and carried in the reason the human reads,
  so what is approved is what runs; it changes what EXECUTES and never what
  AUTHORIZES — the exact approved command string remains the only key.

Dependencies
------------
- oc_diagnostics (structured debug logging via _dbg)
- oc_chat.core.tool_results (CommandResult, ConfirmationRequired, ToolError - the run_command return types)
- oc_chat.tools._sandbox (scrubbed child environment, output secret redaction and the protected-read check)
- 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)
- oc_chat.tools.filesystem (lazy; _sandbox_root, _safe_path, _outside_sandbox_error - the sandbox root and search path checks)
- oc_chat.core.defaults (lazy; authority - the operator-trusted pip index URL for the F78 pin)
- oc_chat.core.resource_caps (lazy; clamp_timeout, clamp_results - the M8 ceilings on model-supplied arguments)
- oc_chat.tools._proc (lazy; run_cancellable - the cancellable subprocess runner)
- oc_chat.core.tool_activity (lazy; TOOL_ACTIVITY - the terminal activity record fed redacted output lines)
- oc_chat.core.cancellation (lazy; tool_cancel_requested - cooperative Ctrl+C polling in text_search)
- cmd (not imported; the Windows command interpreter, run as cmd /v:off /c for the built-ins dir and echo)

See Also
--------
oc_chat/docs/conventions/CONTRACTS.md §3 Tool registry contract; §4 Tool return-
value conventions (run_command → §4.1; confirmation gating → §4.5).

Function Documentation

◆ _classify()

str oc_chat.tools.shell._classify ( str command)
protected
Return ``'safe'``, ``'confirm'``, or ``'denied'`` for *command*.

Back-compat wrapper over the whole-argv policy (M8.0 WU-3): Appendix A
``allow`` → ``'safe'``, ``confirm`` → ``'confirm'``, ``deny`` → ``'denied'``.

Definition at line 143 of file shell.py.

Here is the caller graph for this function:

◆ _first_value_option()

"tuple[str, str]" oc_chat.tools.shell._first_value_option ( str token)
protected
The first value-taking option letter in a single-dash CPython *token*,
and the value attached to it inside that same token.

Returns ``("", "")`` for a cluster of pure flags and
``(_PY_UNKNOWN_OPTION, "")`` for a letter the interpreter refuses. A flag
continues the cluster; the first value-taking letter ends it and takes the
remainder of the token as its value — empty when the token ends there, in
which case the value is the next argv element.

Definition at line 301 of file shell.py.

Here is the caller graph for this function:

◆ _is_allowed()

bool oc_chat.tools.shell._is_allowed ( str command)
protected
Return ``True`` if *command* is admitted by the whole-argv policy
(Tier-1 ``allow`` or Tier-2 ``confirm``); ``False`` if denied.

Definition at line 154 of file shell.py.

Here is the call graph for this function:

◆ _pip_index_pin()

"tuple[list[str] | None, str]" oc_chat.tools.shell._pip_index_pin ( list[str] argv,
str program )
protected
Pin an index-reaching pip *argv* to the operator-trusted index (F78).

Returns ``(executed_argv, denial)``. *executed_argv* is ``None`` when *argv*
is not an index-reaching pip command, and the caller executes it unchanged.
*denial* is non-empty when the command names an index or a trusted host
itself: the argv policy already refuses that for the bare ``pip`` spelling,
and this closes the ``python -m pip`` spelling on the same terms. Refusing
rather than overriding is deliberate — appending a pin to a command that
already carries ``--index-url`` would leave which index wins to pip's own
last-one-wins parsing, and would leave the human approving a string whose
index quietly did not apply.

Definition at line 379 of file shell.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _pip_subcommand_at()

int oc_chat.tools.shell._pip_subcommand_at ( list[str] argv,
str program )
protected
Position of an index-reaching pip subcommand in *argv*, or ``-1``.

Both Tier-2 spellings are matched: a direct ``pip install ...`` and
``python -m pip install ...`` — the pin applies after ``pip`` wherever it
sits in the argv, in whichever spelling the interpreter accepts
(:func:`_python_m_pip_at`).

Definition at line 360 of file shell.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _python_m_pip_at()

int oc_chat.tools.shell._python_m_pip_at ( list[str] argv)
protected
Index of the token after ``-m pip`` in a *python* argv, or ``-1``.

``-m`` takes a value, and CPython's own parser accepts that value ATTACHED
to the option token as readily as separated: ``-mpip``, and inside a flag
cluster ``-qmpip`` / ``-Impip`` / ``-SEmpip``, are all ``-m pip``. Matching
only a bare ``-m`` token let the attached spelling walk past both the
refusal and the pin (gate finding, 2026-09-09) — the same class of gap the
optparse abbreviations were for pip's own options.

The scan is deliberately no WIDER than the interpreter. ``-W`` and ``-X``
take their values the same way, so ``-Xmpip`` is X's value and not a module;
``-c`` terminates the option list, so ``-cmpip`` is code; and a token that
is not an option at all is the script path, after which no module can
follow. Each of those was verified by running CPython 3.14, because a scan
that matched more than the interpreter does would deny commands that never
reach pip at all.

Definition at line 319 of file shell.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ deny_unconfirmed_tier2()

dict oc_chat.tools.shell.deny_unconfirmed_tier2 ( dict result,
* ,
str note = "" )
Fail-closed converter for contexts with no interactive human.

A Tier-2 ``requires_confirmation`` cannot be satisfied without a human, so in a
sub-loop / batch / headless run it is converted to a terminal ``error`` the model
can act on (stop asking) instead of being handed back the un-satisfiable signal.
Any other result passes through unchanged (identity).

Definition at line 247 of file shell.py.

◆ human_budget()

Iterator[None] oc_chat.tools.shell.human_budget ( "int | None" seconds,
* ,
"Callable[[], int | None] | None" on_expired = None )
Set the approving human's wait for one confirmed :func:`run_command`.

For use ONLY by the interactive react-loop, alongside
:func:`human_confirmed_command`, after a human answered the Tier-2 ask
(plan 283). *seconds* is the budget they chose and ``None`` means they chose
"until it finishes"; *on_expired* is the callable that will ask them for one
extension when the budget runs out. Like the approval token, this is reset on
exit, so a budget never leaks to another command or outlives the one call.

It authorises NOTHING on its own: a budget without a matching
:func:`human_confirmed_command` is never read, because the branch that reads
it is only reached once the exact-string approval has already matched.

Definition at line 224 of file shell.py.

◆ human_confirmed_command()

Iterator[None] oc_chat.tools.shell.human_confirmed_command ( str command)
Authorize exactly *command* for one Tier-2 :func:`run_command` execution.

For use ONLY by the interactive react-loop after an out-of-band human 'y'
(M8.0-C). Not authority the model can forge: it sets no env var and is not a
tool argument. The token is reset on exit so the grant is scoped to the single
re-dispatch this brackets.

Definition at line 178 of file shell.py.

◆ run_command()

CommandResult | ConfirmationRequired | ToolError oc_chat.tools.shell.run_command ( str command,
int timeout = 30,
bool confirmed = False )
Execute a whitelisted shell command and return its output.

Parameters
----------
command : str
    Shell command string.
timeout : int
    Maximum seconds to wait (default 30), clamped to the M8 ceiling of
    300 s. On the human-confirmed Tier-2 path an out-of-band
    :func:`human_budget` replaces it with the approving human's own wait —
    see the module docstring's "The two timeouts".
confirmed : bool
    Retained for back-compat and **ignored** (M8.0-A): a model-supplied flag
    never authorizes a Tier-2 command. The only authorization is the out-of-band
    :func:`human_confirmed_command` ContextVar (interactive human 'y', M8.0-C) —
    unreachable by the model — or a one-use grant (M8.1).

Returns
-------
dict
    ``{"stdout": str, "stderr": str, "returncode": int}`` on success, or
    ``{"requires_confirmation": True, "command": str, "reason": str, "message":
    str}`` for a Tier-2 command the agent cannot self-authorize (the react-loop
    relays it to a human via :func:`_resolve_tier2_confirmation`).

Definition at line 407 of file shell.py.

Here is the call graph for this function:

◆ text_search()

dict oc_chat.tools.shell.text_search ( str pattern,
str search_dir = ".",
* ,
str | None file_path = None,
bool case_sensitive = False,
int max_results = 100 )
Search for a text pattern in files under *search_dir*.

Phase P.10a: the historical parameter name was ``file_path``. That
name was misleading (the argument is a *directory*, not a file path).
The canonical name is now ``search_dir``. Callers passing
``file_path=`` still work for now but get a one-time
:class:`DeprecationWarning`; on conflict (both keywords supplied),
``file_path`` wins for backwards compatibility.

A *single file* is also a valid target (F26): ``search_dir`` may name a
file, in which case only that file is searched; a directory walks the tree.

Args:
    pattern: Regular-expression pattern to search for.
    search_dir: File or directory to search (default: project root).
    file_path: **Deprecated** alias for ``search_dir``. Keyword-only.
        Will be removed in a future release.
    case_sensitive: Match case when True (default False).
    max_results: Maximum number of matching files to return (default 100).

Returns:
    Dict with ``matches`` list of ``{file, line_numbers, match_count}``,
    plus ``total_matches``, ``searched_files``, ``pattern``,
    and ``case_sensitive``.

Definition at line 604 of file shell.py.

Variable Documentation

◆ _human_approved_command

contextvars oc_chat.tools.shell._human_approved_command
protected
Initial value:
1= contextvars.ContextVar(
2 "oc_chat_human_approved_command", default=None
3)

Definition at line 172 of file shell.py.

◆ _PY_SHORT_FLAG_OPTIONS

frozenset oc_chat.tools.shell._PY_SHORT_FLAG_OPTIONS = frozenset("bdhiqstuvxBEIOPRSV?")
protected

Definition at line 294 of file shell.py.

◆ _PY_SHORT_VALUE_OPTIONS

frozenset oc_chat.tools.shell._PY_SHORT_VALUE_OPTIONS = frozenset({"c", "m", "W", "X"})
protected

Definition at line 286 of file shell.py.

◆ _PY_UNKNOWN_OPTION

str oc_chat.tools.shell._PY_UNKNOWN_OPTION = "\0"
protected

Definition at line 298 of file shell.py.

◆ _PYTHON_PROGRAMS

frozenset oc_chat.tools.shell._PYTHON_PROGRAMS = frozenset({"python", "python3", "py"})
protected

Definition at line 278 of file shell.py.

◆ SCHEMAS

list oc_chat.tools.shell.SCHEMAS

Definition at line 741 of file shell.py.

◆ TOOLS

dict oc_chat.tools.shell.TOOLS
Initial value:
1= {
2 "run_command": run_command,
3 "text_search": text_search,
4}

Definition at line 736 of file shell.py.