Skip to content

fix: sandbox Hermes gateway validation to protect real user units - #284

Merged
dnth merged 5 commits into
mainfrom
fm/fm-hermes-unit-guard
Oct 28, 2026
Merged

dnth merged 5 commits into
mainfrom
fm/fm-hermes-unit-guard

Conversation

@dnth

@dnth dnth commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

One executable guard, one regression, three pointers.

             before                                    after
  validation starts a gateway            validation starts a gateway
  HERMES_HOME=<worktree>                 bin/fm-hermes-sandbox.sh -- hermes gateway run
  HOME=<captain>                           HOME=<sandbox>/home
        |                                  HERMES_HOME=<sandbox>/hermes
        v                                  XDG_RUNTIME_DIR unset, DBUS -> /dev/null
  ~/.config/systemd/user/                        |
  hermes-gateway.service rewritten               v
  to the test home                       <sandbox>/home/.config/systemd/user/
                                         hermes-gateway.service rewritten there
  • bin/fm-hermes-sandbox.sh (new): resolves every sandbox destination physically and refuses symlinks that leave the root or touch a real home or unit directory; walks the sandbox unit tree so a linked unit cannot be followed into the real one; exports HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME, XDG_STATE_HOME, and HERMES_HOME; unsets XDG_RUNTIME_DIR and points DBUS_SESSION_BUS_ADDRESS at /dev/null so a check cannot reach the real user manager; checksums the watched real unit directories before and after the command and exits 70 on any change.
  • tests/fm-hermes-sandbox.test.sh (new): the regression, 15 assertions.
  • .no-mistakes.yaml: the gate's test instruction points at the guard and forbids service-control subcommands.
  • bin/fm-brief.sh: ship rule 9 says the same for every future brief.
  • contrib/hermes-gateway-firstmate-comms/README.md: "Testing a gateway" owns the rule; the header owns the mechanics.
  • docs/scripts.md and bin/fm-test-run.sh: script inventory and family/changed-file mapping for the new test.

Evidence

Before - on the base commit, a gateway started with a throwaway HERMES_HOME but the real HOME rewrites the real user unit. The regression's first case reproduces it with a stub and a fake real home:

$ HOME=<fake real> HERMES_HOME=<throwaway> hermes gateway run   # stub, same rewrite
ok - unguarded gateway start rewrites the HOME unit (the hazard is reproduced)

After - the same command through the guard, and the same test file at head:

ok - the guard confines the unit rewrite to the sandbox
ok - the guard replaces every inherited home variable and disconnects the user manager
ok - the guard refuses roots that overlap the real home or its unit directory
ok - the guard refuses escaping symlinks at every sandbox destination
ok - the guard refuses escaping default, profile, nested, and directory-linked unit entries
ok - the guard accepts confined unit symlinks and terminates directory cycles
ok - real systemctl cannot reach the user manager through the sandbox
ok - installed Hermes stale-unit probe leaves real and fake-real units unchanged
ok - real user unit directory is unchanged before and after the run

Recorded real ~/.config/systemd/user checksums are identical before and after, including hermes-gateway.service ac35d99f5a87f77320bb79dac1ee5bd53d8a25eb0baea6aaa674a26c5976332d.

Criterion Proving test
AC1 every gateway path sandboxed or refused refusal, symlink, unit-tree, and manager-disconnection cases, plus the inventory below
AC2 regression fails on old behaviour, passes with guard, real units unchanged cases 1, 2, 6, 8, 9; case 9 records the checksums
AC3 validation guidance points at the guard, CI green .no-mistakes.yaml test instruction, bin/fm-brief.sh rule 9, plugin README; this PR's checks

Inventory of every tracked hermes gateway start and HERMES_HOME set (grep -rnE "hermes gateway|HERMES_HOME" tests bin docs skills .agents/skills contrib .no-mistakes.yaml):

Surface Hits Disposition
repository code 0 executable gateway starts only the guard's usage comments mention hermes gateway
bin/fm-spawn.sh, bin/fm-hermes-turnend-hook.sh HERMES_HOME for TUI/hooks safe: Hermes rewrites the unit only from systemd_install, systemd_start, systemd_restart, and run_gateway
bin/fm-ext-lib.sh, plugin, bridge tests HERMES_HOME reads safe: attachment cache roots and client routing, no gateway start
tests/fm-hermes-live-e2e.test.sh, docs/verification/runtime-backends.md throwaway HERMES_HOME, real HOME safe: chat/TUI sessions only
ad-hoc validation commands (the 2026-10-12 path) not repository code now forbidden by the gate instruction and brief rule; the guard refuses unconfined roots

