Synced verbatim from
fornax-coreat build time — edit it there, not here.
ADR 0023: External adapter registry — declarative manifests, no executable plugin boundary
Status: Accepted Date: 2026-10-02 Jira: HORO-1619, FORNX-428 (S4 design, S6 implementation)
Context
FORNX-428 S2/S3 replaced seven hand-maintained match tables with
crates/fornax-cli/src/adapter_registry.rs: one AdapterPlugin trait, one
registry() array, and AdapterArg parsing straight through resolve().
Adding a built-in adapter is now one struct plus one registry() entry.
That is not enough for HORO-1619 AC#3 ("a third test adapter can be registered without rebuilding Fornax") or AC#11's anti-vacuity demand. Both require an adapter to enter the registry at runtime, from data on disk.
Note on naming: this ADR's AdapterPlugin is the host-configuration
install boundary in fornax-cli. It is a different concept from ADR-0004's
AgentAdapter, the observation/normalization boundary in fornax-types.
Two unrelated traits, both historically called "adapter". ADR-0004 is
unchanged by this ADR; docs/contributing/adding-an-adapter.md continues to
document that other concept.
Three candidate plugin boundaries
- In-process dynamic library loading (
dlopen/libloading). Rejected. It grants arbitrary native code the full address space of a process that holds the user's audit ledger and policy cache, has no containment story short of OS sandboxing Fornax does not have, and HORO-1619 forbids it absent an explicit security review proving it correct. No such proof exists, and the need does not justify producing one. - Subprocess/protocol boundary (manifest declares an executable; Fornax
spawns it and speaks a versioned protocol over stdio). Rejected on a
hard, pre-existing repo invariant:
crates/fornax-daemon/tests/adversarial_daemon_input.rs:: subprocess_surface_is_still_zero_in_production_code(FORNX-238) scans every non-tests/.rsfile undercrates/and fails on any occurrence ofprocess::Command,Command::new, orsh -c. ADR-0004 already treated this as binding when it routed git access throughfornax-vcs(pure-Rustgix) rather than shelling out, and ADR-0016/ADR-0022 reason from the same invariant. Introducing a subprocess as the plugin mechanism would make Fornax's most load-bearing security invariant the first casualty of its extensibility story. - Declarative data-only manifest. Chosen.
What an install actually is
Both built-ins do the same narrow thing. Claude Code: ensure a hook group
carrying command: "fornax-hook-claude" exists under
hooks.<event>[] in ~/.claude/settings.json, idempotently, refusing to
overwrite a value of unexpected shape, writing via temp-file + rename.
Codex: ensure a notify entry bearing the fornax-codex-notify.sh marker
in ~/.codex/config.toml. Uninstall removes exactly the marker-bearing
entries and prunes containers it emptied.
That is not a general-purpose capability. It is one operation: ensure a marker-bearing element exists in a declared container, and be able to remove exactly it again. It is expressible as data, so an external adapter needs no code — and therefore no code-execution boundary.
Decision
D1. External adapters are data. Nothing in a manifest is ever executed.
There is no executable, entrypoint, command, or script field in
the manifest schema, at any version, by design. This is a deliberate
omission from HORO-1619's "fields to consider" list: a field named
executable that is never executed is a trap for the next maintainer, and
the first bug report asking "why doesn't my entrypoint run" would be
answered by adding execution. The schema is deny_unknown_fields, so a
manifest carrying such a field is a parse failure naming the field, not a
silently-ignored key.
Consequence: every HORO-1619 security requirement phrased in terms of
execution ("bounded subprocess execution/timeouts", "sanitized
environment", "no silent PATH-wide plugin execution", "no execution during
--help") is satisfied by the absence of an execution surface, not by a
mitigation that could regress. subprocess_surface_is_still_zero_in_ production_code is the standing proof, and it runs on every CI build.
D2. Manifest schema v1
A single JSON file. schema_version is gated through this repo's existing
#[serde(try_from = "...Wire")] + SUPPORTED_MANIFEST_SCHEMA_VERSIONS
pattern (as in fornax-types/src/audit.rs), so an unsupported version is a
specific, named error rather than a confusing field-level parse failure.
The wire struct and every nested struct are #[serde(deny_unknown_fields)].
{
"schema_version": 1,
"id": "acme-agent",
"display_name": "Acme Agent",
"summary": "Wires the Fornax hook into Acme Agent's settings.json.",
"min_fornax_version": "0.0.8",
"provenance": "https://github.com/acme/fornax-adapter-acme",
"capabilities": ["plan", "install", "uninstall"],
"target": { "format": "json", "path": "~/.acme/settings.json" },
"operations": [
{
"kind": "ensure_marked_array_element",
"pointer": "/hooks/PostToolUse",
"marker_key": "command",
"marker_value": "fornax-hook-acme",
"element": { "type": "command", "command": "fornax-hook-acme" }
}
]
}
| Field | Type | Req | Rule |
|---|---|---|---|
schema_version | u32 | yes | must be in SUPPORTED_MANIFEST_SCHEMA_VERSIONS (&[1]) |
id | String | yes | 3..=64 chars, ^[a-z0-9][a-z0-9-]*[a-z0-9]$ (ASCII only — no unicode, no uppercase, so no homoglyph/case-fold spoof of a built-in id); must not collide with a built-in or another entry |
display_name | String | yes | 1..=64 chars, no control chars, no newline |
summary | String | yes | 1..=200 chars, no control chars, no newline |
min_fornax_version | String | yes | major.minor.patch of plain integers; parsed to (u64,u64,u64) and compared against env!("CARGO_PKG_VERSION"). Deliberately not semver-range syntax — avoids a new dependency and avoids range semantics nobody asked for |
provenance | String | yes | 1..=256 chars, no control chars. Display-only, unverified, and labelled as such in adapter info output |
capabilities | Vec<Capability> | yes | non-empty subset of plan | install | uninstall; duplicates rejected. install or uninstall requires operations non-empty |
target.format | enum | yes | "json" only in v1. "toml" is a clean "unsupported target format" rejection today and a purely additive v1 change later |
target.path | String | yes | see D6 containment rules |
operations | Vec<Operation> | yes | 1..=16 entries; (pointer, marker_key, marker_value) must be unique within the manifest |
Deliberately absent fields, against HORO-1619's "fields to consider":
executable/entrypoint— see D1. Never.aliases— deferred. An alias namespace is a second place ids can collide with built-ins and with each other, for zero current demand.resolve()stays a single-key lookup.supported_host_tools— subsumed. The host tool istarget.pathplusdisplay_name; a separate free-text field would be unverifiable duplication.enabled— not a manifest field. Enabled state is Fornax-owned registry state (D3), not something a manifest asserts about itself.
D3. One operation kind, not a DSL
enum Operation {
EnsureMarkedArrayElement {
pointer: String, // RFC 6901 JSON pointer to a JSON array
marker_key: String, // key within each array element
marker_value: String, // the Fornax-owned marker value
element: serde_json::Value, // object to insert; element[marker_key] must == marker_value
},
}
Semantics mirror claude_adapter::install_claude_hooks/
uninstall_claude_hooks exactly:
- plan/install — resolve
pointer, creating missing intermediate objects only; if any existing intermediate value is not an object, or the terminal value exists and is not an array, refuse and error rather than overwrite (the existing non-clobber rule, unchanged). If no element already satisfieselement[marker_key] == marker_value, appendelement. Otherwise no change. - uninstall — remove every element where
element[marker_key] == marker_value; prune containers emptied by this removal only; never remove a container that still holds another tool's entry. changedis computed bybefore != afteron the parsed document, so idempotence is structural, not asserted.
element[marker_key] is validated at parse time to equal marker_value.
Without that, a manifest could install an entry it can never uninstall —
a one-way mutation dressed as a reversible one.
This is the only operation kind. Adding a second requires an ADR amendment. Stated explicitly so the next ticket does not grow a config-mutation DSL by accretion.
D4. Discovery: exactly one Fornax-owned directory
$FORNAX_HOME/adapters/ (default ~/.fornax/adapters/), containing:
registry.json— the Fornax-owned index (below)<id>.manifest.json— Fornax's own copy of each registered manifest
No PATH scan, no XDG directory chain, no CWD, no
environment variable listing extra directories, no recursion. A manifest
file merely present in that directory is not registered and is never read:
only entries in registry.json are loaded. There is no filesystem-presence
path to registration — only fornax adapter register.
Read paths never create this directory. adapter list on a machine with
no $FORNAX_HOME/adapters/ reports only built-ins and creates nothing. Only
register creates the directory.
{
"schema_version": 1,
"entries": [
{
"id": "acme-agent",
"manifest_file": "acme-agent.manifest.json",
"digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"source_path": "/Users/x/src/acme/acme.manifest.json",
"registered_at": "2026-10-02T11:04:19Z",
"enabled": true
}
]
}
source_path is recorded for provenance display only. It is never read
after registration. All loading reads manifest_file inside
$FORNAX_HOME/adapters/, which removes the entire class of "user's manifest
file changed under us between invocations".
D5. Trust model
What the digest pin is. digest is sha256:<64 lowercase hex> over the
raw bytes of the manifest file, not over a canonicalized parse. Hashing
raw bytes means the pin covers exactly what was shown to the user, including
whitespace and key order — a canonical-form hash would let two
byte-different files share a pin.
Register is a two-invocation ceremony. Invocation 1 reviews and refuses;
invocation 2 registers. Within each invocation the source file's bytes are
read exactly once into memory, and the digest, the parse, the displayed
summary, and the bytes copied to the owned location are all derived from
that same in-memory buffer — never from a second read of the path. Across
the two invocations, invocation 2 necessarily re-reads the source path; the
digest passed to --confirm-digest is precisely what makes that re-read
safe, because a file swapped between the two invocations produces a
different digest and is refused by name. This is the mechanism, not a
ceremony: without the flag the second read would be an unchecked TOCTOU
window.
Load-time verification. On every registry() load, each enabled entry's
owned copy is read and re-digested; a mismatch against the pin rejects that
entry with reason digest mismatch and does not load it. This catches
drift, truncation, partial writes, and clumsy hand-editing of the owned
copy.
What the pin is not. registry.json and the owned manifest copies live
in the same directory and the same trust domain. An attacker who can write
$FORNAX_HOME/adapters/ can update both the copy and its pin consistently,
so the pin is an integrity check against drift and tampering-by-accident,
not an authenticity boundary against a local attacker with write access to
$FORNAX_HOME. That threat is out of scope because such an attacker can
replace the fornax binary itself, and no in-process check survives that.
Authenticity against an untrusted publisher would require signature
verification against a key Fornax trusts — deliberately not built here: this
is a local-only CLI with no distribution channel for third-party adapters,
so there is no publisher to authenticate. If such a channel ever exists,
ADR-0007's signed-bundle machinery is the precedent to extend, not this
digest field. This paragraph exists so no future reader mistakes the pin for
a trust boundary it is not.
$FORNAX_HOME/adapters/ permissions. Directory 0700, files 0600, on
unix, set at creation — matching codex_adapter::save_codex_config's
existing reasoning about not widening a sensitive file to the umask default.
Source-path permission check. register additionally refuses a source
manifest whose file, or whose containing directory, is group- or
world-writable (mode & 0o022 != 0), checked before the source is ever
read. This is a precondition on who may plausibly have written the
file being registered, not a replacement for the digest pin above:
the digest proves the bytes didn't change between the review and
confirm invocations; this check narrows who could have written those
bytes in the first place.
D6. target.path containment
target.path is the one genuinely dangerous field: it is a file-write
primitive with a manifest-chosen destination, where both built-ins had their
paths hardcoded in Rust. Containment is structural, applied at parse time
and again immediately before any write:
- A single leading
~/is expanded to$HOME;~elsewhere, or~user, is rejected. No other variable or tilde expansion of any kind. - The resolved path must be absolute and must have
$HOMEas a prefix after normalization. Anything outside$HOMEis rejected. - No
..component anywhere, pre- or post-expansion. No empty components. - The final component must have an extension matching
target.format(json), so a manifest cannot target a shell profile, a key file, an authorized_keys, or a binary. - The parent directory must already exist. External adapters get no
create_dir_all— unlikeclaude_adapter::save_settings, which may create its parent because its path is a compile-time constant. Reusing that helper for an external target would hand a manifest a directory-creation primitive on a chosen path. - Immediately before writing,
symlink_metadatathe final component; if it is a symlink, refuse. Fornax writes the file it named, never a link's target. (The built-ins never needed this; with a manifest-chosen path a pre-planted symlink is a real escape.) - The temp file for the atomic write is created in the target's own
parent directory with the target's filename plus a
.fornax-tmpsuffix, so rename stays same-filesystem and the temp path is as contained as the target. It is created withcreate_newso an existing/planted temp path is an error, not a silent overwrite.
These checks run at parse time (so register and adapter list reject a
bad manifest without ever touching the target) and again at write time (so a
path that became a symlink after registration is still caught).
D7. Bounds
Fail closed, with a named limit, on: manifest file larger than 64 KiB;
registry.json larger than 1 MiB or more than 64 entries; more than 16
operations; JSON pointer longer than 256 bytes or deeper than 16 segments;
element serializing to more than 4 KiB; target config file larger than
8 MiB. A data-only design has no runaway-process failure mode, but it does
have parse and write amplification, and an unbounded element written into
a user's real settings.json is a real denial-of-service against their
coding agent.
D8. Duplicate and collision resolution — built-in always wins
registerrejects anidequal to any built-in id, or to any existing entry's id, before writing anything.- Load time re-checks, because a built-in added in a later Fornax release
must not be shadowed by an already-registered external. On collision the
built-in loads and the external is rejected with reason
id collides with built-in adapter. - Rejections are never silent.
adapter listprints aRejected entriessection with each id and reason;adapter info <id>reports the rejection; andAdapterArg::from_stron a registered-but-rejected id returns that specific reason rather than a misleading "unknown adapter". Silent skipping would turn a security rejection into a mystery.
D9. Registry assembly keeps registry()'s signature
registry() continues to return &'static [&'static dyn AdapterPlugin],
now backed by a OnceLock populated once per process: the two built-in
statics first, in display order, then successfully-loaded external adapters
(Box::leak-ed — process-lifetime by construction in a short-lived CLI).
resolve(), AdapterArg, install, uninstall, plan, doctor, and
every Commands/AdapterAction shape in main.rs are unchanged:
registry() was already the single registration point, and it still is. The
only trait change is &'static str → &str on id/display_name/
summary, so a manifest-owned String can be returned; called on a
&'static dyn AdapterPlugin these still yield &'static str, so
AdapterArg's accessors keep their signatures too.
This is the concrete answer to AC#11: adding an external adapter touches no
enum, no parser, and no match.
D10. Lifecycle verbs
register, info, enable, disable, remove operate on
registry.json only. disable sets enabled: false (entry and owned copy
retained, excluded from registry(), still visible to list and info);
remove deletes the entry and its owned copy. Independent per entry, so
AC's "enabled/disabled/removed independently" is a property of the index
shape rather than a special case.
Consequences
- An external adapter can wire a marker-bearing entry into one JSON config
file under
$HOMEand remove it again. It cannot run code, read credentials, touch a second file, write outside$HOME, or mutate a TOML file. A TOML host tool needs a built-in adapter until a future amendment extendstarget.format— a real limitation, accepted: the alternative was doubling the mutation engine in its first slice. registry()performs filesystem reads on first call. It is lazy, never creates anything, and cannot panic: all failures become rejections.--versionand--helpnever call it (help text does not enumerate ids), so AC#8's side-effect-free guarantee is untouched.adapter_registry.rs's existingall_lists_exactly_the_two_known_adapterstest must be rewritten to assert over built-ins via an explicit seam — it would otherwise fail on any developer machine that has registered an external adapter.provenanceis unverified free text, displayed as such. This ADR does not introduce adapter authenticity; see D5.
Security considerations
Mapped to HORO-1619's Security section, mechanism by mechanism.
| Requirement | Mechanism |
|---|---|
| Explicit registration or trusted discovery location | D4: one Fornax-owned directory; only registry.json entries load; presence of a file registers nothing. D5: registration requires --confirm-digest and refuses a group-/world-writable source file or directory. |
| No silent PATH-wide plugin execution | D1 (nothing is executed, ever) + D4 (PATH is never consulted) + the subprocess_surface_is_still_zero_in_production_code invariant as the standing proof. |
No execution during --help/adapter list | D1: no execution surface exists at any time. --help/--version additionally never reach registry(); adapter list reads data and creates nothing (asserted by test). |
| Bounded subprocess execution/timeouts | N/A by construction, with proof, not by assertion. There is no subprocess to bound and no call that can hang; the FORNX-238 invariant test guarantees none can be introduced without failing CI. D7 bounds the surface that does exist: parse size and write amplification. |
| Sanitized environment | N/A by construction — no child process inherits an environment. Manifest path handling performs no environment interpolation at all beyond a single leading ~/ → $HOME (D6.1), so a manifest cannot reference $ANYTHING. |
| Non-destructive ownership-aware config mutation | D3: marker-based, additive, idempotent, refuses to overwrite a value of unexpected shape, uninstall removes only marker-bearing elements and prunes only what it emptied — the same rules uninstall_claude_hooks already enforces. D6.5/6/7: no directory creation, no symlink follow, contained atomic temp+rename. |
| Clear provenance in adapter info | D10/adapter info: source path, registered-at, pinned digest, enabled state, declared capabilities, compat range, and the provenance string explicitly labelled unverified. |
| No credential values in registry/help output | Structural: deny_unknown_fields on every manifest struct means a manifest carrying a token/api_key field fails to parse — there is no field a credential can be stored in, so none can be echoed. All displayed strings are length-capped and control-char-rejected (D2). AdapterActionResult.message never includes config file contents. |
| Reject duplicate/conflicting IDs | D8: rejected at register time and re-checked at load time; built-in always wins; rejections surfaced in list, info, and AdapterArg::from_str. |
| Fail closed on malformed/untrusted descriptors | Every validation failure (schema version, regex, bounds, path containment, digest mismatch, missing owned copy, unsupported target.format) yields a Rejection carrying a reason; the entry does not load, no partial registration is written, and register writes nothing on any error. |
Acceptance criteria coverage
- AC#3 — D4+D9+D10:
fornax adapter register --manifest <path> --confirm-digest <d>adds a working adapter toregistry()with no rebuild. Testexternal_adapter_is_resolvable_after_register. - AC#4 —
list/inforead manifest data only. Nothing is executed (D1), the target config file is not opened bylist/info, and no directory is created. Testsadapter_list_with_external_registered_touches_no_filesystem_state,adapter_info_renders_provenance_without_reading_target_file. - AC#5 —
plan/install/uninstalltouch the target file only when that verb is the explicitly selected operation;plannever writes (same propertyplan_matches_install_without_writingalready asserts for the built-ins). An operation absent fromcapabilitiesis refused before any filesystem access. - AC#6 — D2 (schema/regex/version gating), D6 (path containment), D7 (bounds), D8 (collisions), D5 (digest). Each is a named rejection reason, each has a test.
- AC#9 — this ADR plus
docs/contributing/registering-an-external-adapter.md. - AC#10 — see the S6 implementation plan's test matrix; the "timeout" case is N/A-with-proof per the table above, discharged by an explicit test asserting the manifest wire struct has no execution-bearing field.
- AC#11 — D9:
registry()keeps its signature and remains the single registration point; no enum, parser, ormatchchanges. Testregistering_an_external_adapter_requires_no_cli_enum_change.