fix(agent): protect the runtime's own interpreter from agent deletes
A session asked to clean up older Pythons removed the uv-managed base
interpreter its own venv depended on; the next boot died with 'uv
trampoline failed to spawn Python child process' and no agent tool could
repair it, because the agent itself no longer started (#58748). Prior
uninstall detection (85ce25687e) only flagged package-manager commands.
Add agent/runtime_self_protection.py and wire it into both layers:
- The approval floor (_floor_block) now blocks shell commands that
delete the running interpreter, its own venv, the pyvenv.cfg base, or
the uv-managed install directory — rm/rmdir/rd/del/erase/Remove-Item
with any flags, find <root> -delete, and uv python uninstall of the
running version (including --all). The floor runs before yolo /
approvals.mode=off / cron approve mode, so no session setting can
bypass it.
- The file-safety write classifier denies write/patch/move/delete to the
same paths, so the file tools cannot overwrite the interpreter either.
Only the runtime the process itself boots from is protected; every other
venv and interpreter on the machine stays manageable.
Fixes #58748
This commit is contained in:
@@ -255,6 +255,15 @@ def _classify_write_denial(path: str) -> Optional[str]:
|
||||
return "nt_namespace"
|
||||
homes, resolved = _homes_and_resolved(path)
|
||||
|
||||
# The runtime's own interpreter/venv is never agent-writable (an overwrite
|
||||
# bricks the next start exactly like a delete, #58748) — and this must fire
|
||||
# BEFORE the approval-gated allow so ~/.ssh-style gating cannot re-open it.
|
||||
from agent.runtime_self_protection import is_protected_path
|
||||
|
||||
runtime_hit = is_protected_path(path)
|
||||
if runtime_hit:
|
||||
return "credential"
|
||||
|
||||
# Approval-gated paths are allowed at this layer so interactive tools can
|
||||
# prompt; checked first so the ``.ssh/`` prefix deny doesn't swallow them.
|
||||
if any(resolved in build_write_approval_paths(home) for home in homes):
|
||||
|
||||
270
agent/runtime_self_protection.py
Normal file
270
agent/runtime_self_protection.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""The running Hermes runtime's own interpreter/venv is not agent-deletable.
|
||||
|
||||
Deleting the Python interpreter the current process boots from — or the base
|
||||
interpreter its venv depends on — bricks the install: the next boot fails with
|
||||
``uv trampoline failed to spawn Python child process`` and no amount of agent
|
||||
work can repair it, because the agent itself no longer starts (#58748). The
|
||||
same applies to overwriting the interpreter through the file tools.
|
||||
|
||||
Two questions, one coordinate system:
|
||||
|
||||
* :func:`command_deletes_runtime` — would a shell command delete a protected
|
||||
path? Wired into the approval floor (``tools.approval._floor_block``), so it
|
||||
cannot be bypassed by yolo / approvals.mode=off / cron approve mode.
|
||||
* :func:`is_protected_path` — is a filesystem path protected? Wired into the
|
||||
file-safety write classifier, covering write/patch/move/delete.
|
||||
|
||||
Protected set (all resolved through the same normalizer so shell spellings —
|
||||
native, git-bash ``/c/...``, WSL ``/mnt/c/...``, ``$HOME`` — compare equal):
|
||||
|
||||
* ``sys.executable`` and ``sys.prefix`` (the runtime's own venv),
|
||||
* the base interpreter from ``pyvenv.cfg``'s ``home =`` / ``sys._base_executable``,
|
||||
* the uv-managed install directory holding that base (matched by version for
|
||||
``uv python uninstall``).
|
||||
|
||||
Deliberately NOT protected: any other venv or interpreter on the machine — the
|
||||
agent may freely manage project environments. Only the runtime it is itself
|
||||
running from is off-limits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Split a command line into individually-analyzable commands. Newlines, `;`,
|
||||
# `&&`, `||` and `|` each start a fresh command whose own name decides whether
|
||||
# it deletes anything.
|
||||
_SEGMENT_SPLIT_RE = re.compile(r"\n|&&|\|\||[;|]")
|
||||
|
||||
# Words whose argument tail is a *different* command (sudo rm ...) or an
|
||||
# environment assignment prefix (FOO=1 rm ...).
|
||||
_COMMAND_PREFIXES = frozenset({"sudo", "command", "nohup", "exec", "env", "nice", "time"})
|
||||
|
||||
# Direct-deletion command names (basename, lowercase). `rm` covers POSIX and
|
||||
# git-bash; the Windows-native spellings cover cmd (`rd`, `del`, `rmdir /s`)
|
||||
# and PowerShell (`Remove-Item`, and its `ri`/`rm`/`del`/`erase` aliases).
|
||||
_DELETING_COMMANDS = frozenset({
|
||||
"rm", "rmdir", "rd", "del", "erase", "remove-item", "ri",
|
||||
})
|
||||
|
||||
# `find <roots...> -delete` (or `-exec rm ...`) deletes its search roots.
|
||||
_FIND_DELETE_RE = re.compile(r"(?:^|\s)-(?:delete|exec\s+rm\b)", re.IGNORECASE)
|
||||
|
||||
# uv-managed install layout: .../uv/python/cpython-3.14.7-arm64-apple-darwin/...
|
||||
_UV_INSTALL_DIR_RE = re.compile(
|
||||
r"(?i)(?P<root>.*[/\\](?:uv[/\\])?python[/\\]cpython-(?P<version>\d+(?:\.\d+)*)(?:[-_][^/\\]*)?)(?:[/\\].*)?$"
|
||||
)
|
||||
|
||||
_WIN_DRIVE_FROM_POSIX_RE = re.compile(r"^/(?:(mnt)/)?([a-zA-Z])/(.+)$")
|
||||
|
||||
|
||||
def _normalize_path(raw: str) -> str:
|
||||
"""One canonical form for both shell spellings and runtime paths.
|
||||
|
||||
Expands ``~``/``$HOME``, maps git-bash (``/c/...``) and WSL (``/mnt/c/...``)
|
||||
drive forms to native Windows paths, then ``realpath``s so symlink mirrors
|
||||
(macOS ``/private``) and relocated installs compare equal. Missing tails are
|
||||
fine: POSIX ``realpath`` resolves the existing prefix lexically.
|
||||
"""
|
||||
path = str(raw or "").strip()
|
||||
if not path:
|
||||
return ""
|
||||
if len(path) >= 2 and path[0] == path[-1] and path[0] in "\"'":
|
||||
path = path[1:-1]
|
||||
try:
|
||||
path = os.path.expandvars(os.path.expanduser(path))
|
||||
except Exception:
|
||||
try:
|
||||
path = os.path.expanduser(path)
|
||||
except Exception:
|
||||
pass
|
||||
if os.name == "nt":
|
||||
m = _WIN_DRIVE_FROM_POSIX_RE.match(path)
|
||||
if m:
|
||||
path = f"{m.group(2)}:\\{m.group(3)}"
|
||||
path = path.replace("/", "\\")
|
||||
try:
|
||||
return os.path.normcase(os.path.realpath(os.path.normpath(path)))
|
||||
except Exception:
|
||||
return os.path.normcase(os.path.normpath(path))
|
||||
|
||||
|
||||
def _pyvenv_home(prefix: str) -> str:
|
||||
"""The ``home =`` base-interpreter directory from ``<prefix>/pyvenv.cfg``."""
|
||||
try:
|
||||
lines = (Path(prefix) / "pyvenv.cfg").read_text(encoding="utf-8-sig", errors="replace").splitlines()
|
||||
except Exception:
|
||||
return ""
|
||||
for line in lines:
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip().lower() == "home":
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _protected_snapshot(executable: str, prefix: str) -> tuple[tuple[str, str], ...]:
|
||||
"""Resolved ``(path, description)`` pairs for the runtime's own interpreter.
|
||||
|
||||
Cached per (executable, prefix) — the pair cannot change while a process
|
||||
runs, and keying on the inputs lets tests swap ``sys`` attributes freely.
|
||||
"""
|
||||
entries: list[tuple[str, str]] = []
|
||||
|
||||
exe = _normalize_path(executable)
|
||||
if exe:
|
||||
entries.append((exe, "the Python interpreter this Hermes runtime is running from"))
|
||||
|
||||
venv = _normalize_path(prefix)
|
||||
if venv and venv != exe:
|
||||
entries.append((venv, "this Hermes runtime's own virtualenv"))
|
||||
|
||||
base_dir = _normalize_path(_pyvenv_home(prefix)) or _normalize_path(getattr(sys, "_base_executable", "") or "")
|
||||
if base_dir:
|
||||
entries.append((base_dir, "the base interpreter this Hermes venv depends on"))
|
||||
m = _UV_INSTALL_DIR_RE.match(base_dir.replace("\\", "/"))
|
||||
if m:
|
||||
uv_root = _normalize_path(m.group("root"))
|
||||
if uv_root:
|
||||
entries.append((uv_root, f"the uv-managed Python install this Hermes venv depends on ({m.group('version')})"))
|
||||
|
||||
return tuple((path, desc) for path, desc in entries if path)
|
||||
|
||||
|
||||
def _protected() -> tuple[tuple[str, str], ...]:
|
||||
return _protected_snapshot(getattr(sys, "executable", "") or "", getattr(sys, "prefix", "") or "")
|
||||
|
||||
|
||||
def _overlaps(a: str, b: str) -> bool:
|
||||
"""True when ``a`` and ``b`` are the same path or one is an ancestor of the other."""
|
||||
if not a or not b:
|
||||
return False
|
||||
sep = os.sep
|
||||
return a == b or a.startswith(b + sep) or b.startswith(a + sep)
|
||||
|
||||
|
||||
def is_protected_path(path: str) -> Optional[str]:
|
||||
"""Description of the protected runtime path ``path`` touches, else ``None``."""
|
||||
resolved = _normalize_path(path)
|
||||
if not resolved:
|
||||
return None
|
||||
for protected, description in _protected():
|
||||
if _overlaps(resolved, protected):
|
||||
return description
|
||||
return None
|
||||
|
||||
|
||||
def _split_words(segment: str) -> list[str]:
|
||||
try:
|
||||
return shlex.split(segment)
|
||||
except ValueError:
|
||||
return segment.split()
|
||||
|
||||
|
||||
def _strip_prefixes(words: list[str]) -> list[str]:
|
||||
out = list(words)
|
||||
while out:
|
||||
first = out[0]
|
||||
if first in _COMMAND_PREFIXES:
|
||||
out = out[1:]
|
||||
continue
|
||||
if "=" in first and not first.startswith(("-", "/")) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", first):
|
||||
out = out[1:]
|
||||
continue
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _version_spec_matches(spec: str, version: str, uv_root_name: str) -> bool:
|
||||
"""``uv python uninstall`` spec against the running base's version/dir."""
|
||||
spec = spec.strip().lower()
|
||||
if not spec:
|
||||
return False
|
||||
if spec in ("--all", "-a", "all"):
|
||||
return True
|
||||
if spec.startswith("-"):
|
||||
return False
|
||||
if uv_root_name.lower().startswith(spec):
|
||||
return True
|
||||
return spec == version or (version.startswith(spec) and version[len(spec):len(spec) + 1] == ".")
|
||||
|
||||
|
||||
def _uv_uninstall_target(words: list[str]) -> Optional[str]:
|
||||
"""Description when ``uv python uninstall`` would remove the running base."""
|
||||
protected = _protected()
|
||||
uv_entries = [
|
||||
(root, desc) for root, desc in protected
|
||||
if desc.startswith("the uv-managed Python install")
|
||||
]
|
||||
if not uv_entries:
|
||||
return None
|
||||
lowered = [w.lower() for w in words]
|
||||
try:
|
||||
python_at = lowered.index("python")
|
||||
uninstall_at = lowered.index("uninstall", python_at + 1)
|
||||
except ValueError:
|
||||
return None
|
||||
specs = words[uninstall_at + 1:]
|
||||
for root, description in uv_entries:
|
||||
m = _UV_INSTALL_DIR_RE.match(root.replace("\\", "/"))
|
||||
version = m.group("version") if m else ""
|
||||
root_name = os.path.basename(root)
|
||||
if any(_version_spec_matches(spec, version, root_name) for spec in specs):
|
||||
return description
|
||||
return None
|
||||
|
||||
|
||||
def command_deletes_runtime(command: str) -> Optional[str]:
|
||||
"""Description of the runtime path ``command`` would delete, else ``None``.
|
||||
|
||||
Covers ``rm``/``rmdir``/``rd``/``del``/``erase``/``Remove-Item`` (any flags —
|
||||
the interpreter is a file, so no recursion is needed to kill it), the
|
||||
``find <roots> -delete`` form, and ``uv python uninstall``. Known limits:
|
||||
paths assembled by ``xargs``/command substitution are invisible here, and
|
||||
``shutil.rmtree`` inside executed *code* is a different sandbox.
|
||||
"""
|
||||
if not command or not command.strip():
|
||||
return None
|
||||
protected = _protected()
|
||||
if not protected:
|
||||
return None
|
||||
|
||||
for segment in _SEGMENT_SPLIT_RE.split(command):
|
||||
words = _strip_prefixes(_split_words(segment))
|
||||
if not words:
|
||||
continue
|
||||
name = os.path.basename(words[0]).lower()
|
||||
|
||||
if name == "uv":
|
||||
target = _uv_uninstall_target(words)
|
||||
if target:
|
||||
return target
|
||||
continue
|
||||
|
||||
if name == "find" and _FIND_DELETE_RE.search(segment):
|
||||
for word in words[1:]:
|
||||
if not word.startswith("-"):
|
||||
hit = is_protected_path(word)
|
||||
if hit:
|
||||
return hit
|
||||
continue
|
||||
|
||||
if name not in _DELETING_COMMANDS:
|
||||
continue
|
||||
|
||||
for word in words[1:]:
|
||||
if word.startswith("-"):
|
||||
continue
|
||||
hit = is_protected_path(word)
|
||||
if hit:
|
||||
return hit
|
||||
|
||||
return None
|
||||
161
tests/agent/test_runtime_self_protection.py
Normal file
161
tests/agent/test_runtime_self_protection.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""The runtime's own interpreter/venv is not agent-deletable (#58748).
|
||||
|
||||
A Hermes session asked to clean up "older Pythons" removed the base
|
||||
interpreter its own venv pointed at; the next boot died with ``uv trampoline
|
||||
failed to spawn Python child process``. These tests pin both defense layers:
|
||||
|
||||
* the approval floor (``_floor_block``) must hard-block shell commands that
|
||||
delete the running interpreter/venv/base — even under yolo;
|
||||
* the file-safety write classifier must deny writes/deletes to them.
|
||||
|
||||
The ``_protected_snapshot`` cache is keyed on (executable, prefix), so tests
|
||||
swap ``sys`` attributes and let the cache key change with them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import runtime_self_protection as rsp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""A venv whose pyvenv.cfg points at a uv-managed base interpreter."""
|
||||
venv = tmp_path / "hermes-agent" / "venv"
|
||||
venv.mkdir(parents=True)
|
||||
exe_dir = venv / ("Scripts" if sys.platform == "win32" else "bin")
|
||||
exe_dir.mkdir()
|
||||
exe = exe_dir / ("python.exe" if sys.platform == "win32" else "python")
|
||||
exe.write_text("", encoding="utf-8")
|
||||
|
||||
uv_base = tmp_path / "uv" / "python" / "cpython-3.11.9-windows-x86_64-none"
|
||||
uv_base.mkdir(parents=True)
|
||||
base_exe_dir = uv_base / ("Scripts" if sys.platform == "win32" else "bin")
|
||||
base_exe_dir.mkdir()
|
||||
(base_exe_dir / ("python.exe" if sys.platform == "win32" else "python")).write_text("", encoding="utf-8")
|
||||
|
||||
home_dir = str(base_exe_dir)
|
||||
(venv / "pyvenv.cfg").write_text(
|
||||
f"home = {home_dir}\nversion = 3.11.9\ninclude-system-site-packages = false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sys, "executable", str(exe))
|
||||
monkeypatch.setattr(sys, "prefix", str(venv))
|
||||
rsp._protected_snapshot.cache_clear()
|
||||
yield {"exe": str(exe), "venv": str(venv), "uv_base": str(uv_base), "base_dir": home_dir}
|
||||
rsp._protected_snapshot.cache_clear()
|
||||
|
||||
|
||||
def test_pyvenv_home_is_parsed(fake_runtime):
|
||||
assert rsp._pyvenv_home(fake_runtime["venv"]) == fake_runtime["base_dir"]
|
||||
|
||||
|
||||
def test_rm_of_running_interpreter_is_detected(fake_runtime):
|
||||
assert rsp.command_deletes_runtime(f'rm "{fake_runtime["exe"]}"') is not None
|
||||
|
||||
|
||||
def test_rm_rf_of_own_venv_is_detected(fake_runtime):
|
||||
assert rsp.command_deletes_runtime(f"rm -rf {fake_runtime['venv']}") is not None
|
||||
|
||||
|
||||
def test_rm_of_base_interpreter_is_detected(fake_runtime):
|
||||
base_exe = os.path.join(fake_runtime["base_dir"], os.listdir(fake_runtime["base_dir"])[0])
|
||||
assert rsp.command_deletes_runtime(f"sudo rm -f '{base_exe}'") is not None
|
||||
|
||||
|
||||
def test_rm_of_whole_uv_install_dir_is_detected(fake_runtime):
|
||||
assert rsp.command_deletes_runtime(f"rm -rf {fake_runtime['uv_base']}") is not None
|
||||
|
||||
|
||||
def test_uv_python_uninstall_of_running_version_is_detected(fake_runtime):
|
||||
assert rsp.command_deletes_runtime("uv python uninstall 3.11") is not None
|
||||
|
||||
|
||||
def test_uv_python_uninstall_all_is_detected(fake_runtime):
|
||||
assert rsp.command_deletes_runtime("uv python uninstall --all") is not None
|
||||
|
||||
|
||||
def test_uv_python_uninstall_of_other_version_is_allowed(fake_runtime):
|
||||
assert rsp.command_deletes_runtime("uv python uninstall 3.9") is None
|
||||
|
||||
|
||||
def test_find_delete_over_own_venv_is_detected(fake_runtime):
|
||||
# Both are deletes under the protected root: an unfiltered -delete removes
|
||||
# the interpreter itself, and a filtered one still deletes protected files.
|
||||
assert rsp.command_deletes_runtime(f"find '{fake_runtime['venv']}' -delete") is not None
|
||||
assert rsp.command_deletes_runtime(f"find '{fake_runtime['venv']}' -name __pycache__ -delete") is not None
|
||||
assert rsp.command_deletes_runtime(f"find /tmp -name __pycache__ -delete") is None
|
||||
|
||||
|
||||
def test_rm_of_unrelated_venv_is_allowed(fake_runtime, tmp_path):
|
||||
other = tmp_path / "project" / ".venv"
|
||||
other.mkdir(parents=True)
|
||||
assert rsp.command_deletes_runtime(f"rm -rf {other}") is None
|
||||
|
||||
|
||||
def test_windows_del_and_powershell_spellings(fake_runtime, monkeypatch):
|
||||
with mock.patch.object(os, "name", "nt"):
|
||||
exe = fake_runtime["exe"]
|
||||
assert rsp.command_deletes_runtime(f'del "{exe}"') is not None
|
||||
assert rsp.command_deletes_runtime(f'Remove-Item -Recurse -Force "{exe}"') is not None
|
||||
assert rsp.command_deletes_runtime(f"rd /s /q {fake_runtime['venv']}") is not None
|
||||
|
||||
|
||||
def test_mkdir_over_protected_path_not_flagged_as_delete(fake_runtime):
|
||||
# mkdir touches the venv dir but deletes nothing — command layer says fine
|
||||
# (the file-safety layer still denies the write).
|
||||
assert rsp.command_deletes_runtime(f"mkdir -p {fake_runtime['venv']}") is None
|
||||
|
||||
|
||||
def test_is_protected_path_covers_venv_children_and_base(fake_runtime):
|
||||
site = os.path.join(fake_runtime["venv"], "lib", "site-packages", "x.py")
|
||||
assert rsp.is_protected_path(site) is not None
|
||||
assert rsp.is_protected_path(fake_runtime["base_dir"]) is not None
|
||||
assert rsp.is_protected_path("/tmp/scratch.txt") is None
|
||||
|
||||
|
||||
def test_file_safety_denies_write_to_running_interpreter(fake_runtime):
|
||||
from agent.file_safety import _classify_write_denial
|
||||
|
||||
assert _classify_write_denial(fake_runtime["exe"]) == "credential"
|
||||
assert _classify_write_denial(os.path.join(fake_runtime["venv"], "pyvenv.cfg")) == "credential"
|
||||
|
||||
|
||||
def test_file_safety_allows_unrelated_paths(fake_runtime, tmp_path):
|
||||
from agent.file_safety import _classify_write_denial
|
||||
|
||||
scratch = tmp_path / "scratch.txt"
|
||||
assert _classify_write_denial(str(scratch)) is None
|
||||
|
||||
|
||||
def test_approval_floor_blocks_runtime_delete_even_under_yolo(fake_runtime, monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
monkeypatch.setattr(approval, "_yolo_active", lambda: True)
|
||||
result = approval._floor_block(f'rm -rf "{fake_runtime["venv"]}"')
|
||||
assert result is not None
|
||||
assert result.get("approved") is False
|
||||
|
||||
|
||||
def test_check_all_guards_blocks_runtime_delete(fake_runtime, monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
# Outside CLI/gateway/ask contexts with yolo on, the floor is the only
|
||||
# thing standing; it must still block.
|
||||
monkeypatch.setattr(approval, "_yolo_active", lambda: True)
|
||||
result = approval.check_all_command_guards(f'rm "{fake_runtime["exe"]}"', "local")
|
||||
assert result.get("approved") is False
|
||||
|
||||
|
||||
def test_normal_command_unaffected(fake_runtime, monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
monkeypatch.setattr(approval, "_yolo_active", lambda: True)
|
||||
assert approval.check_all_command_guards("ls /tmp", "local").get("approved") is True
|
||||
@@ -1053,11 +1053,20 @@ def _floor_block(command: str, *, sudo_guard: bool = False) -> dict | None:
|
||||
"""Unconditional floors, BEFORE yolo / mode=off / cron approve-mode so no
|
||||
session-level setting can bypass them: hardline catastrophic commands,
|
||||
password-piping to ``sudo -S`` with no SUDO_PASSWORD configured (full guard
|
||||
only), and the user's own approvals.deny rules ("never, even under yolo")."""
|
||||
only), the user's own approvals.deny rules ("never, even under yolo"), and
|
||||
deletion of the Python interpreter/venv this very runtime boots from (a
|
||||
delete the agent cannot walk back — the next start fails before any tool
|
||||
can run, #58748)."""
|
||||
from agent.runtime_self_protection import command_deletes_runtime
|
||||
|
||||
is_hardline, hardline_desc = detect_hardline_command(command)
|
||||
if is_hardline:
|
||||
logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200])
|
||||
return _hardline_block_result(hardline_desc, command)
|
||||
runtime_target = command_deletes_runtime(command)
|
||||
if runtime_target:
|
||||
logger.warning("Runtime self-delete block: %s (command: %s)", runtime_target, command[:200])
|
||||
return _hardline_block_result(f"recursive/any delete of {runtime_target}", command)
|
||||
if sudo_guard:
|
||||
is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command)
|
||||
if is_sudo_guess:
|
||||
|
||||
Reference in New Issue
Block a user