Local gates at head: FM_LINT_JOBS=1 bin/fm-lint.sh clean; bin/fm-test-run.sh --all ran once (209 scripts, 55 failures, none in this change: 29 pass with the task-worker environment scrubbed, 23 reproduce at base, 3 are Herdr lanes contending with live fleet sessions).

Merge Danger

Door: two-way. Revert the commit and the guard, test, and instruction pointers disappear; nothing in the live gateway, ~/.hermes, or the fmcomms profile is touched by this change or by its revert.

Blast Radius: test and validation only. The YAML instruction is read by the gate from the default branch, so merging is what starts steering validation runs to the guard.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed (2) ✅
  • 🚨 bin/fm-hermes-sandbox.sh:202 - The confinement check compares path strings without resolving symlinks. With a reused --root whose home/.config points to the real ~/.config, gateway refresh writes the real unit: mkdir accepts the link, the prefix check passes, and Path.home() returns the lexical sandbox HOME. Resolve and validate filesystem destinations before execution, including HOME, HERMES_HOME, all four XDG directories (lines 185–205), and HOME/.config/systemd/user. The post-write tripwire cannot prevent the damage.
  • 🚨 bin/fm-hermes-sandbox.sh:250 - HOME isolation does not isolate systemctl --user. The command inherits the real user bus and XDG_RUNTIME_DIR, so gateway start/restart can operate the captain's existing service on the supported Hermes version where arbitrary HERMES_HOME paths share hermes-gateway.service (contrib/hermes-gateway-firstmate-comms/README.md:13). Its service process then uses the live unit's environment despite this wrapper's exports. gateway install also reaches the real manager. Refuse these service-control operations or isolate their manager access before exec; apply the same boundary to the install recipe at line 54 and the gateway instructions in .no-mistakes.yaml:49, bin/fm-brief.sh:755, and contrib/hermes-gateway-firstmate-comms/README.md:22.
  • 🚨 tests/fm-hermes-sandbox.test.sh:155 - Criterion 2 requires using the real Hermes install when available and asserting that 'the sandboxed unit is the one written.' This added probe calls refresh_systemd_unit_if_needed() with no existing sandbox unit (line 160), then explicitly requires False (lines 173–174). Installed Hermes immediately returns False when that file is absent, so the real-install case never exercises the failing rewrite. Replace this no-write probe with a safely isolated stale-unit rewrite and assert the resulting sandbox unit and unchanged fake-real unit.
  • ⚠️ bin/fm-hermes-sandbox.sh:86 - Simplification: the added --keep mode (lines 91–94, 112, 262–264; tests/fm-hermes-sandbox.test.sh:138–143) and --root= alias (lines 86–90) exceed the requested simplest guard. Explicit --root already supports retained fixtures and live-check setup. Remove --keep and the alternate argument spelling; neither is required by the acceptance criteria.

