feat(tokens): a private store is created EMPTY, not seeded with the host anchor

TokenManager::forIdentity seeded every new private store by COPYING the
host's tokens for bootstrapKeys() = {core, capability_module}. Those values
are the HOST's, so an isolated in-process consumer presented basecamp's
anchor and ModuleProxy::resolveCaller answered HostAnchor: a sandboxed view
wearing the host's authority.

Half of that was LIVE, not latent. informModuleToken's trusted-channel gate
compares against the SAME two keys, so anything holding an isolated
LogosAPI* could read getToken("capability_module") and call
informModuleToken on capability_module — three public calls, no glue — and
write into the map that is both its known-caller gate and its moduleToken
source. Reading a caller needs generated glue; writing one did not.

The copy could not simply be deleted. Measured: removing it alone turns 5
of 495 protocol tests red, and two are behavioural — an isolated identity
cannot reach capability_module.requestModule (it dies at ModuleProxy's
`authToken.isEmpty()`), and an isolated PROVIDER can never be told about a
caller. Isolation without a credential is a lockout.

The credential already existed and was being thrown away. All five host
registration sites minted a per-spawn UUID, registered it with
capability_module, and then dropped it: the identity was registered under a
token nobody held, and it worked only because the store presented the
copied anchor. The anchor copy was masking that at every site, which is why
neither could be fixed alone.

So: a private store starts empty, and an identity's store carries THAT
IDENTITY's own host-issued credential under the bootstrap keys —
adoptCredentialFor, which refuses the host anchor by construction. This is
not a new rule. ui-host already does exactly it for the out-of-process half
(saveToken(core/capability_module, its own authToken)), and
LogosAPIProvider::seedHandshakeTrustAnchor does it for a module image. The
in-process private store was the only store in the system seeded with
somebody else's credential.

`core` is not part of it for a CONSUMER: every reader of a store's "core"
entry is provider-side, and in a real host instance() has no "core" key at
all — the host ring is written only under module names, and no module is
named core.

Closing the elevation also makes the consumer NAMEABLE in the same change:
it now resolves as {"kind":"module","name":<identity>} at capability_module
and at ordinary modules, instead of {"kind":"host"}.

NOTE FOR CONSUMERS OF THE C ABI: lp_token_reset_identity changed meaning on
an existing exported symbol — it no longer re-seeds, so an out-of-tree
caller that reset and kept going is now locked out. No in-workspace caller
exists; carried by the MINOR bump to 0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-23 00:47:28 -03:00
co-authored by Claude Opus 5
parent 6c24fcb132
commit c698402c06
14 changed files with 1033 additions and 159 deletions
+19 -6
View File
@@ -265,12 +265,25 @@ bool ModuleProxy::informModuleToken(const QString& authToken, const QString& mod
// store isAuthorized scans, so the proxy has exactly one notion of who it
// trusts. Identical objects until a host isolates the provider's identity.
//
// A HOST THAT PASSES AN ISOLATED STORE MUST SEED THE ANCHOR INTO IT.
// logos-plugin-qt's LogosAPIProvider::seedHandshakeTrustAnchor writes "core"
// and "capability_module" into TokenManager::instance() by name; against an
// isolated store that seeding would be invisible here and every token push
// would be refused during the handshake window. Moving that write to the
// same store is part of wiring this parameter up, not a separate cleanup.
// A HOST THAT PASSES AN ISOLATED STORE MUST INSTALL THAT IDENTITY'S OWN
// CREDENTIAL IN IT (TokenManager::adoptCredentialFor / lp_token_adopt_
// credential), because a private store is now created EMPTY. It must NOT be
// the host's anchor: capability_module pushes to a provider identity using
// getToken(moduleName), which is that identity's own credential, so the gate
// below still passes on the identity's own value and no longer requires a
// copy of the host's. logos-plugin-qt's LogosAPIProvider::
// seedHandshakeTrustAnchor does exactly this for a module IMAGE, writing the
// host-issued `authToken` property under both keys; logos::admitConsumer
// does it for an in-process consumer.
//
// ONE GAP SURVIVES, and it is here rather than in either of those:
// seedHandshakeTrustAnchor still writes to TokenManager::instance() BY NAME
// (logos-plugin-qt cpp/logos_api_provider.cpp:185-190), and is the last
// site that spells these two key strings itself. Against an isolated store
// it therefore seeds the wrong object — invisibly, because the write
// succeeds and only the read comes up empty. It is unreached today: that
// function runs in a module IMAGE, whose ring is the process ring. It stops
// being unreached the moment a provider identity is isolated in-process.
const QString coreToken = m_store->getToken(QStringLiteral("core"));
const QString capToken = m_store->getToken(QStringLiteral("capability_module"));
const bool callerIsTrusted =