Metadata-Version: 2.4
Name: scribesh
Version: 1.1.1
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 (`-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

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

```sh
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`](.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](https://opencode.ai/docs/commands/),
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:

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

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

```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 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`](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.
