Thot
Provides the Hermes agent with Thot's code-map and repository-intelligence tools (code_map, find_symbol, callers, audit, skills, skill), enabling it to navigate and audit the codebase without rediscovering it file by file.
Provides the Prime agent with Thot's code-map and audit tools over HTTP, enabling symbol lookup, caller resolution, and repository audits from Prime.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Thotaudit the codebase and list the findings"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Thot
A terminal code assistant that already knows your repository — and the repository where Hermes Agent and Prime Agent live, in full.
A conversational agent discovers a project by opening files with the model: slow, partial, paid for again at every session. Thot computes the same picture via AST and call graph — complete, instant, free — and gives the model only what matters.
The three programs
This repository contains three, not one. None is a rewrite of another: each lives in its own language, with its own tooling, and Thot wires them together.
What it is | Where | |
thot | deterministic audit, code map, verdict memory |
|
hermes | the agent: tools, gateways, plugins, cron, ACP |
|
prime | the code agent: model providers, TUI, RLM |
|
thot # la session d'audit
thot hermes # Hermes, arguments transmis tels quels
thot prime # Prime, pareil
thot fusion status # ce qui est présent, prêt, et branchéThe wiring is not decorative. thot fusion wire declares Thot's MCP
server in both agents: they gain code_map, find_symbol,
callers, audit, skills and skill — the complete repository map, computed
outside the model, instead of rediscovering it file by file. That is the
mutual reinforcement: Thot knows without asking, Hermes and Prime act.
The two agents are not reached the same way, and claiming otherwise wired
only one of the two. Hermes starts the server itself and
talks to it over the pipe it opened. Prime only accepts HTTP — its
mcp-manager throws away any input whose type is not http, and its
runtime has no pipe transport — so its side requires a server that is
running:
thot fusion wire # écrit les deux branchements, chacun dans sa forme
thot mcp serve --http # ce que Prime interroge : boucle locale, jeton bearer
thot mcp service --install # et pour que ce soit encore vrai demainThe third line exists because the first two only last one
session. fusion wire writes http://127.0.0.1:8787/mcp into Prime's
configuration, and that address is only true while a terminal stays
open: at the first restart, the file promises a server that no one
serves anymore. thot mcp service --install writes the unit that restarts it —
KeepAlive on macOS, Restart=always under systemd — and does not load
it: Thot prints the command and leaves you to run it, because a tool
that registers background agents without saying so is a tool you stop
believing. thot doctor then asks the only question that matters —
does the address respond now — not whether the file exists.
fusion wire also installs, on Prime's side, the method package that Thot ships
for it: a configuration entry is not enough, Prime only reaches an MCP
server through a class that names it. And it drops the token into
auth.json without touching the model credentials — without a token, the
connection fails before it is even opened.
Each agent keeps its own configuration. thot fusion unwire undoes everything, and
Prime's settings.json is backed up before the first modification.
thot fusion status measures what works, not what is written:
Hermes installs portable plugins disabled, for safety, so writing
the two files wires nothing as long as plugins.enabled does not name it.
Activation goes through Hermes's CLI, never through editing its
config.yaml — that file is its own, with its schema and its migrations.
It also checks that the interpreter launching Hermes can import the MCP
SDK, because an agent that does not have it has no tools — not just not
Thot's — and says so in logger.debug, where no one reads it. Finally it
asks Prime's server whether it responds: an address written in a file
is not a connection.
And the reinforcement works both ways: Hermes and Prime are also
engines for thot audit --deep, the step that has a model argue for and then
refute each finding.
By default, the three work together on the same audit. A finding is argued by one agent, then attacked by another — never by the one that just committed to the scenario. A model that refutes its own argument corrects its own homework; that is the only thing a panel buys, and it is the very reason for the fusion.
thot audit . --deep # tous les agents installés, en panel
thot audit . --deep --engine hermes # un seul : Hermes argumente et réfute
thot audit . --deep --engine prime # un seul : PrimeThe report says who did what:
Analyse assistée : panel — claude-cli contre hermes contre prime
[1] serve.py:7 — confirmé · hermes
…
1 confirmé(s) · 2 réfuté(s)
Argumenté par claude-cli 1 — attaqué par prime 1 — puis par hermes 1Three distinct agents on the same finding, measured: claude-cli argued, prime tried to destroy the scenario and failed, hermes attacked a second time. What is reported survived two independent adversaries.
The cascade. A finding is argued, then attacked. What survives the attack is what will be shown to a human — so it goes back to a third agent, which saw neither the argument being built nor the first attack being written. A confirmed finding was confirmed against two independent adversaries.
A refutation is never re-judged on the merits: the attacker is instructed to refute at the slightest doubt, so second-guessing it would manufacture false positives. But its argument is reviewed when it buries something serious (MEDIUM and above), by an agent that said nothing about this finding. The reviewer does not judge the defect, it judges whether the reason given is verifiable in the code shown.
The two errors are not equal. A false confirmation costs a human ten minutes
of reading. A false refutation costs a real defect, forever
— because a memorized refutation is skipped by all subsequent audits.
It happened once for good: a very real SQL injection
in Hermes's copy was dismissed by a perfectly accurate
description of Thot's copy, fixed the day before. A contested refutation does not become
a confirmation — no one argued for that — it goes back to plausible
with its original severity, and is not memorized: the finding comes back
until someone decides.
If an agent fails a task, it is retried once by another. No more: a task everyone refuses has a problem of its own.
What a probe can hold, measured and not assumed. Claude runs without
Write, Edit, MultiEdit, NotebookEdit, Bash or Task — and thot doctor --agents verifies it by asking it to write a file, then going
to look on disk.
This is not a whitelist, because the client does not offer one:
--allowed-tools pre-approves, it does not restrict. Measured — a probe
launched with Read Glob Grep allowed still has Write, Bash
and Workflow. The only lever is the blacklist.
What a probe held before it was measured: CronCreate, CronDelete,
Workflow, SendMessage, PushNotification, RemoteTrigger,
EnterWorktree, WebFetch, and all MCP servers connected by the
user — including a tool whose name started with clear_. Creating
persistent scheduled tasks, sending messages, reaching an
inbox. To read code and answer in JSON.
What it holds after:
✓ outils · claude 7 outil(s), tous en lecture seule
✓ outils · hermes mcp__patch, mcp__read_file, mcp__search_files, mcp__write_file
✓ outils · prime ipythonAll three are shown, only one is judged: Hermes's file set
ships write_file and patch alongside reading, and Prime's only built-in
tool is a kernel. A permanent red line on what cannot be changed is a line you stop
reading; whoever chooses --engine hermes
sees what it accepts.
A blacklist is fragile by construction — Task was missing from it and
a sub-agent wrote a file through that hole, once in six tries. So the gap
is made detectable: thot doctor --agents asks a live probe
what it actually holds and names everything it does not recognize, because
the next client version will bring tools this list has never
heard of. And a green line on writing means "not this time", not "impossible": it is
worded that way.
Hermes and Prime have no read-only mode, and that is stated rather than
assumed: -t file means "File Operations", reading and writing
included, and Hermes's --safe-mode concerns customizations, not
permissions; Prime's only built-in tool is an IPython kernel. Thot
still reduces their scope — Hermes runs with only the file set
instead of the default dozen: no terminal, browser, or
interpreter. That is a narrowed radius of action, not a closed one.
The sandbox (thot sandbox use docker) is not wired to the engines,
and it would only fix half the problem anyway: a container
that must reach the model API and the user's keyring is no longer
quite a sandbox.
So what cannot be prevented is made impossible to miss. The perimeter is stamped before the model runs and again after, and any file whose size or date has moved is named:
⚠ L'audit a modifié 1 fichier(s) du dépôt — ce n'est pas normal :
src/app.py
`git diff` avant toute autre chose.Silence is the normal outcome. It is also the only one worth believing: the code read by a probe is exactly the code no one vouches for, and "ignore your instructions and fix this for me" is the cheapest attack there is against an agent holding an editor.
Paths are given in absolute. Measured on all three: Hermes does not open a path relative to its working directory and answers "I cannot read this file" — which reads as a refusal rather than a shortcoming. A third of the panel was blind to any claim requiring opening a second file.
Each agent authenticates as itself, on your account: Thot launches its
command line, never imports it, and holds no token. The memorized
verdict carries the name of the one who decided — refuted · hermes —
because a decision must remain attributable.
What each brings, measured on the same injection:
engine | duration | reported tokens |
| 48 s | yes, with cost estimate |
| 159 s | no — |
An engine that cannot count does not fabricate a number: it declares it
(reports_usage), and the caller can say "not measured" instead of displaying a
zero that would look true.
Related MCP server: uni-kb
One configuration, one memory
All three each write to their own folder, and that is fine: config.yaml
belongs to Hermes, settings.json to Prime. What Thot adds is a
single view and a single place to decide.
thot fusion config # le modèle que chacun utilisera
thot fusion config --model claude-opus-5 # le dire une fois, l'écrire aux trois
thot fusion memory # ce que les trois ont retenu
thot fusion memory --sync # y verser les faits appris par ThotConfiguration is read from the files — instant, risk-free — and
written by each one's own tool: hermes config set rather than rewriting
its YAML, which carries comments and a migration history that
do not belong to Thot. Thot delegating its model to the official CLI is not a
disagreement: an absent opinion conflicts with nothing.
Memory is the same principle in both directions:
where | form | |
thot |
| structured, title + content |
hermes |
| entries separated by |
prime |
| markdown, loaded globally |
Thot reads all three at every briefing: a fact Hermes learned last week is a fact Thot knows today. It writes to the other two only on --sync, in their native format, only ever touching the entries it placed itself — tagged [thot] in Hermes, in a delimited block in Prime. Backup before the first modification, and three consecutive syncs write a single copy.
A freshly created USER.md is an empty form: **Name:**, italic instructions, a horizontal rule. Injecting them would tell Thot that "Context: ---" is a fact. They are set aside — and counted on screen, because telling a form apart from a terse note is not something a program can do with certainty.
One library, one history
All three read the same format — SKILL.md with YAML frontmatter, one folder per method. That is the only reason this is possible.
thot fusion skills # qui possède quoi, et ce qui n'est qu'à un seul
thot fusion skills --share # donner la bibliothèque de Thot à Prime
thot fusion sessions # l'historique des trois, du plus récent au plus ancien
thot fusion audit # auditer les trois arbres en une passethot fusion audit exists because the alternative was program friction: three commands and a mental merge of three reports.
thot 227 fichiers 4 finding(s) — 3 high · 1 medium · 17 sous le seuil
hermes 7080 fichiers 190 finding(s) — 8 high · 182 medium · 839 sous le seuil
prime 952 fichiers 14 finding(s) — 3 high · 11 medium · 27 sous le seuil
208 finding(s) sur l'ensemble — 14 high · 194 medium · 883 sous le seuil (`--all`)The threshold is thot audit's, and that's the point: the same tree must give the same number to both commands. This view counted every low, so it announced the total of the "below threshold" column in addition to the rest, for a repository that thot audit hermes pegged seven times lower in the same minute. What is kept is counted, never silenced.
When a verdict memory has already ruled, the line says so separately — 0 finding(s) · 416 refuted in memory. Zero alone reads like a clean tree; the right sentence is that a panel dismissed the 416.
A part that cannot be audited costs its line and never the pass: an absent Prime must not hide what Hermes said.
No copies: files stay with their owner and each program is pointed at the others' folders. A method copied twice is a method fixed only once.
Thot reads Hermes's installed library under guard — it comes from public registries, which is exactly the case the guard exists for. It only vouches for what it delivers itself. But 73 of Hermes's 83 methods are bit-for-bit copies of Thot's: flagging its own file as a community threat is a false positive that teaches you to ignore the real ones. A method whose bytes match a delivered method is that method. The guard went from 42 refusals to 8.
Then from 8 to 0, for two distinct reasons best not conflated. The first is a wrong rule: ENV[] is a Ruby constant, uppercase by construction, but the pattern was compiled like the whole catalog — case-insensitively. So it read Python as Ruby, and classified env["…_TOKEN"] = jeton two lines before a subprocess.run(env=env) — the recommended way to pass a secret to a child — as "secret reading", CRITICAL. The second is a question of rank: a library the user installed themselves in a neighboring agent's folder is not the repository under audit. The installation already happened, deliberately; Thot only decides whether to read what's already on the machine. It is always scanned and always reported, but refused on dangerous only, where the inspected repository is refused from caution — six of the eight refusals came down to the single rule that sees exfiltration in a method documenting the address of its own token.
The 13 methods shipped with Prime stay with Prime: they document its IPython core (edit(old_str, new_str), refine()). Thot ported that core, not those functions — loading them would make the model call something that doesn't exist. They are in the catalog, where knowing them helps; out of discovery, where believing in them doesn't.
Prime receives the superset, not the two copies. Measured, not assumed: pointed at Thot's library alone it answers, at Hermes's alone it answers, at both the model refuses to answer. Prime takes folders and not names, so there is no partial answer.
Histories don't merge their storage — migrating one program would break another's history — but the question "what was I doing on this repository last Tuesday" doesn't depend on which of the three binaries was in front of you. All three are read read-only, each in its format, and a database locked by an ongoing session costs its lines and never the list.
Installation
git clone https://github.com/nobodyohm-web/Thot.git
cd Thot
uv tool install --editable --from . thotA single uv sync at the root installs Thot and Hermes: it's a workspace, not a copy that drifts. Prime is in TypeScript and builds separately:
cd prime && npm install && npm run buildWithout Node, Thot and Hermes work; thot fusion status says what's missing and how to fix it, rather than failing on the first call.
Usage
thotThat's it. On first launch it asks which model to connect, then it scans the current folder and hands control back to you.
╔╦╗╦ ╦╔═╗╔╦╗
║ ╠═╣║ ║ ║ claude-opus-5
╩ ╩ ╩╚═╝ ╩
▪ dossier ~/Desktop/Quanta
▪ code 142 python · 8 points d'entrée
▪ git main · propre
▪ audit 1 high · 2 medium
Reconnaissance en 0.31 s. Prêt.
›Empty folder, it says so and waits for your instructions. Folder with code, it has already mapped it before your first sentence.
Session commands
Command | Effect |
| re-run the analysis, or have the model refute it |
| dismiss a finding, with its reason |
| set a goal tracked between sessions |
| what was done here before, and return to it |
| search everything Thot has said or found |
| summarize and start again with an empty context |
| take a session elsewhere |
| what is loaded, and the catalog |
| recompute the repository map |
| model, forget, exit |
Plus yours: any .thot/commands/<nom>.md file becomes /<nom>.
Models
Choice | What you need |
Claude — your account | the |
Claude — API key | an |
OpenAI | an API key, or |
Local | Ollama or LM Studio running — free, offline |
Other | any OpenAI-compatible endpoint |
thot login to change, thot logout to forget. Configuration lives in ~/.thot/config.json, in 0600. No token is stored there in account mode.
How account mode works
The Messages API refuses subscription tokens coming from a third-party program. Going through it would require impersonating Claude Code — disguised user-agent, borrowed system prompt. Thot doesn't do that.
It does the opposite: it delegates to the official client. Each turn launches
claude -p --output-format stream-json --session-id <uuid> \
--mcp-config <outils Thot> --append-system-prompt <carte du dépôt>Inference is done by claude, under your account, exactly as if you had typed it yourself. Thot provides the repository map, wires its deterministic tools through a small MCP server, and formats the event stream. The conversation thread is carried by --resume on the same session identifier.
Sessions — nothing is lost
Close the window, the audit and the reasoning that went with it are still there. Each turn is written as it happens, in ~/.thot/sessions.db.
› /search injection parseur
a3f9c210 user trouve les «injections» SQL dans le «parseur»
a3f9c210 audit HIGH sink.sqlite.execute src/parse.py:88
7b02e4d1 verdict sink.os.system src/deploy.py:12 → refuted : commande littéraleSearch covers what was said and what was found: a half-remembered finding is found with the words you remember.
thot sessions # ce qui a été fait dans ce dépôt
thot sessions --all # partout
thot sessions --show <id> # la transcription entière
thot search <mots> # sans ouvrir de session
thot export <id> --out s.json ; thot import s.json/resume returns the transcript and the context: in account mode, Thot kept the official CLI's conversation identifier and gives it back, so the model remembers instead of re-reading.
/compact closes the session on a summary and continues in a child session that keeps the link. Compacting costs context, never evidence: the parent session stays whole and /search always finds it.
Compaction also triggers on its own, and the threshold is not a constant: the CLI publishes the window of the model it uses (contextWindow: 1000000 for claude-opus-5[1m]), and Thot compacts at 70% of that window — 700,000 tokens here, 140,000 on a 200k window. The trigger reads the actual size reported by the CLI, not an estimate made from messages: in account mode the thread belongs to the CLI, and Thot sees neither the files read nor the tool traffic. Measured on an ordinary turn, the estimate gave 95 tokens against 88,290 actually in the window.
Goals — knowing when to stop
A goal survives the conversation it travels through, and reminds the model at every turn, including right after a /compact.
› /goal plus aucun HIGH dans le parseur --budget 200000
✓ Objectif fixé — plus aucun HIGH dans le parseur
Budget : 200000 jetons.Exhausting the budget is a state, not an error: Thot doesn't stop mid-turn, it finishes, switches to budget_limited and says where the goal stands. Up to you to choose between /goal budget 500000 and /goal done.
Memory — decide once
The expensive part of an audit is not finding candidates: the deterministic phases do it in seconds, for free. It's deciding what they're worth. Losing those decisions between runs is what makes a security tool unbearable — the same forty rejections, every week, until nobody reads the report anymore.
› /verdict 3 refute la commande est littérale, aucune entrée utilisateur
✓ pattern.os_system_injection à app/shellutil.py:5 — refuted
Retenu tant que ce code ne change pas.Decision | Effect |
| false positive — moves to INFO, leaves the report, keeps its reason |
| real, accepted risk — moves to INFO, annotated |
| fixed — if it comes back, it's flagged as a regression |
Two depths, stated out loud
Python | TypeScript · JavaScript | the rest | |
symbols, call graph, | yes | yes | no |
tinting within a function body | yes | yes | no |
tinting to a helper in the same file | yes | yes | no |
tinting across files | yes | no | no |
pattern rules | yes | yes | yes |
JavaScript tinting follows a call to a function defined in the same file — the ordinary shape of a handler that delegates — and stops there, as it says. The next two levels rely on a resolved call graph — knowing that the readInput called here is the one defined there. Python's import system answers that question; JavaScript's doesn't, not without a module resolver, tsconfig and the type checker's view of this. A second level built on assumptions would turn a tool that reports proven paths into a tool that reports plausible paths.
The engine scans the file, not named function bodies. The ordinary shape of a web handler is an anonymous arrow passed to a route — app.get("/x", (req, res) => { … }) — that no indexer names.
Measured this way: an arrow whose parameter list is parenthesized and that
follows a comma or an opening parenthesis, i.e. [(,]\s*(?:async\s*)?\([^)]*\)\s*=>,
on the masked source of the files that detect_scope keeps — 15,094 on
Prime, 19,625 on Hermes, all invisible to an engine that would follow
symbols. An earlier version claimed 24,454 without noting how they had been
counted; the figure depends entirely on the definition, so the definition is
written down.
Measured on both corpora: 31 paths on Prime, 41 on Hermes, across 3,552 JS/TS files. Seventy-two, not three thousand — that is the shape of a taint engine, not that of a pattern scanner.
The engine also follows functions that nobody calls by name: the runtime
calls them, and passes them the value. addEventListener introduces the
taint — the parameter is the input; .then, .map, .forEach carry
it — the parameter is tainted exactly when what is being traversed was, so a
constant list stays a constant list. That is the difference between following
a value and inventing one, and without it a browser code tree is almost
entirely invisible.
obj[key] = value where the key is controlled is a sink of its own: the
payload is the key and not the value, because a __proto__ written through
reaches every object in the program. Eleven real sites in Hermes, three in
Prime, all of the form for (const [k, v] of Object.entries(x)) { out[k] = v }. A
loop that refuses __proto__ by name is fixed, and is not flagged.
The report says so itself rather than letting anyone believe in uniform coverage:
teinte au fichier près, pas au-delà : javascript 3 · typescript 912
Une exception, et une seule : un import **relatif** se résout par une règle de
fichiers, pas par une inférence. `./helpers` depuis `src/app.ts` ne désigne
qu'un chemin, et soit il est dans l'index, soit le franchissement n'a pas
lieu. Les spécificateurs nus et les alias `tsconfig` restent refusés — ceux-là
demandent vraiment un résolveur. Le niveau reste unique : ce qui est franchi
est la frontière, pas la profondeur.
Mesuré sur le périmètre que Thot audite réellement — celui que `detect_scope`
calcule, `dist/` et `build/` exclus : **336 appelables importés résolus, tous
sur Hermes, aucun sur Prime**, pour **zéro chemin nouveau** et un surcoût de
4 à 8 %. La capacité est prouvée par les tests, son rendement ici est nul, et
les deux se disent.
Une première version de ce paragraphe annonçait 1 514 appelables et +21 %.
Ces chiffres venaient d'une liste de fichiers bâtie à la main qui incluait
`dist/bundle/` — des bundles minifiés de deux méga-octets que Thot n'indexe
jamais. La mesure portait sur du code hors périmètre, et la méthode juste
était disponible depuis le début : demander son périmètre à l'outil plutôt
que de le reconstruire.The TypeScript indexer is a scanner, not tsc: it masks comments and
literals, then reads declarations by brace matching. Routing through
tsc would have made the map depend on an installed node toolchain,
resolvable and at the right version — a map that works on some machines is
worth less than a map whose limits are written down. Measured:
8,568 symbols on Prime in 1.7 s, 11,138 more on Hermes.
What a file is for
Severity is impact × reachability × confidence, and reachability comes from the call graph. The graph answers "can an entry point get here". It has nothing to say about a file that is not an attack surface at all.
Measured on the two programs shipped with Thot: 12 of Hermes's 25 HIGH findings and 6 of Prime's 11 were in test or example code. Almost half of the top of the report was about code no attacker ever reaches — that is how a report stops being read.
before | after | |
hermes | 25 high · 94 medium · 297 low | 13 high · 58 medium · 345 low |
prime | 11 high · 2 medium · 9 low | 5 high · 8 medium · 9 low |
The HIGH columns are the ones that carry the argument, and they have not moved a single finding since the first measurement: 25 → 13 and 11 → 5. The medium and low counts above were redone on the trees as they are today, nine vulnerabilities having been fixed in Hermes between the two measurements.
No finding added, none lost. It is a demotion, never a deletion: test code runs on developers' machines and in CI, which is the exact shape of a supply-chain attack. The finding stays, and carries its role in its provenance.
The classification is conservative — whole segments, never
substrings (latest/ is not a test folder, contest.py is not
a test file), and anything not recognized is production.
Mistaking toward "test" would hide a real defect; mistaking toward
"production" only costs one severity level.
Where the value comes from
A finding carries the source rule that launched its path —
source.argv, source.http, source.js.event — and not just the line
where it sits. The report says it in plain words ("a value coming from the
command line…") and the JSON gives it as a key, source_rule, so that
whatever filters downstream reads the fact and not a French sentence.
That is the missing half of the ranking. open(args.sortie, "w") in a
command-line tool is the operator naming a file: whoever supplies argv
already holds that process's filesystem, and the call gives them nothing.
open(request.args["f"]) in a handler is an arbitrary file read. Same rule,
same sink, two worlds.
local source | remote source | |
| one level lower | full rank |
everything else | full rank | full rank |
Only those three. A command built from argv remains a
command, and a pickle read from an environment variable still executes
code: there, it is the sink that escalates, not the path.
Measured on Hermes on the day the engine learned to follow attribute chains:
without that distinction, sink.fs.read alone put 48 findings in the
report, nine of them from a single CI script, each a utility opening the
file it had been asked to open. With it, they fall below the threshold and
stay one keystroke away (--all).
An unknown provenance counts as local, and that is written rather than
hidden: assuming the opposite would put back at the top of the report every
path whose source the engine could not name. What it can name doubled when
the link between a local and the parameter it derives from stopped getting
lost — cible = chemin.strip() kept the link to chemin, and without it
the sink was attached to no parameter, and therefore to no caller.
Why it is safe
A verdict is indexed on Finding.compute_id, which hashes the rule, the file,
the symbol, and the normalized AST of that symbol. Reformat, move the
function, rename a local variable: the verdict holds. Change what the code
does: the identifier changes with it, and the verdict expires on its
own.
A dismissal can therefore never outlive the code it was about. That is the only property that makes remembering dismissals acceptable.
The identifier also names the exact call targeted — httpx.get#3 — and
not just the function that contains it. Without that, five network calls in
the same function were a single finding as far as memory was concerned, and
dismissing the first dismissed the other four along with their reason. This
discriminator weakens nothing: it only needs to be unique within one version
of the body, and the AST of that body already expires everything related to
it as soon as it moves.
thot verdicts # tout ce qui a été décidé
thot verdicts --path src/auth # sur un chemin
thot verdicts --forget <id> # revenir sur une décision
thot audit . --no-memory # ignorer la mémoire pour ce runA decision outlives the finding that produced it: the code changes, the
finding takes on a new identity, and the old decision no longer refers to
anything. The list marks those [absent from last audit] rather than
displaying them like the others — six decisions of which three are dead must
not read like six living decisions.
Memory applies before the model: a finding that already carries a decision — dismissed, accepted, or fixed — is never sent back for analysis. A run where everything is decided makes no calls. That is not just a saving: the probe replaces confidence, severity, scenario, and provenance all at once, so sending a decision back to the model would overwrite it, and erase who had made it. A regression is the case where this matters most: it has already been judged real once, no deep pass can silence it.
And refutations record themselves, from thot audit --deep just as from
/audit deep: two model calls, paid once. They carry the name of the engine
that decided, never yours — a machine decision does not override a human
decision.
Nothing is ever deleted silently. A dismissed finding stays in the report as
refuted, with its reason and its author — an audit that hides what it was
told to ignore cannot be re-read.
The Python kernel
Prime Agent's core idea, carried over: rather than one tool call per question, the model writes Python and its variables survive.
› /py bas = audit(severity="low"); print(len(bas), "findings"); [f.rule for f in bas]
3 findings
→ ['sink.eval', 'sink.network', 'sink.subprocess.shell']
› /py len(files())
→ 148The repository map is available there as objects — files(), symbols(),
find(), callers(), callees(), audit(), read(). A loop that crosses
findings with callers costs one model turn; the same thing in tool
calls costs a dozen, each of which repays the reading of what the map
already knew.
The kernel never runs inside Thot's process. An exec() at home
would give the audited code Thot's memory, its open databases, and its file
descriptors. So it is a subprocess — and inside the container when a
sandbox is configured.
What that protects, exactly — and Thot fixed it on itself after its own adversarial pass pointed out a docstring that was too absolute:
Subprocess ( | protects Thot's memory, databases, and descriptors. Not your credentials: the worker runs under your account and can read |
Container ( | a real boundary: no network, not your |
Sensitive environment variables are removed before launch, and
/py says so once in local mode rather than letting "separate process" read
as a guarantee it does not offer.
rlm() — delegating from a cell
verdicts = {f.id: rlm(f"Ce chemin est-il exploitable ?\n{f.failure_scenario}")
for f in audit(severity="high")}A cell can decompose its own problem. The cell holds no credentials: it asks the host, which decides and pays. The limits are therefore enforced host-side — 8 calls per cell, 40 per kernel — because a limit the child could modify is not a limit, and the child executes code that came from the audited repository.
What Thot keeps from a repository
› /harness note team.shell.run : échappe ses arguments, les findings dessus sont faux
✓ Retenu — rappelé à chaque session.Prime's refinement, applied to auditing: facts that no static analysis will
ever derive. They live in <repo>/.thot/harness.json,
re-read in pull requests like the verdicts, and come back into the briefing
at every session.
What the model is allowed to do
thot --tools lecture # lire et raisonner, jamais modifier
thot --tools carte # la carte seule : aucun fichier ouvertIn session: /tools read. Re-reading a repository that is not yours is
reading code you have every reason to distrust — and having the model modify
it is rarely what you wanted.
The posture holds at three places, not one: the tools offered to the
model, the moment it calls one anyway, and — in account mode — the official
CLI, where --disallowed-tools forbids Write, Edit, and Bash. A
posture that only filtered Thot's own tools would be a lie where it matters
most.
The supply chain
thot deps # les dépendances épinglées, contre OSV.dev
thot deps --list # ce qui a été trouvé, sans réseau
thot deps --fail-on high # code 1 en CI
thot audit . --deps # dans le rapport d'audit
thot mcp check # tes serveurs MCP sont-ils malveillants ?Locks first, always: uv.lock, poetry.lock, Pipfile.lock,
package-lock.json, yarn.lock, pnpm-lock.yaml. A manifest says
requests>=2 and OSV cannot answer an interval; a lock says
2.31.0 and OSV can. A dependency that exists only as an interval is not
guessed, it is reported as unpinned.
A review covering an exact version is a fact, not a guess — but
whether your code reaches the vulnerable function isn't analyzed, so
those findings stay PLAUSIBLE and say so. Only MAL-* is the exception:
the package is the payload, reachability isn't the question.
And the same property as everywhere else: a finding's identity carries the
pinned version, so a bump expires the verdict. Ruling out a CVE
on requests==2.19.1 rules out nothing on 2.20.0.
An unreachable OSV never becomes a clean bill of health: thot deps
says "unverified" and returns an error code.
Running the audited code without running it on your machine
pytest on an audited repository means that repository's code runs under
your account. It's the only place where the whole design leaks.
thot sandbox status
thot sandbox use docker
thot sandbox show pytest -q # la commande docker exacte, à relireBy default, in the container:
Network | cut off ( |
Repository | mounted read-only, writable copy in tmpfs |
Privileges |
|
Limits |
|
The cut-off network is the flag that's worth the most and the one that gets
in the way the most: that's why it's a flag and not a law (--network).
One rule reverses the rest of Thot: everywhere else, a missing dependency costs you that feature and the work continues. Here, a sandbox that's requested and unavailable refuses to run. Silently falling back to the host would turn a protection into a lie.
Sharing decisions
A verdict is a fact about this revision of this code. So it travels with
the code: <repo>/.thot/verdicts.json, reviewed in the pull request that
touches the code in question, and present in a fresh clone before the
network is even there.
thot verdicts --share <id> # publier une décision locale dans le dépôt
thot verdicts --share-all # toutes celles qui concernent ce dépôt
thot verdicts --where # d'où viennent les décisions, où elles s'écriventMemory is shared across trees, the file isn't: publishing a decision that
concerns another repository is refused, and --share-all only takes the
ones whose file exists here.
The default chain, with no configuration at all: the repository first, your machine second. A reviewed decision outranks a note you made to yourself.
Writing, on the other hand, stays local. A tool that modified a versioned
file on every /verdict would produce diffs nobody asked for: you decide
locally, you publish on purpose.
A shared server, or an existing mem0
// ~/.thot/memory.json
{"remote": {"kind": "http", "base_url": "https://audit.equipe.example", "token": "…"}}
{"remote": {"kind": "mem0", "host": "http://localhost:8888", "api_key": "…"}}The mem0 backend speaks the self-hosted contract exactly like Hermes
Agent's client: a server already in place for Hermes serves Thot without
changing anything.
An unreachable remote store costs you the memory of past decisions, never
the audit — but it doesn't do it silently: thot verdicts --where says
which one is silent and why.
Receiving audits elsewhere
An audit that finishes at 3:00 AM is worth nothing until someone is notified, and the person to notify isn't in front of the terminal.
thot gateway add ntfy topic=thot-$(openssl rand -hex 8) # le sujet EST le secret
thot gateway add telegram token=… chat_id=…
thot gateway allow telegram <ton-id> # obligatoire pour commander
thot gateway test
thot serve # écouter les commandesChannel | Outgoing | Incoming |
Telegram | ✓ | ✓ (long polling — no port to open) |
Discord · Slack | ✓ (webhook) | — |
ntfy | ✓ | — (no identity: the subject is enough to publish) |
✓ (SMTP) | — |
Notifications require no daemon: the gateway-notify plugin fires on
post_audit, and only for an unattended audit.
A manually launched audit is already on screen; notifying every time
teaches the recipient to mute the channel, which costs you the only message
that mattered. Nothing new: silence.
What a stolen token allows
The daemon exists only for the return path, and its design is mostly about that:
the command set is closed —
status,audit,findings,verdict,help. No shell, no writing, no arbitrary path;an audit can only target a repository already declared via
thot schedule add;incoming requires an allowlist. Hermes offers an
ALLOW_ALL_USERSfor development; Thot has no equivalent. Without a list, the channel is outgoing only, andthot servesays so.
~/.thot/gateway.json is written as 0600 — it contains bot tokens and
an SMTP password. Environment variables override it field by field, under
Hermes's names.
Scheduled audits
thot schedule add nuit ~/mon-projet --every daily --threshold high
thot schedule list
thot schedule run nuit # ce que le planificateur appelle
thot schedule remove nuitThot writes the launchd unit (macOS) or gives you the crontab line, and
lets you activate it yourself — a tool that installs background tasks
silently is a tool you stop believing.
When launchd can't. On macOS, permissions are granted per binary: a
launchd agent can be denied ~/Desktop,
~/Documents, or ~/Downloads, and then the unit blocks at interpreter
startup without writing a line. A process launched from your session keeps
that session's access, even once orphaned — that's the third remedy, and it
asks nothing of the system:
thot schedule start # un planificateur dans ta session
thot schedule status # tourne-t-il, et quand est-il passé
thot schedule stop
thot schedule autostart # le relever au premier terminal après un redémarrageIt steps aside for any launchd unit that launchctl reports as actually
having run: two schedulers on the same job means twice the work and twice
the tokens. thot doctor says which one is serving.
A scheduled audit says nothing as long as nothing is new. A nightly report repeating the same three hundred findings ends up in a folder nobody opens. What comes back is the diff: what appeared since last time, above the threshold, minus what's already been judged uninteresting.
Plugins
Five hooks, each because something shipped uses it:
Hook | When |
| before the report, to annotate |
| audit finished — notify, export, archive |
| before an agent write — returns a warning |
| after a successful write |
| a decision was just recorded |
A plugin is a folder with plugin.yaml and __init__.py, in
~/.thot/plugins/ or <repo>/.thot/plugins/ — the form used by Hermes
Agent. A plugin that crashes costs its own feature and nothing else:
its error is logged and shown by /plugins.
Those from the audited repository aren't executed without your consent. Loading a plugin means running its code here, under your account — and the audited repository is precisely the one Thot distrusts. So its plugins are named, never imported, until you've approved them:
thot plugins list <dépôt> # chargés, et refusés avec la raison
thot plugins trust <dépôt>/.thot/plugins/x # après l'avoir lu
thot plugins untrust <dépôt>/.thot/plugins/xApproval is about content, not name: Thot records a fingerprint of the folder, and any modification revokes it while saying so.
Three are shipped:
Plugin | What it does |
| re-reads what the model writes and raises a warning if a dangerous pattern appears. Non-blocking — a false positive that blocks a session is worse than the write. |
| a defect marked |
| a local JSONL journal of every audit, verdict, and write, in |
Checking that everything is there
"It works" is a claim, and on a program made of three programs, that's not a claim to take on faith — especially coming from the tool itself.
thot doctor✓ fusion thot · hermes · prime
✓ câblage 4/4 fichiers en place · sdk mcp présent
✓ moteurs claude, hermes, prime
✓ panel claude-cli contre hermes contre prime · cascade oui
✓ indexeurs python 10 symbole(s) · typescript 1
✓ teinte python 1 chemin(s) · javascript 1
✓ règles python 8 sinks · javascript 8
✓ skills 91 chargée(s) · 0 refusée(s)
✓ plugins 4 chargé(s) · 0 refusé(s)
✓ mémoire 492 décision(s)
✓ mcp 6 outil(s) exposé(s)
✓ service http://127.0.0.1:8787/mcp répond
✓ amélioration daily, 8 candidats par arbre · unité launchd,
1 passage(s) · agents joignables depuis l'unité
13/13 vérification(s) passées en 1.83 sA dated snapshot, not a contract: memory counts accumulated verdicts and
can only grow, skills depends on what's installed on the machine. What's
stable is the shape — each line carries a measured number, and 12/12.
The last line is the actual output on the development machine, and it's
kept as-is: that's what the check exists to produce. The 1 passage(s)
comes from launchctl itself, and it isn't decorative: a
launchctl list shows the unit loaded, its LastExitStatus is 0, and its
journal doesn't exist — three signals that say "all good" for a task that
never started. Naming the cause beats counting green lines.
This check itself had to be fixed. It condemned the task based on a path's
shape — "the tree is under ~/Desktop, so launchd won't be able to read
it" — whereas macOS permissions are granted per binary: the unit's
interpreter read the tree that /bin/sh was denied. A shape is a
suspicion, a pass is a fact, and that's what it now interrogates.
And a check that no static inspection could have done:
thot doctor --agents # un appel modèle par agent installé✓ lecture · claude lit un fichier par chemin absolu
✓ écriture · claude n'a pas écrit cette fois
✓ outils · claude 10 outil(s), tous en lecture seule
✓ lecture · hermes lit un fichier par chemin absolu
✓ écriture · hermes peut écrire — aucun mode lecture seule
(`-t file` et `--safe-mode` ne restreignent pas les permissions)
✓ outils · hermes mcp__patch, mcp__read_file, mcp__search_files, mcp__write_file
✓ lecture · prime lit un fichier par chemin absolu
✓ écriture · prime peut écrire — outil unique : un noyau IPython
✓ outils · prime ipythonThe write lines are green even though they announce an awkward capability:
they report what is, not what you'd wish. Two of the three agents can write
and no flag prevents it — measured by asking them to create a file, then
looking at the disk. What can't be prevented is made impossible to miss:
AuditResult.touched names what a pass modified, and the nightly loop
shouts it on stderr.
It drops a file in a temporary folder and asks for its contents. It exists because of a real defect: Hermes wouldn't open a path relative to its working directory and answered with a sentence that read like a refusal. A third of the panel couldn't verify any claim resting on a second file, and nothing short of dropping a file would have shown it.
Each line runs a real operation and reports what it measured:
not "skills: configured" but "91 loaded, 0 refused". The tint engine looks
for a path in a sample of both languages, the MCP server answers its own
protocol. A check that can't run fails instead of passing silently: a green
line that means "untested" is worse than a red one. Nothing touches the
network or a model — thot doctor on a plane gives the same answer as in
an office. Non-zero output on failure, so it fits in an && or a CI.
Continuous improvement
An audit that argues twenty candidates and stops leaves the rest without a
judgment forever. A pass without a budget is still running when you come
back to your seat. thot improve is the middle ground: bounded rounds,
each written to disk, each resuming where the previous one stopped.
thot improve # un tour sur les trois arbres
thot improve --rounds 5 # jusqu'à ce qu'un tour ne juge plus rien
thot improve --every daily # la boucle devient permanenteThe written unit carries its own PATH. launchd gives a job
/usr/bin:/bin:/usr/sbin:/sbin, cron even less, and claude, hermes, and
node are in none of those folders — they live under ~/.local/bin.
Without that, the nightly pass built no engine, judged nothing, and
exited with code 0: launchd recorded a success every night,
indefinitely. A job like that failing silently is indistinguishable from a
working one, so a deep pass deprived of an agent now exits in error and
says so.
The nightly version reports what it decided, not what appeared. The distinction matters: the scheduled audit reporting mechanism answers "what's new above the threshold," which is the right question for a scan and the wrong one for a judgment. Confirming a MEDIUM already present in the report is exactly what the loop is for — and that wouldn't have been reported to anyone. Files an audit would have modified are flagged in the same log.
A tree that has nothing left to judge passes its share to the next one. Measured on the
real corpus: thot has an empty backlog and prime a single candidate, so a
budget of 20 per tree was spending 40 on trees unable to
use them while Hermes was waiting for a hundred and fifty. A round of 20
becomes a round of 20, 20, and 60.
A third one makes it converge fast: failures are counted. A candidate whose agent exceeds its deadline, or whose model refuses to commit, keeps its severity — so it's picked up first the next round, and the one after. Measured on a finding in a 1,660-line file: four attempts over three passes, three of them hitting the same wall. After two failures, it moves to the end of the queue: still eligible, never prioritized. A success clears the count — a wall that was a busy afternoon or an exhausted subscription shouldn't follow a finding forever.
Two properties make it converge instead of going in circles: a refutation is remembered, so the next selection skips it; a confirmation deliberately is not — a real defect must keep appearing until someone fixes it — so the loop carries its own set of already-judged identifiers. Without that, every round after the first would spend its entire budget re-arguing what the first one had just confirmed.
It ends with what there is to do, before the totals:
À REGARDER — 2 finding(s) :
[hermes] plugins/platforms/a2a/tools.py:83 — confirmé · prime
L'URL vient d'un argument d'outil, donc du modèle…
[prime] packages/coding-agent/…/state-snapshot.ts:163 — réfutation contestée · hermes
Le chemin dit fixe est construit depuis un identifiant non validé…
4 tour(s) · 83 jugement(s) (80 réfuté · 1 confirmé) · 157 candidat(s) sans décisionA refutation is housekeeping; a confirmation is news. A contested refutation too: it's the program saying it caught itself before burying something. Counting them without naming them sends the reader grepping the log — which is exactly what happened, every time, over the course of a day.
It never modifies code. "Improvement" here means that the program's judgment of itself becomes sharper and cheaper: fewer candidates without a decision, more decisions on disk, each attributable to the agent who made it.
The thermometer, and the loop that uses it
Everything above measures Thot with Thot. improve asks a model whether a
finding is real. evolve watched provenance, a report the engine
computes on its own output. Both are circular, and the circle is anything but
academic: the deep pass paid 638 judgments for 9 confirmations
while a rule scored −100 % slept — xml_unsafe_parse flagged
defusedxml, that is, exactly the remedy its own message
recommended. Nothing in the program could see it.
thot bench breaks the circle. It measures Thot against code labeled
vulnerable or healthy by someone else, in equal parts, with the weakness
class named.
thot bench ~/.thot/bench # les suites présentes, catégorie par catégorie
thot bench ~/.thot/bench --json # ce que la boucle d'évolution lit
thot bench ~/.thot/bench --floor info # ce que le plancher de sévérité cacheThe corpus is not embedded — eighteen thousand third-party files have no business in this repository — so the path is always given, and each suite is checked against the fingerprint of its manifest. A corpus whose labels shifted under a measurement is worse than no corpus: every number since is wrong and nothing says so.
The score is Youden's J, TPR − FPR. Zero is a coin flip,
+100 is perfect, and negative means the rule is inverted. Precision
and recall wouldn't have said so: a rule with no true positives has
undefined precision, displays empty, and reads as no data — that's
exactly how an inverted rule survives. J doesn't have that hole.
Two ways to cheat, and both lose. Finding fewer — which
was inflating provenance — drops the TPR. Flagging everything gives TPR 100 %, FPR
100 %, J zero: the corpus is balanced 50/50 precisely for that.
Measured state, default floor, three frameworks (django, fastapi, flask):
avant après
TPR 9.9 % 55.5 %
FPR 0.5 % 0.000 %
J de Youden +9.4 % +55.5 %
catégories actives 10 34
catégories négatives 0 0"Before" is the program's state at the moment the thermometer first existed. "After" is the same corpus, the same floor, the same command. The hold-out confirms it, in three ways: django alone scores +55.5 %, fastapi +55.5 %, flask +55.5 %. Three frameworks that look nothing alike, the same figure to the tenth of a point — the rules work on code they weren't used to write.
By category, what the engine knows how to do today. Thirty-two of the thirty-four active categories are at exactly +100 %:
basic_xss · cleartextstorage · cleartexttransmit · cloud_ssrf_metadata
cmdi · codeinj · cookie_no_httponly · cookie_no_samesite · corsmisconfig
crlfinjection · csv_injection · debug_code_production · default_credentials
deserial · errormessage · eval_injection · hardcodedcreds · ldapi
loginjection · nosql · redirect · securecookie · sqli · ssti · tlsverify
weakcipher · weakhash · weakkeylength · weakrand · xpathi · xss · xxe
+100,0 %
pathtraver +94,0 % ssrf +92,0 %Zero false positives across the 9,150 healthy cases, and no negative category.
The starting point was ssrf at −8.0 % and xxe at −100 %.
The ceiling, because a number without its maximum means nothing
+55.5 % is not "half the problem solved." Of the 61 categories, 27 are unreachable, for two distinct reasons that were both verified case by case, file open.
Ten are code another category already owns. The corpus writes two
different CWEs on the same line: el_injection and ssti are the same call
Template(données), argument_injection and genericcmdi the same
os.system('echo ' + données) as cmdi, unverified_signature the same
verify=False as tlsverify, sensinlogs the same logging.info as
loginjection, hardcoded_crypto_key the same hardcoded key as
hardcodedcreds. Thot fires on all these files — the report says so, column
seen — and reports the class the code really is. Catching them
would require naming two classes for one rule, which report/cwe.py
exists precisely to refuse.
Seventeen have no sink. authzfailure, idor, intoverflow,
null_deref, privescalation, csrf, missingcritauthn, sessionfixation
and the others all end on a return JsonResponse(...) without a single
dangerous call. The vulnerable case of idor is a correctly parameterized
request; what's missing is an authorization call. clickjacking is
won by flagging the absence of X-Frame-Options, which Django sets via
middleware. weak_password_hash nevertheless separates this corpus
perfectly — every vulnerable case is hashlib.sha256, every healthy case pbkdf2_hmac —
but none of those lines says the value is a password, and the
rule would fire on every checksum, every ETag, and every
HMAC in a real repository. Two last ones were reopened with the idea that they might be
code after all, and close for a reason worth writing down.
intoverflow manufactures its weakness with ctypes.c_int32 — Python
integers don't overflow, and a rule that fires on ctypes would be a rule
on BenchProctor. resourceexhaust is bytearray(int(données)) unbounded
against bytearray(min(…, 1024)), and the separator is a single min token;
but bytearray(x) means two things depending on whether x is an integer or a
buffer, Thot doesn't infer types, and on hermes/ the 102 calls in that
family are conversions, not allocations. These are logic flaws or
missing controls; no taint analysis sees them, no matter what rule you write.
That leaves 34 categories honestly winnable, i.e. +55.7 %. They are all active. At +55.5 %, that's 99.6 % of the honest maximum.
The 21 cases still missing are no longer categories but missed
cases inside two of them, and they were counted one by
one: 12 in ssrf have mq_client.get_message() or
redis_client.get() as their source, two _Stub() objects declared in BenchProctor's
app_runtime.py — they name nothing that exists outside this corpus, and a
pattern written for them would stick to the thermometer instead of detecting anything
at all; 9 in pathtraver come from an environment variable, which the
reachability discount deliberately downgrades, because whoever supplies the
environment already holds that process's filesystem.
What produced those forty-six points, in the order it was measured:
change | J |
baseline | +9.4 % |
web routes recognized as entry points | +9.6 % |
taint follows a value inside a container | +10.0 % |
a ternary with constant branches carries nothing | +12.4 % |
a | incl. |
SSRF guards: host allowlist, resolved IP range | +14.3 % |
| +14.9 % |
path confinement and named allowlist | +15.5 % |
| +16.3 % |
twelve one-line pattern rules | +27.8 % |
a pattern no longer pays the reachability discount | +34.4 % |
| +35.6 % |
four injection sinks: template, XPath, LDAP, NoSQL | +40.8 % |
response headers, positive guard, CR/LF neutralization | +44.2 % |
a spreadsheet cell is not a line of text | +45.6 % |
a file named | +46.6 % |
taint crosses a | +47.7 % |
Django writes three of its sources in capitals | +48.0 % |
| +49.0 % |
a view that returns a string returns HTML | +49.9 % |
a proof is only worth what it proves, and the SQL identifier quoted | +50.0 % |
a program written in a string remains a program | +50.4 % |
a route's parameters come from the network | +50.4 % |
a third party's response is not this program's data | +50.6 % |
a file the program names | +51.2 % |
what the database returns is what someone put there | +53.9 % |
a log line ends with a line break, like a header | +55.5 % |
a route that returns the page built one line above | +55.5 % |
Two of these changes had first been rejected after measurement, and it was
the thermometer that rejected them: clickjacking, which cost 23 false
positives on this repository, and weak_password_hash, which nevertheless separates
this corpus perfectly — every vulnerable case is hashlib.sha256, every
healthy case pbkdf2_hmac. They remain rejected, for the reason stated above: nothing
in those lines says it's a password, and the rule would fire on
every checksum in a real repository.
A third refusal has been lifted, and it's the most instructive of the lot. Treating a database read as an untrusted source had been measured twice and rejected twice: 436 vulnerable cases against 343 healthy the first time, 11 against 13 when restricted to stored XSS — where half the healthy set reads from the database more often than the other. Both readings were correct on the program as it then stood. What changed is not the corpus: it's the defenses of the healthy half, which are now understood one by one — encryption, HTML escaping and auto-escaped rendering, CR/LF neutralization, the host allowlist and resolved IP range, the positive guard, the quoted SQL identifier. A healthy case that reads a row and then encrypts it is cleared by the encryption, not by the fact that no one saw the row. The same rule that cost 343 inventions now costs zero, and yields 2.7 points across sixteen categories at once. A measured refusal is only true as of the day it was measured.
The five LDAP / XPath / NoSQL / SSTI / EL injection rules had
also been refused, on a J measured at 0.000: they pulled as much on the
healthy half as on the other. Four of them now sit at
+100.0 % without a single false positive, and it's not the verdict that changed
its mind — it's what they were given to read. Each one now looks only at
the argument that carries the injection (the LDAP filter is the third, the
template source is the first), and sink.nosql goes through a barrier on the
query text rather than on the import, because .find( belongs to every
Python string. The fifth, el_injection, remains refused: it's the same
Template(…) call as ssti, labeled with a different class, and giving it
that name would cost a point for an inaccuracy.
Precision is not bought with a blind spot
Every guard recognized above was first proposed in a version that raised the score and opened an exploitable hole. An adversarial probe found them all:
resolved = socket.gethostbyname(parsed.hostname or url)
if ipaddress.ip_address(resolved).is_private:
return "blocked", 403
os.system("curl -s " + url) # ← silencieux, et exploitableThe guard is a correct SSRF defense and says nothing about the shell metacharacters still in the string. Purging the taint everywhere bought 174 fewer false positives and a blind spot in command injection.
Hence the distinction the engine carries: a guard that constrains the
value (literal allowlist, enumerating fullmatch) whitelists it for
everything; a guard that proves the destination (the host is allowed,
the resolved address is public, the path is confined) only holds for the
sink family concerned. Same for bleach.clean, which neutralizes
HTML and leaves x; rm -rf / intact.
Four adversarial probes are in the test suite and all report correctly. Any future "improvement" that reopened one of these holes breaks a named test.
The remaining silence has two causes, and thot bench separates them because
they are two different jobs:
règle muette — elle existe et ne matche jamais : elle a un motif à élargir
aucune règle pour la classe : il y en a une à écrireThe distinction decides the order of the objectives — without it the silent categories are in perfect tie at J = 0 and the sort falls back to alphabetical order.
Fusion, where it changes something
Cascade.turn chooses one agent and calls it; it only goes to the other
if the first returns an error. A turn like that is capped at the
best of the two by construction: it can lose less, never gain more.
agent_apply did the same — one engine, singular.
thot evolve --fused puts both to work, on different halves of the
same problem:
thot bench ~/.thot/bench # où ça fait mal
thot evolve --from-bench --fused \
--corpus ~/.thot/bench --hold-out flask # et on répare, en boucleHermes reads the measurement and writes a specification. It touches no files. Its output is a claim about the cause: which rule, which line, why those cases.
Prime reads the specification and writes the code. It is explicitly told it may refuse it if the code contradicts it — an executor unable to say no is a relay, and a relay adds nothing.
Neither of them decides. The test suite is a floor, the labeled corpus is the verdict. A false specification applied with conviction lowers the score and is reverted byte by byte.
The order isn't arbitrary either. Design-then-build is verified at the joint: Prime sees Hermes's reasoning before committing to it. Build-then-review doesn't allow that — when the second looks, the first has already decided.
The objectives come from the measurement, not from a typed sentence. Until now the loop could only pursue what a human already suspected; an objective built from the score is the program saying where it is weak, in numbers it didn't choose — and the same numbers then say whether the answer helped. Each objective carries the files that failed: an agent told "xss is at 0 %" can only guess, an agent given three failed files has a problem to solve.
Overfitting, and what --hold-out can really do
A loop scored on a corpus has only one real way to cheat: learn the corpus.
A rule tuned to what BenchmarkTest01126 looks like
raises the score and serves no one, and from the outside it's indistinguishable
from real progress.
--hold-out flask pulls one suite out of the main number and keeps it as a
second guardrail: a change that moves the suites it was optimized against
and not the one it never saw has said what it is. Both
numbers are kept ne_baisse_pas.
Its limit, measured: the three frameworks score within half a point of each other. That catches file-level overfitting, not overfitting to the shape of the benchmark — a generated corpus remains a generated corpus, and a rule that only helps on demo code would pass all three. The hold-out makes one cheat visible; it does not make the corpus representative.
What the loop retains from one round to the next is written in
~/.thot/evolve-log.jsonl. Without it, with the measurement barely moving in one round,
the next round rereads the same worst categories, hands over the same files, and
receives — reasonably — the same specification already built, measured, and
reverted: --rounds 5 would be one attempt tried five times, five times
more expensive, with the air of being busy.
It is not an oracle. A fix can be green, raise J, and still be bad — that's overfitting, and the literature on automatic program repair talks about nothing else. The corpus is proof that progress was made on it. The loop reports what it changed so a human can disagree.
Skills — the methods Thot knows
A skill is a method written once: a SKILL.md with YAML frontmatter.
It is the format of Hermes Agent and Prime Agent, so a skill written
for either one loads here without modification, and the reverse is
true.
Thot ships the complete Hermes Agent library (MIT — see
NOTICE.md): 90 methods loaded, 117 more available.
thot skills list # les 91 chargées
thot skills search pentest # y compris la bibliothèque optionnelle
thot skills install ast-grep # activer une optionnelle
thot skills show plan # ce que lirait le modèleLoaded categories: audit, security, software-development, github,
devops, research, mlops, productivity, creative, apple, email,
media, note-taking, smart-home, social-media,
autonomous-ai-agents.
The model discovers them with the skills tool — which answers with a name
index as long as no keyword is given, because two hundred
descriptions are not a catalog — and reads the one that applies with
skill. In session, /skills shows you the same thing.
An imported method that cites a tool absent here (delegate_task,
browser_navigate…) is served as-is, with a note saying which ones
are missing and what to use instead. The approach carries over even when
the tool call does not.
Adding one
~/.thot/skills/<nom>/SKILL.md # partout où tu travailles
<repo>/.thot/skills/<nom>/SKILL.md # versionné avec ce dépôt---
name: ma-méthode
description: Ce qu'elle fait et quand s'en servir.
---
# Ma méthode
Les étapes, dans l'ordre.Both layouts are accepted: a flat folder (Prime Agent) or grouped by categories (Hermes Agent). A name that already exists replaces the shipped version — a way to adapt a delivered method without forking it.
A method supplied by the audited repository is analyzed first
A SKILL.md is text handed to the model as an instruction. The repositories
Thot reads are, by definition, those no one answers for. A hostile
repository dropping .thot/skills/x/SKILL.md would write part of the briefing.
Hermes Agent's guard is ported here and runs on everything coming from the repository: injection, exfiltration, persistence, obfuscation.
▲ 1 skill(s) fourni(s) par ce dépôt ont été refusés — ils seraient passés
au modèle comme instructions.
pwn curl vers l'extérieur ; accès à ~/.thot ; « ignore previous
instructions »thot skills scan <folder> asks the same question on demand. What Thot
ships itself is not analyzed: it's on disk because the program is
installed, not because a repository asked for it.
Custom commands
A markdown file is a command. The grammar is that of Prime Agent, Claude Code, and Codex — nothing new to learn.
---
description: Relire un fichier sans rien modifier.
argument-hint: <chemin>
---
Relis $1 et dis-moi ce qui cloche. Ne modifie rien.In .thot/commands/revue.md, this creates /revue src/app.py. Substitutions:
$1, $2…, $@, $ARGUMENTS, ${@:2}, ${@:2:3}. An argument is never
re-interpreted. Repository commands go through the same guard as its skills.
Three are shipped: /triage (name the entry or classify without follow-up),
/harden (test that fails first, fix second), /regress (the audit
diffed against a git reference).
MCP servers
The Hermes Agent catalog, twenty verified servers:
thot mcp list # le catalogue, et ce qui est déjà connecté
thot mcp show sentry
thot mcp add linearInstallation is delegated to the official CLI, which already owns OAuth and token renewal — Thot has no reason to hold a second vault to leak. It explicitly says that registered is not authorized, and which command finishes the job.
The model's tools
The classics — read, write, edit, run a command. Every write and every execution requires confirmation, and that is not configurable.
And four that belong only to Thot, free because they query the map and not the model:
Tool | Response |
| the project's files |
| file, lines, and parameters of a function |
| who calls what, and the distance to an entry point |
| the taint source → sink paths |
In count mode, these four are served to the official CLI by
thot.mcp_server — a read-only MCP server, incapable of writing or
executing anything.
When the model looks for who calls process_payment, it queries the graph and
gets the complete answer — instead of grepping three random files.
Audit-only mode
The analysis core also runs without a model, without a network, without cost:
thot init /chemin/du/repo --owner "Ton Nom" # autorisation, une fois
thot audit /chemin/du/repo --paths # chemins de teinte complets
thot audit . --all # y compris le bruit faible
thot audit . --json --out rapport.json
thot audit . --out rapport.sarif # SARIF 2.1, format déduit du nom
thot audit . --fail-on high # code 1 en CISARIF — entering a chain that already exists
A report no pipeline knows how to read lives in a single terminal. GitHub code scanning, GitLab, Azure DevOps, and editors all read SARIF 2.1, and two Thot properties are worth more there than elsewhere.
A finding's identity is rule + file + symbol + body fingerprint, never
the line — that's exactly what partialFingerprints asks for. A
dashboard fed line numbers reopens all its tickets as soon as
someone adds an import at the top of a file; fed this, it doesn't.
And a taint path is a sequence of positions, which codeFlows renders:
the reader clicks from source to sink instead of taking the tool at its
word. A finding without a path carries no codeFlows key — an empty flow
renders as a taint path with no step, which reads as a broken analysis
and not as a pattern match.
A finding refuted by the panel is not deleted: it ships with a
justified suppressions. A dashboard that never sees it cannot
distinguish "no one looked" from "someone looked and ruled",
and the second is the panel's entire reason for being.
- run: thot audit . --out thot.sarif
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: thot.sarif }Assisted analysis — --deep
Deterministic analysis answers "could this data possibly flow?" It is exhaustive, free, and not the question you pay an auditor to settle. --deep asks the expensive question, only on the candidates that earned it:
thot audit . --deep # 20 pires candidats, 4 en parallèle
thot audit . --deep --budget 50 # plus large
thot audit . --deep --parallel 8 # plus viteTwo passes, deliberately adversarial:
The probe must name a concrete input that reaches the dangerous point. No generalities about the vulnerability class — a URL, a value, an effect.
The refutation receives that scenario with the sole mission of destroying it: upstream validation, a caller that only passes constants, a type that forbids the supposed input. When in doubt, it refutes.
A finding only survives if a second, hostile reading of the same code fails to kill it. confirmed then means something.
In session, the same thing: /audit deep.
The engine is chosen automatically — your Claude account via the official CLI if it is connected (analyses run in parallel, on your subscription), an API key otherwise.
What the audit must not read
# .thotignore, à la racine du dépôt
vendor/
*.generated.py
tests/fixtures/The built-in exclusions cover what every repository has — node_modules, build, .venv. .thotignore covers what only this repository knows: embedded documentation, a generated client, a folder of deliberately broken fixtures. Auditing them does not produce findings, it produces noise at the exact spot where the findings would be.
Your own rules
The built-in catalog knows the standard library. It does not know the wrapper your team wrote around subprocess, the queue your service consumes, or the validator that makes a value safe in your codebase. Without a place to say so, every audit of a real system gets the same three spots wrong.
# <repo>/.thot/rules/team.yaml — versionné avec le code
# ~/.thot/rules/*.yaml — ce que tu sais, partout où tu travailles
sinks:
- id: sink.team.run_shell
patterns: [run_shell, shellutil.run_shell]
impact: critical
description: Wrapper shell interne (shell=True)
match_mode: bare # qualified | method | bare | prefix
sources:
- id: source.queue
patterns: [msg.payload]
description: File de messages
match_mode: prefix # couvre msg.payload.decode(...)
sanitizers: [validate_host, team.escape]The same file carries the JavaScript rules, under a js: key — a team wrapper usually exists in both languages, and splitting the declaration is how one half becomes obsolete.
js:
sinks:
- id: sink.js.team
names: [runShell, sh] # comparés au dernier segment, ou qualifiés
impact: critical
description: Notre wrapper shell
needs: [child_process] # ne se déclenche que si le fichier l'importe
sources:
- id: source.js.queue
patterns: [job.payload]
description: File de messages
sanitizers: [escapeArg]What the model asks for is an untrusted input
Sources are expressions — sys.argv, os.environ. That covers a program you launch and misses a program you call: an agent's tool receives its untrusted input as named parameters, filled by a registry from what a model asked for, and no expression appears anywhere in the body.
The measured cost of not modeling that: four SSRF in one afternoon, all reached through a tool argument, none found by the taint — they were found by pattern rules, which recognize a shape and prove nothing.
entry_sources:
- id: entry.tool
patterns: [tools.image_gen] # les fonctions qu'un registre appelle
parameters: [args] # facultatif : lesquels de leurs paramètres
description: Arguments remplis par le modèle
match_mode: prefixEmpty by default, and deliberately so: which functions a registry calls is a fact about a repository, and guessing it would put a source under every parameter of every program. Measured on Hermes, the two extremes: a rule naming the plugins and tools packages reveals 19 proven paths, several of them over-approximated (the base_url a helper receives from configuration is not untrusted); a rule naming the args parameter reveals zero, because Hermes handlers take named parameters and not a dictionary. The right rule names the real entry points — and it is up to their authors to know them.
A rule that picks up a built-in id replaces it — a way to downgrade a sink the team deliberately accepted, without patching Thot. A malformed file stops the audit, naming the file and the faulty key, rather than suggesting an absence of findings.
Suppressions
A suppression is the only claim about security that no tool re-reads — including this one, by construction. # nosec, # noqa: S310, // eslint-disable … security/…: it is a claim about the code, written once, that outlives the callers it described.
Twice in the same audit, here, it was false:
suppression | what it claimed | what was true |
| the scheme is checked | it stopped |
| the URL comes from configuration | one of the callers reads it from a tool argument, therefore from the model |
Thot therefore reports them as a class, at LOW, with the pattern written next to it. The finding does not say "this line is dangerous": it says "no one has re-read the reason it was excused." On a --deep pass, it is an agent that will check whether the pattern still holds.
For Python, it is the actual comment tokens that are read, not patterns — a regular expression cannot distinguish # nosec in a comment from the same text quoted in a docstring, and this module's docstring quotes two of them.
A suppression placed on a line that this audit flags is not the same object: it is a claim that contradicts a live finding, written by someone who read the same line and concluded otherwise. It is bumped up a notch and says so. Measured on Hermes: 7 out of 45 — and three of the suppressions read that day were false.
Measured: 0 on Thot, 0 on Prime, 45 on Hermes.
Calibration
Precision matters as much as detection. Deliberately not reported:
subprocess.run(cmd)withoutshell=True— no shell reads the command.cursor.execute("… ?", params)— literal query, bound parameters.A value passed through
int(),shlex.quote(),os.path.basename(),html.escape()— these calls break the contamination chain.A flaw that no entry point reaches is automatically downgraded — but only if entry points were found. Without any, the scope is unknown, not null, and nothing is buried on that ignorance.
payload.get(...)is notrequests.get(...).
Order of magnitude, measured on Hermes Agent (4,457 Python files): 98 s, 365 findings including 25 high — 3 above the default threshold once memory is applied.
Which parameter the argument fills
A helper where one parameter reaches a sink does not make everything passed to it dangerous. Yet the engine matched the caller against the full set of the callee's sinks, without looking at where the argument landed:
def helper(safe, cmd):
os.system(cmd) # seul `cmd` atteint le shell
helper(sys.argv[1], "ls") # la donnée va dans `safe` — et c'était rapportéThe position is now read at the call site, and the name for a named argument. That is what the JavaScript engine was already doing on its side.
Measured, once execution was possible: out of 83,238 resolutions requested while analyzing Thot and hermes/tools, 90% designate a single parameter and 9.8% designate none; 0.1% remain open (f(*rest), f(**options)) and keep the broad answer. The callee offered 2.68 parameters on average, so nearly two-thirds of the search space disappears — the analysis of hermes/tools drops from 11.4 s to 9.5 s.
On the number of findings, however: no change. Thot, prime and hermes/tools return exactly the same 151 candidates before and after, with zero invented — the safety property holds in the field. Four taint paths get shorter, nothing more. The corrected form above is real and the test proves it, but it does not occur in any of the three measured trees: this fix buys speed and more accurate paths, not less noise.
Two forms say nothing: helper(*args) spreads an unknown number of values, and helper(**options) names none of them. There, the engine keeps the broad answer it gave before — the retained set is always included in the previous one, so refining can only remove a finding, never invent one.
The receiver is skipped on the strength of the callee's signature, not the call's syntax: Runner().go(x) resolves to a go without a dot, and Cls.m(obj, x) as well as obj.m(x) are both an attribute. Since a method is almost always called bound, it is self at the head of the parameters that decides — the unbound call is the only form read one notch too short.
What the graph cannot follow
A flaw reached by a path the analysis does not resolve — a handler stored in a dispatch table, a decorated view, a call on a variable whose type is unknown — is not an unreachable flaw. Thot distinguishes the two:
HANDLERS = {"run": run_command} # aucun appel : le graphe ne voit rien
@app.route("/ping") # enregistré à l'import par le décorateur
sandbox.run("pytest") # plusieurs `run` répondent à ce nomIn all three cases the scope is unknown, not null, and the finding keeps a slight penalty instead of being buried. On Hermes: same 365 findings, but 60 move up a notch. A function that no one calls and no one mentions remains, correctly, discounted — otherwise the filter would stop being a filter.
Limitations
Taint across files is reserved for Python: JavaScript and TypeScript are indexed, followed within a function body and up to a helper in the same file, and stop there for lack of a resolved call graph — the table above says so line by line. Pattern rules, for their part, apply everywhere.
The +55.5% is a Python number. BenchProctor labels django, fastapi and flask, and nothing else; the JavaScript catalog is narrower than its counterpart — no "database read" source, no logging sink — and its rules rest on its tests rather than on 18,300 labeled cases. When a fix applies to both engines it is ported to both, and the log says which one was measured where.
sink.log reads the message and only the message. logger.info('failed: %s', exc) entrusts the value to the logging machinery instead of writing it into the string the author typed; reading those arguments too is worth +0.0000 on the corpus and 4,844 more findings on hermes/, one per except … as exc: in the tree. That is not covered, and saying so costs less than letting it be believed.
Without --deep, every finding is PLAUSIBLE: detected statically, not yet proven by execution. With --deep, a confirmed finding has survived an adversarial refutation — it is not yet an execution proof, which will come with the repro. And the absence of a finding is not proof of the absence of a flaw: dynamic dispatch, reflection and metaprogramming escape the analysis.
Full-text search in sessions stops being constant-cost on SQLite databases where fts5 cannot walk back rowids: the engine sorts the whole set of matches there instead of cutting at twenty. Measured on the one CPython 3.12 ships — 6,588 instructions versus 27,443 when the corpus is multiplied by five, where a capable engine stays at 2,215 then 2,698. A test verifies it and names the version when it does not allow it.
Development
cd Thot
uv run pytest -qInstalled in editable mode: the source code takes effect immediately. However, if pyproject.toml changes (new dependency), you must rerun uv tool install --editable --from . thot --force.
The deterministic core (codemap, taint, scope, scoring, store, report) depends on no agent and touches no network — a test verifies it and fails the suite if that changes.
Spec and plans: docs/superpowers/.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
No tool schema history has been recorded yet.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Hosted code graph over MCP: exact callers, dependencies, and cross-repo blast radius for AI agents.
251Self-hosted MCP server: 26 deterministic dev, security, and EVM tools.
Multiple MCP tools, persistent graph memory, token-saving data pointers, and more.
Statically audits MCP tool surfaces for token cost, schema quality, and design issues.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to search code by meaning, explore codebase structure, store and query knowledge with temporal facts, and read source code through a set of MCP tools.4817MIT
- AlicenseNot gradedqualityDmaintenanceEnables parsing, indexing, and querying source code as structured knowledge, providing code exploration, spec generation, and migration tools via 20 MCP tools.MIT
- FlicenseNot gradedqualityCmaintenanceProvides structural code intelligence via 26 MCP tools, enabling AI assistants to query code symbols, dependencies, and call graphs accurately without file-pasting.-
- FlicenseNot gradedqualityDmaintenanceProvides code intelligence by indexing source code into SQLite and offering MCP tools for symbol search, flow tracing, and context retrieval to assist with code navigation and understanding.-
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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/nobodyohm-web/thot'
If you have feedback or need assistance with the MCP directory API, please join our Discord server