plugin add grants derived_names by default and never names it on the screen the operator answers #277

Closed
opened 2026-09-15 19:25:07 +02:00 by buildagent · 1 comment
Member

Found in an adversarial security review during #268. Pre-existing, not introduced by that work. It is the one finding of that review I would act on first, because the threat model rests an argument on a lever that is invisible at the moment it is pulled.

What the threat model claims

_prdoc/guides/80-threat-model.md calls derived_names the whole of §4's weakness, and rests the defence on the operator's lever existing since #103. verify_grant's rule 5 states what the authority is:

a package holding this authority may bind ANY name at any in-range span

Measured

step file behaviour
plugin add with no --grant crates/cli/src/plugin.rs:1914 uses approval::GrantSpec::Requested
Requested resolves the flag crates/indexer/src/approval.rs (requested_derived_names) to whatever the manifest asked for — the comment says so: "#103 IS PART OF 'WHAT IT ASKED FOR'."
the value is written into the record crates/cli/src/plugin.rs:1974-1990 .with_derived_names(...)
the confirmation is rendered crates/indexer/src/confirm.rs ZERO occurrences of derived_names in the entire file
$ grep -c derived_names crates/indexer/src/confirm.rs
0

The Disclosure struct carries grant, withheld, bridge_grant, bridge_withheld, domain, language_profiles, displaces, activation_owner, writer_lock, notes. There is no field for it, and VerifiedGrant::render() returns capabilities and bridges only.

The path

A signed package requests [capabilities] derived_names = true. The operator runs plugin add, reads a block listing two or three capability names plus the MEANS sentence about signatures, and presses the key labelled "approve as requested". They have granted the authority above, and nothing on screen said the word.

--yes requires --grant (plugin.rs:1652), but --grant requested carries it silently too.

Why this is worse than an ordinary disclosure gap

The value is disclosed everywhere except the decision point:

  • plugin status prints derived_names=yes|no (crates/cli/src/plugin.rs:3982-3995)
  • plugin enable --derived-names requires the flag explicitly
  • plugin inspect shows the request

So the information exists, is formatted, and is shown — after the answer has been given. An operator who audits later can see what they granted; an operator answering the prompt cannot see what they are granting.

It also means the threat model's §4 argument is, as written, a claim about a lever rather than about the screen. The lever is real. The screen is where it is pulled.

Scope

