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
- Manifest — declared by the plugin author in
package.jsonunder theditherblock. Seeds install-time defaults; not an enforcement boundary, just a description. - 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. - Globals — managed env values shared across plugins, stored in
<config-dir>/env.json. A plugin granted--allow-env OPENAI_API_KEYreads the current global value each run; rotate once and every plugin sees it next run. Seedither env.
The grant kinds
Each grant in <config-dir>/grants/<name>.json is one of these:
env— literal values: a plainRecord<string, string>via--env "KEY=VALUE,...", written intoinput.jsonand read asinput.env.KEY. Not exposed as process env vars.envRefs— references to globals: names in<config-dir>/env.jsonthe plugin may read, granted via--allow-env KEY,KEY2. Each run, dither resolves each name to its current global value intoinput.env; an unset global means the plugin sees nothing.files— granted paths:Record<string, string>ofid → absolute pathvia--file "ID=PATH,...". Each path is added to Deno's--allow-readand written intoinput.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*.mdto its run dir, but dither inspects each file'scollection: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. Acreategrant only lets a plugin write entries it owns; overwriting an entry owned by a differentsource:additionally needs aneditgrant 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.tsCan: 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:
- The file has a YAML frontmatter block.
source:matches the plugin's package name — plugins cannot impersonate other plugins.collection:is set, passes path validation (no.., no leading/trailing/, allowed charset, no.mdsuffix), and matches a pattern ingrants.create.- If the destination file already exists and was written by a different
source:, a pattern ingrants.editmust 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>.jsonandenv.jsonare 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 viajoin(runDir, name). - Promote does a blind copy. A plugin emitting
notes.mdinto collectionnotessilently overwrites a hand-authored<library>/notes/notes.mdwith 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-netto Deno for a sole*entry. Audit a manifest's declarednetbefore granting, or pass an explicit--allow-net host1,host2to 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.