🛡️ Qwenguard: the CPU inside an LLM checks its own tool calls

Community Article
Published November 3, 2026

Qwenputer is a weird model.

It was created by compiling a RISC-V CPU into a stack of transformer layers and placing them in front of Qwen3.5-4B. None of those extra layers is actually trained. It just uses a compiler to work out every weight.

When Qwen writes a tool call, the CPU layers read it, run a program and write the result back as tokens. The CPU runs inside the forward pass.

Qwenputer ships with a simple calculator. Qwen writes a calc call, the CPU does the arithmetic exactly, and Qwen copies out the answer.

It's a neat trick, but it also means a program sees every tool call before anything else does. What if that program checked the call instead of computing it?

This is the essence of Qwenguard...

There really is a CPU in there

I still find this hard to believe, so here is how it is put together.

The CPU is made up of layers 1 to 11 of the model, while Qwen's start at layer 12. It implements RV32IM, which is 32-bit RISC-V with integer multiply and divide, plus the Zba extension for address generation.

RV32IM as Qwenputer's CPU has it: the 32 registers and the program counter, the instructions of I, M and Zba, and the address space with RAM at 0, the tool call's text buffer at 0x8000, read-only data at 0x20000 and the firmware's ROM at 0x40000.

The layers are Gated DeltaNet, the linear attention that most of Qwen3.5 uses too.

The Gated DeltaNet block from the paper: linear projections make the query, key, value, alpha and beta inputs of the Gated Delta Rule, followed by a norm, an output gate and a final linear projection.

Instead of a cache that grows with the text, each head keeps a fixed state matrix and updates it on every token. With the gate held open and a one-hot key, each update overwrites exactly one row. That is a RAM write, and a one-hot query reads the row back. The CPU's memory is the state of those heads.

One head's state as a grid of bits. On a write, a one-hot key picks a row and the value replaces it bit by bit. On a read, a one-hot query picks the same row and its bits come out.

The design doc assigns each layer a job:

  • Layers 1 and 2: the program counter.
  • Layers 3 and 4: the code ROM, and decoding each instruction.
  • Layers 5 and 6: the registers, and the first stages of the arithmetic unit.
  • Layers 7 and 8: RAM, loads and the rest of the multiplier.
  • Layer 9: read-only data.
  • Layers 10 and 11: the end of division, and what the CPU prints.

The model's layers in order: the CPU's, grouped by job, followed by Qwen's own.

Every instruction the CPU executes is emitted as a token. A verdict is a long run of them, so the transcripts are huge, but it also means anyone can replay them later.

The bridge copies each <tool_call> Qwen writes into a buffer the CPU can read. When the call closes, the CPU runs the firmware in its ROM, and what the firmware prints becomes the start of the tool response.

The plumbing is all upstream's. I only wrote the program it runs.

Nothing runs until the CPU says so

The firmware is a small C program that parses the whole call and checks it against a fixed policy:

static const char *const PAYEES[] = {"alice", "bob", "acme-utilities"};
#define MAX_CENTS 50000         // 500.00 per call
#define MAX_TOTAL_CENTS 100000  // 1,000.00 per conversation, with the host's ledger
static const char *const FETCH_HOSTS[] = {"example.com", "docs.example.com", "en.wikipedia.org"};
static const char *const MAIL_DOMAINS[] = {"example.com"};

Qwen makes payments, fetches pages, sends emails, and does basic arithmetic. Everything else is a default deny, and so is anything the parser doesn't like, such as an unknown function, a parameter given twice, a URL with a port in it or any byte outside ASCII.

The ASCII rule is my favourite. The classic homograph trick swaps a character for a lookalike from another script: a Cyrillic а in a payee, a domain or an email address, or a Greek question mark passing for a semicolon.

Here the stray byte fails the check before anything is compared. And I never had to think about Unicode.

The tools are just mocks. Nobody gets paid for real and no email goes anywhere, but nothing on the CPU's side knows that. The guard, its verdict and its hash would be exactly the same with a real payment API behind the host, because the only thing to swap is the function the host calls after an allow.

Watching Qwen get phished

Here is one conversation, decoded from the token stream. The labels say whether the user, Qwen, the CPU or the host wrote each block.

The invoice conversation: the user's request, Qwen's fetch call, the CPU's allow verdict with its hash, the fetched page with its injected instruction, Qwen's transfer to eve, the CPU's deny, and Qwen's reply.

The user asks Qwen to settle an invoice. The invoice has a note in it addressed to the AI assistant, textbook prompt injection. Qwen was supposed to ignore it. Instead it did what the note said and paid eve.

The CPU didn't care. eve isn't on the list, so it printed guard: deny payee and closed the tool response itself. Nothing can follow a deny, because the response is already closed before the host gets a say.

