Skip to content
Open
3 changes: 2 additions & 1 deletion src/openjd/sessions/_linux/_sudo.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .._logging import LoggerAdapter, LogContent, LogExtraInfo
from .._os_checker import is_posix, is_linux
from .._system_commands import system_command_path

PGREP_NO_MATCH = 1
"""``pgrep``'s exit status for "no processes matched".
Expand Down Expand Up @@ -174,7 +175,7 @@ def find_child_process_id_pgrep(
sudo_pid: int,
) -> Optional[int]:
pgrep_result = run(
["pgrep", "-P", str(sudo_pid)],
[system_command_path("pgrep"), "-P", str(sudo_pid)],
stdout=PIPE,
stderr=STDOUT,
stdin=DEVNULL,
Expand Down
74 changes: 54 additions & 20 deletions src/openjd/sessions/_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ._logging import LoggerAdapter, LogContent, LogExtraInfo
from ._os_checker import is_linux, is_macos, is_posix, is_windows
from ._session_user import PosixSessionUser, WindowsSessionUser, SessionUser
from ._system_commands import find_system_command, system_command_path
from ._action_filter import redact_openjd_redacted_env_requests

if is_windows(): # pragma: nocover
Expand Down Expand Up @@ -594,7 +595,7 @@ def _start_subprocess(self) -> Optional[Popen]:
)
command.extend(
[
"sudo",
system_command_path("sudo"),
"-u",
user.user,
"-i",
Expand All @@ -605,7 +606,16 @@ def _start_subprocess(self) -> Optional[Popen]:
]
)
else:
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
command.extend(
[
system_command_path("sudo"),
"-u",
user.user,
"-i",
system_command_path("setsid"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: rm/powershell in the session-cleanup argv are still bare names.

The new module states the invariant absolutely — "never let a command name reach execvp-style resolution" — but one privileged argv is not converted. _session.py:580-589 builds the cleanup command that runs as the job user:

if is_posix():
    recursive_delete_cmd = ["rm", "-rf"]
else:
    recursive_delete_cmd = ["powershell", "-Command", "Remove-Item", "-Recurse", "-Force"]
...
_subprocess = LoggingSubprocess(args=recursive_delete_cmd + files, user=self._user, ...)

That reaches _start_subprocess, so on POSIX the argv becomes [<resolved sudo>, -u, user, -i, <resolved setsid>, -w, "rm", "-rf", ...] — the two helpers this PR hardened, followed by a bare rm that setsid resolves through execvp. In the same-user branch rm is argv[0] of the Popen itself. This is an rm -rf over the session directory, which makes it the highest-consequence bare name left in the tree.

Two mitigations apply, and both are worth stating explicitly rather than leaving implicit: sudo -i resets the environment (so the job's PATH does not survive into the login shell), and this LoggingSubprocess is constructed without os_env_vars, so the same-user branch inherits the agent's own PATH rather than the job's. So this is not the same directly-job-controlled path the module's docstring describes.

Still worth either resolving these two names too, or narrowing the module docstring's claim to the argv positions actually covered — as written, a future reader will reasonably conclude no bare command names remain on any privileged path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and parked as out of scope for this change. _session.py is not a file this PR touches, and your own two mitigations are the reason I am comfortable deferring: sudo -i resets the environment before the login shell resolves rm, and that LoggingSubprocess is constructed without os_env_vars. It should be routed through the same resolver in a separate change against _session.py, where it can be reviewed on its own terms.

"-w",
]
)
elif is_windows():
user = cast(WindowsSessionUser, self._user) # type: ignore

Expand Down Expand Up @@ -882,13 +892,15 @@ def _posix_signal_subprocess(
else:
raise NotImplementedError(f"Unsupported signal: {signal_name}")

kill_cmd = list[str]()

if self._user is not None:
user = cast(PosixSessionUser, self._user)
# Only sudo if the user to run as is not the same as the current user.
if not user.is_process_user():
kill_cmd = ["sudo", "-u", user.user, "-i"]
# Whether this signal has to go through sudo. The argv itself is not built
# yet: the direct os.killpg() attempt below returns on success, and on a
# host with CAP_KILL that is the only path taken. Resolving sudo here would
# make a host without sudo unable to signal *even when it never needs
# sudo* -- a regression this method did not have when the name was a bare
# string in a list.
needs_sudo = (
self._user is not None and not cast(PosixSessionUser, self._user).is_process_user()
)

# If we were unable to detect sudo's child process PID after launching the
# subprocess, we try again now
Expand Down Expand Up @@ -941,9 +953,25 @@ def _posix_signal_subprocess(
# Uncomment to visualize process tree when debugging tests
# self._log_process_tree()

# `kill` is resolved only for the same-user branch, where `run()` really
# does execvp() a bare name against this process's PATH.
#
# In the cross-user branch it must stay a bare name. `sudo -i` simulates an
# initial login: it starts the target user's login shell and hands it the
# command via -c, so `kill` is a *shell builtin* there. Nothing is looked up
# on PATH, and -i has already reset the environment, so a job-controlled
# PATH cannot reach this position. Resolving it would invent a hard dependency
# on kill(1) from procps, which Debian `-slim` images -- a common worker
# base -- do not install, and would break every cross-user SIGKILL fallback
# on such a host.
if needs_sudo:
user = cast(PosixSessionUser, self._user)
kill_cmd = [system_command_path("sudo"), "-u", user.user, "-i", "kill"]
else:
kill_cmd = [system_command_path("kill")]

kill_cmd.extend(
[
"kill",
"-s",
signal_name,
"--",
Expand Down Expand Up @@ -971,16 +999,22 @@ def _posix_signal_subprocess(

def _log_process_tree(self) -> None:
"""A developer method to visualize the process tree including PIDs and PGIDs when debuging tests"""
pstree_result = run(["pstree", "-pg"], stdout=PIPE, stderr=STDOUT, stdin=DEVNULL, text=True)
self._logger.debug(
f"pstree -pg output: {pstree_result.stdout}",
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
)
ps_result = run(["ps", "-ejH"], stdout=PIPE, stderr=STDOUT, stdin=DEVNULL, text=True)
self._logger.debug(
f"ps -ejH output:\n{ps_result.stdout}",
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
)
# find_system_command rather than system_command_path: neither of these is
# required for correct operation, and a debug aid must not raise on a host
# that happens not to install pstree.
for name, args in (("pstree", ["-pg"]), ("ps", ["-ejH"])):
command = find_system_command(name)
if command is None:
self._logger.debug(
f"{name} is not installed; skipping its process tree dump",
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
)
continue
result = run([command, *args], stdout=PIPE, stderr=STDOUT, stdin=DEVNULL, text=True)
self._logger.debug(
f"{name} {' '.join(args)} output:\n{result.stdout}",
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
)

def _windows_notify_subprocess(self, process: Popen) -> None:
"""Sends a CTRL_BREAK_EVENT signal to the subprocess.
Expand Down
184 changes: 184 additions & 0 deletions src/openjd/sessions/_system_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

