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 (-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 .raw log keeps going regardless. Set SCRIBE_NO_HOTKEY=1 to 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 n starts session-myhost-2.md and leaves the old file untouched; a different host colliding on a name always gets -2 without asking. The dialogue reads /dev/tty (never the child's stdin) and only appears when stderr is a terminal — SCRIBE_YES=1 takes 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 (/enumerate available 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-enum writes the embedded prompts/enumerate.md and enum_signatures.txt into the current directory — handy on a host with nothing else from the scribe repo on it.
  • python scripts/build_release.py --check dist/scribe regenerates 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-built dist/scribe still matches main before 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.0

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.0
File Size Uploaded
scribesh-1.1.0.tar.gz 65.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for scribesh 1.1.0
File Interpreter ABI Platform
scribesh-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 101.3 kB

Release files / scribesh-1.1.0.tar.gz

Download URL scribesh-1.1.0.tar.gz
Size 65.7 kB
Tags Source
SHA-256 checksum
How to use checksums
88e3a3b95aeec42fdf2b0f3d20940e19abf39e13379f56dbadf1bce8b7d412ca
BLAKE2b-256 checksum
How to use checksums
acbc6d07251056668795ab5547077f98d80a94404b180a687c0461aa0f6ceb2f
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 1, 2026.

Transparency log

Release files / scribesh-1.1.0-py3-none-any.whl

Download URL scribesh-1.1.0-py3-none-any.whl
Size 35.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3339eabc7b9c828b7b2221a1b065dff6a892daaf3fc63effd1bc71547d4bc788
BLAKE2b-256 checksum
How to use checksums
298babb2f23ce0d87d4fc338339b786e1e38debe6bb0f40e35e41ed9e2013c83
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 1, 2026.

Transparency log

Release history Release notifications | RSS feed

1.1.2

2 release files

1.1.1

2 release files

This release

1.1.0 This release

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