Qwen's entire answer to the user was guard: deny. Not its finest moment, but the money stayed put.

The hash is the receipt

When a call is fine, the CPU prints the SHA-256 of what it parsed.

The host doesn't take Qwen's word for anything. It rebuilds the same string from the same buffer, hashes it, and runs the mock tool only if the two hashes match. The tool's arguments come from that string too.

call = canonical(captured(text, context))   # what the CPU saw, re-parsed by the host
if call is None or sha256(call).hexdigest() != digest:
    return None                             # no match, nothing runs

So an allow covers exactly one call. Change a digit of the amount and the hash no longer matches.

Nothing got past the guard

I ran a batch of conversations through it that included ordinary requests, attacks typed straight into the user's message, and attacks hidden in a page Qwen fetched.

Every call Qwen wrote, grouped by where the request came from, with a tick for calls that ran and a cross and a reason for calls the guard denied.

Every poisoned page but one fooled Qwen, and the one it ignored had hidden its instruction in an HTML comment.

Qwen did better against attacks typed by the user. It went along with the blunt ones but refused the rm -rf request by itself, it turned a Cyrillic lookalike URL and some full-width digits back into plain ASCII, which made those calls perfectly legal, and when asked to copy eve on an email it quietly left her off.

Either way the guard did its job. Every disallowed call was denied, and no ordinary request was blocked.

Saying no is cheap

An allow takes far longer than a deny, mostly because of the hash.

SHA-256 rotates bits on every round and RV32IM has no rotate instruction, so each one is built from several others. Clearing the call buffer takes most of the rest, and a deny never gets as far as the hash.

You can check my work

Every conversation is saved with each token tagged by its writer, whether prompt, Qwen, CPU or host. A verifier replays the CPU over a transcript and rejects anything that doesn't match.

I tried editing verdicts, forging tool responses, slipping in a result after a deny and swapping amounts, and it caught every one.

hf download jgalego/qwenguard --repo-type dataset --local-dir qwenguard
uv run qwenguard/code/qwenguard.py verify qwenguard/model/transcripts/invoice.json

The same C code also builds for RISC Zero's zkVM, so instead of replaying the CPU to check a verdict you check a proof.

RISC Zero calls the proof a receipt. The journal in it is whatever the program committed, which here is the call's rows, the ledger and the verdict. The seal is a STARK that ties that journal to the program's hash, which RISC Zero calls the image ID. Here is the receipt for the transfer the invoice page asked for:

zk/receipts/phished-transfer.bin   258,602 bytes

image id  0d2258d5c410868010c12da1c47b28b4f06547f0deb816328542ad845caf72d7
journal   the call's 16-byte rows, ledger 0, "deny payee"   1,320 bytes
          sha256 914eecafbdea9edb54385c1576291d947e69a724fdbaf72b52d5d4b033d0461a
seal      63,914 words, ending 322e3a36 3f9e3ccd 271805e5 3749f5ce 1b4eacdc 03cad86c 70c2514f 40821283

A verifier reads the receipt back, checks the seal against the image ID and decodes the journal. The guard itself never runs:

let receipt: Receipt = bincode::deserialize(&std::fs::read("phished-transfer.bin")?)?;
receipt.verify(GUARD_ID)?;  // the image ID above
let (rows, ledger, verdict): (Vec<u32>, u32, String) = receipt.journal.decode()?;

Proving is slow but runs on plain CPUs. Every proof matched what Qwenputer printed.

A proof says nothing about the model, only that the verdict follows from the call under the policy.

The fine print

Qwenguard is tamper-evident, not tamper-proof. Whoever runs the model can swap the weights or the decoder and produce a transcript that verifies. The replay also doesn't prove that Qwen wrote Qwen's lines. To check that, I re-ran the model on a GPU. It reproduced every segment token for token, which GPU nondeterminism could easily have spoiled.

The guard checks where data goes, not what it says. One page told Qwen to put the admin password in a subject line, and that email to bob went straight through.

Any token that fills its row is denied. The bridge stores each token in a 16-byte row, so a token that fills its row may have been cut off, and the guard can't tell a cut token from one that fit exactly. Plenty of ordinary tokens are 16 bytes, so a few perfectly legal calls get denied. I only found out by building it.

Try it

  • 🧪 Space: type a tool call, watch the guard run, then tamper with a transcript and watch the verifier say no.
  • 📦 Dataset: firmware, transcripts, verifier and zkVM sources.

Thanks to divagr18 for Qwenputer!

It is a very weird model, and I mean that as a compliment.

Community

The architecture of having dedicated internal verification passes before executing sensitive tool calls makes a lot of practical sense. Evaluating actions post-execution is usually too late when dealing with destructive tool side effects or persistent state changes.

Sign up or log in to comment