dither
Concepts

Security model

The grants model, the Deno sandbox, and the honest v0 caveats.

dither plugins are not trusted code running with your full user privileges. Each plugin runs as a deno subprocess with permission flags derived from a per-plugin grants record — and only what you explicitly granted gets through.

Three layers

The model has three distinct layers. Keeping them straight is the easiest way to reason about what a plugin can and can't do.

  1. Manifest — declared by the plugin author in package.json under the dither block. It describes what the plugin would like and seeds the install-time defaults. It is not an enforcement boundary; it's a description.
  2. Grants — written by dither at install time to <dither-home>/grants/<name>.json. This is the actual allowance for this plugin on this machine, and the only thing that gates promote at run time. When you pass a --allow-* flag at install, that's the grant; when you don't, the manifest's declaration is used as the default. The user is the source of truth — the manifest cannot constrain a grant the user explicitly authorized.
  3. Globals — managed env values shared across plugins, stored in <dither-home>/env.json. A plugin granted --allow-env OPENAI_API_KEY reads the current global value at every run. Rotate the global once; every plugin sees the new value next run. See dither env.

The user is the only one who can move a value from the manifest into a grant. Plugins request; you authorize.

The four grant kinds

Each grant in <dither-home>/grants/<name>.json is one of these:

env — literal env values

A plain Record<string, string> of values you supplied via --env "KEY=VALUE,...". These are written into the run's input.json so the plugin reads them through input.env.KEY. They're not exposed to the plugin as actual process env vars.

envRefs — references to globals

A list of names in <dither-home>/env.json the plugin is allowed to read. Granted via --allow-env KEY,KEY2. At every run, dither resolves each name to its current global value and adds it to input.env. If the global isn't set, the plugin sees nothing for that key.

files — granted file/folder paths

A Record<string, string> of id → absolute path pairs supplied via --file "ID=PATH,...". Each path is added to Deno's --allow-read list, and written into input.files so the plugin can resolve it. Granting a file is the same act as authorizing read access.

net — host allowlist

A list of hostnames supplied via --allow-net HOST,HOST2. Becomes Deno's --allow-net=.... If empty, no --allow-net flag is passed at all and the plugin has zero network access.

collections — promote allowlist

A list of collection names supplied via --allow-collection NAME,NAME2. Enforced at promote time, not at write time: a plugin can write any *.md it likes to its run dir; dither inspects each file's collection: frontmatter after the run and rejects the whole batch if any file targets a collection outside this list.

What the Deno sandbox actually gets

When dither runs a plugin, it spawns Deno roughly like this:

deno run \
  --import-map=<run-dir>/_import-map.json \
  --allow-read=<plugin-dir>,<run-dir>,<sdk-path>[,<grants.files paths>…] \
  --allow-write=<plugin-dir>/state,<run-dir> \
  --allow-env=DITHER_RUN_DIR,DITHER_INPUT_FILE,DITHER_STATE_FILE,DITHER_TRIGGER,DITHER_PLUGIN_NAME \
  [--allow-net=<grants.net>…] \
  <plugin-dir>/plugin.ts

A plugin can:

  • Read its own installed source dir, its run scratch dir, and any path in grants.files.
  • Write to its run scratch dir and to its own state/ folder.
  • Read the always-granted DITHER_* env vars (only — see below).
  • Reach the network only on hosts in grants.net.

A plugin cannot:

  • Read or write anywhere else under dither home or the library — including other collections, grants/, env.json, other plugins, or the qmd index.
  • Read arbitrary user files. File access only extends to paths you explicitly granted.
  • Reach a host that isn't in grants.net.
  • Read process environment variables. The --allow-env flag is restricted to the DITHER_* set used by the SDK to find its run dir. Granted env values flow through input.env, not through Deno.env.
  • Spawn subprocesses or load FFI. --allow-run and --allow-ffi are never passed.

Default-grant from manifest

If you install (or run <path>) without passing a grant flag, dither defaults the corresponding grant to everything the manifest declared for that field. Pass --allow-net api.openai.com for a manifest declaring two hosts and that's the grant — only one. Pass nothing and you get both.

When you do pass a flag, that's what the plugin gets — the manifest is not a ceiling. If you want to grant a collection or net host the manifest didn't declare (e.g. you trust this plugin further than the author's defaults), the flag is the way. The grants file is always the source of truth at promote.

Promote-time validation

After the plugin exits, dither walks every *.md file in the run dir and checks:

  1. The file has a YAML frontmatter block.
  2. source: matches the plugin's package name. Plugins cannot impersonate other plugins.
  3. collection: is set, passes path validation (no .., no leading/trailing /, allowed charset, no .md suffix), and matches at least one glob pattern in grants.collections.

Any failure throws and stops the promote. The run dir is removed regardless.

Run vs install grants

dither plugin install writes grants persistently. dither plugin run <name> with grant flags layers them on top for that run only — they don't mutate grants/<name>.json. dither plugin run <path> re-installs (the path-form is "install + run") and does persist the supplied flags as the new grants.

Honest caveats (real today, not future polish)

  • Env values and globals are stored plaintext. Both <dither-home>/grants/<name>.json and <dither-home>/env.json are plain JSON on local disk, scoped by Unix permissions. The architecture commits to OS keychain integration (macOS Keychain, libsecret on Linux); none today. Anything that can read your home dir can read your secrets.
  • Plugin output filenames are not validated for path traversal. A malicious plugin can emit a file with a filename like "../foo.md" and the SDK's join(runDir, name) will write outside the run dir. Don't run plugins you don't trust.
  • Promote does a blind copy. If a plugin emits notes.md for collection notes and you already have a hand-authored <library>/notes/notes.md, the plugin's file overwrites yours with no warning. Back up the library before installing a plugin that writes into a collection you also author into by hand.
  • CLI flag values are comma-separated and not escaped. A grant value containing , or = cannot be expressed through --env/--file etc. and needs the programmatic API. The schema doesn't reject pathological values.
  • grants.net = ["*"] grants any host. The runner detects the sole * entry and passes a bare --allow-net flag to Deno, which is unrestricted. Audit a manifest's declared net before granting; if you want to narrow it, pass an explicit --allow-net host1,host2 instead of relying on the manifest default.

Threat model

dither assumes you trust plugin code at install time. The sandbox limits how much damage a misbehaving plugin can do — it cannot read your SSH keys, browse your Downloads folder, or talk to a host you didn't grant. But the v0 sandbox is not airtight: the caveats above are real escape hatches, not theoretical ones.

Treat plugin installation the way you'd treat installing an npm package or a VS Code extension. Read the source, or only install from authors you trust. Don't paste random dither plugin install … commands from the internet.