Metadata-Version: 2.4
Name: scribesh
Version: 1.1.2
Summary: Local-only shell wrapper that documents an interactive remote-shell session into a per-host Markdown file.
Author: Philipp Herkert
License-Expression: MIT
Project-URL: Homepage, https://github.com/BearlyStable/scribe
Project-URL: Repository, https://github.com/BearlyStable/scribe
Project-URL: Issues, https://github.com/BearlyStable/scribe/issues
Keywords: rlwrap,ssh,pentest,session,shell,logging,documentation
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Information Technology
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pexpect; extra == "test"
Dynamic: license-file

# 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

```sh
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](#raw-logs) for what
you lose without it, because scribe won't warn you.

## Use it

Put `scribe` in front of however you connect:

```sh
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:

````markdown
# 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:

```sh
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](#single-file-deploy-no-pip-install-on-the-target), 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`](.opencode/commands/enumerate.md)**. That file is
self-contained, so copying it is the whole install:

```sh
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](https://opencode.ai/docs/commands/); if your version doesn't list the command,
try the singular `command/` directory.

**Then**, in opencode, from the directory holding your session files:

```sh
/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:

```sh
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

```sh
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`](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`](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](https://github.com/hanslub42/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.
