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 — only what you explicitly granted gets through.

Three layers

  1. Manifest — declared by the plugin author in package.json under the dither block. Seeds install-time defaults; not an enforcement boundary, just a description.
  2. Grants — written by dither at install time to <config-dir>/grants/<name>.json. The actual allowance for this plugin on this machine, and the only thing that gates promote at run time. A grant flag at install becomes the grant; omitting it falls back to the manifest's declaration. Only the user can move a value from manifest into grant.
  3. Globals — managed env values shared across plugins, stored in <config-dir>/env.json. A plugin granted --allow-env OPENAI_API_KEY reads the current global value each run; rotate once and every plugin sees it next run. See dither env.

The grant kinds

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

  • env — literal values: a plain Record<string, string> via --env "KEY=VALUE,...", written into input.json and read as input.env.KEY. Not exposed as process env vars.
  • envRefs — references to globals: names in <config-dir>/env.json the plugin may read, granted via --allow-env KEY,KEY2. Each run, dither resolves each name to its current global value into input.env; an unset global means the plugin sees nothing.
  • files — granted paths: Record<string, string> of id → absolute path via --file "ID=PATH,...". Each path is added to Deno's --allow-read and written into input.files. Granting a file is authorizing read access.
  • net — host allowlist: hostnames via --allow-net HOST,HOST2, becoming Deno's --allow-net=.... Empty means no flag at all — zero network access.
  • create — promote allowlist: collection globs via --create NAME,NAME2. Enforced at promote time, not write time — a plugin can write any *.md to its run dir, but dither inspects each file's collection: frontmatter afterward and rejects the whole batch if any file targets an uncovered collection. Patterns are globs over the collection-path namespace (notes, feeds/*, messages/**); <X>/** also covers the bare <X>.
  • edit — overwrite allowlist: collection globs via --edit NAME,NAME2. A create grant only lets a plugin write entries it owns; overwriting an entry owned by a different source: additionally needs an edit grant covering that collection, otherwise dither skips it. Grants beyond the manifest are flags-only — the interactive install flow never offers them.

What the Deno sandbox actually gets

deno run \
  --import-map=<run-dir>/_import-map.json \
  --allow-read=<plugin-dir>,<run-dir>,<sdk-path>[,<grants.files paths>…][,<watch roots>…] \
  --allow-write=<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

Can: read its source dir, run scratch dir, grants.files paths, and watch roots; write only to its run scratch dir (state is promoted back after the run); read the always-granted DITHER_* env vars; reach hosts in grants.net.

Cannot: touch anything else under the config dir or library (other collections, grants/, env.json, other plugins, the qmd index); read arbitrary files beyond granted paths; reach an ungranted host; read process env vars (--allow-env covers only the DITHER_* SDK set — granted values flow through input.env, never Deno.env); spawn subprocesses or load FFI (--allow-run/--allow-ffi never passed).

Default-grant from manifest

Installing (or run <path>) without a grant flag defaults that grant to everything the manifest declared for that field — pass --allow-net api.openai.com for a manifest declaring two hosts and you get only one; pass nothing and you get both.

Passing a flag overrides the manifest entirely — it's not a ceiling, so you can grant a host or collection the manifest never declared if you trust the plugin further than its author's defaults.

Grants only change through an install. A plugin whose manifest asks for more than last time must be reinstalled; the interactive review marks each newly-requested entry (new) and unchecked, requiring explicit re-approval.

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 a pattern in grants.create.
  4. If the destination file already exists and was written by a different source:, a pattern in grants.edit must cover the collection.

Failures on 1–3 throw and stop the promote. A failure on 4 skips that one output (journaled as a skip), leaving the existing entry untouched. 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, without mutating grants/<name>.json. dither plugin run <path> re-installs (it's "install + run") and does persist the supplied flags as the new grants.

Honest caveats (real today, not future polish)

  • Plaintext storage. <config-dir>/grants/<name>.json and env.json are plain JSON scoped by Unix permissions — no OS keychain integration yet. Anything that can read your home dir can read your secrets.
  • No path-traversal check on output filenames. A malicious plugin's filename: "../foo.md" will write outside the run dir via join(runDir, name).
  • Promote does a blind copy. A plugin emitting notes.md into collection notes silently overwrites a hand-authored <library>/notes/notes.md with no warning — back up before installing a plugin that writes into a collection you also author by hand.
  • No comma/equals escaping in CLI flags. A grant value containing , or = can't be expressed via --env/--file; the schema doesn't reject pathological values, so use the programmatic API instead.
  • grants.net = ["*"] is unrestricted. The runner passes a bare --allow-net to Deno for a sole * entry. Audit a manifest's declared net before granting, or pass an explicit --allow-net host1,host2 to narrow it.

Threat model

dither assumes you trust plugin code at install time. The sandbox limits the damage a misbehaving plugin can do — it can't read your SSH keys, browse Downloads, or talk to an ungranted host — but the v0 sandbox is not airtight; the caveats above are real escape hatches. Treat plugin installation like installing an npm package or VS Code extension: read the source, install only from authors you trust, and don't paste random dither plugin install … commands from the internet.