Skip to main content
Glama

pentest-recorder · pentest-mcp

Your terminal is the best record of an engagement you will ever have. This turns it into one.

CI Python MCP local-first licence: MIT

Two hours into an assessment you have a domain credential, four hosts, a share you can read, and no idea which pane you saw any of it in. So you scroll. Or you re-run the enumeration. Or you paste it all into a notes file by hand, and then do it again tomorrow.

pentest-recorder watches your existing tmux session, keeps the exact bytes your terminal produced, and turns them into structured engagement state. pentest-mcp hands that state to any MCP-compatible agent — so it can answer where did I get svc_backup's password with the segment, the pane, the timestamp, and the original bytes.

You keep your VM, your tmux, your VPNs and pivots, your aliases, your wordlists. Nothing gets wrapped. Nothing gets replaced.

  tmux panes
      │
      ▼
  raw bytes ──────────────────────────────►  raw/pane-000003.log
      │                                       authoritative · never parsed
      │ terminal emulation
      ▼
  segments  ◄── byte-addressable, immutable
      │
      │ LLM (local by default)
      ▼
  observations  ◄── append-only, every fact cites its source
      │
      │ deterministic rebuild
      ▼
  entities · relationships · auth log
      │
      ├──────────────► Obsidian vault   (a projection, not the store)
      │
      └──────────────► pentest-mcp ────► Claude Code · Codex · any MCP agent

This is not an autonomous pentesting agent. The data layer holds truth and memory; the agent does the reasoning. The recorder never connects to anything you scanned — enforced by a test, not by good intentions.


Install

git clone git@github.com:lucianoengel/pentest-mcp.git
cd pentest-mcp
./install.sh

Python 3.11+, tmux, and SQLite with FTS5 — the last of which the installer checks, because some distro Pythons omit it. No Docker, no database server, no browser UI. Three third-party packages total: pyte, httpx, mcp.

Updating

cd pentest-mcp
git pull
./install.sh

Re-running the installer is the update. It rebuilds from the checkout, keeps your existing config.toml, and leaves engagement data alone. If an engagement was written by an older version, its store is migrated the next time a command opens it for writing — pentest-recorder status reports the schema version, and says so if they disagree.

Stop the recorder first if one is running (pentest-recorder stop), since a running daemon keeps using the code it started with.

Related MCP server: mcp-ssh-interactive

Use

pentest-recorder init inlanefreight --client "ACME" --scope "172.16.119.0/24"
tmux new -s inlanefreight
pentest-recorder start

Then work normally. Every pane in that session is captured, including panes and windows you open later.

pentest-recorder status                  # what is — and is NOT — being captured
pentest-recorder search 'Summer2026!'    # find the exact string, across everything
pentest-recorder pause                   # stop capturing, right now
pentest-recorder sync                    # extract, rebuild, export

Connect an agent

claude mcp add pentest -- ~/.local/bin/pentest-mcp --engagement inlanefreight

Now ask it things.

what credentials do I have that I haven't validated yet?

// list_entities(type="credential", filter={"validated": false})
{
  "items": [{
    "id": "credential:INLANEFREIGHT/svc_backup:password:HolyMoly123!",
    "type": "credential",
    "data": {
      "username": "svc_backup",
      "domain": "INLANEFREIGHT",
      "secret": "HolyMoly123!",      // exact, never normalized
      "secret_type": "password",
      "status": "unvalidated"
    },
    "fact_type": "CONFIRMED",
    "observation_count": 1
  }],
  "total": 1
}

where did that password come from?

// get_provenance(entity_id="credential:INLANEFREIGHT/svc_backup:...")
{
  "observations": [{
    "kind": "credential",
    "source": "extraction",
    "actor": "ollama/qwen2.5-coder:7b",
    "verified": true,                 // appeared verbatim in the source
    "segment_ids": [1]
  }],
  "segments": [{
    "id": 1,
    "terminal": "inlanefreight:2.1",
    "ts_start": "2026-08-26T14:32:11+00:00",
    "command": "cat /mnt/backup/scripts/backup.ini",
    "cwd": "/home/kali/eng",
    "raw_path": ".../raw/pane-000003.log",
    "byte_start": 0,
    "byte_end": 125                   // the original bytes, still on disk
  }]
}

catch me up

// get_engagement_summary()
hosts: 2 · services: 1 · identities: 1
credentials: 2  (1 unvalidated)
auth: 1 successful, 1 failed
findings: 1 candidate · open tasks: 0
hosts_with_no_service_recorded: 1     ← DC01 is under-enumerated
entities_with_unverified_fields: 0
segments: 1  (1 pending extraction)

That summary is the agent's index. It's why there is no list_unvalidated_credentials() tool — the counts tell the agent which questions are worth asking, so twelve read tools cover what would otherwise need thirty.


