Component: routes/modules.py — the module-load command-registration loop (station v0.97).
The gap. When a module registers a command, the loader writes the two tables commands are actually dispatched through with last-write-wins and no cross-module owner check:
_mod_slug = str(manifest.get("id") or entry)
LOCAL_HANDLERS[cid] = _scoped_handler(_mod_slug, handler_fn)
cmdreg.COMMANDS[:] = [c for c in cmdreg.COMMANDS if c.get("id") != cid]
cmdreg.COMMANDS.append(dict(cmd, wired=True))
cmdreg.CMD_BY_ID[cid] = cmdreg.COMMANDS[-1]
The invariant this should hold — a command owned by one module must not be silently taken over by a different module — is stated and enforced elsewhere in the same file, on the sibling ACTIONS registry:
_existing = _reg_targets[0].ACTIONS.get(aid)
_owner = (getattr(_existing, "note", "") or "")
if _existing is not None and not _owner.startswith(f"module:{slug}"):
errors.append((cid, "action id '%s' already owned by another integration "
"(%s) — refusing to overwrite" % (aid, _owner[:60] or "built-in")))
continue
ACTIONS refuses a cross-module overwrite; LOCAL_HANDLERS[cid] and cmdreg.CMD_BY_ID[cid] — the tables the runtime dispatches through — have no such check. And command ids are not namespaced to the module slug, so a module may declare any cid, including one another installed module already owns.
Consequence. Two co-installed modules from different publishers, each properly signed and independently TOFU-pinned, both declaring the command acmepay.charge: both load, and the loser is decided by load order (the attacker's marketplace slug sorts last via module_safe, so it wins deterministically). LOCAL_HANDLERS["acmepay.charge"] then resolves to the attacker's handler, which receives the victim command's full in-flight args (amount, card) and returns an attacker-chosen result — while the Modules tab still lists the victim module as the command's owner. This is interception of in-flight command arguments + control of the return value + a deceptive owner label; it is module-vs-module (the intentional module-vs-built-in override is a separate, documented case).
Reproduction (station v0.97, isolated container). Two Ed25519-signed modules with distinct manifest.id (acme/pay and evil/weather, different keys), both declaring acmepay.charge; trust_mode=any. Both pass the trust gate and TOFU-pin independently (the pin is keyed by manifest.id, so two distinct module ids never conflict). After _load_modules(), LOCAL_HANDLERS["acmepay.charge"] is the attacker handler; it is invoked with the caller's real args and returns the attacker result. Source-level, the two registration sites above are unchanged on v0.97: the ACTIONS path carries the owner-check, the command-dispatch path does not.
Scope (stated honestly). Reachable cleanly under trust_mode=any; fresh installs default to attested, where the attacker needs a marketplace-verified publisher attestation (or an operator-allowlisted key). Vault reads stay namespaced (<slug>::<provider>), so this is not credential theft — it is argument interception, return-value control, and a misleading ownership label. Severity is left to the maintainer.
Fix. Mirror the ACTIONS owner-check in the command-registration loop: refuse to overwrite LOCAL_HANDLERS[cid] / cmdreg.CMD_BY_ID[cid] when cid is already owned by a different module:<slug> (idempotent reload of the same owner stays allowed).
---
Reviewed adversarially against the source before posting.