ADR-0019 — Third-party plugins from the consumer repo: path-loading gated by a hash-acknowledged trust store¶
- Status: Superseded by
ADR-0024 (2026-08-29) — was
Accepted (2026-07-01), and never implemented: there is no
[plugins].load, no trust store and nofy plugins trust. The two built-ins whose migration was to be the API-sufficiency proof turned out to implement almost nothing butaxes()— data wearing a class — so ADR-0024 makes consumer axes a declarative table instead, and the gate has no customer left to gate. Along the way (2026-08-08) it was narrowed by ADR-0023 for host-executed credential code specifically; ADR-0024 closes the remainder. Kept below in full, unedited, as the public record of a design reasoned through and then found unnecessary. If a genuinely bespoke host-side mechanism ever appears, start from ADR-0023's rejected alternatives rather than from here — they carry the corrected shape (ack by copying the bytes out of the mount, trust the snapshot, never re-read the working tree). - Sources: spinout-plan D3 (+ Phase 2)
Context¶
The plugin hook set is complete (axes, daemons, derive_env, compose_overlays, mode_issues,
stage_assets, doctor, verify, box_args, box_bootstrap, tui_panels, proxy_rules, doctor_fixes) and
the per-consumer registry makes membership config-driven. But third-party discovery exists only
via the foldyard.plugins entry-point group — the plugin must be an installed distribution,
and since foldyard is a uv tool, installed into foldyard's tool venv (uv tool install foldyard
--with their-plugin). There is no way to load a plugin from a file in the consumer's own repo.
That is the gap between today and "people write their own plugins in their own repo without
contributing back".
The obvious fix — point config at a repo file and import it — collides head-on with foldyard's
threat model. Plugin code runs host-side: in the supervisor process, next to the credential
minters and host.env. Meanwhile the repo contents are exactly what foldyard treats as
untrusted — the repo is what gets mounted into the box, where agents edit it freely. A
malicious branch (a compromised dependency's postinstall, a poisoned PR an agent was asked to
review, an agent gone wrong) could ship a "plugin" that the supervisor then imports with full
host privileges: a credential exfiltrator sitting beside the minters. That is the precise
supply-chain attack the product exists to stop, pointed at itself. So the trust gate below is
load-bearing, not paranoia.
Decision¶
Add repo-relative path loading, gated by a hash-acknowledged trust store (the direnv model):
- Declaration:
[plugins].load = ["tools/my_plugin.py:MyPlugin", …]infoldyard.toml, repo-relative paths, resolved byload_plugins()(src/foldyard/plugins/__init__.py) alongside the entry-point tier. - Trust gate: a plugin file that is new or whose content changed is refused with a
clear message — foldyard never imports it, not even to inspect it (an import executes module
top-level code; "look before trusting" would be the vulnerability). Trust is granted only by
an explicit
fy plugins trust, which recordspath + sha256under~/.foldyard/<project>/trusted-plugins.json— host-side state, deliberately outside the repo mount. - Host-only granting:
fy plugins trustrequires a host-side TTY; the box mirror can never grant trust. Otherwise an agent in the box could trust its own exfiltrator — the gate must sit on the side of the boundary the attacker can't reach. - Any change re-refuses: editing a trusted plugin (including by branch switch) drops it back
to refused until re-acked, exactly like
direnv allowafter an.envrcedit. - Isolated loads: a plugin that throws at load is reported and skipped; it cannot take the hot path down — the same guarantee entry-point plugins already have.
- Two tiers, both documented in the plugin guide: entry-point plugins remain the "installed = trusted" tier (installing a distribution into the tool venv is already an explicit, host-side act); path-loaded plugins are the "acked-by-hash" tier.
Phase 2 makes the model prove itself: the two Tangible-shaped built-ins (auth0_sim, llm)
migrate out of the package into Tangible as path-loaded, trust-acked plugins. If they can't live
outside the package, the plugin API has a hole to fix pre-launch — the migration is the
API-sufficiency proof, not an optional fast-follow.
Consequences¶
- Consumers can keep private plugins private — written, versioned, and reviewed in their own repo — without publishing a distribution or forking foldyard.
- One deliberate friction point: after every plugin edit (or a branch switch that changes one),
the next
fyinvocation refuses untilfy plugins trustis re-run on the host. This is the direnv trade and it is accepted; a doctor check surfaces untrusted/changed plugin files so the refusal is diagnosable at a glance. - The trust store is per-project and host-side, so a repo clone on a new machine starts with zero trusted plugins — correct: trust is a property of an operator on a host, not of the repo.
- The supervisor's launch paths must resolve trust before import, and the refusal must be loud in daemons too (a silently skipped plugin whose daemon should be running is a posture bug).
Rejected alternatives¶
- Per-hook capability tiering (e.g. untrusted plugins may contribute axes but not daemons).
Nearly every hook is host-powerful:
daemonsis host-side command execution by definition,box_argscan mount arbitrary host paths into the box,derive_env/proxy_rulesshape what the credential proxy injects where. A capability matrix would be a fiction of granularity over a surface that is almost uniformly privileged — whole-plugin trust is the honest boundary. - Ungated path loading ("the user declared it in their own config, that's consent") — the config file travels with the repo; the branch is the attacker. Consent recorded in the attacker-writable surface is not consent.
- Import-then-inspect (load the module, examine it, decide) — importing is executing; there is no safe inspection of untrusted Python by the process holding credentials.