What it captures — and what it deliberately doesn't

It sees inside nested sessions. A caught reverse shell, ssh, evil-winrm, msfconsole, sqlplus. That is where a lot of the good evidence lives, and it is exactly what shell-history tooling cannot reach:

$ nc -lvnp 4444                    ← the only local command that ever runs
connect to [10.10.14.7] from (UNKNOWN) [172.16.119.30] 51422
C:\inetpub\wwwroot> type web.config
  <add name="prod" connectionString="...;Password=P@ssw0rd#2026;" />
                                   ↑ captured, extracted, attributed to nothing

The command that produced it is recorded only when it is genuinely known. Inside that reverse shell, command is nc -lvnp 4444 — truthfully — and exit_code stays empty rather than guessed.

It stops when you tell it to:

how

the agent window

excluded by default — an agent's own output must never re-enter as evidence

one pane

tmux set -p @pentest-record off

one window

tmux set -w @pentest-record off

everything, immediately

pentest-recorder pause

a pane you already pipe

detected, left alone, reported

status lists every pane in scope that is not being captured, with the reason. A silently unmonitored pane is the worst failure this tool can have, so it is never a footnote:

Engagement:  inlanefreight  (client: ACME)
Recorder:    running (pid 48213) since 2026-08-26T13:58:02+00:00
tmux server: 347338
Capturing:   3 pane(s)
     %1  inlanefreight:1:recon.0  (0 B buffered)
     %4  inlanefreight:2:ad.0     (2145 B buffered)
     %7  inlanefreight:3:shell.0  (0 B buffered)

NOT capturing 2 pane(s) in scope:
     %9  inlanefreight:5:agent.0  -- window excluded by configuration
    %11  inlanefreight:4:web.0    -- already piped by another tool; left untouched

Segments:    184 total, 3 pending, 181 extracted, 0 failed
Extraction:  ollama / qwen2.5-coder:7b
  Local provider: no engagement data leaves this machine.

Where your data lives

~/.local/share/pentest-recorder/engagements/inlanefreight/
├── engagement.db     one ordinary SQLite file — the canonical store
├── raw/              exact terminal bytes, rotated and gzipped
└── evidence/

Directories 0700, files 0600. The raw logs are the most sensitive thing on the box — plaintext domain credentials and client data sit in them. Treat that directory as loot.

Everything is inspectable with ordinary tools. A backup is cp -r; an audit is a SQL query:

sqlite3 engagement.db \
  "SELECT json_extract(data,'\$.username'), json_extract(data,'\$.status')
   FROM entities WHERE type='credential';"

Separate engagements are separate on disk

One directory and one database file per engagement — not a shared table with a filter. Two clients using the same RFC1918 space stay two distinct sets of entities, and searching one for the other's password returns nothing.

pentest-recorder init acme   --client "ACME"   --local-only
pentest-recorder init globex --client "Globex" --local-only

Each binds to the tmux session of the same name. If two recorders ever target one pane, pipe-pane -o refuses to displace the first and the second reports the pane as unmonitored.

At the end of an engagement:

pentest-recorder purge -e acme --include-vault

What reaches the model

Three things sit between capture and a model, and each disables independently in [extraction].

Facts the text determines are derived without a model. An address, a URL, a UNC path, password=X, and credentials in recognised formats — NTLMv2, Kerberos tickets, pwdump rows, JWTs, PEM keys — are read straight out of the text. Where the format carries the account, as most do, the owner comes from the value itself:

svc_qualys::INLANEFREIGHT:1122334455667788:AB12…:0101…
└────┬────┘  └─────┬─────┘
  username      domain        ← both are part of the value

Measured against a corpus of nine formats: the rule pass recovers 9/9 byte-identically with 9/9 correct attribution; qwen2.5-coder:7b asked to transcribe the same values manages 6/9 and 5/9. What it will not do is recognise a value that means something only because of where a tool prints it — microsoft-ds is a service because nmap has a SERVICE column, and that stays the model's job.

Long tokens are replaced before the model sees them. What remains is a placeholder, its length and character class, and all the surrounding context:

[SMB] NTLMv2-SSP Hash : svc_qualys::INLANEFREIGHT:1122334455667788:<Ta1b2c3d4:1>
                        └──────────── kept, so the model can attribute it ────┘

The recorder substitutes the real bytes back. Transcription error stops being something detected and becomes something that cannot happen. Keeping the identifiers visible matters: hiding the whole credential drops classification to 44%, keeping them raises it to 78% — better than showing the model the raw value.

Repetition is collapsed and empty segments are skipped. Identical lines become one instance and a count, with every distinct value preserved. A segment carrying no candidate and no value the engagement does not already know is never sent — the decision is on unknown values, not line shapes, because mid-engagement every shape is familiar and connected to \\SQL01\payroll would otherwise be dropped along with a new host and share. Skipped segments record why and stay reprocessable.

Numbers behind all of this are in bench/BASELINE.md.

When data leaves the machine

Extraction needs a language model, and there is no redaction option — to extract a secret you have to send the secret.

So the default provider is local (Ollama on 127.0.0.1), and nothing leaves your machine unless you change that. If you configure a remote provider, start tells you exactly what would be transmitted and refuses until you acknowledge it:

Extraction is configured to use openai (gpt-4o-mini) at
  https://api.openai.com/v1

This sends captured terminal output to that service. In a penetration
test that includes, in full and unredacted:
  - plaintext passwords, password hashes, tokens and API keys
  - usernames, domains, internal hostnames and IP addresses
  - file contents, share names and command output
  - vulnerability evidence and client-identifying data

There is no redaction option: extracting a secret requires sending it.

For client work where that is contractually forbidden:

pentest-recorder init acme --local-only

That engagement refuses a remote provider permanently, regardless of what the config later says — and reports the refusal, while capture carries on.

API keys are read from the environment, named by api_key_env in the config. The key is never written into the config file, engagement data, logs, or exported Markdown.


Obsidian

Point obsidian.vault_path anywhere and the engagement is projected into ordinary Markdown — dashboard, hosts, credentials, findings, timeline, and a note per host, cross-linked with wikilinks. No plugin, and Obsidian itself is optional — these are text files; cat, grep and git work fine.

# Credentials

| Identity | Secret | Type | Validated on | Source |
|---|---|---|---|---|
| INLANEFREIGHT\fiona     | `Summer2026!`  | Password | SMB FILE01 | Segment 1 |
| INLANEFREIGHT\svc_backup| `HolyMoly123!` | Password | Not yet    | Segment 1 |

The vault is a projection, not the store: delete it, re-export, lose nothing. Generated files carry generated_by: pentest-recorder front matter, and the exporter never overwrites a file lacking that marker — it writes alongside and tells you. Your own notes live in Notes/, which is never touched.

include_secrets is include by default, because accurate credential tracking is the entire point. Set redact or partial if the vault syncs somewhere you'd rather it didn't; that affects the projection only.


Configuration

~/.config/pentest-recorder/config.toml — generate it with pentest-recorder config --write. Every setting has a documented default, and an invalid value is reported by name with nothing applied.

[data]
root = "~/.local/share/pentest-recorder"

[obsidian]
vault_path = "~/Obsidian/Pentests"
include_secrets = "include"        # include | redact | partial

[tmux]
session = "@engagement"            # or a glob such as "client-*"
exclude_windows = ["agent"]

[capture]
idle_flush_seconds = 3.0
max_segment_bytes = 65536
rotate_bytes = 134217728

[extraction]
rules = true                       # derive facts the text determines
redact = true                      # replace long tokens before prompting
collapse = true                    # collapse repeated lines
route = true                       # skip segments carrying nothing new
escalation_model = ""              # optional stronger model for hard cases

[llm]
provider = "ollama"                # ollama | openai | openai-compatible
model = "qwen2.5-coder:7b"
base_url = "http://127.0.0.1:11434"
api_key_env = "OPENAI_API_KEY"     # names the variable, never holds the key

[mcp]
default_engagement = ""

Optional shell integration

Adds the exact command, working directory and exit code to segments from your outer shell:

source /path/to/pentest-mcp/shell/pentest-recorder.sh

Capture works fully without it, and activity inside a nested session stays correctly unattributed rather than guessed.


How it holds together

Four storage classes, each with exactly one rule:

class

tables

rule

immutable

segments, pane_instances

append only; never rewritten

append-only

observations

superseded, never edited

derived

entities, relationships, auth_attempts

dropped and rebuilt from observations

stateful

findings, tasks, notes

yours; derivation never touches them

Five consequences worth knowing:

A model failure costs nothing. tmux writes pane output to a plain file; the recorder tails it from a checkpointed byte offset. Nothing in the capture path depends on this process running. Recorder dies → bytes keep landing on disk and are picked up on restart. Provider down → segments stay pending.

Every extracted literal is checked against its source. A value that doesn't appear verbatim is kept but flagged, and surfaced as unverified through MCP. HolyMoly123! quietly becoming HolyMoly123 is the exact failure this tool exists to prevent — and plain substring matching cannot detect it, since the truncation is a substring of the truth.

Merging is emergent. Entities are connected components over identity keys. Learn on Tuesday that FILE01 is 172.16.119.10, and Monday's two separate records become one on the next rebuild — no rewriting, no tombstones. Entity ids are content-addressed, so they survive every rebuild.

A wrong merge is fixable and reversible. Tell the agent; it records a separation that derivation honours — including for links asserted transitively.

reprocess --model <better> is safe. New observations are added, never substituted, and your findings and tasks are untouched. Capture cheaply and locally now; improve extraction later.


Measured, not assumed

Three design questions were settled by measurement during the build. Reproduce with the scripts in bench/.

Search index — trigram, not a tuned word tokenizer. Over 20 representative queries against real tool output:

index

matched

unicode61 + pentest tokenchars

5 / 20

trigram

20 / 20

Widening tokenchars enough to hold 172.16.119.10 together also glues INLANEFREIGHT\fiona:Summer2026! into a single token, so searching for the password alone finds nothing.

Capture volume — normalization kills noise, not bulk.

workload

raw

normalized

reduction

progress line rewriting itself

247,637

347

714×

full-screen application

7,731

209

37×

large scrolling dump (find)

178,022

173,977

A 4,000-line find is all real content and passes through whole. That is why local-first extraction is load-bearing rather than merely prudent.

Model — qwen2.5-coder:7b. Scored on whether secrets, hashes and addresses come back byte-identically:

model

literal recall

kind recall

auth-result errors

qwen2.5-coder:7b

83%

90%

0

llama3.2:3b

39%

60%

2

The 3B model returned nothing at all for both authentication fixtures and got success-vs-failure wrong twice — the one error this system must not make quietly.


MCP tools

Twelve read, three write. Tool-count bloat degrades an agent's ability to pick the right one, so the counts in get_engagement_summary carry the hints that would otherwise need a tool each.

read

get_engagement_summary

counts that reveal what to ask next — start here

list_entities · get_entity

hosts, services, identities, credentials, shares, artifacts

search · get_segment

find an exact string; read the source it came from

get_provenance

trace any fact to its segment, pane, timestamp and byte range

get_recent_activity

what happened, newest first

list_auth_attempts

what worked where, and what didn't

list_findings · get_finding · list_tasks

list_engagements

write

record_observation

notes, hypotheses, facts seen outside the terminal, corrections

update_finding · update_task

confirm, reject, dismiss

Writes are always attributed to the operator or the agent — never to extraction, and the caller cannot claim otherwise. No write path can alter a segment.


Development

python -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest tests/ -q
.venv/bin/ruff check src tests bench

CI runs the suite on Python 3.11 through 3.14, installs tmux so the real-tmux acceptance tests actually run rather than skipping themselves, lints, and verifies the wheel builds with its licence metadata intact.

296 tests, including a full acceptance rehearsal against real tmux with a nested reverse shell, a real MCP client over stdio, real concurrent access between the recorder and the server, and an AST check that only the model provider module can reach a network.

tests/test_capture.py       capture, segmentation, rotation, pause, restart
tests/test_normalize.py     terminal emulation, both implementations
tests/test_extract.py       the extraction contract and the verbatim guard
tests/test_derive.py        union-find correlation, merges, corrections
tests/test_mcp.py           tool surface, bounds, attribution
tests/test_acceptance.py    end-to-end rehearsal in real tmux
tests/test_passivity.py     proves the recorder never touches a target

Design decisions, specs and the reasoning behind them live in openspec/changes/add-pentest-recorder-mcp/.

Scope

Deliberately not built: autonomous pentesting, automatic exploitation, a replacement terminal or VM, a web dashboard, a graph database, multi-user collaboration, network MCP transport, or a parser per tool.

The recorder captures and organizes. The MCP server exposes. The agent reasons. Those stay separate.

Licence

MIT — © 2026 Luciano Engel.

Use it, fork it, ship it. It comes with no warranty, which matters more than usual here: this tool stores plaintext credentials and client data on disk. Read Where your data lives and When data leaves the machine before pointing it at a real engagement.

Install Server
A
license - permissive license
A
quality
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • F
    license
    Not graded
    quality
    C
    maintenance
    An MCP server for EMBA firmware analysis that exposes structured security findings and tools to LLMs. It enables users to programmatically query, reason over, and correlate firmware analysis results such as kernel details, SBOMs, and attack paths.
    6
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server that enables AI agents to run fully interactive SSH sessions (via tmux) and execute commands like a human operator, with persistent sessions and multiple concurrent connections.
    6
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server that provides programmatic access to the SOLVE-IT digital forensics knowledge base, enabling LLMs to query, navigate, and search forensic techniques, weaknesses, mitigations, objectives, and citations.
    1
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Records your terminal sessions per command (PTY + OSC 133) into local SQLite, so AI agents can search, retrieve, and diff what commands actually printed. Secret redaction is applied by default to everything served over MCP.
    4
    6
    MIT

View all related MCP servers

Related MCP Connectors

  • Search your AI chat history (ChatGPT, Claude, Codex) from any MCP client. Remote, private, read-only

  • Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.

  • Agentic search over your Dewey document collections from any MCP-compatible client.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/lucianoengel/pentest-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server