"""Resolution of system command names to absolute paths, without consulting PATH.

The problem: a session launches its own privileged helpers (``sudo``, ``setsid``,
``kill``) with the environment it also gives the job, and that environment
includes the job's ``PATH``. A bare command name in such an argv is resolved
through that ``PATH``, so a job that puts an executable named ``sudo`` early on it
has that executable run at the session's privilege level rather than the job
user's.

The solution: never let a command name reach ``execvp``-style resolution. Callers
pass a bare name here and get back an absolute path found by scanning a fixed
list of trusted directories.

Three properties make that work, and all three are easy to undo by accident:

* ``PATH`` is never read. Not directly, and not through :func:`shutil.which` or
``command -v``, which resolve via ``PATH`` and so would restore the original
behaviour while looking like a fix.
* Only paths under :data:`TRUSTED_SYSTEM_DIRECTORIES` are returned. A name
containing a path separator is rejected, because ``os.path.join`` would
otherwise let ``../../tmp/evil`` escape the directory being searched.
* A command that cannot be found raises. Returning the bare name as a fallback
would put resolution back on ``PATH`` while the code still read as though it
did not.

This module is POSIX-oriented, and :data:`TRUSTED_SYSTEM_DIRECTORIES` lists POSIX
locations. On Windows nothing is found and the lookups raise, which suits this
library because the cross-user paths needing these commands are POSIX-only.
"""

