scribe
A local-only shell wrapper that documents an interactive remote-shell session (ssh,
proxychains+ssh, …) into a per-host Markdown file as you work, with rlwrap line editing on
top. Nothing ever runs on the remote host — scribe only observes what you type and what the
child prints.
Install
pipx install scribesh
The distribution is scribesh (the name scribe was taken on PyPI); the command it installs
is scribe. Upgrade with pipx upgrade scribesh.
You also need rlwrap, and script for the raw-log safety net:
| rlwrap | script | |
|---|---|---|
| Fedora | sudo dnf install rlwrap |
sudo dnf install util-linux-script |
| Debian / Kali / Ubuntu | sudo apt install rlwrap |
already there (part of bsdutils) |
Python ≥ 3.11. script is optional and auto-detected — but see Raw logs for what
you lose without it, because scribe won't warn you.
Use it
Put scribe in front of however you connect:
scribe ssh root@10.0.0.5
That's it. You get an ordinary ssh session with readline editing, and scribe writes
session-10.0.0.5.md in the current directory as you work:
# 10.0.0.5 - web-02
## access
```
scribe ssh root@10.0.0.5
```
## enumeration
<!-- SCRIBE:ENUM:START -->
_Not yet analysed. Run the enumerate prompt (see prompts/enumerate.md) over this file._
<!-- SCRIBE:ENUM:END -->
## commands
### id
```
uid=0(root) gid=0(root) groups=0(root)
```
### ip a
```
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
inet 10.0.0.5/24 brd 10.0.0.255 scope global eth0
```
The file is rewritten atomically after every command, so a kill -9 never loses a command
you already ran. Commands are deduplicated (re-running one replaces its output) and ordered by a
sensible heuristic — identity, then users, then network, then processes, then filesystem — not
chronologically. Output longer than 15 lines is folded into a <details> block.
Everything after scribe is passed through verbatim, so your own wrappers work unchanged and
scribe never has to understand them:
scribe pc 10.10.5.4:3492 sshpass -p 'secretpassword' ssho root@192.168.178.2 -D 5001 /bin/sh
Reconnecting continues the same file
Connect to a host you've already documented and scribe extends that document rather than
starting a new one — existing commands are updated in place (last output wins), new ones are
appended, and the new route is added to ## access. It tells you first:
scribe: extending session-myhost.md (7 commands, last written 2026-08-11 14:02)
scribe: last access: scribe ssh root@10.0.0.5
scribe: continue this file? [Y/n]
Answering n starts session-myhost-2.md and leaves the old file alone. A different host that
happens to collide on a name always gets -2 without asking.
Pause recording
Ctrl-T toggles recording mid-session (shows [scribe: recording OFF]), for the things you
don't want in a client report. The .raw log keeps going regardless.
Raw logs
Alongside the .md, scribe keeps a flushed raw typescript —
session-10.0.0.5.<timestamp>.raw — as ground truth: the whole terminal session, including
in-flight commands and anything the filter might have mis-segmented.
This needs script. Without it scribe runs fine and silently skips the raw log — the only
signal is the absence of this second startup line:
scribe: documenting 10.0.0.5 -> session-10.0.0.5.md
scribe: raw typescript -> session-10.0.0.5.20260907T081500Z.raw <- missing without `script`
script runs where scribe runs, so it's your own machine that needs it. Wrapping an ssh to
a Kali box needs nothing installed on the Kali box — only the single-file
deploy, where scribe itself runs on the
target, changes that.
Enumerate (offline AI)
scribe fills every mechanical section; the ## enumeration prose is written later, offline,
by opencode + a small model (e.g. qwen). It reads only the file scribe produced — still local.
Setup, once. /enumerate is not built into opencode; it exists because of
.opencode/commands/enumerate.md. That file is
self-contained, so copying it is the whole install:
mkdir -p ~/.config/opencode/commands
cp .opencode/commands/enumerate.md ~/.config/opencode/commands/
Global (~/.config/opencode/commands/) makes /enumerate available everywhere; a project's own
.opencode/commands/ scopes it to that directory. The filename becomes the command name. See
opencode's docs; if your version doesn't list the command,
try the singular command/ directory.
Then, in opencode, from the directory holding your session files:
/enumerate session-10.0.0.5.md
It fills only the region between the <!-- SCRIBE:ENUM:START/END --> markers with a terse,
evidence-cited summary (OS, privilege, users, network/pivot, egress, security stack, monitoring,
verdict), writing not observed rather than guessing. Or paste prompts/enumerate.md — the same
checklist, model-agnostic — into any other model.
Edit enum_signatures.txt to add the AV/EDR/monitoring process names specific to your
estates; scribe pre-greps recorded output for them and drops a scribe-signals hint into the
file for the model to lean on.
Reference
How the filename is chosen
The name comes from the target, found without touching the network. scribe parses ssh's own
option grammar to pick the destination out of the command, so scribe pc 1024 ssh myhost -D 2304
writes session-myhost.md and not the -D port. Failing that it takes the last user@host, then
the last bare IPv4, then the wrapped command's own name (scribe bash → session-bash.md, and it
asks you for a better one if you're on a terminal).
Hostnames are never resolved — a DNS lookup would leak the target's name to whatever resolver
you sit behind — so ssh myhost and ssh 10.0.0.5 for one box are deliberately two files.
Override with SCRIBE_NAME=whatever.
Line editing
scribe adds rlwrap -a -c -A -r — history, Ctrl-R, filename and word completion — and manages
your readline config so Ctrl-T can be bound. It chains your own inputrc ($INPUTRC, else
~/.inputrc, else /etc/inputrc) so your bindings survive, and turns on
revert-all-at-newline, which readline defaults to off: without it, editing a recalled history
entry and walking away leaves the edit in that entry, so coming back to it later hands you the
half-deleted version instead of the command you ran.
SCRIBE_NO_HOTKEY=1 leaves $INPUTRC untouched if you'd rather keep your own Ctrl-T.
Single-file deploy (no pip install on the target)
For a host you can't install Python packaging on, build one self-contained executable and scp it:
python scripts/build_release.py # writes dist/scribe
scp dist/scribe user@target:~/scribe
ssh user@target 'chmod +x ~/scribe && ~/scribe pc ... ssho ...'
dist/scribe is a plain .py file (with a #!/usr/bin/env python3 shebang) that base64-embeds
the entire scribe package — the target needs only python3 and rlwrap (and, optionally,
script). It works by pointing rlwrap's -z filter back at itself: run normally it execs
rlwrap … -z <its own name>; rlwrap re-execs that same file as the filter subprocess, which
dispatches into scribe.filter.main() instead of scribe.cli.main() the second time round.
Nothing about what scribe does or sends changes — purely a packaging trick, proven end to end
by tests/test_build_release.py.
./scribe --dump-enumwrites the embeddedprompts/enumerate.mdandenum_signatures.txtinto the current directory — handy on a host with nothing else from this repo on it.python scripts/build_release.py --check dist/scriberegenerates the bundle in memory and diffs it against an existing file (non-zero exit on mismatch), confirming a builtdist/scribestill matchesmainbefore you ship it.
Installing from source
pipx install . # from a checkout
pipx install git+<repo-url> # or straight from git
rlwrap execs the filter module by path, so it must stay executable. A wheel install can drop
that +x bit, so scribe re-asserts it at startup — no editable install required. The filter runs
under whatever python3 resolves to at runtime and imports the installed scribe package, so it
needs Python ≥ 3.11 on PATH, same as the launcher.
Environment knobs
| Var | Effect |
|---|---|
SCRIBE_NAME |
name this session yourself instead of parsing the target out of the command |
SCRIBE_YES |
never ask anything — take every default (continue an existing file, use the parsed/slug name) |
SCRIBE_RLWRAP |
path to the rlwrap binary (default rlwrap) |
SCRIBE_SCRIPT |
path to script (default script) |
SCRIBE_NO_RAW |
disable the .raw typescript safety net |
SCRIBE_NO_HOTKEY |
don't bind Ctrl-T (keep your own inputrc binding) |
SCRIBE_FILTER_DIR |
override where rlwrap looks for the filter (default: the package's own directory) — set automatically by the single-file bundle; not normally needed by hand |
SCRIBE_FILTER_NAME |
override the bare filename passed to rlwrap's -z (default filter.py) — ditto |
SCRIBE_RUN_AS_FILTER |
internal: set automatically so a re-exec'd single-file bundle knows to run as the filter, not the launcher |
They're environment variables rather than flags on purpose: it keeps "everything after scribe
is the child command, verbatim" absolutely true, so scribe never has to guess whether an argument
was meant for it or for your ssh.
Design
See CLAUDE.md for the architecture, the local-only invariant and the test that
enforces it, the rlwrap filter protocol, the document model, and the phase-by-phase build.
Licensing
scribe is MIT-licensed (see LICENSE) — Copyright (c) 2026 Philipp Herkert.
Every file in this repository is scribe's own code. scribe/_filterproto.py is an independent
implementation of rlwrap's filter wire protocol (the
length-prefixed tagged-message exchange over rlwrap's -z pipes) — written from the protocol
itself, not derived from rlwrap's own GPL filter library — so scribe carries no third-party
copyleft. scribe still runs under rlwrap (a separate program you install), but ships none of
its code.
Metadata
Release files for scribesh 1.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scribesh-1.1.2.tar.gz | 71.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scribesh-1.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 109.6 kB
Release files / scribesh-1.1.2.tar.gz
| Download URL | scribesh-1.1.2.tar.gz |
|---|---|
| Size | 71.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cb4bee8a90fdf4143c1cbb992c64684795bdb1697fa0b497387f3467c8d5efae
|
|
BLAKE2b-256 checksum How to use checksums |
e7ab7cb043e35e2b54f5da09bede34d631a640a60a7b201d22c661c511d47f45
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / scribesh-1.1.2-py3-none-any.whl
| Download URL | scribesh-1.1.2-py3-none-any.whl |
|---|---|
| Size | 37.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f4a5071970249865792e5e1c00cd031bcd07034ee0b0f93865d6414ddf3f692f
|
|
BLAKE2b-256 checksum How to use checksums |
7c79c863531ac30b2b83c0af2a8328588171b39fe181d15b4c8563980059fb7b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency log