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 (-a -c -A -r) on top. Nothing ever runs on the remote host — scribe only
observes what you type and what the child prints. See CLAUDE.md for the full design,
the local-only invariant, and the phase-by-phase build.
Requirements
rlwrap(sudo dnf install rlwrap/apt install rlwrap)script(util-linux; used for the raw-log safety net — optional, auto-detected)- Python ≥ 3.11
Install
pipx install . # from a checkout (recommended)
pipx install git+<repo-url> # or straight from git, no checkout needed
pipx puts scribe on your PATH in its own isolated venv — nothing leaks into your system
Python. A plain pip install . (or pip install -e .) works too if you'd rather manage the
environment yourself.
rlwrap execs the filter module by path, so it must stay executable. A wheel install (what
pipx builds) 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; that needs Python ≥ 3.11 on PATH, same as the launcher.
Use it — capture
Put scribe in front of however you connect. The rest of the line is passed through
verbatim (your own pc/ssho/sshpass wrappers, whatever — scribe never interprets them):
scribe pc 10.10.5.4:3492 sshpass -p 'secretpassword' ssho root@192.168.178.2 -D 5001 /bin/sh
While you work, scribe writes session-192.168.178.2.md in the current directory, rewritten
atomically after every command — so a kill -9 never loses a committed command. It also keeps
a flushed raw typescript session-192.168.178.2.<timestamp>.raw alongside as ground-truth
backup.
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 — so ssh myhost and ssh 10.0.0.5 for one box are two files.
Override with SCRIBE_NAME=whatever (an env var, not a flag, so everything after scribe stays
verbatim child argv).
The .md has ## access (every invocation you've reached this host with), ## enumeration (a placeholder, filled later
by AI), and ## commands — each distinct command (arguments matter) with its latest output,
ordered by a sensible heuristic (identity → users → network → processes → …), long output folded
into <details>.
-
Ctrl-T pauses/resumes recording mid-session (shows
[scribe: recording OFF/ON]); the.rawlog keeps going regardless. SetSCRIBE_NO_HOTKEY=1to keep your own Ctrl-T binding. -
Reconnecting continues the same file: commands already in it are updated in place (exact command string is the key — arguments matter; last output wins), new ones are appended, and each distinct invocation is added to
## access, so a box reached by a new route keeps both lines. Before extending, scribe tells you what it's about to continue and lets you say no: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
nstartssession-myhost-2.mdand leaves the old file untouched; a different host colliding on a name always gets-2without asking. The dialogue reads/dev/tty(never the child's stdin) and only appears when stderr is a terminal —SCRIBE_YES=1takes every default for scripts.
Use it — 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 — one file, once. /enumerate is not built into opencode; it exists because of the
command file .opencode/commands/enumerate.md in this
repo. It is self-contained (the whole prompt is in it, no includes), so copy that one file to
wherever you want the command available. Per opencode's docs,
command markdown files live in:
- Global (
/enumerateavailable everywhere):~/.config/opencode/commands/ - Per-project (only where you run engagements):
<project>/.opencode/commands/
The filename becomes the command name — enumerate.md → /enumerate. From a scribe checkout:
mkdir -p ~/.config/opencode/commands
cp .opencode/commands/enumerate.md ~/.config/opencode/commands/
If your opencode doesn't list the command, check whether your version wants the singular
command/ directory instead; the docs linked above say commands/.
Use it — in opencode, from the directory holding your session files:
/enumerate session-192.168.178.2.md
…or paste prompts/enumerate.md — the same checklist, model-agnostic — into any other model.
Either way 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.
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.
Single-file deploy (no pip install on the target)
For a target host you don't want to (or can't) install anything Python-packaging-shaped on, build one self-contained executable file and scp it over instead:
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 only needs python3 and rlwrap
(and, optionally, script), nothing else from this repo. It works by pointing rlwrap's -z
filter back at itself: run normally, it execs rlwrap ... -z <its own name>; rlwrap then
re-execs that same file as the filter subprocess, which dispatches into scribe.filter.main()
instead of scribe.cli.main() the second time round (see CLAUDE.md, "Phase 6" for the full
mechanism). Nothing about what scribe does or sends changes — this is purely a packaging
trick, proven end to end by tests/test_build_release.py's bundle integration test.
./scribe --dump-enumwrites the embeddedprompts/enumerate.mdandenum_signatures.txtinto the current directory — handy on a host with nothing else from the scribe repo on it.python scripts/build_release.py --check dist/scriberegenerates the bundle in memory from the current source tree and diffs it against an existing release file (non-zero exit on a mismatch) — confirms a previously-builtdist/scribestill matchesmainbefore you ship it.
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 |
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.1
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.1.tar.gz | 70.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scribesh-1.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 107.9 kB
Release files / scribesh-1.1.1.tar.gz
| Download URL | scribesh-1.1.1.tar.gz |
|---|---|
| Size | 70.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e9f9026d256eb6c1c8efa5de3fe3ad7c12fb608fac6d8456fcd59f05d00c31bd
|
|
BLAKE2b-256 checksum How to use checksums |
f48963b12f358708fed3a6247e530655f8bd2c19ee7ee1734046e880ec260aea
|
| 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 4, 2026.
Transparency logRelease files / scribesh-1.1.1-py3-none-any.whl
| Download URL | scribesh-1.1.1-py3-none-any.whl |
|---|---|
| Size | 37.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
93bba3d913608188dfdb19f15694f055dd0f5744452613a484993d57ac2702ef
|
|
BLAKE2b-256 checksum How to use checksums |
73feeabe06831f2eabedd45044d202ccd075a86f675b18081582fac1d26a2be3
|
| 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 4, 2026.
Transparency log