Reproduction steps:
- A victim module declares a provider that collides with a built-in / another
module (e.g. "stripe"); the operator saves its secret, which is stored under
the NAMESPACED vault key keys.local.json["victimmod::stripe"] (auto-namespacing,
routes/modules.py:245).
- Install any attacker module (trust default "any", so a self-signed bundle
installs) whose manifest DECLARES the victim's namespaced key as its provider:
{"credential_spec": {"provider": "victimmod::stripe"}}
(module slugs are public, so the victim's namespaced key is known/guessable).
- The attacker's handler calls vault_get("victimmod::stripe").
- Run repro_module_cross_module_cred_theft_namespaced_v099.py against a clean
v0.99 extraction (drives the REAL _make_module_vault_get shim). Output:
bare 'stripe' -> None (scoping holds — namespaced to attacker's slot)
'victimmod::stripe' -> {'STRIPE_SECRET_KEY': 'sk_live_VICTIM…'} (STOLEN)
undeclared 'victimmod::stripe' -> None (scope check)
Expected:
The per-module vault scoping ("security Finding 1: a module may read ONLY the
providers it declared ... no two modules read each other's creds",
routes/modules.py:379) must hold: no installed module can read another module's
saved credentials.
Actual:
The scoping shim (_make_module_vault_get) allows ANY provider the attacker module
DECLARES (if pv in allowed: return _vault_get(pv), line 426). The
collision-namespacing that redirects a BARE colliding provider name to the
attacker's OWN slot (<attacker_slug>::<provider>) only triggers for a name that
is in the known-provider set — it does NOT trigger for an already-namespaced<victim_slug>::<provider> key. _module_declared_providers (line 344) adds the
declared name verbatim, and nothing at install rejects a declared provider
containing the "::" namespace separator. So the attacker declares the victim's
namespaced key directly, it lands in the attacker's allowed scope, the shim
passes it straight to _vault_get -> credential_resolver.resolve ->
keys.local.json["victimmod::stripe"], and the victim's live secret is returned.
The repro shows the bare name correctly refused while the namespaced name lifts
the secret — so the Finding-1 fix is incomplete: any installed module can steal
every other module's saved credentials by naming their namespaced slots. (Unlike
the signing-seed finding this is NOT keychain-gated — module credentials always
live in keys.local.json.)
Suggested fix:
- At install, REJECT a manifest whose declared provider (credential_spec.provider,
allowed_destinations[].provider, or a command provider/requires) contains "::"
— a module may only declare bare provider names; the platform owns the
namespace separator.
- In _make_module_vault_get, never pass a declared name through to _vault_get as-is.
Resolve to the module's OWN namespaced slot (<this_slug>::<declared>) or the
bare name only when it is genuinely this module's, so a caller-chosen "::" key
can never address a sibling's slot.
- Defence in depth: credential_resolver.resolve() should refuse any provider name
containing "::" that does not match the calling module's slug prefix