Skip to main content

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-enum writes the embedded prompts/enumerate.md and enum_signatures.txt into the current directory — handy on a host with nothing else from this repo on it.
  • python scripts/build_release.py --check dist/scribe regenerates the bundle in memory and diffs it against an existing file (non-zero exit on mismatch), confirming a built dist/scribe still matches main before 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)

Source distribution for scribesh 1.1.2
File Size Uploaded
scribesh-1.1.2.tar.gz 71.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for scribesh 1.1.2
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.1.2 This release

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page