A from-scratch Python rewrite of rootsh -- a pty-wrapping
shell logger, originally written in C in 2004 and last touched around
2013 -- written in 2026 by Zareh Kasparian, 13 years after development
had stopped (see ../AUTHORS). This directory is a
companion to, not a replacement of, the original C source at the
repository root; see the root README for the original project's
history.
Linux only. Requires Python 3.11+; the installer needs nothing beyond that and Bash.
./install.sh
Run with no flags, this is an interactive wizard: it asks whether to
install system-wide (/etc/rootsh, /var/log/rootsh,
/usr/local/bin -- needs root, and is the real deployment mode, since
rootsh is meant to run via sudo against a shared, root-owned audit
trail) or per-user (under your home directory, for trying it out),
then walks through log locations, syslog settings, an initial
reviewer list, and an optional /etc/sudoers.d/rootsh drop-in. It
creates a virtualenv, installs the two commands, writes the config
file from your answers, and runs a smoke test at the end.
sudo ./install.sh # system-wide, full wizard
./install.sh --mode=user # try it out under $HOME
sudo ./install.sh --yes --reviewers=alice,bob # unattended, defaults + flags
sudo ./install.sh --uninstall # remove commands + venv
Every prompt has a --flag equivalent (./install.sh --help lists
them all), and --yes accepts the default -- or whatever a flag
supplied -- for anything it would otherwise ask, so the same script
works unattended in a provisioning pipeline. Uninstalling removes the
installed commands and virtualenv only; the config file, reviewer
list, and session/review logs are audit data and are left in place.
Prefer to do it by hand? See Manual install below.
Flags on rootsh mirror the original: -i/--login, -u/--user,
-f/--logfile, -d/--logdir (standalone only -- deliberately ignored
under sudo so an escalated user can't redirect their own audit trail,
a security property carried over from the original), -x/--no-logfile,
-y/--no-syslog, plus --config (new) and a trailing command exactly
like the original's -c/bare-args handling.
sudo rootsh # a logged root shell
sudo rootsh -u oracle # a logged shell as user oracle
rootsh -f mysession.log --no-syslog # log your own session to a file, standalone
Reviewing a session log (only for usernames listed in
/etc/rootsh/reviewers):
rootsh-review --list
rootsh-review alice.20260101120000.01234.closed
That replay is itself fully re-recorded to its own tagged log under the configured review-log directory -- see What changed vs. the original for why.
Everything above requires a user to explicitly run sudo rootsh (or
rootsh standalone). If you instead want every command a user runs
-- every login, every ssh user@host command -- captured automatically,
with the user never typing rootsh at all, set rootsh as their
login shell. This is the same mechanism the original C rootsh
supported (setupshell()'s self-detection): when login/sshd exec
a user whose /etc/passwd shell is rootsh, rootsh notices it would
otherwise be about to exec itself as its own child -- which would
recurse forever, forking a new process and opening a new log file at
every level until the machine falls over -- and transparently
substitutes shell.default_shell from the config instead, while
still logging the whole session as normal.
This changes how a user logs in, system-wide, immediately. A mistake here (wrong path, a typo) can leave that user with no working shell at all -- including over SSH, where there's no easy way to override it externally. Test on one non-critical account first, and keep a second, already-authenticated root session open in another window while you do, so you have a way back in if something's wrong.
Use sudo ./install.sh --assign-login-shell=someuser for this,
don't just chsh/usermod by hand. Set as a login shell, rootsh
runs at that user's own uid -- login(1)/sshd already dropped
privilege before exec'ing it -- so it can't write into /var/log/rootsh
(root-owned, mode 0700) on its own. install.sh builds and
capability-enables a small privileged helper
(helper/rootsh-logopen.c, see its header comment for exactly what it
does and why) that bridges that one gap, wires its path into
rootsh.toml's logging.privileged_helper, and only then runs a
pre-flight check (does a real unprivileged session actually open its
log file?) before touching the user's shell, plus a post-flight check
through the real login path after, with automatic revert if either
one fails. Doing it by hand with chsh/usermod skips all of that --
if you do, the account will look like this walkthrough describes, but
every login will crash immediately with a PermissionError opening
the log file until you separately build and setcap the helper
yourself and add its path to the config.
# 1. rootsh must be listed in /etc/shells -- install.sh does this for
# you automatically in system mode. To do it by hand:
echo /usr/local/bin/rootsh | sudo tee -a /etc/shells
# 2. make sure shell.default_shell in rootsh.toml is the shell you
# actually want wrapped users to get (it applies to every
# login-shell-wrapped user -- there's currently no per-user
# override). Check the target user's current shell first:
getent passwd someuser # note the shell field, e.g. /bin/bash
# 3. the guided, safety-netted way (recommended):
sudo ./install.sh --assign-login-shell=someuser
# or by hand, if you really want to (see the warning above --
# you're also on the hook for building+setcapping the helper and
# setting privileged_helper in rootsh.toml yourself):
sudo chsh -s /usr/local/bin/rootsh someuser
# or: sudo usermod -s /usr/local/bin/rootsh someuser
# 4. verify it actually works BEFORE closing your other session:
ssh someuser@thishost 'whoami; echo still alive'
ls -la /var/log/rootsh/ # should show a new someuser.* session
# to revert:
sudo chsh -s /bin/bash someuser # or whatever their shell was
The helper is compiled with the log directory's path baked in
(-DROOTSH_LOG_DIR, from [logging] dir at install time) and carries
only cap_dac_override -- not setuid, not root, just enough to bypass
that one directory's permission check. It refuses to open or finalize
any session-log name that doesn't start with the calling process's
own username (checked via getuid(), never anything the caller
supplies), so even though it's installed world-executable, one local
user can never touch another's session log through it.
install.sh does not automate step 3 (assigning it to specific
users) -- that's a deliberate choice given the blast radius. It does
handle step 1 for you in system mode.
If you'd rather not run install.sh:
python3 -m venv /opt/rootsh/venv
/opt/rootsh/venv/bin/pip install .
ln -s /opt/rootsh/venv/bin/rootsh /usr/local/bin/rootsh
ln -s /opt/rootsh/venv/bin/rootsh-review /usr/local/bin/rootsh-review
Then create /etc/rootsh/rootsh.toml (see etc/rootsh.toml for every
field and its default) and /etc/rootsh/reviewers (see
etc/reviewers). Note that a bare symlink only works if you keep the
config at the compiled-in default path (/etc/rootsh/rootsh.toml) or
always set $ROOTSH_CONFIG yourself -- install.sh avoids that trap
by generating a small wrapper script that pins ROOTSH_CONFIG to
wherever it actually wrote the config, which is why it's the
recommended path for anything other than the default system layout.
- Plain text logs by default. The original's local logfile was
the raw pty byte stream, ANSI escape codes and all -- only its
syslog path stripped them. Here both destinations go through the
same escape-stripping, line-splitting transcript writer
(
rootsh/transcript.py), so the local file is an actually human-readable, greppable text file. - A reviewer/audit workflow that didn't exist before.
rootsh-reviewlets specific users (/etc/rootsh/reviewers) replay session logs, and fully re-records that review session to its own tagged log -- so "who reviewed what, when, and what they did while reviewing" is itself an auditable record, not a blind spot.rootshrecognizes when it's specifically launchingrootsh-reviewfor an authorized reviewer and skips its own content logging for that one invocation, so the same content isn't captured twice. This is the Python equivalent of the sudoers trick the original maintainer adopted instead of continuing rootsh's development (see the root README):%admin ALL=(ALL) LOG_OUTPUT:LOG_INPUT: ALL %admin ALL=(ALL) NOLOG_OUTPUT:NOLOG_INPUT: /usr/bin/sudoreplay - Content-based tamper evidence. The original compared the
logfile's inode/device at close time against what it recorded at
open time (
rootsh.c:beginlogging/endlogging) -- which only catches the file being replaced, not edited in place. Here,rootsh/integrity.pymaintains a SHA-256 hash chain over every transcript line as it's written, checkpoints it to syslog periodically, and writes the final digest to a.sha256sidecar file when the session closes -- so editing any earlier line is detectable (verify_file()recomputes the chain and compares). - ~4,100 lines of C collapsed to a small stdlib-only package.
os.forkpty()/tty/termiosabsorb what the original hand-rolled: its ownforkpty()BSD emulation with SysV STREAMSioctl(I_PUSH "ptem"/"ldterm"/"ttcompat")fallbacks for old Solaris/HP-UX/AIX, its owngetopt/getopt1/gnugetopt.h, its owngetusershell.candbasename.c. None of that exists here -- this rewrite is Linux-only by design (see the questions this was scoped against). - Two bugs found in the original during the port, fixed here, not
reproduced:
execShell()'s login-shell argv[0] construction didstrdup(shell)then replaced the first/character with-, turning/bin/bashinto-bin/bashinstead of-bash. Harmless in practice (bash only checks that argv[0] starts with-), but not what a login shell's process name is supposed to look like. Fixed incli.py:build_command().- The
runAsUser && useLoginShellbranches ofexecShell()passed the literal string"-"as argv[0] instead of the intended dash-prepended shell name (dashShell), a dead variable in those branches. Not reproduced here. --with-maxlogfilesize/MAXLOGFILESIZEwas defined byconfigure.inbut never referenced anywhere inrootsh.c-- configured, documented nowhere, dead. Not carried forward; no max-size enforcement exists in either version.
- A bug of this rewrite's own, found and fixed post-release:
cli.pyoriginally attributed every session topwd.getpwuid(os.geteuid()).pw_name-- the process's effective identity.sudoswitches both real and effective uid to the target identity (root, by default) beforerootsheven starts, so under plainsudo rootshthat lookup returnsrootfor every user, regardless of who actually ran it: every log filename collapsed toroot.*, the "session opened for" message always saidroot, and -- more seriously -- the reviewers-file authorization check in_delegates_to_review()was keying offroottoo.review_cli.pyalready got this right ($SUDO_USER or ...);cli.pydidn't share that logic. Fixed by pulling identity resolution intorootsh/identity.py, used by both entry points now:invoking_user()(prefers$SUDO_USER, for attribution/authorization) vs.effective_identity()(the actual process uid, for "who the shell runs as" -- still correctlyrootfor a plain session).
install.sh the installer wizard
src/rootsh/
cli.py rootsh entry point (argparse; forkpty + logging wiring)
review_cli.py rootsh-review entry point
session.py PtySession: the forkpty()/select() relay loop
transcript.py ANSI-stripping, line-splitting, timestamped text writer
integrity.py HashChain tamper-evidence
syslogsink.py syslog forwarding
identity.py invoking user vs. effective identity (see above)
reviewers.py /etc/rootsh/reviewers loader
config.py TOML config loader
etc/
rootsh.toml example config (install.sh writes its own from your answers)
reviewers example reviewer list (ditto)
tests/ pytest suite (31 tests: transcript, integrity, reviewers,
identity, config, and real forkpty-based session smoke tests)
python3 -m venv .venv
.venv/bin/pip install -e . pytest
.venv/bin/pytest -q
install.sh --editable does the equivalent for a real install (source
stays live-editable instead of being copied into the venv).
inherent to the approach)
- No purely local design defeats a determined root user. Someone
root inside the recorded shell can
kill -9the recorder or edit files on disk. The syslog checkpoint forwarding exists specifically to get evidence off-box in near-real time; it's only as strong as wherever syslog is configured to ship those messages (see the original README's remote-syslog suggestion -- unchanged here, that's a syslog.conf/journald concern, not this tool's). - The rootsh->rootsh-review delegation only covers directly
launching
rootsh-reviewas the command (rootsh -u reviewer -c 'rootsh-review ...'), not someone typingrootsh-reviewby hand inside an otherwise-logged interactive shell. This matches the scope of the sudoers precedent it's modeled on. - Not a full terminal emulator. Escape-sequence stripping is
regex-based (same order of operations as the original's
write2syslog.c: split into lines first, then strip escapes per line), not a real VT100 state machine. Good enough for a readable audit trail; don't expect e.g.vimfull-screen redraws to produce a sensible line-oriented log -- no design in this space does.