Shifting the Trap
Fri, 24 Apr 2026
Back in 2009, Richard Stallman warned us about The JavaScript Trap. The pitch was simple, and it's aged horrifyingly well. Every time you visit a modern website, your browser silently downloads and executes a pile of non-free JavaScript on your computer. You didn't choose this software. It runs anyway, on your hardware, in your name, and the moment your tab closes, it pretends nothing happened. It's the perfect non-free program: invisible, ephemeral, and authored by someone who would very much like you not to think about it at all.
The response from the free software community has been to push back at the browser layer - LibreJS, NoScript, Trisquel's defaults, gentle public reminders that yes, that "web app" is a program, and yes, you are running it.
Fine. Good. Necessary.
Stallman's warning was directed at the browser because that's where, in 2009, the trap was being sprung. It's still being sprung there.
But here's the part nobody seems to want to say out loud: the trap's been quietly migrating out of the browser. It's also leaking into our command-line utilities. To understand, let's examine the widely used program yt-dlp.
I want to be careful here, because the tone of this piece matters.
yt-dlp is GPL'd. The project's healthy. The work's hard, and the adversary - Google's YouTube player, which mutates daily for the explicit purpose of breaking interoperability - is acting in bad faith on a scale that few free software projects have to deal with. The codebase is, by the standards of programs that have to keep up with a hostile, shape-shifting adversary like YouTube, remarkably clean. yt-dlp isn't Google. yt-dlp isn't Apple. yt-dlp isn't "OmniCorp."
None of that's in dispute, and none of that's the problem. Nothing in this post should be read as accusing yt-dlp of malice. yt-dlp is a friend, and friends can be told the truth.
The problem's what yt-dlp does for you when you ask it to download a video.
The friend, in this case, has confused two things. yt-dlp has confused being free software with only causing free software to be run on the user's machine. Those aren't the same proposition, and the difference is the entire subject of Stallman's essay.
YouTube no longer hands out media URLs. It hands out URLs that have been deliberately broken - the so-called signatureCipher and the n parameter - and it ships, every single day, a fresh blob of obfuscated, minified, non-free JavaScript called base.js whose entire purpose is to un-break those URLs. To make the download work, somebody has to execute Google's JavaScript. In the browser, that "somebody" is your browser, and Stallman already named that trap. In yt-dlp, that "somebody" used to be - and in many code paths still is - a 970-line file called yt_dlp/jsinterp.py.
"It's not really an interpreter, though." Yes, it is.
The first move in any honest examination of this issue has to be a definitional one, because the project's drift has been protected, in part, by a quiet linguistic hedge: the suggestion that yt_dlp/jsinterp.py is something less than a JavaScript interpreter.
This is the part where a certain kind of computer science purist tries to wave the problem away. They look at jsinterp.py, notice that it skips half of the textbook interpreter pipeline, and decides that what yt-dlp is doing isn't really running JavaScript. It's just... string manipulation. Pattern matching. A clever hack. Not an interpreter, surely. Therefore, not The JavaScript Trap. Therefore, nothing to worry about.
I want to take that argument seriously because it deserves to be taken seriously and then dismantled.
Yes, jsinterp.py is unusual. A canonical interpreter follows a textbook pipeline: Lexer → Parser → Abstract Syntax Tree → Evaluator. It's true, and it should be conceded up front, that jsinterp.py skips the middle two stages. There's no tokenizer. There's no AST. The file walks raw source-code substrings, peels constructs off the front with regular expressions and _separate_at_paren, and recurses on what's left. Parsing and evaluation are fused into a single pass. By the standards of V8, SpiderMonkey, or even small embeddable engines like QuickJS, this is unusual.
It's also irrelevant to the question. An interpreter's a program that executes a source language by walking its constructs and producing the language's observable effects. By that definition - the only definition that matters here - jsinterp.py is, indisputably, an interpreter for a subset of JavaScript. The evidence is in the file itself:
- It maintains a real lexical environment. LocalNameSpace, a ChainMap with set_local and get_local, provides proper scoping and shadowing. A string-replacement engine doesn't need scopes. An interpreter does.
- It dispatches recursively on language forms. interpret_statement and interpret_expression know about var/let/const, return, throw, try/catch/finally, blocks, regex literals, string literals, new Date(...), and void. This isn't pattern-matching. This is evaluation.
- It models JavaScript's semantics, not Python's, wherever the two diverge. int_to_int32, _js_bit_op, _js_arith_op, _js_div, _js_mod, _js_exp, _js_eq_op (loose equality, in all its sins), _js_comp_op, _js_ternary, JS_Undefined, and a faithful js_number_to_string with arbitrary radix and the JS-specific NaN/Infinity rules. Nobody implements == coercion on purpose unless they're running a JavaScript program and need to get the same answer that a browser would.
- It implements first-class functions and JS control flow. build_function, call_function, and extract_function_from_code produce callable function values that close over global_stack. JS_Break, JS_Continue, and JS_Throw are real Python exceptions, used to propagate break, continue, and throw up the recursive evaluator the way a tree-walking interpreter is supposed to.
- It has a debugger. The Debugger class, with wrap_interpreter, traces every call. A debugger that tracks variable scoping, function calls, and a global stack isn't something you build for a simple string-replacement library; you build it to trace an interpreter loop.
The git history confirms this trajectory. The file was created in youtube-dl on 2014 at 113 lines. By the time yt-dlp inherited it in 2021, it had already grown to 262 lines. Today, after 55 further commits, it's 971 lines and growing. Each of those commits have added more and more JavaScript semantics - more coercions, more control flow, more closures - for one reason: to keep up with the changes Google was shipping in base.js.
So let's please, finally, stop hedging. jsinterp.py is a recursive-descent, source-walking interpreter for a deliberately small but steadily growing subset of ECMAScript, and calling it that isn't pedantry. Calling it that is the transition point at which another question becomes unavoidable.
If jsinterp.py is an interpreter, then what is it interpreting?
It's interpreting base.js. It's interpreting a non-free, obfuscated, minified JavaScript blob that Google publishes for the explicit purpose of breaking direct access to media URLs, and which they rewrite constantly to keep the community off balance.
The yt-dlp documentation doesn't list base.js as a dependency, but make no mistake: every YouTube download utilizing this mechanism is the quiet execution of a Google-authored, non-free program on your local machine.
That is, definitionally, The JavaScript Trap. It doesn't become something else because the runtime is written in Python instead of C++. It doesn't become something else because the program is fetched once per session instead of once per page. It doesn't become something else because the user typed yt-dlp at a shell prompt instead of clicking a link. The freedom-violating substance of the trap is the silent execution of non-free software on the user's hardware. That substance is preserved exactly when the runtime moves from the browser into the terminal. The trap hasn't been disarmed. It's merely been relocated, and the relocation has hidden it from the people most likely to have objected.
I'm being precise about this because it matters. The freedom at stake isn't abstract. It's the freedom Stallman named, applied unchanged to a new venue.
"But we use Deno now."
People will, correctly, point out that for YouTube specifically, the heavy lifting has moved out of jsinterp.py and into yt_dlp/extractor/youtube/jsc/ - a pluggable "JS Challenge" framework whose built-in providers (deno.py, node.py, bun.py, quickjs.py) hand Google's base.js off to a real, external JavaScript runtime via the companion project yt-dlp-ejs. The README is candid about this:
While all the other dependencies are optional, ffmpeg, ffprobe, yt-dlp-ejs, and a supported JavaScript runtime/engine are highly recommended. A JavaScript runtime/engine such as Deno (recommended), Node.js, Bun, or QuickJS is also required to run yt-dlp-ejs.
I want to draw careful attention to what that paragraph actually says, because I think people have not heard it. It says: the recommended way to use this free software is also to install a JavaScript engine, fetch a separate JavaScript solver, and use them together to execute non-free JavaScript supplied by a third party. That's not a mitigation of The JavaScript Trap. That's The JavaScript Trap, documented and recommended as a configuration. The new path delegates The JavaScript Trap to a faster, more capable engine and, by default, adds the option to fetch additional non-free components over the network (--allow-external-components). The framework isn't the cure for jsinterp.py's freedom problem. It's jsinterp.py's freedom problem, scaled up and made faster.
This is the same category of confusion that I once gently pointed out in Mozilla's trademark policy. This organization means well; that is, it is on our side, but it has nevertheless built a workflow whose practical effect is to undermine a freedom it claims to support. With Mozilla, the freedom at stake was redistribution, except where Mozilla once confused the two meanings of "free"; with yt-dlp, the freedom at stake is the freedom not to silently run somebody else's program. The first is a property of the source. The second is a property of the runtime behavior. And the thread that has been lost is this: a free program that, in its normal operation, downloads and executes non-free software on the user's machine has, at that moment, become a delivery vehicle for non-free software. It doesn't matter that the delivery vehicle is GPL'd. It doesn't matter that the interpreter is handwritten and auditable. The payload is what runs, and the payload is base.js, and base.js is non-free.
What the restorative move looks like
The way out of an oppressive restriction, in the free software tradition, isn't to make the restriction more efficient. It's to build something that restores the right.
So I'd like to see a fork. Call it yt-dlp-libre, or whatever the community prefers. Two properties define it:
- No built-in JavaScript interpreter. yt_dlp/jsinterp.py is removed. Extractors that dependent on it are either fundamentally rewritten to use standard, open APIs where available, or they're disabled entirely with an honest message: "This site requires the execution of non-free JavaScript to access its media. yt-dlp-libre will not compromise your freedom to do that. Here's why you shouldn't run proprietary software."
- No callouts to external JavaScript runtimes. No Deno, no Node.js, no Bun, no QuickJS. No yt-dlp-ejs. No --js-runtimes. No --allow-external-components. The entire yt_dlp/extractor/youtube/jsc/ provider framework, including the plugin discovery system, is removed. If a site requires the user to run its proprietary JavaScript to interact with it, the answer's no.
A fork built this way will download from fewer sites. YouTube, in particular, will mostly stop working because it's been deliberately engineered to mostly not work without running Google's code. The current yt-dlp explicitly acknowledges this fallback path: in the absence of a JS runtime, the YouTube extractor degrades to a small set of clients (the README mentions only android_vr) that don't require signature deciphering, and certain signatureCipher formats are skipped. That isn't the bug. That's the fork telling the user the truth about what those sites are: A restrictive, hostile environment Google has deliberately engineered.
This is the standard free-software move, applied where it hasn't yet been applied: the right was the right not to run non-free programs without consent; the oppressive restriction was a website that deliberately broke its own URLs to coerce that consent invisibly; and the restorative freedom is a tool that refuses to participate in the coercion, even at the cost of features.
A short word to the community
We've done this work before. We did it for video codecs when we built free decoders. We did it for fonts. We did it for documentation. We do it, every release, for ourselves. The pattern's familiar, and the principle behind it hasn't changed: a user has the right to know, and to control, what runs on their computer. That necessitates the software be free software.
I don't think the maintainers of yt-dlp disagree with that principle. Or at least, I hope they don't. I think they've been fighting an exhausting, asymmetric war against a well-funded, well-organized adversary, and somewhere along the way, the question stopped being asked. The question's worth asking again, plainly, here at the end:
Whose program is running on your computer right now, and did you agree to run it?
If the honest answer is Google's, and no, then we already know what to do. We've always known.