This document describes how the Python implementation under python/
actually works: the process/data flow, the on-disk formats, and in
particular the privilege-separation design that lets rootsh log a
user's session even when it isn't running as root. It assumes you've
read README.md for install/usage first. For what
changed relative to the original 2004 C implementation, see
README.md's "What changed vs the original" --
this document only occasionally references specific differences where
they explain a design choice.
- Design goals
- Repository layout
- Component overview
- The pty relay loop
- Transcript processing
- Session log lifecycle and format
- Integrity: the hash chain
- Syslog forwarding
- Identity resolution
- Configuration
- CLI entry point and shell substitution
- The reviewer workflow
- Privilege architecture
- The installer
- Testing
- Threat model and known limitations
- Linux only.
os.forkpty()wraps the platform's native pty support directly; none of the original's BSDforkpty()emulation or SysV STREAMS fallback code is needed. This is a deliberate scope cut, not an oversight. - stdlib only.
pyproject.tomldeclares zero runtime dependencies. TOML parsing usestomllib(3.11+), hashing useshashlib, pty handling usesos/tty/termios/select/signal, fd-passing usessocket'ssend_fds/recv_fds(3.9+). The one piece of code that can't be pure Python (see Privilege architecture) is a ~180-line C file with no dependencies beyond libc. - Human-readable logs by default, not a raw terminal capture. Both the local file and syslog go through the same escape-stripping, line-splitting writer.
- Tamper-evidence over tamper-proofing. Nothing running as the logged-in user's own uid can be made un-killable or un-editable by a sufficiently privileged attacker (see Threat model); the design goal is to make tampering detectable, and to get evidence off-box before it can happen.
python/
install.sh installer wizard (build, configure, optionally
build+setcap the privileged helper)
pyproject.toml
etc/
rootsh.toml example config (install.sh writes its own)
reviewers example reviewer list (ditto)
helper/
rootsh-logopen.c privileged helper (see Privilege architecture)
src/rootsh/
cli.py `rootsh` entry point
review_cli.py `rootsh-review` entry point
session.py PtySession: the forkpty()/select() relay loop
transcript.py TextTranscript: ANSI-stripping, line-splitting,
timestamped writer
integrity.py HashChain: tamper-evidence
syslogsink.py syslog forwarding
identity.py invoking user vs. effective identity
reviewers.py /etc/rootsh/reviewers loader
config.py TOML config loader
privlog.py client for the privileged helper
tests/ pytest suite
flowchart TB
subgraph entry["Entry points"]
cli["cli.py: main()\n(rootsh)"]
review["review_cli.py: main()\n(rootsh-review)"]
end
subgraph core["Session recording"]
session["session.py\nPtySession"]
transcript["transcript.py\nTextTranscript"]
integrity["integrity.py\nHashChain"]
end
subgraph sinks["Output sinks"]
file["local .closed file\n+ .sha256 sidecar"]
syslog["syslogsink.py\n-> syslog"]
end
subgraph support["Support"]
identity["identity.py"]
config["config.py"]
reviewers["reviewers.py"]
privlog["privlog.py\n<--> helper/rootsh-logopen.c"]
end
cli --> session
review --> session
session -->|pty output bytes| transcript
transcript -->|cleaned line| integrity
transcript -->|write bytes| file
transcript -->|line text| syslog
cli --> identity
cli --> config
cli --> reviewers
cli -->|euid != 0| privlog
review --> identity
review --> config
review --> reviewers
cli.py and review_cli.py are thin orchestrators: they resolve
configuration and identity, wire up a TextTranscript with the right
sinks, then hand control to a PtySession and block until it exits.
Almost none of the actual pty/logging logic lives in the entry points
themselves -- it's factored into the pieces below so both entry points
(and, in principle, future ones) share it.
session.py's PtySession replaces the bulk of the original
rootsh.c: forkpty(), manual raw-mode termios setup, and the
select() loop relaying stdin↔pty and pty→stdout+log.
pid, master_fd = os.forkpty()
if pid == 0:
os.execvpe(executable, argv, env) # child: becomes the shell (or su, or a pager)
# parent: relay loopThe relay loop (_pump) select()s on three descriptors: stdin (0),
the pty master, and a self-pipe wake-up descriptor. Two things worth
calling out:
- Signal handling via self-pipe. The original used
pselect()with an empty signal mask, relying on a signal interrupting a blocking syscall withEINTR(checked explicitly, since it ran withoutSA_RESTART). Python 3.5+ (PEP 475) automatically retries syscalls onEINTR, which defeats that trick outright. The replacement is the standard one:signal.set_wakeup_fd()writes a byte to a pipe whenever a signal is delivered, and that pipe is one of the three descriptorsselect()blocks on -- soSIGWINCH(terminal resize) andSIGCHLD(child exited) reliably wake the loop up. - Draining on child exit. On
SIGCHLD, the loop doesn't return immediately -- it first drains whatever output is already buffered in the pty (_flush_trailing, a non-blocking read loop) so the last burst of output before exit isn't lost, matching the original's reasoning for reacting toSIGCHLDimmediately rather than waiting for orphaned grandchildren to release the pty.
PtySession is reused for three different children across the
codebase: the wrapped shell (cli.py), su (for -u), and a pager
like less (rootsh-review) -- the relay/logging logic doesn't care
what's on the other end of the pty.
transcript.py's TextTranscript is the one place both output
destinations (local file, syslog) go through, replacing the original's
duplicated logic (the local file got the raw byte stream; only the
syslog path, write2syslog.c, stripped escape sequences).
Processing order, matching the original's approach in
write2syslog.c:
- Split on line boundaries first (
\r\nand bare\r-- used by prompt/progress-bar redraws -- both normalized to\n). - Strip ANSI/VT100 escape sequences per line (
_ANSI_RE: CSI, OSC, DCS, SOS, PM, APC, and generic 2-byte sequences). - Strip remaining C0 control bytes except tab.
This is not a terminal emulator -- see Known limitations.
Partial lines (no trailing \n yet, e.g. mid-command-line output) are
buffered (_pending) across feed() calls, so an escape sequence or
multi-byte UTF-8 character split across two read()s is handled
correctly. _pending is bounded at 8192 bytes, past which it's forced
out as its own line, so malformed or binary input can't grow memory
without limit.
Each emitted line (_emit) does three things: writes the raw bytes
(with an optional [ISO-8601] timestamp prefix) to the file object,
calls on_record with those exact bytes (feeds the hash chain -- see
below), and calls each line_sinks callback with the decoded text
(feeds syslog).
A session's local log file is named <user>.<YYYYMMDDHHMMSS>.<pid>
under [logging] dir (default /var/log/rootsh), e.g.:
test10.20260923171123.251536
While the session is active the file has that exact name. At session
end (cli.py's _finalize_log_file) it's renamed to
test10.20260923171123.251536.closed, and a sidecar
test10.20260923171123.251536.closed.sha256 is written next to it
containing the final hash-chain digest. The .closed suffix is a
structural signal, not decoration: rootsh-review and any archival
tooling can distinguish "finished, safe to read/archive" sessions from
one still being actively written to.
Example transcript content (with logging.timestamps = true):
[2026-09-23T17:11:23.104+00:00] *** rootsh session opened for test10 as test10 on /dev/pts/3 ***
[2026-09-23T17:11:24.512+00:00] test10@host:~$ id
[2026-09-23T17:11:24.520+00:00] uid=1001(test10) gid=1001(test10) groups=1001(test10)
[2026-09-23T17:11:30.001+00:00] test10@host:~$ exit
[2026-09-23T17:11:30.004+00:00] *** rootsh session ended by user (exit status 0) ***
Structural markers (session opened/ended) are written via
TextTranscript.write_marker(), which bypasses line buffering and
flushes immediately -- they're not shell output, they're rootsh's own
bookkeeping, wrapped in *** ... *** to stand out when grepping a
transcript.
The original detected tampering by comparing the logfile's inode and
device number at close time against what was recorded at open time
(rootsh.c: beginlogging/endlogging). That only detects the file
being replaced (unlinked and recreated); an in-place edit of the
same inode goes completely undetected.
integrity.py's HashChain instead makes every record's digest
depend on the digest of everything before it:
digest = sha256(bytes.fromhex(previous_digest) + record_bytes).hexdigest()starting from a genesis value of 64 zero characters. Altering or
removing any earlier line changes every digest computed after it.
verify_file() recomputes the chain over a .closed transcript from
scratch and returns the final digest, for comparison against what's
stored in the .sha256 sidecar -- a mismatch means the file was
edited after the fact.
Beyond the sidecar (which itself lives on the same, potentially
compromised, host), [integrity] checkpoint_every optionally forwards
the running digest to syslog every N transcript lines. This is the
off-box evidence: if syslog is configured to ship to a remote
collector (see the original README's suggestion, unchanged in intent
here), a checkpoint recorded before an attacker gains control is
outside their reach even if they later edit or delete the local file
and its sidecar entirely.
syslogsink.py's SyslogSink is a thin wrapper around the stdlib
syslog module: resolves configured facility/priority names
(local5/notice by default) to their syslog module constants, and
optionally prefixes each line with a rolling 3-digit counter
([syslog] line_numbering) to help spot dropped messages, mod 1000. This replaces job (2) of the original's
write2syslog.c; job (1) of that file (escape-stripping/line-
splitting) is now shared with the local-file path via
transcript.py, instead of being reimplemented here.
identity.py exists because of a subtlety sudo introduces:
sudo rootsh switches the process's real and effective uid both to
the target identity (root, by default) before rootsh ever runs, so
a geteuid()-based lookup can't distinguish alice's sudo rootsh
from bob's -- both come back as root. $SUDO_USER is what sudo
sets to record the original caller.
Two functions, deliberately kept separate:
effective_identity():pwd.getpwuid(os.geteuid()).pw_name-- who the process (and therefore the child shell) is actually running as right now. Genuinelyrootfor a plainsudo rootsh.invoking_user(): prefers$SUDO_USER, falls back toeffective_identity(). Used for attribution (log filenames, "session opened for" messages) and authorization (the reviewers-file check) -- anywhere the question is "who is the human at the keyboard," not "what uid is the shell running under."
is_standalone() is simply $SUDO_USER is None -- whether this
invocation went through sudo at all, which governs whether -f/-d
are honored (see CLI entry point).
Note this only disambiguates sudo's privilege model. The login-shell
deployment (see Privilege architecture)
never goes through sudo at all -- there, real uid == effective uid
== the actual logged-in user from the moment the process starts, so
invoking_user() naturally resolves correctly via the
effective_identity() fallback with no $SUDO_USER involved.
config.py loads a TOML file (default /etc/rootsh/rootsh.toml, or
$ROOTSH_CONFIG, or --config) into a Config dataclass tree. A
missing file at the default/env location is not an error (built-in
defaults apply); an explicitly-passed --config path that doesn't
exist is. An unknown key anywhere in the file is a hard error
(_apply_section raises ValueError), to catch typos rather than
silently ignoring them.
| Section | Key | Default | Meaning |
|---|---|---|---|
[logging] |
enabled |
true |
write local transcript files |
dir |
/var/log/rootsh |
where session logs live | |
timestamps |
true |
prefix each line with an ISO-8601 timestamp | |
privileged_helper |
"" |
path to rootsh-logopen; see Privilege architecture |
|
[syslog] |
enabled |
true |
forward lines to syslog |
facility |
local5 |
syslog facility name | |
priority |
notice |
syslog priority name | |
line_numbering |
false |
prefix syslog lines with a rolling counter | |
log_username |
true |
include the username in the syslog ident | |
[shell] |
default_shell |
/bin/sh |
shell to wrap; also the login-shell substitution target |
su_command |
/usr/bin/su |
used for -u |
|
[review] |
reviewers_file |
/etc/rootsh/reviewers |
who may run rootsh-review |
log_dir |
/var/log/rootsh/review |
where review-session transcripts go | |
[integrity] |
checkpoint_every |
20 |
syslog checkpoint interval in transcript lines (0 disables) |
cli.py's main() flow:
_guard_against_recursive_self_invocation()-- a depth counter via$_ROOTSH_DEPTH, independent of (2) below; refuses to start past a shallow depth rather than fork-bombing the machine if rootsh ever ends up exec'ing itself._resolve_shell()-- determines what shell to actually wrap. Normally$SHELLor the passwd entry's shell. If that resolves torootshitself -- which happens exactly when rootsh is the user's configured login shell, andlogin(1)/sshdjust exec'd it directly -- substitute[shell] default_shellinstead. This substitution is the login-shell deployment mechanism: it's what makeslogin/sshdexec'ing rootsh transparently become "run the user's real shell, but logged," rather than infinite recursion.build_command()-- constructs the child's(executable, argv). Mirrors the originalexecShell()'s branches (runAsUser×useLoginShell×shellCommands), collapsed since argv0 is built correctly here ("-" + basename) rather than the original's"-bin/bash"bug (see README.md).- Resolve the log path, open the log file (direct
open()or via the privileged helper -- see next section), wire upTextTranscript/HashChain/SyslogSink. - Run the
PtySession, block until the shell exits. - Write the closing marker, finalize the log file (rename to
.closed, write the.sha256sidecar -- again, direct or via the helper), close the syslog sink.
-f/-d (custom logfile name/directory) are only honored when
is_standalone() is true. Under sudo, they're silently ignored: an
escalated user must not be able to redirect their own audit trail
somewhere they control. This is a security property carried over
directly from the original.
rootsh-review (review_cli.py) has no equivalent in the original C
version -- the 2013 README describes its maintainer solving the same
problem by switching to sudo's own I/O logging plus a sudoers
NOLOG_OUTPUT:NOLOG_INPUT: /usr/bin/sudoreplay rule. This is rootsh's
own version of that second half:
- Only usernames listed in
[review] reviewers_filemay run it. _resolve_session_log()resolves the requested log name strictly inside[logging] dir, refusing..components or anything that would escape it -- reviewers get read-only access to session logs, not an arbitrary-file reader.- It opens the target log in a pager (
$PAGER, elseless, elsemore, elsecat) through aPtySessionexactly like a normal session -- and fully re-records that review session (the reviewer's own scrolling, searching, whatever they do) to its own tagged log (review.<reviewer>.<timestamp>.<pid>) under[review] log_dir, with the same.closed/.sha256lifecycle as any other session. Who reviewed what, when, and what they did while reviewing is itself an auditable record, not a blind spot.
cli.py's _delegates_to_review() recognizes when it's specifically
launching rootsh-review as the command
(rootsh -u reviewer -c 'rootsh-review ...') for an authorized
reviewer, and skips its own content logging for that one invocation --
otherwise the same content would be captured twice (once by rootsh
wrapping the outer su, once by rootsh-review itself). This only
covers that direct-launch case, not someone typing rootsh-review by
hand inside an already-logged interactive shell, matching the scope of
the sudoers precedent it's modeled on.
This is the part of the design that isn't obvious from reading any single module in isolation, so it gets its own section.
sudo rootsh (or standalone, unprivileged use with --no-logfile
pointed at a directory the user owns): sudo sets the process's
real and effective* uid to the target identity (root, by default)
before rootsh ever runs. os.geteuid() == 0. Opening a file under
/var/log/rootsh (root-owned, mode 0700) is trivial -- open()
just works.
rootsh set as a user's login shell (--assign-login-shell, see
README.md):
login(1)/sshd run as root, but by the time they exec() the
configured shell, they've already switched the process's real
and effective uid to the target user -- that's the normal,
unavoidable behavior of every login path on the system, not something
rootsh controls. So when rootsh starts, os.geteuid() is the ordinary
logged-in user's uid, not root. It never touches root privilege at
all in this path.
That second case is where the actual bug this document exists partly
to explain was: open(log_path, "ab") on a root-owned, mode-0700
directory, called by a process running at a normal user's own uid,
fails with PermissionError. Every login-shell-wrapped login crashed
immediately.
The natural instinct is: give the Python process itself a Linux
capability (CAP_DAC_OVERRIDE -- bypass DAC file permission checks --
is the minimal one; the login path already put the process at the
correct uid, so CAP_SETUID is neither needed nor wanted) via
setcap on the interpreter, and skip writing any C.
Two things rule that out:
- Linux only honors file capabilities (like setuid) on the ELF
binary directly named in
execve(). They can't be attached to a text file with a#!shebang line -- the kernel'sbinfmt_scripthandler re-invokesexecve()on the interpreter, and it's the interpreter's own file capabilities that apply, not the script's (which the kernel doesn't even look at for this purpose). So in principle,setcap cap_dac_override=+epon the venv's own private Python interpreter would be honored. - But
/etc/passwd's shell field must be a bare executable path with no arguments.login(1)/sshdexecve()it directly asargv[0] = "-" + basename, nothing else. There's no way to say "run this specific interpreter with this specific script" from that one field. And even if there were a trick around that: a general-purpose interpreter carrying a DAC-bypass capability is catastrophic on its own terms -- its entire purpose is to run arbitrary code supplied by whoever invokes it, so granting it that capability effectively grants DAC-bypass to anyone who can execute it with-cand arbitrary Python, not just to rootsh's own logic. A setuid/capability-bearing interpreter is a well-known anti-pattern for exactly this reason.
So the privileged part has to be a small, fixed-behavior compiled
program with no general code-execution surface -- not more Python.
That's helper/rootsh-logopen.c.
The helper is invoked by the already-running, unprivileged Python
process (privlog.py) -- not by login/sshd, and not listed
anywhere in /etc/passwd. It supports exactly two subcommands:
rootsh-logopen open <name> <sockfd>
rootsh-logopen finalize <name>
open opens (creating if needed) LOG_DIR/<name> for append, and
sends the resulting file descriptor back to the caller over a UNIX
domain socket via SCM_RIGHTS ancillary data -- the standard
mechanism for passing an open file descriptor between unrelated
processes. The caller never needs to open the path itself; it just
gets to use an fd that was opened with more privilege than it has.
sequenceDiagram
participant P as rootsh (unprivileged,<br/>euid = real user)
participant H as rootsh-logopen<br/>(cap_dac_override)
participant FS as /var/log/rootsh<br/>(root:root, 0700)
P->>P: socket.socketpair()
P->>H: exec("open", name, sockfd)<br/>pass_fds=[socket]
H->>H: getuid() == prefix of <name>?
H->>FS: openat(dirfd, name, O_CREAT|O_APPEND)
FS-->>H: fd
H->>P: sendmsg(SCM_RIGHTS, fd)
H->>H: exit(0)
P->>P: os.fdopen(fd, "ab")
Note over P: writes to fd need no further<br/>permission check -- access was<br/>already granted at open() time
finalize renames LOG_DIR/<name> to LOG_DIR/<name>.closed and
writes LOG_DIR/<name>.closed.sha256 from stdin (the final hash-chain
digest) -- the same two directory-write operations cli.py does
directly when euid == 0.
CAP_DAC_OVERRIDEonly -- set viasetcap, not setuid, not full root. It can bypass file permission checks; it can't change who the process is, escalate to arbitrary other privileges, or survive being handed off anywhere except the one already-open fd it produces.LOG_DIRis fixed at compile time (-DROOTSH_LOG_DIR, baked in byinstall.shfrom[logging] dir) -- never taken from argv or the environment. A caller can never point the helper at an arbitrary directory.<name>must be a bare filename: no/, not.or..(valid_bare_name). Combined with the fixedLOG_DIR, there's no path-traversal escape.<name>must start with"<calling process's own username>."(name_matches_caller), checked viagetuid()-- never anything the caller supplies. This is the property that makes it safe for the helper to be installed world-executable (mode0755, same threat model assu(1)/sudo(1): safe because the logic is fixed and narrow, not because invocation is restricted): one local user can never open, append to, or finalize (rename/overwrite the sidecar of) another user's session log through it.- Minimal lifetime. Each invocation does one thing and exits immediately; there's no persistent privileged daemon, no listening socket, nothing to attack between sessions.
A plain sudo rootsh never touches any of this -- cli.py checks
os.geteuid() == 0 first and opens/finalizes directly in that case,
both because it's unnecessary overhead otherwise and because the
helper's getuid()-based ownership check would actively break under
sudo (real uid is root there too, not the original invoker -- see
Identity resolution).
install.sh is a single bash script, interactive by default with a
--flag equivalent for every prompt (so it also runs unattended, e.g.
--yes). Key responsibilities beyond what's covered in
README.md:
- Creates a Python virtualenv and installs the package into it, then
writes small wrapper scripts (not bare symlinks) at
rootsh/rootsh-reviewthat pin$ROOTSH_CONFIGto wherever it actually wrote the config -- so a non-default--config-dirisn't silently ignored. - System mode only: builds
helper/rootsh-logopen.cwith a C compiler (cc/gcc/clang, whichever is found),setcap cap_dac_override=+eps it, and writes its path into the generated config as[logging] privileged_helper. If no compiler orsetcapis available, it warns clearly and continues without the helper --sudo rootshstill works fully; only the login-shell deployment is affected, and re-running the installer later (once a compiler is available) picks it up. - If an existing config is kept rather than overwritten, and the
helper was just built, it attempts to safely patch just the
privileged_helperkey into the existing file (only when doing so is unambiguous: no such key present anywhere yet, exactly one[logging]table to anchor on) rather than silently leaving a freshly-built helper unreferenced. --assign-login-shell=user1,user2,...is deliberately not automated by any other flag combination -- given the blast radius (a mistake here can leave a user, including over SSH, with no working shell at all), it's opt-in per invocation. For each user it:- Pre-flight checks (
su user -s /bin/sh -c '... rootsh -c true', run as root so it genuinely drops to that user's real+effective uid) that an unprivileged session can actually open its log file before touching anything. usermod -sto the rootsh path.- Post-flight checks (
su - user -c true, the real login path end to end). - Automatically reverts the shell change and stops (without touching any further users in the list) if either check fails.
- Pre-flight checks (
python3 -m venv .venv
.venv/bin/pip install -e . pytest
.venv/bin/pytest -q
The suite covers transcript.py (ANSI stripping, line splitting,
buffering edge cases), integrity.py (hash chain properties),
config.py (TOML loading, defaults, unknown-key rejection),
identity.py and reviewers.py, real forkpty()-based PtySession
smoke tests (test_session.py), the login-shell substitution logic
(test_login_shell.py), and -- where a C compiler is available (the
suite skips these tests otherwise) -- test_privlog.py, which
compiles rootsh-logopen fresh into a temp directory and exercises
the real open/finalize protocol end-to-end, including both
security checks (cross-user name rejected, path traversal rejected).
- No purely local design defeats a determined root user. Someone
who is root inside the recorded shell (i.e. exactly the person
this tool is meant to be watching, under
sudo rootsh) cankill -9the recording process or edit files on disk directly -- nothing running with no more privilege than the thing it's observing can prevent that. The syslog checkpoint forwarding (Integrity) exists specifically to get evidence off-box before that can happen; it's only as strong as wherever syslog is actually configured to ship those messages. - Not a full terminal emulator. Escape-sequence stripping is
regex-based, not a VT100 state machine. Full-screen redraws (
vim,top, ...) won't produce a sensible line-oriented log -- no design in this space does, without emulating an actual terminal. - The rootsh→rootsh-review delegation only covers directly
launching
rootsh-reviewas the command, not someone typing it by hand inside an otherwise-logged interactive shell (matches the scope of the sudoers precedent it's modeled on -- see The reviewer workflow). shell.default_shellhas no per-user override. Every login-shell-wrapped user gets the same substituted shell.- The privileged helper's compiled-in
LOG_DIRmust stay in sync with[logging] dir. Changing the config's log directory by hand after install requires rebuilding and re-setcap-ing the helper (or just re-runninginstall.sh) -- otherwise sessions opened via the helper and sessions opened directly (undersudo) would end up writing to two different directories.