No shipped first-party package is affected in the sense of exploiting this — de.h-dv.ruby requests derived_names = true for a real and documented reason (Rails' has_many :posts emits a type ref named Post at the span of the literal :posts), and that request is legitimate. The finding is that a malicious package's identical request is answered by the same keystroke, with the same screen.

Fix

Add derived_names to the Disclosure struct and render it in confirm.rs beside grant/withheld. It is a one-field change to the screen the whole of §4 rests on.

Worth considering alongside it, though each is a separate decision:

  1. Should GrantSpec::Requested grant it at all? Every other high-authority answer in this tree is opt-in. Requested meaning "everything the manifest asked for, including this" is defensible only if the screen names it.
  2. Rendering vocabulary. VerifiedGrant::render() is capability-name-shaped and derived_names is a bool, which is presumably why it never got a row. That is a reason for the omission, not a justification.

crates/abi/src/frame.rs:53-63 argues that a package emitting TAG_SYMBOL_BASENAME declares fact_minor_min = 2 so an older host refuses it rather than storing the guest's placeholder as a name. Nothing enforces the first clause. Manifest::validate refuses fact_minor_min > ABI_MINOR, but no site couples EMISSION of tag 0x8004 to the DECLARATION. A third-party package declaring fact_minor_min = 0 and emitting the tag installs on an ABI_MINOR = 1 host, whose decoder skips the tag and stores the placeholder verbatim — the exact MISLEAD case the bracket is documented as preventing.

de.h-dv.svelte declares it correctly; this is a trap for a well-meaning third-party author, and the threat model states it as a system property when it is a convention. The enforceable point is plugin check's conformance leg, which holds both the ValidatedFacts (hence name_is_basename) and the Manifest.

Found in an adversarial security review during #268. **Pre-existing**, not introduced by that work. It is the one finding of that review I would act on first, because the threat model rests an argument on a lever that is invisible at the moment it is pulled. ## What the threat model claims `_prdoc/guides/80-threat-model.md` calls `derived_names` the whole of §4's weakness, and rests the defence on the operator's lever existing since #103. `verify_grant`'s rule 5 states what the authority is: > a package holding this authority may bind ANY name at any in-range span ## Measured | step | file | behaviour | | :-- | :-- | :-- | | `plugin add` with no `--grant` | `crates/cli/src/plugin.rs:1914` | uses `approval::GrantSpec::Requested` | | `Requested` resolves the flag | `crates/indexer/src/approval.rs` (`requested_derived_names`) | to **whatever the manifest asked for** — the comment says so: *"#103 IS PART OF 'WHAT IT ASKED FOR'."* | | the value is written into the record | `crates/cli/src/plugin.rs:1974-1990` | `.with_derived_names(...)` | | the confirmation is rendered | `crates/indexer/src/confirm.rs` | **ZERO occurrences of `derived_names` in the entire file** | ``` $ grep -c derived_names crates/indexer/src/confirm.rs 0 ``` The `Disclosure` struct carries `grant`, `withheld`, `bridge_grant`, `bridge_withheld`, `domain`, `language_profiles`, `displaces`, `activation_owner`, `writer_lock`, `notes`. There is no field for it, and `VerifiedGrant::render()` returns capabilities and bridges only. ## The path A signed package requests `[capabilities] derived_names = true`. The operator runs `plugin add`, reads a block listing two or three capability names plus the `MEANS` sentence about signatures, and presses the key labelled **"approve as requested"**. They have granted the authority above, and nothing on screen said the word. `--yes` requires `--grant` (`plugin.rs:1652`), but `--grant requested` carries it silently too. ## Why this is worse than an ordinary disclosure gap The value is disclosed **everywhere except the decision point**: * `plugin status` prints `derived_names=yes|no` (`crates/cli/src/plugin.rs:3982-3995`) * `plugin enable --derived-names` requires the flag explicitly * `plugin inspect` shows the request So the information exists, is formatted, and is shown — after the answer has been given. An operator who audits later can see what they granted; an operator answering the prompt cannot see what they are granting. It also means the threat model's §4 argument is, as written, a claim about a lever rather than about the screen. The lever is real. The screen is where it is pulled. ## Scope **No shipped first-party package is affected in the sense of exploiting this** — `de.h-dv.ruby` requests `derived_names = true` for a real and documented reason (Rails' `has_many :posts` emits a `type` ref named `Post` at the span of the literal `:posts`), and that request is legitimate. The finding is that a *malicious* package's identical request is answered by the same keystroke, with the same screen. ## Fix Add `derived_names` to the `Disclosure` struct and render it in `confirm.rs` beside `grant`/`withheld`. It is a one-field change to the screen the whole of §4 rests on. Worth considering alongside it, though each is a separate decision: 1. **Should `GrantSpec::Requested` grant it at all?** Every other high-authority answer in this tree is opt-in. `Requested` meaning "everything the manifest asked for, including this" is defensible only if the screen names it. 2. **Rendering vocabulary.** `VerifiedGrant::render()` is capability-name-shaped and `derived_names` is a bool, which is presumably why it never got a row. That is a reason for the omission, not a justification. ## Related, from the same review — reported here because the fix may as well cover both `crates/abi/src/frame.rs:53-63` argues that a package emitting `TAG_SYMBOL_BASENAME` declares `fact_minor_min = 2` so an older host refuses it rather than storing the guest's placeholder as a name. **Nothing enforces the first clause.** `Manifest::validate` refuses `fact_minor_min > ABI_MINOR`, but no site couples EMISSION of tag `0x8004` to the DECLARATION. A third-party package declaring `fact_minor_min = 0` and emitting the tag installs on an `ABI_MINOR = 1` host, whose decoder skips the tag and stores the placeholder verbatim — the exact MISLEAD case the bracket is documented as preventing. `de.h-dv.svelte` declares it correctly; this is a trap for a well-meaning third-party author, and the threat model states it as a system property when it is a convention. The enforceable point is `plugin check`'s conformance leg, which holds both the `ValidatedFacts` (hence `name_is_basename`) and the `Manifest`.
Author
Member

Fixed in d5547cc — 11/11 CI green

derived_names is now on the screen the operator answers.

capability_grant    same_file_candidate, exported_candidate
capability_withheld (none)
bridge_grant        (none)
bridge_withheld     (none)
derived_names       GRANTED — this package may emit a symbol or reference whose
                    NAME IS NOT THE TEXT AT ITS SPAN. Every other producer is
                    held to name == source bytes; this one is not, and the
                    exemption is per-fact and unbounded. Withdraw with
                    `plugin enable <digest>` without `--derived-names`

It sits with the capability lines because that is what it is: an operator scanning "what am I granting" reads those four, and a fifth authority rendered anywhere else is one this surface disclosed without putting it where the answer is formed.

Four states, not a bool

not requested · WITHHELD (requested by this package) · GRANTED · not decided here — <why>.

A bool collapses the first two, and they are different facts about different packages — one never asked, one asked and was REFUSED. capability_withheld already draws that distinction for the pool capabilities; this is the same distinction for the authority that outranks them.

The fourth state was not in the plan. There are NINE Disclosure construction sites, not one, and they are not interchangeable: only add, enable and check decide this authority (check because it RUNS C1 under it and takes derived_names as a parameter). The other six grant nothing, and rendering not requested on plugin gc would state a fact about the package on a screen that is not about the package's grant — a default that reads like an answer, which is the same defect as the silence being fixed. They render not decided here plus which command does decide, mirroring Domain::Absent's existing "no value here, and here's why".

Graded

the_confirmation_names_the_derived_names_answer and a_refusal_and_an_absent_request_do_not_render_alike, with three mutations run:

mutation verdict
delete the derived_names push from disclosure_text RED — both tests (5 passed; 2 failed)
render every arm as the bare field name, no verdict RED — "a granted authority must say so in a word an operator scanning the screen cannot miss"
collapse NotRequested and Withheld into one arm RED — the two screens become equal, assert_ne! fires

A first reading of the first mutation reported ONE failure. It was wrong: the log was piped through tail -30 before the grep counted it, so the count described what survived the truncation rather than the run. That correction is recorded on the test, because it is the mistake a reader would repeat.

NOT fixed here — split to #280

Two items from this issue are not addressed by d5547cc, and closing without saying so would lose them:

  • Nothing couples EMISSION of tag 0x8004 to the fact_minor_min declaration meant to bracket it. Different defect, different fix location (plugin check's conformance leg, the only place holding both the ValidatedFacts and the Manifest). Now #280.
  • Whether GrantSpec::Requested should grant this authority at all by default. Raised here as a question rather than a defect, and it stays one — the reported hole was the screen, and changing what --grant requested MEANS is a behaviour change for anyone scripting plugin add. Carried into #280 for a decision.

Verified: fmt 0, clippy -D warnings 0 (workspace, all targets), rustdoc -D warnings 0, cargo test --workspace 359 binaries / 3927 passed, and all 11 CI jobs green on d5547cc.

One local suite failure was attributed rather than waved through: an_idle_worker_holding_a_compiled_grammar_costs_what_this_says reported a worker WITH a compiled grammar costing 6231 KiB against 13398 KiB WITHOUT — inverted, so the "difference of 0 KiB" was a saturating subtraction on contended RSS readings while six review agents were running. Isolated 4/4 green, and CI's cargo test on a quiet runner confirms it.

## Fixed in `d5547cc` — 11/11 CI green `derived_names` is now on the screen the operator answers. ``` capability_grant same_file_candidate, exported_candidate capability_withheld (none) bridge_grant (none) bridge_withheld (none) derived_names GRANTED — this package may emit a symbol or reference whose NAME IS NOT THE TEXT AT ITS SPAN. Every other producer is held to name == source bytes; this one is not, and the exemption is per-fact and unbounded. Withdraw with `plugin enable <digest>` without `--derived-names` ``` It sits with the capability lines because that is what it is: an operator scanning "what am I granting" reads those four, and a fifth authority rendered anywhere else is one this surface disclosed without putting it where the answer is formed. ### Four states, not a bool `not requested` · `WITHHELD (requested by this package)` · `GRANTED` · `not decided here — <why>`. A bool collapses the first two, and they are different facts about different packages — one never asked, one asked and was REFUSED. `capability_withheld` already draws that distinction for the pool capabilities; this is the same distinction for the authority that outranks them. The fourth state was not in the plan. **There are NINE `Disclosure` construction sites, not one**, and they are not interchangeable: only `add`, `enable` and `check` decide this authority (`check` because it RUNS C1 under it and takes `derived_names` as a parameter). The other six grant nothing, and rendering `not requested` on `plugin gc` would state a fact about the package on a screen that is not about the package's grant — a default that reads like an answer, which is the same defect as the silence being fixed. They render `not decided here` plus which command does decide, mirroring `Domain::Absent`'s existing "no value here, and here's why". ### Graded `the_confirmation_names_the_derived_names_answer` and `a_refusal_and_an_absent_request_do_not_render_alike`, with three mutations run: | mutation | verdict | | :-- | :-- | | delete the `derived_names` push from `disclosure_text` | RED — both tests (`5 passed; 2 failed`) | | render every arm as the bare field name, no verdict | RED — "a granted authority must say so in a word an operator scanning the screen cannot miss" | | collapse `NotRequested` and `Withheld` into one arm | RED — the two screens become equal, `assert_ne!` fires | A first reading of the first mutation reported ONE failure. It was wrong: the log was piped through `tail -30` before the grep counted it, so the count described what survived the truncation rather than the run. That correction is recorded on the test, because it is the mistake a reader would repeat. ### NOT fixed here — split to #280 Two items from this issue are **not** addressed by `d5547cc`, and closing without saying so would lose them: * **Nothing couples EMISSION of tag `0x8004` to the `fact_minor_min` declaration** meant to bracket it. Different defect, different fix location (`plugin check`'s conformance leg, the only place holding both the `ValidatedFacts` and the `Manifest`). Now **#280**. * **Whether `GrantSpec::Requested` should grant this authority at all by default.** Raised here as a question rather than a defect, and it stays one — the reported hole was the screen, and changing what `--grant requested` MEANS is a behaviour change for anyone scripting `plugin add`. Carried into #280 for a decision. Verified: fmt 0, clippy `-D warnings` 0 (workspace, all targets), rustdoc `-D warnings` 0, `cargo test --workspace` 359 binaries / 3927 passed, and all 11 CI jobs green on `d5547cc`. One local suite failure was attributed rather than waved through: `an_idle_worker_holding_a_compiled_grammar_costs_what_this_says` reported a worker WITH a compiled grammar costing 6231 KiB against 13398 KiB WITHOUT — inverted, so the "difference of 0 KiB" was a saturating subtraction on contended RSS readings while six review agents were running. Isolated 4/4 green, and CI's `cargo test` on a quiet runner confirms it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#277
No description provided.