Skip to content

fix(cron): scan dot-operator sourced scripts in lifecycle guard (#77925) - #77926

Open
Mengchee118 wants to merge 1 commit into
NousResearch:mainfrom
Mengchee118:fix/lifecycle-guard-dot-source-bypass
Open

fix(cron): scan dot-operator sourced scripts in lifecycle guard (#77925)#77926
Mengchee118 wants to merge 1 commit into
NousResearch:mainfrom
Mengchee118:fix/lifecycle-guard-dot-source-bypass

Conversation

@Mengchee118

Copy link
Copy Markdown

What does this PR do?

cron/lifecycle_guard.py scans scripts pulled in with the source builtin so a
gateway-lifecycle command inside them is blocked. The equivalent POSIX dot
operator
was not caught, so . ./restart.sh was never scanned.

The executable test compared basename only:

executable_name = Path(executable).name
if executable_name in {".", "source"}:

Path(".").name is the empty string — pathlib normalises "." to the
current directory, whose .name is "" — so the "." entry could never match.
source matched; . silently fell through, the sourced script was never added
to the reference walk, and its contents were never scanned.

The fix compares the raw token as well as the basename:

if executable in {".", "source"} or executable_name == "source":

The executable_name == "source" arm is kept deliberately so a path-qualified
spelling keeps behaving as before, while the raw-token test catches . without
depending on pathlib normalisation.

Related Issue

Fixes #77925

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • 🔒 Security fix

Changes Made

  • cron/lifecycle_guard.py_iter_referenced_shell_scripts: compare the raw
    executable token as well as the basename, so the dot operator reaches the
    referenced-script scan. Comment records why the basename test cannot work
    for ..
  • tests/hermes_cli/test_gateway_restart_loop.py — three tests in
    TestLifecycleGuardModule.

How to Test

Reproduce on main (all three assertions describe current behaviour):

from pathlib import Path
from cron.lifecycle_guard import (
    contains_gateway_lifecycle_command_or_referenced_script as scan,
)

p = Path("/tmp/restart.sh")
p.write_text("#!/bin/bash\nhermes gateway restart\n")

assert scan(f". {p}") is False       # BUG on main: not blocked
assert scan(f"source {p}") is True   # blocked
assert scan(f"bash {p}") is True     # blocked

With this PR the first assertion becomes True.

Automated:

pytest tests/hermes_cli/test_gateway_restart_loop.py -q
  • 85 passed with this PR.
  • Reverting only cron/lifecycle_guard.py to main and keeping the new tests
    fails exactly test_dot_operator_sourced_script_is_scanned
    (AssertionError: assert False is True) — so the test genuinely covers the
    defect rather than passing vacuously.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run pytest tests/hermes_cli/test_gateway_restart_loop.py -q and all tests pass (85 passed)
  • I've added tests for my changes
  • I've tested on my platform: macOS 26.6 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation (docstrings/comments) — inline comment explains the pathlib subtlety
  • cli-config.yaml.example — N/A, no config keys
  • CONTRIBUTING.md / AGENTS.md — N/A, no architecture change
  • Cross-platform impact considered — the fix removes a pathlib-normalisation
    dependency rather than adding one. . and source are POSIX-shell
    builtins, so behaviour is unchanged on Windows, where this walk is not
    reached for shell scripts.
  • Tool descriptions/schemas — N/A, no tool behaviour change

Notes for reviewers

On duplicates: there are several open PRs on this file (#77383, #77729,
#77806, #77894) — all of them address the crash class (ValueError: embedded null byte). This is a different defect: a silent scan miss, no
crash involved. It does not overlap or conflict with those changes; the hunk is
40 lines away from the crash sites.

Scope: the same audit turned up a second, independent defect — the #76762
binary test (b"\x00" in data) treats any NUL-bearing file as an unscannable
binary, but bash executes a text script straight past an embedded NUL, so
a single pad byte bypasses the scan entirely. That is a separate PR; keeping
them apart so each can be reviewed on its own merits.

`_iter_referenced_shell_scripts` recognises the `source` builtin so a script
pulled in with `source ./restart.sh` gets scanned for lifecycle commands. The
POSIX dot operator is the same builtin, but it was not caught:

    if executable_name in {".", "source"}:

`executable_name` is `Path(executable).name`, and `Path(".").name` is the
**empty string** -- pathlib normalises "." to the current directory, whose name
is "". So the set membership never matched for `.`, the sourced script was
never added to the reference walk, and its contents were never scanned.

Verified against current main:

    . /tmp/restart.sh        -> not blocked   (script never scanned)
    source /tmp/restart.sh   -> blocked
    bash /tmp/restart.sh     -> blocked

where /tmp/restart.sh contains a `hermes gateway restart` line. Sourcing runs
the script in the current shell, so the dot spelling is not merely equivalent
to `source` -- it is the more common form in practice.

Fix compares the raw token as well as the basename:

    if executable in {".", "source"} or executable_name == "source":

Keeping the `executable_name == "source"` arm preserves the existing behaviour
for a path-qualified spelling, while the raw-token test catches `.` without
relying on pathlib normalisation.

Tests (tests/hermes_cli/test_gateway_restart_loop.py):

- test_dot_operator_sourced_script_is_scanned -- the regression; fails on main
- test_source_builtin_sourced_script_is_scanned -- `source` stays blocked
- test_dot_operator_clean_script_not_blocked -- widening the check must not
  false-block an innocent `. ./activate.sh`

Found while auditing the guard after NousResearch#76762. Scoped deliberately to this one
defect; the NUL-padded-script bypass I found in the same audit is a separate
PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have tool/terminal Terminal execution and process management type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lifecycle_guard: dot-operator sourced scripts are never scanned (Path(".").name is "")

2 participants