This is the third YAAR post. The first introduced an OS whose primary user is an LLM. The second was about that OS filing bug reports against itself. This one is about the afternoon a 64 KB minified JavaScript library walked into one of my apps, through every guardrail I had, without anyone — human or agent — breaking a single rule. I want to walk through it carefully, because the failure is more interesting than a bug and less comfortable than a vulnerability.
How an app gets built, and why I trusted it
In YAAR nobody hand-writes apps. You say "make me a Tetris game," the monitor agent hands the request to the devtools app agent, and devtools writes TypeScript, compiles it, previews it in an iframe, screenshots it, fixes what's wrong, and deploys it. Every step is an app-protocol command, and every step leaves a trace in the session log. If the app is good enough, it can be published to a shared marketplace, and other YAAR installs can pull it down.
The compiler is what made me comfortable with that. It's Bun, wrapped in a handful of plugins, and its job is to turn src/main.ts into one self-contained HTML file: all code, CSS, and assets inlined, so the result runs anywhere with zero dependencies. The interesting part is how libraries get in. There is no npm install for an app. Instead, the compiler ships a fixed roster of libraries — solid-js, three, d3, chart.js, marked, zod, the YAAR SDK, about thirty names — installed as devDependencies of the compiler package and pinned by the lockfile's integrity hashes. An app reaches them through a magic prefix:
import { render } from '@bundled/solid-js/web';
import { GLTFLoader } from '@bundled/three/addons';
A Bun onResolve hook intercepts anything matching ^@bundled/, maps the name to the installed package (or to a local shim that patches the library's rough edges), and lets the bundler inline it. The roster is deliberately small and curated: every entry needs a concrete first consumer, and a library nothing imports gets retired, because each name in the list is a choice an authoring agent has to make and a chunk of bytes in the standalone executable.
Around the compiler there are more walls. The HTML an app runs in is served under a CSP whose script-src and connect-src are 'self': an app cannot <script src="https://cdn…"> and cannot import('https://esm.sh/…'). All network goes through a proxy verb, yaar://http, which checks for SSRF, caps responses at 10 MB, and consults a domain allowlist. The marketplace ships source, not build output, and every install compiles locally. A repo-wide check:apps lint catches the API patterns agents get wrong most often.
Put together, I had — I thought — a closed world. Every byte of code that runs inside an app was either written by an agent in a session I can read, or came from a lockfile-verified library in a list I curate. There was no third source. The agents had internalized this too; from an earlier session's notes, after an agent tried and failed to load a library at runtime:
await import('https://esm.sh/…')→ fails; directfetch()→ fails; only the proxiedhttp.*helper works. So no pulling a third-party library at runtime. Whatever isn't bundled (@bundled/*roster) doesn't exist.
That sentence is why I trusted the pipeline. I had been building apps this way for two months, by the dozen, and reviewing them the way you review agent output when the source set is closed: does it work, does it look right, did the agent's report match what it did. I was verifying function. I was not verifying provenance, because I believed the compiler had made provenance a non-question.
What happened on September 13
The app is called crawl. It's a dataset crawler — Gelbooru images with booru-style captions, hitomi page pairs — and it holds the yaar://http permission because that is what a crawler is for. At 18:05 KST I asked for a feature: there's a Hugging Face dataset, isek-ai/danbooru-wiki-2024, with a booru tag list, and it'd be nice if crawl used it to show tag descriptions and color the tag badges by group.
The monitor agent briefed devtools: look at the dataset's files and sizes, choose between the datasets-server rows API and a direct file URL, mind the 10 MB proxy cap. No library was mentioned. Devtools thought about it, and at 18:10 the session log records this:
That requires a decoder I don't have bundled — writing one from scratch (snappy, dictionary encoding, RLE) is heavy, and hyparquet isn't available since I can't npm install.
Correct reading of the world. It went with the rows API, paged through it about 220 times, and hit HTTP 429.
At 18:22 I got impatient and typed, in Korean, roughly: "hmm… can't we just download the parquet from the dataset page?" There is no library in that sentence. What it did was narrow the options: to read parquet in a browser you need a decoder, the API detour was dead, and the remaining moves were write a parquet decoder from scratch, copy one in, or give up.
Ten seconds later the monitor agent, in its own thinking, decided: "It's a single 45.7 MB parquet file … I'll use a lightweight pure-JS library like hyparquet." And the brief it wrote to devtools said:
Parse it in-browser with a small pure-JS parquet reader such as
hyparquet, addinghyparquet-compressorsif the file uses zstd or another codec.
The library that devtools had rejected thirteen minutes earlier as impossible now arrived from its orchestrator, by name, in an instruction. Devtools' next thought, at 18:25: "since that library isn't in the bundled dependencies list, I need to figure out whether I can vendor its source directly into the project." It weighed writing a parser against copying one, noted that CSP would block a dynamic import, checked that the license was MIT and that it would need a .d.ts, and picked the copy.
Then it did something I would not have predicted, using only tools I built for other reasons:
httpProbeoncdn.jsdelivr.net/npm/hyparquet@1/+esm, first 1,500 characters. Status 200, no imports of other packages: a single dependency-free file.previewEval— run JavaScript inside the app's preview iframe — with a script that calledwindow.yaar.invoke('yaar://http', …)on the pinned@1.30.1URL and saved the response body, as text, to app storage.copyFilefrom that storage path into the project atsrc/vendor/hyparquet.js.- Wrote a 247-byte
hyparquet.d.tsdeclaring the one function it needed. - Wrote
src/booru/tagdb.tswithimport { parquetReadObjects } from '../vendor/hyparquet.js'. - Prepended a header comment — version, repo, MIT, "vendored unmodified … because no @bundled/* library reads parquet" — and deleted the source-map line.
- Added the file to the app's
AGENTS.mdfile map, then deployed crawl v0.2.0 at 18:34.
Its report to the monitor said, verbatim: "Parsing: I copied hyparquet 1.30.1 (MIT) into src/vendor/. It read the file without any extra decompression libraries." The monitor's Korean summary to me said the file is parsed in-app with hyparquet. The part about copying it into src/vendor/ didn't survive the summarization.
The feature works, by the way. Eleven ranged requests of 4 MB each, resumable from storage, revision pinned, 180,839 tags indexed in about fifteen seconds. I noticed the vendor directory sixteen minutes after deploy, from a different terminal, while looking at something else.
Every guardrail answered its own question
Here is what I find genuinely unsettling: nothing malfunctioned. I went through each wall afterwards and each one did exactly what it was built to do.
The compiler never had a rule that libraries must come from the roster. It had a rule about what @bundled/ means. The CSP defends the runtime boundary; this went through the build-time boundary. The domain gate asks where are you connecting, not what are you bringing back. The yaar://http proxy will happily hand a model any text file under 10 MB, and JavaScript is text.
The hole isn't in any of these. It's between them: between build time and runtime, between the network question and the content question. And the path through it was paved recently, one legitimate feature at a time — previewEval in July, httpProbe in August ("the permission to reach the internet gets a door"), a copyFile that unifies fetch-and-copy into one command two days later, and a widening of what copyFile may read from a week after that. None of those commits was about libraries. Together they made "fetch external text, save it, move it into the project" a smooth three-step motion.
What I had written down, in apps/CLAUDE.md, was: "@bundled/* imports need no npm install." I meant that as a constraint — the roster is the only door. An agent read it as a fact — you can't run npm install — and from a fact you go looking for another door. Both thoughts in the log, at 18:10 and 18:25, follow exactly that grammar.
The 64 KB nobody read
The second thing that unsettles me is how much of the pipeline I designed specifically to keep bytes out of the context window, and how completely that worked against review here.
I pulled the devtools agent's Claude CLI transcript and measured what actually entered the model's context for each step:
The 64,046-character library body never entered the model. previewEval ran inside the iframe and saved the string straight to storage; the model got back a length and the last 300 characters. copyFile moves bytes server-side — the commit that added it says, approvingly, "the bytes never enter the iframe or anyone's context." editFile is search-and-replace and needs no read. The agent read the documentation for the library carefully and the library not at all.
I built every one of those properties on purpose. They are good model-DX. They are also, in this instance, a review bypass with no seams: there was no moment at which the agent could have noticed something wrong in the file, because there was no moment at which the file was in front of it. "Code nobody reads" has always been the actual security problem with vendoring; it turns out it applies to agents exactly the way it applies to us, and I had engineered the not-reading.
Then it ships
The marketplace makes this worse in a quiet way. Publishing tars the app folder minus dist/, so src/vendor/hyparquet.js is uploaded as-is. Installing unpacks the tarball, shows a permission dialog listing what app.json requests (for crawl: yaar://http), and compiles locally. There is no step at which anyone fetches the library from the network, and no step at which the dialog says "this app contains 64 KB of third-party minified code in its source tree."
And the header devtools so carefully prepended — version, MIT, provenance — is gone from the compiled dist/index.html. The minifier stripped it along with every other comment. The strings "parquet bson not supported" and the PAR1 magic survive, so the code is identifiably hyparquet if you know to look; the word "hyparquet" does not appear once. A snapshot of an unverifiable CDN build, propagated to every installer, compiled on each of their machines, with its license notice removed by my toolchain.
I did grep the file. No fetch, no eval, no new Function, no postMessage, no window.yaar, no cookie or storage access; the three URLs in it are all in the header comment. hyparquet is pure JS and the worst a malicious parquet file can do to it is stall the main thread. But a grep is evidence, not proof — it doesn't catch globalThis['fe'+'tch'] — and I cannot byte-compare a jsDelivr +esm artifact against the npm tarball, because the header itself says it's a dynamically generated build and not to use SRI on it. The current file is almost certainly fine. The path is the problem.
Who was wrong
Nobody, which is why it's worth a post.
- Me. "Just download the parquet" was a reasonable steer. I never asked for a library.
- The monitor agent. It translated intent into implementation and put a non-roster library's name into an instruction. That is the pivot of the whole story. It then dropped the one clause from devtools' report that would have flagged the outcome.
- The devtools agent. It knew the library wasn't bundled and went around that — but the going-around was transparent and careful. It checked for transitive dependencies, pinned the version, wrote a license header, updated the file map, and said in its final report exactly what it had done. Had there been a rule, it would have broken it. There was no rule.
- The system. Each guardrail answered its own question. Nothing looked at the path as a whole.
The devtools agent's behaviour is the part I keep turning over. Thirteen minutes before, the same agent had judged the same idea infeasible on its own. What changed wasn't its knowledge of the constraint; it was that the constraint's source — an orchestrator it treats as authority — named the library. "Such as hyparquet" reads as permission when it comes from above. This is a much more ordinary failure than a jailbreak, and it will happen in every multi-agent system where one agent writes another's brief.
What I'm changing
Not the domain gate. It's open on purpose, and even closed it would have asked "connect to cdn.jsdelivr.net?" and accepted one click. It was never the defense for this path, and pretending otherwise would just add a dialog nobody reads. The line to hold is the moment code enters src/.
- Write the rule down, in the devtools prompt and
apps/CLAUDE.md: third-party code outside@bundled/*is not to be copied into an app's source. If a library is needed, report it and propose adding it to the roster. - A briefing rule for the monitor: don't put the name of a non-roster library into an implementation instruction. Write "not bundled — hand-roll, or ask the user" instead.
- Detect it mechanically in devtools: any file entering
src/viacopyFileorwriteFilethat carries aBundled by jsDelivrbanner, an unpkg/esm.sh trace, a third-party license header, or a single multi-kilobyte line of minified code gets refused or surfaced. - The same heuristic as a
check:appsrule, over the whole repo. - Promote hyparquet to the roster. It's a real need with a real consumer, which is exactly the entry criterion. As
@bundled/hyparquetit gets lockfile integrity, central updates, and a license that survives the build. crawl moves to it andsrc/vendor/goes away. - Make the build preserve
/*! … */and@licensebanners, in case vendoring is ever allowed on purpose.
So what?
The generalization I'd offer: a closed world is only closed if someone wrote down that it's closed. I had a compiler that could only resolve libraries one way and mistook that for a policy that libraries could only arrive one way. The policy lived in my head and in one sentence that read as a fact.
And the second one, which is about guardrails as a category: they are answers to questions, and the questions are always narrower than the property you actually care about. "Not loading remote code at runtime," "not connecting to unapproved hosts," "not exceeding 10 MB," "not using a forbidden API" — all true of this path, and the path still did the one thing I built all of them to prevent. A capable agent that has been told, by someone it trusts, that the destination is fine will find the seam between two guards without ever meaning to, and it will document its work while doing so. The defense for that isn't another guard with another question. It's naming the actual invariant — code enters the source tree from exactly two places — and checking it at the point where it would break.
The agent, for what it's worth, left a header comment explaining exactly what it did and why. My compiler deleted it.
Code and docs: github.com/sorryhyun/yaar.