🔧 Fix applied.
4 issues (3 errors, 1 warning) still open:

  • 🚨 bin/fm-hermes-sandbox.sh:202 - The confinement check compares path strings without resolving symlinks. With a reused --root whose home/.config points to the real ~/.config, gateway refresh writes the real unit: mkdir accepts the link, the prefix check passes, and Path.home() returns the lexical sandbox HOME. Resolve and validate filesystem destinations before execution, including HOME, HERMES_HOME, all four XDG directories (lines 185–205), and HOME/.config/systemd/user. The post-write tripwire cannot prevent the damage.
  • 🚨 bin/fm-hermes-sandbox.sh:250 - HOME isolation does not isolate systemctl --user. The command inherits the real user bus and XDG_RUNTIME_DIR, so gateway start/restart can operate the captain's existing service on the supported Hermes version where arbitrary HERMES_HOME paths share hermes-gateway.service (contrib/hermes-gateway-firstmate-comms/README.md:13). Its service process then uses the live unit's environment despite this wrapper's exports. gateway install also reaches the real manager. Refuse these service-control operations or isolate their manager access before exec; apply the same boundary to the install recipe at line 54 and the gateway instructions in .no-mistakes.yaml:49, bin/fm-brief.sh:755, and contrib/hermes-gateway-firstmate-comms/README.md:22.
  • ⚠️ bin/fm-hermes-sandbox.sh:86 - Simplification: the added --keep mode (lines 91–94, 112, 262–264; tests/fm-hermes-sandbox.test.sh:138–143) and --root= alias (lines 86–90) exceed the requested simplest guard. Explicit --root already supports retained fixtures and live-check setup. Remove --keep and the alternate argument spelling; neither is required by the acceptance criteria.
  • 🚨 bin/fm-hermes-sandbox.sh:208 - Round 1 fixed directory symlinks but left unit-file symlinks unchecked. Reusing --root with home/.config/systemd/user/hermes-gateway.service linked to the real unit passes every directory check; the rewrite modeled at tests/fm-hermes-sandbox.test.sh:54–56 then follows that link and overwrites the real unit. The tripwire detects damage only afterward. Validate existing hermes-gateway*.service destinations before execution, including default and profile units. tests/fm-hermes-sandbox.test.sh:157 currently covers directory symlinks only; include a linked unit file in that behavioral coverage.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Run installed Hermes foreground gateway against a stale sandbox unit; start and stop without changing real user units ✅ pass live foreground-gateway-forced.log; real-units-before.json; real-units-after.json
Run a command with inherited home variables; observe HOME, HERMES_HOME and all four XDG directories confined to the sandbox ✅ pass live live-boundaries.json
Reuse roots with escaping directory, default-unit, profile-unit or nested-unit symlinks; refuse with exit 78 before executing the command ✅ pass live live-boundaries.json
Supply roots overlapping the caller home or its unit directory; refuse before executing the command ✅ pass live live-boundaries.json
Attempt real systemctl user-manager access through the guard; connection is refused ✅ pass live live-boundaries.json
Exercise command exit status, automatic cleanup and removed options; preserve status, remove default roots and reject unsupported options ✅ pass live live-boundaries.json
Reproduce the original rewrite with a stub, then verify guarded rewrite confinement and the fake-real-unit tripwire ⏸️ untested no The prior payload records only a stub-based regression with live=false, so it does not establish a live result for this scenario.
  • TMPDIR="$PWD/.sandbox-validation/tmp" PYTHONDONTWRITEBYTECODE=1 bash tests/fm-hermes-sandbox.test.sh
  • python3 ~/.no-mistakes/evidence/01M6256Q8VCHS2612XHHP1G8ZJ/drive-boundaries.py
  • python3 ~/.no-mistakes/evidence/01M6256Q8VCHS2612XHHP1G8ZJ/drive-sandbox.py — installed Hermes foreground startup with a stale sandbox unit, followed by SIGINT shutdown
  • rg -n &#39;hermes gateway|HERMES_HOME&#39; tests bin docs skills .agents/skills contrib .no-mistakes.yaml and inspection of relevant callers
  • Compared before/after SHA-256 maps for all real user unit files, removed disposable fixtures and confirmed a clean working tree
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

dnth added 5 commits October 29, 2026 05:03
…ser unit

A no-mistakes validation run once started `hermes gateway run` with a
throwaway HERMES_HOME under `~/.no-mistakes/worktrees/` but the real HOME.
Hermes resolves its user unit through `Path.home()`, and its own temp-home
belt only rejects /tmp and pytest paths, so the start rewrote the captain's
`~/.config/systemd/user/hermes-gateway.service` to point at that worktree.

- `bin/fm-hermes-sandbox.sh` runs any Hermes command with HOME,
  XDG_CONFIG_HOME, and HERMES_HOME confined to a throwaway root, refuses
  roots that overlap the real home, proves `Path.home()` stayed inside, and
  fails the run when a watched `<home>/.config/systemd/user` changed.
- `tests/fm-hermes-sandbox.test.sh` reproduces the unguarded rewrite, proves
  the guard confines it, exercises every refusal, and runs the installed
  Hermes path when present. It records the real unit checksums before and
  after, which stay identical.
- `.no-mistakes.yaml` test instructions and the ship brief rules point test
  steps at the guard; the plugin README owns the gateway testing rule and the
  script header owns the mechanics.
@dnth
dnth merged commit 7a733e5 into main Oct 28, 2026
19 checks passed
@dnth
dnth deleted the fm/fm-hermes-unit-guard branch October 28, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant