Skip to content

Close the apply boundary: forgeable eligibility, replayable plans, cwd-relative state #14

Description

@oveddan

Five defects in the write path, found while reviewing the distribution plan in #12. All are live in the CLI today. None is caused by the MCP server work, but that work widens the blast radius by letting an agent supply plan JSON, so these should land first.

This blocks the MCP server. See #12.

1. applyEligibility sits outside the content hash

planBody() (src/planner.ts:212) hashes {createdAt, expiresAt, snapshotHash, deviceBinding, changes, frames}. applyEligibility is attached outside that body (src/planner.ts:207). But assertValidPlan gates on it (src/planner.ts:227):

if (!plan.applyEligibility.eligible) throw new Error(...)

So editing a plan file to flip eligible: true and clear reasons leaves planId valid, and a plan rejected for legacy firmware or weak device identity applies anyway. The firmware allowlist is the main guard against writing to an untested firmware, and it is bypassable with a text editor.

Fix: include applyEligibility in planBody(), and recompute eligibility inside the apply path from the freshly-read device rather than trusting the serialized field.

2. Frames are not proven to match changes

The hash is unkeyed, so it proves integrity in transit, not authenticity — anyone can recompute it. Nothing re-derives frames from changes, so a plan can declare one change and carry frames that write something else. assertConfigurationWrite (src/protocol.ts:97) validates frame shape, not correspondence to the declared intent, and the mismatch would only be noticed after bytes were sent.

Fix: have assertValidPlan re-derive frames from changes via encodeGlobalDryRun / encodeEncoderDryRun and compare before connecting.

3. Plans are not single-use

assertPlanNotConsumed (src/journal.ts:28) rejects only outcome === "complete". A plan whose apply died mid-write — journaled unknown — is replayable. There is also no atomic reservation between the check and the started record, so two applies can both pass the check.

The README states "Completed plans are single-use", which is literally true and materially misleading.

Fix: block on any prior started / pending / unknown until explicitly resolved; make check→started an atomic reservation; add a per-device apply mutex. A long-running MCP server makes concurrent invocation natural, so this stops being theoretical.

4. .mft-state is relative to the current working directory

src/cli.ts:149 resolves the journal, backups, and last-verified.json against cwd. Consequences:

  • Running apply from a different directory than a previous apply consults a different journal, silently defeating (3).
  • An MCP server's cwd is set by its host, not the user's shell, so server and CLI would never share a journal.
  • Backups can land in a plugin cache directory the user never looks at.

This was previously masked by the skill instructing the agent to run from the repository root. Now that the CLI installs from npm there is no repository root, so the constraint is documented prose in the skill and README rather than anything enforced.

Fix: one stable per-user state root shared by CLI and any server, with an explicit --state-dir / env override, plus migration for existing cwd-local journals.

5. The display re-enable write is unverified

src/applier.ts:81-97 sends a global write after encoder targets so the LED display comes back. It sits outside the per-target try/catch and:

  • is not in the plan's frames, so the hash-covered list a reviewer approves is not the complete set of bytes sent;
  • gets no read-back and never receives verified;
  • on a throw leaves the journal's last entry as pending with no terminal outcome, contradicting docs/write-safety.md item 7 — and since (3) only blocks complete, the plan stays replayable;
  • means postSnapshot (returned at src/applier.ts:98, written to last-verified.json) predates the final write.

Reachable in practice: encodeGlobalDryRun calls assertExactTags (src/write-codec.ts:41), which throws if a freshly-read globals tag set is not exactly allowlisted — after every encoder write has already landed.

Fix: either make it a planned, verified target, or document and test it as explicitly best-effort with honest journal semantics. Correct docs/write-safety.md and the README's "reads the full configuration after every target" either way.

Acceptance criteria

  • A plan with hand-edited applyEligibility is rejected
  • A plan whose frames do not match its changes is rejected before any send
  • A plan with a prior started / pending / unknown journal entry is refused
  • Two concurrent applies of one plan cannot both proceed
  • CLI and a differently-rooted process share one journal
  • The journal always reaches a terminal outcome, including when the display refresh fails
  • Docs claiming full verification and single-use match what the code does

None of these paths has a regression test today.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions