Fork of Limnoria — the original bot framework by Valentin Lorentz et al. This repo contains the upstream Limnoria core plus custom plugins specific to fatbot.
→ Setup guide: Debian Trixie from scratch
⚠️ VIBECODED — USE AT YOUR OWN RISKThis entire codebase was written with AI assistance (Claude Code / Claude Sonnet). It has not been formally audited. It runs a bot connected to a public IRC network, exposes a subprocess-based LLM interface to channel users, and handles untrusted URLs from the internet. Known risks include prompt injection, SSRF, session-token leakage, and general "AI wrote this" bugs. Read the code before deploying it. Security mitigations for the Claude plugin are documented in the Claude plugin security section below.
- Bot home:
/home/botuser/runbot/ - Master config:
fatbot.conf(rewritten on shutdown — see "conf-rewrite gotcha") - Per-section conf:
conf/{channels,users,networks}.conf(also rewritten on shutdown) - Custom plugins:
plugins/<Name>/ - Channel logs:
logs/ChannelLogger/<network>/<#channel>/ - Cookie jars (mode 600):
reddit-cookies.txt,youtube-cookies.txt(see "Session cookies" below) - Channel digest:
chaninfo.md(nightly summary, generated by the Brain plugin)
pipx inject limnoria curl_cffi
pipx inject limnoria PySocks --pip-args="--no-deps"
curl_cffi— used by the Title plugin's HTTP layer to impersonatechrome131and bypass TLS-fingerprint bot detection (Akamai, Cloudflare). Single sharedcc.Session(), cookie jar persistence, SSRF guard viagetaddrinfo.PySocks— required forsocks5h://URLs in requests/urllib, used to route a retry through cloudflare-warp when a page returns a bot challenge (see the Title plugin's WARP two-pass below).
Debian package cloudflare-warp (repo
https://pkg.cloudflareclient.com bookworm main, signed-by
/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg).
Runs in SOCKS5 proxy mode on 127.0.0.1:40000, NOT as a default
route. A default-route ("warp" mode) install will kill SSH because
warp injects a tunnel interface and reroutes all egress.
Why: some sites front their pages with a Cloudflare/Akamai anti-bot challenge that blocks VPS egress IPs. The Title plugin retries such a fetch through WARP (a residential-looking egress) to get the real page; everything else continues to use the normal VPS egress. (Historically this proxy was used by the ShrinkUrl plugin for is.gd, which has since been removed — WARP is now optional and only used by the Title two-pass.)
Setup / recovery sequence:
apt install cloudflare-warp
warp-cli --accept-tos registration new
warp-cli --accept-tos mode proxy # MUST be set before connect
warp-cli --accept-tos proxy port 40000
warp-cli --accept-tos connect
Verify:
warp-cli --accept-tos status→Connectedwarp-cli --accept-tos settings→Mode: WarpProxy on port 40000ip routedefault still points at your gateway; noCloudflareWARPinterface inip linkss -tlnp | grep 40000shows the local SOCKS5 listenercurl -x socks5h://127.0.0.1:40000 https://www.cloudflare.com/cdn-cgi/tracereturnswarp=on; directcurlreturnswarp=off
Persistence: warp-svc.service is enabled; Always On: true in
settings; reg.json + settings.json in /var/lib/cloudflare-warp/
persist registration and mode across reboots.
Consumer: the Title plugin's bot-challenge two-pass retry. If WARP is disconnected, the fetch simply stays on direct egress — the bot stays functional, it just can't retry challenge-walled pages.
- Unit:
/etc/systemd/system/fatbot.service - Drop-ins:
/etc/systemd/system/fatbot.service.d/{hardening,claude}.conf - Control:
sudo systemctl {start,stop,restart,status} fatbot - Logs:
sudo journalctl -u fatbot -fand/home/botuser/runbot/logs/{messages,error}.log
ProtectHome=read-only with ReadWritePaths=/home/botuser/runbot,
PrivateTmp=true. Subprocesses allowed (yt-dlp, claude). Hardening:
caps dropped, seccomp @system-service ~@privileged, MDWE,
PrivateDevices, ProtectProc=invisible. Score ~1.5 via
systemd-analyze security.
Seccomp gotcha: do NOT add ~@resources to SystemCallFilter — the
bundled claude binary (V8 JIT) needs scheduling/rlimit syscalls and
SIGSYS-crashes on startup if they're blocked.
XDG redirect for subprocess plugins writing to $HOME: pass
XDG_CACHE_HOME=/home/botuser/runbot/.cache,
XDG_CONFIG_HOME=/home/botuser/runbot/.config,
CLAUDE_CONFIG_DIR=/home/botuser/runbot/.claude so writes stay inside
ReadWritePaths.
supybot rewrites fatbot.conf and conf/*.conf on shutdown with its
in-memory state. To edit any of those:
systemctl stop fatbot- edit
systemctl start fatbot
A restart after editing while the bot is running silently reverts the
edits. Plugin code (plugins/<Name>/plugin.py) is safe to edit live —
those aren't rewritten:
rm -rf plugins/<Name>/__pycache__
systemctl restart fatbot # or `!reload <Name>` / `!rl` (Reload plugin) from IRC
Custom plugins live in plugins/. Each plugin must be added to
fatbot.conf:
supybot.plugins: ... <Name>(space-separated)supybot.plugins.<Name>: True
ChannelLogger (stock) is also loaded but does not appear in the
supybot.plugins: line.
Custom plugins currently installed:
- Claude — Claude-CLI-backed chat / Q&A, asked by addressing the bot by
nick;
!claude/!haiku(default, cheap) and!fable(top model,--effort max, expensive) switch the per-channel mode. Per-channel "brain" recall. The Gemini chat fallback was removed 2026-06-13 — Gemini is only used by the MCP video tools. Exposes a small MCP tool suite to the model: an image viewer, a URL fetcher, YouTube transcript/download tools, and a Reddit-video analyzer (Gemini Files API, with optional rehosting on the Zipline image host). See Claude plugin security - Create — image / video generation (
!pic/!picnsfw/!video/!videonsfw) via Gemini and Runware, rehosted on a Zipline image host; channel-gated by agenerativecapability - EasyControl — consolidated user/channel admin commands
(
cap/chancap/adduser/chanmode/topic…) plus channel-mode and topic enforcement. Supersedes the older ChanModes, InfoToggle, Aliases and TopicLock plugins, which remain in the tree but are no longer loaded. - Seen —
!seen <nick>last-activity lookup, gated by a per-channelseencapability - Title — URL-title snarfer using
curl_cffi; combined-mode with ShrinkUrl posts<title> | <short>; per-site brand prefixes and reddit/twitter special-casing (rewrites toold.reddit.com, usesapi.fxtwitter.com); WARP two-pass retry on bot-challenge pages - ShrinkUrl — overridden to default to t.ly (Bearer-token API), falling back through tinyurl → x0.no
- YouTube — yt-dlp-backed metadata snarfer, plus
!ytdl <url>which downloads a video and rehosts it on the Zipline image host (gated by a per-channelytdlcapability) - SportsNews —
!sports <league>posts the top 3 ESPN headlines for a league (17 supported: nfl, nba, mlb, nhl, epl, laliga, ucl, ufc, f1, …), article URLs shortened via ShrinkUrl - Reload —
!rlbatch-reloads every plugin listed insupybot.plugins.Reload.pluginsin one shot (owner-only); stockreloadonly takes one plugin at a time - Ash — Evil-Dead "Ash Williams" quote / persona responder
- Wikibear — creepy-cheerful Wikipedia fact responder
- Relay — relays public messages between two channels
- Greeter, Hamster, Repo — small utility plugins
- ChanModes, InfoToggle, Aliases, TopicLock — legacy plugins, folded into EasyControl and no longer loaded
Two plugins read Netscape-format cookies.txt files containing
live session cookies from a logged-in browser. These are required
because both sites now block anonymous requests from VPS egress IPs;
TLS-fingerprint impersonation alone is not enough.
| File | Consumer | Config key | Why |
|---|---|---|---|
reddit-cookies.txt |
Title plugin (HTTP fetch path) | supybot.plugins.Title.cookiesFile |
Reddit blocks unauthenticated requests from VPS IPs. With a reddit_session cookie, old.reddit.com returns a normal page (the new app's title only appears at ~430 KB offset, past our 256 KB cap — so the plugin rewrites (www.)?reddit.com → old.reddit.com before fetching). |
youtube-cookies.txt |
YouTube plugin (yt-dlp subprocess) | supybot.plugins.YouTube.cookiesFile |
yt-dlp otherwise hits "Sign in to confirm you're not a bot" on metadata extraction. A logged-in cookie jar bypasses that gate. |
How to provide them:
- In a logged-in browser session, export
cookies.txtfor the target domain (browser extension oryt-dlp --cookies-from-browser). - Save with mode 600, owned by the bot user.
- Point the config keys at the absolute path.
The Title plugin auto-reloads its jar when the file's mtime changes, so refreshing cookies doesn't need a bot restart. Cookies expire — re-export periodically.
Security note: these files contain live session tokens. Never
commit them. The bot's .gitignore excludes *-cookies.txt.
The Claude plugin spawns claude (Claude Code CLI) as a subprocess and relays
its output to an IRC channel. Access is restricted: the plugin must be enabled
for the channel, and the user must be a registered Limnoria user with the
claude channel capability explicitly granted. The attack surface is therefore
limited to trusted, named users — not the general public. The following
mitigations are in place regardless.
| Mitigation | How |
|---|---|
| No tool access | --tools "" passed on every invocation — claude cannot shell out, read files, or make network requests regardless of what a prompt tells it to do. |
| No shell | Plugin builds argv as a Python list and passes the question via stdin. No shell=True, no interpolation, no command-injection surface. |
| Email redaction | EMAIL_RE strips any email-shaped string from every output line before it reaches the channel. Claude Code injects the OAuth account email into model context regardless of --system-prompt; the regex catches leakage even across future model updates. |
| Rate/token line suppression | LEAK_LINE_RE drops any output line mentioning rate limits, token usage, or pricing. |
| Stderr never relayed | subprocess stderr is captured and discarded; internal errors don't reach the channel. |
| Capability gate | Both the !claude command and the nick-addressed doPrivmsg listener check ircdb.checkCapability for a per-channel claude cap. Only explicitly registered users hold this cap — channel-default was deliberately not used to avoid granting access to unregistered users. |
| Public-only | The !claude command uses Limnoria's 'public' wrap filter; doPrivmsg filters by channel prefix. PMs are blocked. |
| Mitigation | How |
|---|---|
| System prompt | Instructs the model to reply concisely, avoid disclaimers, and not relay internal context. Soft barrier — overridable by a sufficiently clever prompt, which is why the hard stops above exist. |
| No session persistence | --no-session-persistence --disable-slash-commands passed on every call. Context is synthetic (Q/A pairs prepended to stdin), lost on restart. |
| Per-user context TTL | In-memory context store with a 6-minute sliding TTL, max 5 turns. Not persisted to disk. |
The fatbot service itself runs under ProtectHome=read-only with
ReadWritePaths=/home/botuser/runbot, PrivateTmp=true, dropped capabilities,
seccomp filter (@system-service ~@privileged), MDWE, PrivateDevices,
ProtectProc=invisible. The claude subprocess inherits this sandbox. XDG env
vars redirect any writes into ReadWritePaths so nothing escapes to $HOME.
Known remaining risks:
--tools ""is a CLI flag, not a kernel guarantee. A future Claude Code update could change flag semantics.- DNS rebinding: the Title/image SSRF guard re-validates hosts via
getaddrinfobutcurl_cffi0.15 doesn't supportresolve=, so curl re-resolves at connect time (TOCTOU window). - Session cookies (
reddit-cookies.txt,youtube-cookies.txt) contain live browser tokens — if the bot process is compromised they're exposed. - The MCP tools (
mcp_imageview.py,mcp_fetch.py,mcp_youtube.py,mcp_reddit.py) fetch arbitrary URLs from IRC on model request — image viewing, generic fetch, and YouTube/Reddit video download + Gemini Files API analysis with optional Zipline rehosting. They are SSRF-guarded and scope-limited (image-only, YouTube/Reddit hosts), but the network attack surface exists and the video tools shell out toyt-dlp/ffmpeg.
! — configured via supybot.reply.whenAddressedBy.chars.
supybot.reply.whenNotCommand: False is set deliberately so the Claude
plugin's nick-addressed doPrivmsg doesn't produce "not a valid command"
noise.