Skip to content
BearlyStablePublic

About

A local-only shell wrapper that documents an interactive remote-shell session

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

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.

About

A local-only shell wrapper that documents an interactive remote-shell session

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages