╶─┬─────────┬─────┬─────┬───┬─────────────┬───────┐
🯇╮│ ╭┈┈┈┈┈╮│ ╭┈╮│ │ │ ╭┈┈┈╮│ │
╷┊│ ╷┊┌───╴┊│ ╷┊╷┊│ ╷ ╶─┘ ╷ ╵ ┌─┬───┐┊┌─┐┊└───┐ ╷ │
│┊│ │┊│╭┈┈┈╯│ │┊│┊│ │ │ │ │╭┈╮│┊│ │╰┈┈┈╮│ │ │
│┊└─┤┊│┊╶─┬─┘ │┊│┊│ └─────┴───┘ │┊╷┊╵┊│ └───╴┊│ │ │
│╰┈╮│┊│╰┈╮│ │┊│┊│ │┊│╰┈╯│╭┈┈┈┈┈╯│ │ │
├─╴┊│┊└─┐┊└─┬─┘┊│┊├─────────────┤┊├───┤┊╶─┬───┴─┘ │
│╭┈╯│╰┈╮│╰┈╮│╭┈╯│┊│FluidNumerics│┊│ │╰┈╮│ │
│┊╶─┤ ╷┊└─┐┊╵┊┌─┘┊│ │┊│ ╶─┼─╴┊│ ╷ ┌─╴ │
│╰┈╮│ │╰┈╮│╰┈╯│╭┈╯│ WALKBLOCKER │┊│ │╭┈╯│ │ │ │
├─╴┊├─┴─╴┊├───┤┊╶─┤ │┊│ ╷ │┊╶─┤ │ └─┐ │
│╭┈╯│╭┈┈┈╯│ │╰┈╮│ stops slow │┊│ │ │╰┈╮│ │ │ │
│┊╶─┤┊╶───┤ ╶─┴─╴┊│ filesystem │┊└─┤ └─┐┊├─┴─╴ │ │
│╰┈╮│╰┈┈┈╮│╭┈┈┈┈┈╯│ traversals │╰┈╮│ │┊│ │ │
├─┐┊└───╴┊│┊╶─┬───┴───┬─────────┘ ╷┊│ ╷ ╵┊│ ╶───┴─┤
│ │╰┈┈┈┈┈╯│╰┈╮│╭┈┈┈┈┈╮│ │┊│ │╭┈╯│ │
│ └───┬───┼─╴┊│┊┌───┐┊└───┬───────┤┊└─┤┊┌─┘ ╶───┐ │
│ │ │╭┈╯│┊│ │╰┈┈┈╮│╭┈┈┈┈┈╮│╰┈╮│┊│ │ │
│ ╷ ╶─┘ ╷ ╵┊╶─┘┊│ ╶─┴───╴┊╵┊┌───╴┊└─╴┊│┊└───────┘ ╵
│ │ │ ╰┈┈┈╯│ ╰┈╯│ ╰┈┈┈╯│╰┈┈┈┈┈┈┈┈┈┈🯈
└─┴─────┴───────┴───────────┴─────────┴───────────╴
The maze was generated on 2026-09-17 by tkphd with the asciiart.eu maze generator, then redrawn by hand; the route, the walkers and the words are the author's own.
Two guardrails for a shared HPC login node, generic and config-driven: a
PATH shim that refuses an unbounded filesystem walk before it starts, and a
periodic reaper that finds the walks that got past it. walk-job is the
sanctioned alternative every refusal names; deploy.py installs all of it
as root and takes no arguments.
Every fact about a particular site lives in site.toml, is validated by
schema/site.schema.json, and is compiled into the node artifacts by
walk-blocker build. The node never reads configuration.
Copyright (c) 2026 Trevor Keller, PhD. Licensed under the BSD 3-Clause
License; see LICENSE, which every payload carries so the terms travel with
the code it installs.
This section is for the people on a node that runs walk-blocker, whether it
stopped them on purpose or by mistake. The node's own copy, filled in with
that node's limits, is docs/what-to-run-instead.md under the install
prefix, and every refusal prints its path.
- Why. A refusal means the command would have walked an expensive
filesystem with no bound. A kill means the reaper found a traversal tool
past its time budget on such a mount, whether or not the session that
started it is still open, or too many running at once under one parent. The reaper kills only when the
site has set
[reaper].action = "kill", which puts--killin its unit. By default it reports and signals nothing. - How to check.
systemctl cat walk-blocker.service | grep ExecStart=shows, to any account, whether killing is on at all. A refusal is recorded in your own journal (journalctl -t walk-blocker) for as long as the node keeps it. A kill is not visible to you: it is recorded in the reaper's audit trail and the system journal, which root and the site's spool group read (and anyone the node's ACLs name). Ask them, with the time and the command. From your shell a kill looks likeTerminatedorKilled, the same as any other signal. - How to avoid it. Bound the walk to the mount's depth allowance or start
it deeper, run long walks with
walk-job, make sure a remote command ends with its session, and do not fan walks out in parallel. - If it was wrong, say so to whoever runs the node. A refusal of a command that should have been allowed is a bug in the rule table. A kill of a process that was not a walk is a bug in the reaper.
An automated agent ran an unbounded find over ssh against a very large
network filesystem. The client timed out and retried; the remote process was
never killed, and it ran for days. A guard that reads commands as text
cannot see inside the quoted string ssh carries, and a guard keyed on CPU
or memory would have watched that process run for days and reported the node
healthy — it was a metadata-I/O hog and nothing else. So there are two
layers, one in front of the command and one behind it, and the second does
not care how the process arrived.
| Layer | What it is | Catches | Bypassable by |
|---|---|---|---|
| 1, the shim | a POSIX sh dispatcher on every user's PATH, generated from the rule table and site.toml |
the naive invocation, before it starts, in under a second, with a message naming the alternative | an absolute path, a private PATH, a container, a batch script, a second-level shell, a shell function — by design (ADR-0001) |
| 2, the reaper | a root-run systemd timer, stdlib Python, reading PSI at each user slice and classifying the whole process table | whatever reached the node regardless of how, once it is past budget on an expensive mount | nothing that changes how a process arrives; it reports by default and kills only when a human decides |
Layer 1 is advisory and the code says so. Layer 2 is the backstop, and its
default is --report.
- A shell starts. Its system startup file —
[hooks.<shell>].file, verified to fire forssh host 'cmd'at install — puts<prefix>/binfirst onPATH. - The user runs
grep -r,find,du,rgor another wrapped name. The symlink in<prefix>/binresolves toguard.sh. - The shim reads its argv and its cwd. A call it can decide from those
alone opens no file and creates no process; a call that needs a mount
judgement reads
/proc/mountsonce, through one trustedawk, and nothing that can block. If the walk is bounded within the depth ceiling, or every root is on a cheap mount, or the root sits at least[filesystems].unscoped_depthcomponents below the mount point, itexecs the real tool. - Otherwise it exits 77 with a refusal that names the bound the tool
accepts, the allowance on that mount, and
walk-job, and it prints one line on stdout pointing at that text. 77, not 2:grepuses 1 for "no match" and 2 for its own errors, so a refusal must read as neither an empty result nor a tool failure — and stderr alone does not reach a caller that discards it (ADR-0024). - Whether or not the shim ran, the reaper polls on
[timer].on_calendar, differences PSI per user slice, walks/proc, and writes a finding for every process that is a known tool on an expensive mount past[reaper].traversal_budget_s— withstalling_slicesaying whether PSI agreed.
Every refusal prints the alternative for the tool that was refused, and
names two pages installed with the payload: docs/what-to-run-instead.md,
rendered from the site's own site.toml — its mounts, their depth
allowances, the survey's measurements and its walk-job — and
docs/alternatives.md, the grimoire: the walk patterns seen at deployments
of this tool, the question each was really asking, and the redirect for
each, with a per-tool table. Read the grimoire before you are refused.
Refusals are recorded. Every refusal writes one refused record to the
journal under the walk-blocker tag, with the tool, the mount judgement and
the caller's uid, before the message is printed. An empty journal on a
node with the shim installed means nothing was refused, not that nothing was
looked at.
The escape hatch. WALK_BLOCKER_UNSCOPED=1 turns a refusal into an
allow. Every use is recorded in the journal under the same tag,
with the tool, the mount judgement and a uid stamped by journald rather
than taken from the environment being audited:
journalctl -t walk-blocker -o jsonIt exists because a block with no override gets routed around with \find,
after which nothing is measurable.
| Path | What it is |
|---|---|
src/walk_blocker/ |
the workstation tool: validate, schema, survey, build; the config loader; the stamp and render machinery |
src/walk_blocker/search_rules.py |
the rule table both layers are built from |
schema/site.schema.json |
what a site.toml may say |
node/reaper.py |
Layer 2 |
node/shim/guard.sh.in, wrapped_names.sh.in |
Layer 1, as templates |
node/shim/install.sh |
the shell installer and the per-poll reconcile |
node/shim/measure.sh, measure-flags.sh |
the shim's performance gate; the flag-clustering probe for the rule table |
node/survey.py |
the mount survey, tier three of the mount policy |
node/docs/what-to-run-instead.md.in |
the users' page, rendered per site into the payload's docs/ |
node/walk-job |
the sanctioned alternative |
node/deploy.py |
the argumentless deployer |
examples/site.example.toml |
a fictional site with every key written out |
examples/payload/ |
that site, built; a build product checked by CI |
docs/adr/ |
the decisions, 0001 through 0035 |
docs/alternatives.md |
what to run instead: observed walk patterns and their redirects |
docs/site-config.md |
what a site measures before filling site.toml |
docs/node-requirements.md |
what the destination node must provide |
docs/operating.md |
the operator's runbook |
docs/plan.md |
the architecture as built, on one page |
docs/evidence.md |
where the evidence is, and why it is not here |
tools/check_no_site_literals.py |
the IP-hygiene gate CI runs |
tests/ |
the suite, including the shim-versus-table matrix |
- Enforcement by Layer 1. A PATH shim cannot bind; describing it as a sandbox invites reliance it cannot support (ADR-0001).
- Killing by default. The reaper reports.
--killis a decision made at the site after reading real findings, recorded as[reaper].actionand compiled into the unit (ADR-0009, ADR-0034). - Processes that predate the deployment. The reaper will report them; nothing here retroactively wraps a shell that already started.
- Sudo for agent sessions. Automated agents working in this repository never act as root, in any mode: the install is gated on root alone, so that rule is the only barrier in front of it. The installer is for an authorized human who already holds root (ADR-0004, ADR-0021).
- Restricting non-interactive ssh, or any transport. Layer 2 is transport-agnostic on purpose (ADR-0003).
- An index of the large filesystem. Not at that scale, by this tree or a
vendor;
walk-jobis the general answer (ADR-0007). - Site facts. Hostnames, capacities, timers, measured figures — none of
it lives here (ADR-0014).
docs/evidence.mdsays how to ask.
uv run --group dev pytest tests/ -q
uv run walk-blocker validate --site examples/site.example.toml
uv run walk-blocker build --site examples/site.example.toml --out examples/payload --check
uv run walk-blocker survey --mounts /proc/mounts --site examples/site.example.toml
shellcheck --shell=sh --severity=warning examples/payload/shim/*.sh examples/payload/walk-job
uvx --from pymarkdownlnt==0.9.39 pymarkdown --config .pymarkdown scan README.md CLAUDE.md docs/*.md docs/adr/*.md
uvx yamllint==1.38.0 -c .yamllint .github/workflows/ci.yml
python3 tools/check_no_site_literals.pyexamples/payload/ is a build product: a finding about it is a finding
about the rule table, a template, the schema or site.example.toml, and
--check is the oracle. Never edit it by hand.
VERSION at the root of this tree is the only hand-written version.
walk-blocker build stamps it as a literal into every node artifact that
answers --version, so one payload carries one number and the node has
nothing to read it from at run time. Ask a node four ways, and expect them to
agree:
cat <prefix>/.walk-blocker-payload
cat <prefix>/site.lock.json
python3 <prefix>/reaper.py --version
sh <prefix>/shim/install.sh --versionwalk-job --version is the fifth. The lock file carries a hash per emitted
file and the hash of the site.toml that built it, so a deployed
configuration can be checked against a commit.
- This file — what it is, what is in and out of scope
docs/plan.md— the architecture as built, on one pagedocs/adr/0001throughdocs/adr/0035, in order — the decisionsdocs/site-config.md— what a site measures before it can be deployeddocs/node-requirements.md— what the node must provide, and the floor below which a fault is unsupporteddocs/operating.md— the operator's runbookdocs/alternatives.md— what to run instead: the users' grimoire, and the generic half of the page every refusal namesdocs/evidence.md— where the evidence is, and why it is not hereCLAUDE.md— the non-negotiables, and the parts that are easy to get wrong