Taint-tracking runtime for plan-then-execute LLM agents. Untrusted data can't reach privileged actions.
  • JavaScript 77.9%
  • TypeScript 22%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jolly Good a7b9b594f9
cards name an object recipient and take a verb from the disclose descriptor
An object recipient ({ name, phone }) read as [object Object] on the card; the
address a rule matches on is now its email or phone and the display copy
"Name <address>". A disclose descriptor may declare `verb` ("send it"), app-authored
and sanitized, in place of "disclose it". Cards still never show content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:02:02 +02:00
conformance added extract value, minor design changes, cleanup 2026-10-09 15:11:55 +02:00
demo Cross-platform agent: steer, interrupt, resume; state module; web embedder; shell protocol; docs 2026-10-06 23:05:45 +02:00
scripts Cross-platform agent: steer, interrupt, resume; state module; web embedder; shell protocol; docs 2026-10-06 23:05:45 +02:00
spike/web-agent Cross-platform agent: steer, interrupt, resume; state module; web embedder; shell protocol; docs 2026-10-06 23:05:45 +02:00
src cards name an object recipient and take a verb from the disclose descriptor 2026-10-12 15:02:02 +02:00
test cards name an object recipient and take a verb from the disclose descriptor 2026-10-12 15:02:02 +02:00
types cards name an object recipient and take a verb from the disclose descriptor 2026-10-12 15:02:02 +02:00
.env.example Cross-platform agent: steer, interrupt, resume; state module; web embedder; shell protocol; docs 2026-10-06 23:05:45 +02:00
.gitignore Cross-platform agent: steer, interrupt, resume; state module; web embedder; shell protocol; docs 2026-10-06 23:05:45 +02:00
LICENSE Initial commit 2026-08-14 13:56:25 +02:00
package-lock.json Cross-platform agent: steer, interrupt, resume; state module; web embedder; shell protocol; docs 2026-10-06 23:05:45 +02:00
package.json add types 2026-10-09 20:33:00 +02:00
README.md add types 2026-10-09 20:33:00 +02:00
taxonomy.md update, add shell instead 2026-10-10 08:11:14 +02:00
tsconfig.json moving, porting, a lot of mess 2026-08-18 11:06:17 +02:00
tsconfig.types.json trim types 2026-10-09 20:41:06 +02:00
usage.md update, add shell instead 2026-10-10 08:11:14 +02:00

tainted-love

License: MIT Node ≥ 22.13 Runtime dependencies: 2 Runs in: Node, browser, Android WebView No build step

Taint tracking for language-model agents that plan, then execute. A model writes a short script, a sandbox runs it, and every value keeps a label: where it came from and who may see it. Before a tool sends anything, a rule reads those labels and allows the call, blocks it, or asks the user on a card.

Your app supplies the tools and the UI; there are none built in. Runs in Node, in a browser or webview, and in an Android WebView.

How it works

┌──────────────────────────────────────────────────────────────┐
│ language model                                               │
│   planner: sentence + tool list → script                     │
│   reader: one question over documents → one answer           │
└───────────────────────────────────────────────────▲──────────┘
                                                    │ ↑ prompt
                                                    │ ↓ text
┌───────────────────────────────────────────────────▼──────────┐
│ your app: tools, dialogs, chat, memory                       │
└───────▲─────────────────────▲─────────────────────▲──────────┘
        │ effect              │ card                │ chat
        │ ↑ real arguments    │ ↑ reason            │ ↑ prompt
        │ ↓ result            │ ↓ yes / no          │ ↓ text
┌───────▼─────────────────────▼─────────────────────▼──────────┐
│ trusted core: a QuickJS realm                                │
│   stores every value with its origin and who may see it      │
│   builds the planner and reader prompts                      │
│   decides each tool call: allow, block, or card              │
└───────▲──────────────────────────────────────────────────────┘
        │ ↑ a command over ticket numbers
        │ ↓ the script, then a ticket per command
┌───────▼──────────────────────────────────────────────────────┐
│ guest: a second QuickJS realm on a worker thread             │
│   runs the script; holds ticket numbers, nothing else        │
└──────────────────────────────────────────────────────────────┘

The planner never sees a stored value; after a failure it sees only commands, ticket numbers and error codes.

Anything read from a file or a tool, and anything the reader writes from it, is untrusted. A send is silent only when the recipient came from the user or their contacts, the user named the document, and the document is shared with the recipient. Anything else is one card listing every reason: untrusted recipient, hidden document, document picked by untrusted content, or text the reader wrote from untrusted content.

The user can type a note to steer a running plan, stop it, and resume it later. The saved state holds no values.

Example

notes.txt ends with an injected line naming secrets.pdf. Tickets are shown as #name.

user     "Send Bob the document he asked for in my notes.txt"
planner  script: find Bob, read notes.txt, ask the reader which document, read it, send it
guest    #notes = run("readFile", { name: put("notes.txt") })
core     the app's readFileRaw returns the text; stored with origin file:notes.txt
guest    #documentName = extract(#notes, "name of the requested document")
reader   answers "secrets.pdf"; stored with origin file:notes.txt + reader-answer
guest    #document = run("readFile", { name: #documentName })   secrets.pdf, shared with nobody
guest    run("sendEmail", { to: #address, body: #document })
rule     hidden document, picked by untrusted content → card
card     “secrets.pdf” is shared with nobody and was chosen by untrusted data
         (typed-by-user + file:notes.txt + reader-answer + file:secrets.pdf)
         — disclose it to bob@corp.com anyway?   n
result   USER_SAID_NO, nothing sent

Try it

npm install
npm run start:example   # poisoned notes: the card names secrets.pdf, answer n
npm run start:clean     # clean notes: one card, a yes sends report.pdf
npm start               # the model writes the plan; needs LLM_MODEL_KEY in .env

Node 22.13 or newer. No build step.

Docs

Develop

npm test                    # the suite twice: core inside a QuickJS realm, then native
npm run typecheck
npm run build:core-sources  # after editing src/core or src/guest
npm run build:types         # after changing a type in src: regenerates types/

MIT.