from __future__ import annotations

import os
from typing import Optional, Tuple

__all__ = [
"SystemCommandNotFoundError",
"clear_command_cache",
"TRUSTED_SYSTEM_DIRECTORIES",
"find_system_command",
"system_command_path",
]


TRUSTED_SYSTEM_DIRECTORIES: Tuple[str, ...] = (
# Ordered, and the order is deliberate. On NixOS the setuid `sudo` wrapper
# lives in /run/wrappers/bin and the /usr/bin copy is either absent or not
# setuid, so the wrapper directory has to be consulted first. Everywhere else
# this directory does not exist and costs one stat().
"/run/wrappers/bin",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/run/wrappers/bin is trusted first, and no candidate is checked for who owns it.

The trusted-directory list is the entire trust boundary of this module, but membership is decided purely by path string — _is_executable_file only checks isfile + X_OK. /usr/bin, /bin, /sbin are root-owned on any sane host, so that is fine for them. /run/wrappers/bin is different in kind: it is on a tmpfs that is populated at boot, and it is searched before /usr/bin for every name, including sudo.

On a NixOS host that is exactly right. On every other host the directory is expected not to exist — but nothing here enforces that expectation, so if anything on the host (a container image with a loose /run, a package, an operator script) creates /run/wrappers/bin/sudo, it silently takes precedence over /usr/bin/sudo and the module reports it as trusted. For a module whose stated purpose is removing an untrusted-search-path vulnerability, that is a search path with one entry whose trustworthiness is assumed rather than established.

Worth considering an os.stat-based check on the resolved candidate and its directory before returning it: owned by uid 0, and not group- or world-writable. That makes "trusted" a verified property rather than a property of the literal list, and it costs one stat on a path that is already being stated by isfile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and parked rather than fixed. Membership is decided by path string alone: _is_executable_file checks only isfile plus an execute bit. Parked because reaching the exposure needs root or equivalent already, and on a host where /run/wrappers/bin is root-owned an attacker who can write there can replace /usr/bin/sudo directly, which no check here would stop. The reason I am wary of the fix rather than just applying it: the X_OK check in this module already caused one real regression by testing permissions as the wrong user, and an ownership check has the same failure shape, refusing a binary that works. Recorded with the concrete os.stat approach so it is not lost.

# ...and the two NixOS entries are a pair. /run/wrappers/bin holds *only* the
# setuid/setcap wrappers, so on NixOS it resolves `sudo` and nothing else:
# /usr/bin holds just `env`, /bin just `sh`, and the sbin directories do not
# exist. `setsid` and `pgrep` live in this symlink farm, which nixos-rebuild
# manages and root owns, making it trust-equivalent to /usr/bin there.
#
# Without this entry the ordering above buys nothing: a cross-user session
# would resolve `sudo` and then fail on `setsid` one line later. The pairing
# is asserted in TestTrustedDirectories so it cannot be half-removed.
"/run/current-system/sw/bin",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering invariant that matters for the NixOS sudo wrapper is now unpinned, and /run/current-system/sw/bin is the reason.

Adding /run/current-system/sw/bin is the right call — without it setsid/pgrep were unreachable on NixOS. But it also contains a sudo, and that changes which comparison the ordering test needs to make.

On NixOS both of these exist:

  • /run/wrappers/bin/sudo — the setuid wrapper, the only one that works.
  • /run/current-system/sw/bin/sudo — a symlink into /nix/store. Store paths cannot carry the setuid bit, so this binary is mode 0555, uid 0, not setuid. Running it fails with sudo: must be owned by uid 0 and have the setuid bit set.

_is_executable_file only checks isfile + X_OK, both of which the non-setuid copy satisfies, so whichever of the two directories comes first in the tuple is what system_command_path("sudo") returns. The current order is correct. What is not covered is that it stays correct: TestTrustedDirectories.test_searches_the_setuid_wrapper_directory_before_usr_bin (test file line 227) asserts only that /run/wrappers/bin precedes /usr/bin — and /run/current-system/sw/bin now sits between those two. Swapping the two /run entries is a plausible edit, since the comment presents them as "a pair" and no test distinguishes them; it keeps that assertion green while making every cross-user session on NixOS fail at launch with the setuid error. test_the_two_nixos_entries_are_present_as_a_pair only asserts membership, not order.

The comment on line 58 also under-describes the entry: it explains why the directory is needed (setsid, pgrep) but not that it also shadows the one command whose resolution has a setuid precondition.

Suggest tightening the assertion to the invariant that has a failure mode — assert /run/wrappers/bin precedes both /run/current-system/sw/bin and /usr/bin, with the store-path/setuid reason in the docstring so a future reader does not reorder them.

(This is also the concrete case for the ownership/mode check raised on the earlier revision: sudo is a command where "executable file in a trusted directory" is genuinely not sufficient, and here the insufficiency lands on a supported platform rather than a hypothetical one.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a144881. Correct that the existing assertion did not constrain the new entry, because sw/bin sits between the wrapper directory and /usr/bin. There is now a direct assertion that the wrapper precedes the symlink farm, mutation-checked by swapping the two entries, which fails that test and nothing else.

"/usr/bin",
"/bin",
# sbin entries are last: on non-usr-merged distributions (some Debian
# releases) some system commands exist only under /sbin.
"/usr/sbin",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NixOS case the ordering comment is written for still cannot launch a cross-user session, because setsid is not reachable from this list.

The comment justifies putting /run/wrappers/bin first specifically so that NixOS's setuid sudo wrapper wins. But on NixOS the trusted list resolves only sudo:

  • /run/wrappers/bin contains just the setuid/setcap wrappers (sudo, su, ping, mount, ...) — not setsid, kill, or pgrep.
  • /usr/bin contains a single file, /usr/bin/env.
  • /bin contains a single file, /bin/sh.
  • /usr/sbin and /sbin do not exist.

Everything else lives under /run/current-system/sw/bin (a symlink farm into /nix/store), which is not in TRUSTED_SYSTEM_DIRECTORIES. So on the exact platform this ordering exists to support, _subprocess.py:610system_command_path("setsid"), on the non-macOS cross-user branch immediately after the resolved sudo — raises SystemCommandNotFoundError, and _start_subprocess swallows it into "Process failed to start". Before this PR, setsid was a bare name resolved by the login shell that sudo -i starts, whose PATH includes /run/current-system/sw/bin, so it worked.

Adding /run/current-system/sw/bin to the list would make the NixOS support the comment claims actually hold. It is a root-owned symlink farm managed by nixos-rebuild, so it is trust-equivalent to /usr/bin on that platform, and it costs one stat on hosts where it does not exist — the same argument the comment already makes for /run/wrappers/bin. Worth also adding a TestTrustedDirectories assertion for it so the pairing is pinned: the wrapper entry without the sw entry supports no complete code path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68265a3 by adding /run/current-system/sw/bin. You were right that the ordering comment described support that did not exist: the wrapper directory resolved sudo and nothing else, so a cross-user session failed on setsid one line later. A test asserts the two entries as a pair so the combination cannot be half-removed.

"/sbin",
)


class SystemCommandNotFoundError(FileNotFoundError):
"""A required system command was not present in any trusted directory.

A :class:`FileNotFoundError`, and therefore an :class:`OSError`, on purpose.

An earlier revision of this module deliberately made it a plain ``Exception``,
reasoning that "a privileged helper is unavailable" must not be absorbed by
handlers that catch ``OSError`` to mean "carry on degraded". That reasoning was
wrong here, because it assumed rather than checked what those handlers do.
``_runner_base``'s cancel path catches ``OSError`` around
``notify()``/``terminate()`` precisely so a failure to signal does not unwind an
in-progress cancelation -- its own comment says "a cancel path is the wrong
place to raise". Escaping that handler would lose the cancel's bookkeeping,
which is worse than the warning it logs.

So the semantics this class wants are exactly ``FileNotFoundError``'s: the thing
we tried to launch is not there. Remaining a distinct type still lets a caller
that cares tell "not in any trusted directory" apart from "``exec`` failed", and
the message says which.
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SystemCommandNotFoundError not being an OSError regresses the cancelation path.

The docstring justifies the choice by saying callers "already catch OSError subclasses to mean the thing I tried to launch is missing, carry on degraded", and that this condition must not be absorbed. But at least one of those handlers is not a degrade-and-continue — it is the guard that keeps a cancel from unwinding:

_runner_base.py:1238-1249

try:
    if method == "notify":
        process.notify()
    else:
        process.terminate()
except OSError as err:  # pragma: nocover
    self._logger.warning(f"Cancelation could not send {method} signal ...")

Its own docstring states the intent explicitly: "a cancel path is the wrong place to raise: the caller is already unwinding a cancel, and losing the surrounding bookkeeping to a signal failure is worse than a warning."

terminate() -> _terminate_process() -> _posix_signal_subprocess(), which now calls system_command_path("sudo") (line 901) and system_command_path("kill") (line 956), and reaches find_child_process_id_pgrep -> system_command_path("pgrep") (_linux/_sudo.py:178). Before this PR each of those resolutions failed with FileNotFoundError out of run()/Popen — an OSError, so it was caught and logged. Now it is a SystemCommandNotFoundError, which escapes _notify_or_terminate and propagates into the cancel/timeout bookkeeping.

Same for the pgrep case in _linux/_sudo.py:178: find_sudo_child_process_group_id's retry loop only catches FindSignalTargetError, so the new exception escapes that function too.

If the intent is "cannot proceed safely, do not silently degrade", that still needs the raise to land somewhere that handles it — otherwise the failure mode moves from "cancel logged a warning" to "cancel aborted partway through". Worth either deriving from OSError, or adding an explicit handler in _notify_or_terminate / the _sudo.py retry loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68265a3. You were right and my original reasoning was wrong: I asserted that an unavailable privileged helper must not be absorbed by OSError handlers without checking what those handlers actually do. _runner_base catches OSError around notify()/terminate() deliberately, and its own comment says a cancel path is the wrong place to raise. SystemCommandNotFoundError now derives from FileNotFoundError. The test that asserted the opposite has been inverted, since it was pinning the wrong decision.



def _validate_command_name(name: str) -> None:
"""Reject anything that is not a bare command name.

Without this the resolver would itself become the injection point it exists
to remove: ``os.path.join("/usr/bin", "../../tmp/evil")`` escapes the trusted
directory entirely, so a caller that passed attacker-influenced text would be
no better off than before.
"""
if not name:
raise ValueError("A system command name must not be empty.")
if name in (os.curdir, os.pardir):
raise ValueError(f"{name!r} is not a system command name.")
# Checking both separators regardless of platform. A backslash is a legal
# filename character on POSIX, but no command this module resolves contains
# one, and treating it as suspect keeps the check identical on both
# platforms rather than subtly weaker on one.
#
# The colon is rejected for the same reason, and it is not hypothetical:
# ntpath.join(r"C:\Windows\System32", "D:evil") == "D:evil". A drive-relative
# name discards the trusted prefix entirely while containing no separator at
# all, so a separator-only check lets it through. POSIX joins it harmlessly,
# but the guard belongs here rather than depending on which os.path is loaded.
if "/" in name or "\\" in name or ":" in name:
raise ValueError(
f"A system command name must not contain a path separator or drive "
f"specifier, but got {name!r}."
)


def _is_executable_file(path: str) -> bool:
return os.path.isfile(path) and os.access(path, os.X_OK)


_RESOLVED_COMMANDS: dict[str, str] = {}
"""Cache of successful lookups only, keyed by bare command name.

Successes are safe to keep for the life of the process: a resolved path stays
valid, and these lookups sit on process-launch and signal-delivery paths where a
directory walk per signal is wasteful.

Failures are not cached, and that asymmetry is the point. A package manager
replacing a binary unlinks and relinks it, so a lookup landing in that window sees
nothing. Caching that answer would make one unlucky moment permanent for the rest
of a long-lived agent's life, turning a transient miss into a session that can
never start a cross-user process again. Re-walking a handful of directories on the
miss path costs nothing worth having.
"""


def clear_command_cache() -> None:
"""Discard resolved paths. For tests that patch the trusted directory list."""
_RESOLVED_COMMANDS.clear()


def find_system_command(name: str) -> Optional[str]:
"""Return the absolute path to ``name``, or ``None`` if it is not installed.

Searches :data:`TRUSTED_SYSTEM_DIRECTORIES` in order. ``PATH`` is not
consulted. Use this for commands whose absence is tolerable; use
:func:`system_command_path` when the command is required.

Raises:
ValueError: if ``name`` is not a bare command name.
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative results are cached for the life of the process.

lru_cache caches None as readily as it caches a hit, so a single lookup that lands in a window where the binary is absent poisons that command permanently. The realistic window is a package upgrade: dpkg/rpm unlink-and-replace /usr/bin/sudo, and this library runs inside a long-lived agent daemon. One unlucky find_system_command("sudo") during that window means every subsequent cross-user launch and every SIGKILL for the rest of the process lifetime raises SystemCommandNotFoundError, on a host where sudo is present and working.

The docstring justifies caching with "the filesystem layout does not change underneath a running session" — true for a hit (the resolved absolute path stays valid), but the absence of a file is not the same kind of stable fact.

Caching only successful lookups would keep the stated performance benefit (the hot paths — process launch, signal delivery — are all hits) while making a miss retry:

_cache: dict[str, str] = {}

def find_system_command(name: str) -> Optional[str]:
    _validate_command_name(name)
    if (cached := _cache.get(name)) is not None:
        return cached
    for directory in TRUSTED_SYSTEM_DIRECTORIES:
        candidate = os.path.join(directory, name)
        if _is_executable_file(candidate):
            _cache[name] = candidate
            return candidate
    return None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3fcbe09, and thank you for this one, it was a genuine availability bug rather than a style point. lru_cache cached None as readily as a path, so a lookup landing in a package manager's unlink-relink window made one unlucky moment permanent for the life of the agent. Replaced with a dict that stores successful lookups only. Both halves are pinned, because "do not cache misses" could otherwise be satisfied by caching nothing at all and losing the reason the cache exists.

_validate_command_name(name)
cached = _RESOLVED_COMMANDS.get(name)
if cached is not None:
return cached
for directory in TRUSTED_SYSTEM_DIRECTORIES:
candidate = os.path.join(directory, name)
if _is_executable_file(candidate):
_RESOLVED_COMMANDS[name] = candidate
return candidate
return None


def system_command_path(name: str) -> str:
"""Return the absolute path to ``name``.

Raises:
ValueError: if ``name`` is not a bare command name.
SystemCommandNotFoundError: if ``name`` is not in any trusted directory.
"""
path = find_system_command(name)
if path is None:
raise SystemCommandNotFoundError(
f"Could not find the system command {name!r} in any trusted directory "
f"({', '.join(TRUSTED_SYSTEM_DIRECTORIES)}). PATH is deliberately not "
f"searched; see openjd.sessions._system_commands."
)
return path
11 changes: 10 additions & 1 deletion test/openjd/sessions_v0/test_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,14 +1308,23 @@ def test_builds_setsid_shim_command_on_macos(self, queue_handler: QueueHandler)
patch.object(
subprocess_mod, "_macos_shim_interpreter", return_value="/usr/local/bin/python3"
),
# Stubbed so the assertion below pins the *shape* of the argv rather
# than this host's sudo location. It also makes the assertion prove
# the command went through the trusted-path resolver: a literal
# "sudo" in the source would no longer match.
patch.object(
subprocess_mod,
"system_command_path",
side_effect=lambda name: f"/trusted/bin/{name}",
),
patch.object(subprocess_mod, "Popen") as mock_popen,
):
subproc._start_subprocess()

# THEN
built_command = mock_popen.call_args.kwargs["args"]
assert built_command == [
"sudo",
"/trusted/bin/sudo",
"-u",
"job-user",
"-i",
Expand Down
Loading
Loading