Extensions
A VS Code extension is arbitrary JavaScript running with your full privileges. It
can read ~/.ssh/id_ed25519, open a socket and spawn a shell, and nothing in the
extension API makes that visible, let alone preventable. Installing one is
trusting its author — and every package in its node_modules — with everything you
can reach.
deco keeps the separate Node process, because extensions are JavaScript and there is no way around that. It removes the ambient authority.
State of this: a code extension’s commands appear in the command palette, and choosing one starts its host in a container and runs it. What an extension can reach from there is still small — registering commands, saying something, logging — and everything else is refused by name. Theme and grammar extensions, which have no
mainand never start a host process, work fully.
Running one
Press ctrl+shift+p and an extension’s commands are in the list, beside deco’s own.
The right-hand column is the extension’s name rather than the command identifier
that deco’s own commands show there: for something contributed from outside, which
extension it came from is the fact that decides whether you want it.
Choosing one starts the host, activates the extension, and runs the command. The first start in a container pulls the image, which can take a minute — the editor does not block while it happens, the status bar says what is going on, and the command you asked for runs when the host is ready rather than being forgotten.
Nothing starts on its own. onLanguage: and onStartupFinished are understood by
the catalogue and deliberately not acted on yet: while this is new, a process should
start only when you asked for something, because that is the version where a mistake
costs least. Opening a Rust file will not start three extensions.
An extension is started once and reused. If its host dies, the status bar says so.
Four independent layers
0. A container. The host runs inside one by default, from an image named by digest. This is the outermost layer and the newest; the three below it all live inside the Node process, which left one assumption unpinned — the runtime itself. See The container.
1. Node’s permission model. The host runs with --permission, which blocks
filesystem, child-process and worker access below JavaScript, where an extension
cannot argue with it. No --allow-child-process, no --allow-fs-write. It is
passed inside the container too: a layer is not dropped because another one
arrived.
That flag is why the host needs Node 22.13 or newer: the permission model
became stable there and the flag lost its --experimental- prefix, and older
Node rejects the spelling deco passes. In the default container this is the
image’s problem rather than yours — the pinned image carries Node 22.23 — and it
only becomes a requirement on your own machine if you turn the container off.
2. The host bootstrap. It removes the network globals and refuses to load
fs, net, http, child_process and friends, so a blocked call produces a
clear error naming its brokered replacement rather than an opaque permission trap.
Node’s permission model does not cover the network; this layer is why that gap is
closed.
The module loader is part of the sandbox, which is why the host’s own code
requires node:path and node:module with the prefix. An unprefixed require
can be shadowed by a node_modules package of that name, and the host has a test
asserting no bare require of a non-builtin survives anywhere in src/.
3. The capability broker. It checks every request that does get through.
The container
Layers 1 to 3 are all enforced by the Node process or by deco. Layer 1 is the
only one an extension genuinely cannot argue with — and it is a feature of a
node binary deco borrows from whatever machine it is installed on. The version,
the build, and everything linked into it were outside deco’s control.
A container closes that gap:
- The runtime is pinned by digest, the same way this project pins its CI
actions.
docker.io/library/node:22-bookworm-slim@sha256:d649c27…is a specific set of bytes, not a tag someone can move. deco refuses an image reference that is not pinned, including one you configure yourself. --network=nonesevers the network in the kernel. Layer 2 deletesfetchand refusesnet, which produces a clear error — good manners, and manners are not a boundary. A native module has none.--read-only, one 16MBnoexectmpfs, and two read-only bind mounts: deco’s own host code, and the single extension being run.--cap-drop=ALLand--security-opt=no-new-privilegesleave nothing to escalate with.--memoryand--pids-limitmake a runaway extension the container’s problem.
The workspace is not mounted
This is the part that makes a container worth its cost here, so it is worth stating plainly: your project is not visible inside the container. Extensions read and write files through brokered requests that deco performs on their behalf, so the container needs no view of the workspace at all.
Had the workspace been bind-mounted — as a dev container would — the container
would add very little. The files an extension actually wants are in there, and a
mount hands them over wholesale, with the broker bypassed for anything the
extension can reach through fs.
If there is no container runtime
deco refuses to start the host, and says so, naming the setting that would let you proceed. It does not fall back to running the host without a container. A sandbox that silently degrades is worse than no sandbox, because nobody can tell which one they have.
Neither does a workspace get to make that decision. deco.extensions.sandbox,
deco.extensions.containerRuntime and deco.extensions.containerImage are read
from deco’s defaults and your own settings only. A .vscode/settings.json
arrives with a cloned repository, and a repository that could turn off the sandbox
that was about to contain its own extensions would make the sandbox decorative. An
attempt is reported rather than silently dropped.
Turning it off
{
// Runs the host as an ordinary child process, as deco did before containers.
"deco.extensions.sandbox": "process"
}
This exists for telling a container problem apart from an extension problem. It
costs you layer 0 and pins nothing about the runtime, so node on your PATH
then has to be 22.13 or newer.
What deco does not pass
--user. Under rootless Podman the container’s root is already your own
unprivileged uid, and naming a uid there maps it into a subordinate range that
cannot read the bind mounts — so the flag would break the common case while adding
nothing to it.
An extension inside the container sees deco’s own two variables, the five the Node
image sets in its own layers (PATH, HOME, HOSTNAME, NODE_VERSION,
YARN_VERSION), and — under Podman — container=podman, which OCI runtimes set
so software inside can tell where it is. Nothing from deco’s environment crosses,
which is checked by starting a real container and asking the extension to report
what it can see.
What the broker enforces
- Deny by default. A capability the manifest never declared is refused outright and never offered to you. Consent cannot be manufactured at request time by an extension that did not say up front what it wanted.
- Declaration is a ceiling, not a grant. A declared capability still needs a decision — remembered, prompted for, or refused by policy.
- Scopes are checked on resolved paths, so
workspaceaccess cannot be walked out of with.., and/project-secretsdoes not pass as a child of/project.
Capabilities: readFile, writeFile, process, network, env, clipboard,
secrets, openExternal. Path scopes: workspace, extensionStorage,
extensionInstall.
A refusal says which of these it was — undeclared, denied by you, denied by policy, or outside the declared scope — because “permission denied” without the reason is not something you can act on.
Declaring capabilities
In a deco section of package.json, which VS Code ignores:
{
"name": "my-extension",
"main": "./out/extension.js",
"deco": {
"capabilities": [
{ "capability": "readFile", "scope": { "kind": "workspace" } },
{ "capability": "network", "host": "*.example.com" }
]
}
}
extensions.permissions.default chooses what an undecided-but-declared capability
does:
| Value | Behaviour |
|---|---|
prompt |
Ask once, remember the answer (the default) |
deny |
Refuse silently — right for shared machines and CI |
allow |
The declaration becomes the only check |
The honest trade-off
An extension written for VS Code declares nothing, so under deco it starts with no capabilities and will break wherever it reaches for the filesystem or the network. deco does not guess a declaration on its behalf. The alternative to breaking it is granting it everything silently, which is the thing this design exists to avoid.
Starting one
deco_ext::connection is the layer between the command line and the protocol:
Host::spawn starts the process described by
build_spec, and one JSON object per line travels each
way.
Newline-delimited rather than the Language Server Protocol’s Content-Length framing.
There is no specification to match here — both ends are deco’s — and a newline is a
position a reader can resynchronise from, so an unreadable line costs one message
rather than the rest of the stream.
The program must be an absolute path. The host’s environment is built from nothing,
so it carries no PATH for the operating system to search, and a bare node fails as
“no such file” — true, and no help to whoever configured it. Host::spawn refuses it
by name instead. That one was found by writing the round-trip test below and reading
the error it gave.
dispatch is the only way in
Every inbound request goes through one function, and it is a pure function of the broker and the request so that every path through it is testable without a process. It fails closed twice:
- a method [
required_capability] does not recognise is refused as unknown, so a host built from a newer deco cannot reach an older one’s editor surface by naming something it has never heard of; - a capability the manifest never declared is refused by the broker whatever the user has agreed to since — the declaration is a ceiling, not a starting point.
Registering a command, showing a message and appending to the log need no declaration at all: they only touch state deco already owns and shows to the user. So the extension in the round-trip test declares nothing and still works, which is the shape most extensions should have.
Tested against the real host, and against no host
The connection’s own tests drive it over a Cursor or a channel, because the Rust
suite has to run where there is no Node — under Wine, for one.
crates/deco-ext/tests/host_round_trip.rs is the other half: it starts the real
extension-host with the real node, activates a real extension, and watches
commands.registerCommand arrive and pass the capability seam. It is #[ignore]d so
cargo test stays portable, and cargo xtask host-test runs it — the same command CI
runs in the one job that installs Node.
One of its tests asserts the environment of the running process rather than of
the spec: the extension reports every variable it can see, and anything but deco’s own
two is a failure. An extension that could read $GITHUB_TOKEN would make every other
guard here moot, so it is worth checking against a process and not against a BTreeMap.
A third test does the same thing inside the container, against the pinned image.
It is the only check that the digest deco ships still resolves to a working Node, and
that everything else — the mounts, the translated paths, --permission with container
roots, the vscode shim — agrees once the filesystem is not the machine’s own.
cargo xtask host-test selects it only when Podman or Docker is on the PATH, and
prints which of the two it decided: a test that quietly does not run looks exactly
like a test that passed.
That test is also where the claim above got more honest. It was written asserting the
container hands the extension nothing but deco’s two variables, and it failed: an image
sets variables in its own layers, so PATH, HOME, HOSTNAME, NODE_VERSION and
YARN_VERSION are there too. None of them come from deco, which is the part that
matters — but the assertion now names the exact set rather than the set that would have
been tidier.
Which extensions start, and when
deco_ext::catalogue is the decision: given what is installed and something that
happened, which extensions should be running. It is pure — the directory walk belongs
to the frontend, the same way finding themes does — and it holds three rules worth
stating.
Only code extensions activate. An extension with no main never starts a process
at all. A theme’s "activationEvents": ["*"] fires for nothing, because there is
nothing to fire: the whole sandbox would be spent starting a process to read a JSON
file. That is why a marketplace theme works in deco today.
A contributed command activates its extension, with or without onCommand:. VS
Code stopped requiring the declaration in 1.74, and the reason to follow it is not
compatibility: a palette entry that does nothing is worse than either alternative, and
the trigger is the user naming the command. An empty activationEvents is still not a
wildcard — such an extension activates only through its own commands.
An activation event deco does not understand fires for nothing. Activation is a
security control before it is a performance one: an extension that has not activated
has no process, so no request it could make exists. Treating an unknown event as *
would turn every future VS Code event into a startup activation.
Collisions are reported rather than resolved silently. The same extension installed twice keeps the first copy; a command contributed by two extensions stays with the first, and the second is told so — otherwise the loser looks broken with the reason visible nowhere.
Running an extension’s command
commands.registerCommand goes one way: the extension tells deco a name. Running it
goes the other, as $/executeCommand, and Host::execute_command is that call. The
reply carries whatever the extension’s callback returned; a command the host does not
have is an error reply naming it, not a dropped connection.
This path existed in the vscode shim from the beginning and nothing had ever asked
it to run. It works — the round-trip test now activates the fixture, calls
roundTrip.hello, and asserts "hello from the host" comes back — and the shim’s
command registry has tests of its own now, which it did not before: arguments in
order, async callbacks awaited, a dispose()d command no longer callable, a throwing
command reported without ending the session.
What an extension can reach, today
Commands, messages, the filesystem, and edits — all of which only touch state deco already owns and shows you:
| Call | What happens |
|---|---|
commands.registerCommand |
Recorded, so the command can be run |
window.showInformationMessage and its warning, error and status-bar siblings |
The message reaches the status bar |
log.append |
Kept in deco’s own record of what extensions did |
fs.readFile and fs.writeFile |
The file, read or written where the session’s files are — over the connection in a remote session |
fs.stat and fs.readDirectory |
What something is and what is directly in it, from the same place, in VS Code’s own FileStat and [name, type] shapes |
fs.createDirectory, fs.delete, fs.rename, fs.copy |
The change, made where the session’s files are |
workspace.applyEdit |
The edits, applied to the open document’s buffer when it is open, and to the file when it is not |
Everything else is refused by name. An extension that asks to spawn a process gets an error saying deco does not implement it yet — not a fake exit code, not an empty list of open editors. An extension told “no” can cope; one told “here is your empty answer” cannot, and neither can the person reading its behaviour.
A capability the manifest declared and nobody has ruled on is asked about. The extension waits — its request is held rather than answered — and the question names the extension and what it wants in words: “Acme Tools wants to read files under /home/u/project/notes.txt”, not a Rust value. The answer is remembered — written down, refusals included, as below — because a refusal that is not remembered is a prompt loop, and a prompt loop is how someone ends up allowing something to make it stop.
Only one question is open at a time. A second extension asking while one is on screen is refused, with that as the reason: a queue would mean answering about a request that was abandoned long before anyone read it.
A decision can be taken back. Extensions: Forget a Permission Decision in the
command palette lists what has been decided — “Acme Tools: refused — read files
under /home/u/project/notes.txt” — and choosing one makes that extension ask
again the next time it wants it. Without that, a deny chosen in a hurry means
the extension quietly fails from then on — and now that decisions are written
down, “then on” outlives the session — with nothing to undo it and no hint that a
decision is the reason.
Where a decision lives
permissions.json, next to settings.json, 0600 on Unix. Nothing in it is
secret; what matters is the other direction — anything that can write that file
can grant capabilities to code that runs as you, so it must not be one another
account can edit. It is written after every answer rather than at shutdown,
because being killed is an ordinary way for an editor to end.
A stored decision is a decision about one version of one extension. Each entry records the version it was made for, and after an update the user is asked again — with the reason said, so it does not look like deco forgot. A grant on disk otherwise outlives the reason it was given: an extension allowed to read the workspace at 1.0.0 is different code at 1.1.0, and carrying the answer across would be allowing something without having seen what it now does. An update therefore costs a prompt, which is the intended price.
A damaged permissions file is reported and treated as empty. Refusing to start an editor over one would be the worse failure; being asked again is recoverable.
deco --print-config prints which sandbox you would get, including the resolved
runtime and the pinned image — or the reason there is none, which is the state in
which extensions refuse to start.
What is still not connected
The table above is the whole mediated surface. No activation on opening a file or
on startup. No editor state, no quick pick, no
tree views, no webviews, no debug adapters. process, net, env, secrets and
openExternal are declared and brokered and then refused by name at the last
step, because nothing implements them.
An edit goes through the editor, not past it
workspace.applyEdit takes a path and a list of LSP-shaped edits, and where they
land depends on whether that file is open:
- Open — in any tab, not only the one on screen — and the edits go to its buffer. They become one undo step, the document goes unsaved, and nothing is written. That is VS Code’s behaviour and it is also the only correct one: a document with unsaved changes would overwrite anything written past it the next time it was saved, so an edit that went to the file would be an edit that silently did not happen.
- Not open, and the file is the document: read, edited and written through the same connection as everything else.
Overlapping edits are refused rather than guessed at, exactly as they are when a language server sends them: the specification forbids them, so a sender that emits them is broken, and picking one to honour would corrupt the file quietly. An empty list is a success — an extension that computed no changes has not failed.
What a write refuses
useTrashis refused, not ignored. deco has no trash, and an extension that asked for something recoverable and got an unrecoverable deletion instead would have no way to tell.- A directory with anything in it needs
recursive, which is the caller’s own word. Nothing here supplies it for them, because that is the difference between “delete this” and “delete everything under this”. - A rename or copy is checked at both ends. The broker checks the target, which is the one capability a request can carry; the source is a write too — moving a file out of a directory changes that directory — so deco checks it as well, and it has to be already covered rather than newly asked about.
- A link is removed as a link. Deleting resolves the path only to check where it is, then removes the name that was given: resolving and then deleting would remove the file a link points at and leave the link, which is the wrong file and says nothing about it. On the remote a link pointing out of the workspace cannot be deleted through at all — every path there is confined after being canonicalised, and one exception is how a rule stops being one.
A symbolic link is reported as a link rather than as what it points at — 65 for a link to a file, in VS Code’s numbering — because following one is how a listing starts describing files outside the scope that was granted.
Zero npm dependencies
The extension host has no node_modules at all, and a test in
extension-host/test/dependencies.test.js asserts it: package.json declares no
dependencies, and nothing under src/ requires a non-builtin. A sandbox whose own
supply chain is unbounded is not a sandbox.