feat: capability_module as a universal module (C4)

Rewrites the trust root as a plain Qt-free C++ class over the host-services
veneer, replacing the hand-written Qt plugin that reached into TokenManager
directly. The RPC surface is preserved EXACTLY — lm reports
requestModule(QString,QString) and registerRestriction(QString,QString,
QStringList) identically before and after; only initLogos(LogosAPI*) is gone,
which is the legacy Qt init hook no universal module has.

  * getTokenKeys()      -> logos::host::tokenKeys()        [token_registry]
  * getToken(name)      -> logos::host::tokenFor(name)
  * informModuleToken_module -> logos::host::informModuleTokenTo [token_delivery]
  * QHash/QSet          -> std::map/std::set, mutex-guarded (the Qt original
                           was implicitly serialised by the event loop, which
                           is not a property to inherit silently)
  * constantTimeEquals  -> the std::string one in logos_host_services.h

Token minting uses boost::uuids::random_generator — deliberately the SAME
generator the host uses for each module's token (liblogos module_manager.cpp),
not a hand-rolled std::random_device formatter: boost seeds from the platform
CSPRNG, while std::random_device is permitted to be deterministic and
historically was on MinGW, which is a live target. This value IS the auth token.

The argument order of the delivery call is spelled out at the call site because
it is the trap: authenticate with the TARGET's token, origin_module is the
TARGET, module_name is the REQUESTER. Swapping the last two compiles and
returns an ok-shaped status while telling the wrong module about the wrong
token.

PROVEN AT RUNTIME: logos-test-modules ipc-tests FAIL -> PASS with this module in
place — a universal trust root minting tokens under a host-granted privilege.

Getting there needed a fix outside this repo. The grant was delivered to the
module's process and then dropped, because module-builder emitted the cdylib
glue with logos-qt-sdk's STALE copy of the generator instead of the maintained
one in logos-plugin-qt (both compile, so nothing failed). The explicit refusal
message this impl logs is what made that findable at all:

  [capability_module] REFUSING 'core_service': this module was not granted the
                      token_registry host service, so it cannot verify any caller

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-08-16 09:46:01 -03:00
co-authored by Claude Opus 5
parent 0cb33fb21c
commit fc39b1b46e
7 changed files with 276 additions and 255 deletions
+6 -3
View File
@@ -9,10 +9,13 @@ else()
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
endif()
# Universal module: `interface: "universal"` makes the builder run
# logos-cpp-generator --from-header on src/capability_module_impl.h and emit the
# Qt plugin glue, the dispatch and the C-ABI exports into generated_code/.
# There is no hand-written plugin or interface header any more.
logos_module(
NAME capability_module
SOURCES
src/capability_module_interface.h
src/capability_module_plugin.h
src/capability_module_plugin.cpp
src/capability_module_impl.h
src/capability_module_impl.cpp
)
+18 -4
View File
@@ -12,16 +12,30 @@
"module_coordination",
"permission_management"
],
"nix": {
"packages": {
"build": [],
"runtime": []
"build": [
"boost"
],
"runtime": [
"boost"
]
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"find_packages": [
"Boost"
],
"extra_sources": []
}
},
"interface": "universal",
"host_services": [
"token_registry",
"token_delivery"
],
"codegen": {
"impl_class": "CapabilityModuleImpl",
"impl_header": "src/capability_module_impl.h"
}
}
+191
View File
@@ -0,0 +1,191 @@
#include "capability_module_impl.h"
#include <boost/uuid/uuid.hpp>
#include <boost/uuid/uuid_generators.hpp>
#include <boost/uuid/uuid_io.hpp>
#include <logos_host_services.h>
#include <logos_protocol.h>
#include <cstdio>
namespace {
// The minted value IS the auth token, so this is the one place entropy matters.
// Deliberately the SAME generator the host uses to mint each module's token
// (logos-liblogos module_manager.cpp), rather than a hand-rolled
// std::random_device formatter: boost seeds from the platform CSPRNG
// (/dev/urandom, BCryptGenRandom), whereas std::random_device is permitted to
// be DETERMINISTIC and historically was on MinGW — which is a live target here.
std::string mintToken()
{
static boost::uuids::random_generator gen;
return boost::uuids::to_string(gen());
}
// RAII for the per-target client. lp_client_destroy is safe from any thread and
// defers teardown to the owner thread when needed, so an early return cannot
// leak the handle.
class ClientHandle {
public:
ClientHandle(const std::string& target, const std::string& origin)
: m_c(lp_client_create(target.c_str(), origin.c_str(), nullptr, nullptr)) {}
~ClientHandle() { if (m_c) lp_client_destroy(m_c); }
ClientHandle(const ClientHandle&) = delete;
ClientHandle& operator=(const ClientHandle&) = delete;
lp_client* get() const { return m_c; }
explicit operator bool() const { return m_c != nullptr; }
private:
lp_client* m_c = nullptr;
};
void warn(const char* fmt, const std::string& a, const std::string& b = {})
{
std::fprintf(stderr, fmt, a.c_str(), b.c_str());
}
// A module that calls out from its own initializer has not published its
// source yet, so this one grant can be unsatisfiable. Waiting the protocol
// default (20s) there would blow every startup deadline downstream — the
// standalone app gives a ui-host 10s to report ready — and turn one
// unreachable module into a dead UI. Fail fast instead: the caller gets no
// token and a clear reason, and the rest of startup keeps moving.
constexpr int kTokenPushTimeoutMs = 3000;
} // namespace
std::string CapabilityModuleImpl::requestModule(const std::string& fromModuleName,
const std::string& moduleName)
{
if (fromModuleName.empty() || moduleName.empty()) {
warn("[capability_module] rejecting empty module name (from='%s' target='%s')\n",
fromModuleName, moduleName);
return {};
}
// Known-caller gate. The requesting identity must be one this image has a
// token for; fail closed on an unknown name rather than mint a token for a
// self-asserted identity that was never loaded.
//
// This reads the token REGISTRY, so it is the call that makes the
// "token_registry" grant load-bearing: ungranted, tokenKeys() returns empty
// and every request is refused. That is the correct fail-closed direction,
// but it means a missing grant looks exactly like "nothing is loaded" —
// hence the explicit status check rather than an `empty()` test.
logos::host::Status keysStatus;
const std::vector<std::string> known = logos::host::tokenKeys(&keysStatus);
if (keysStatus.ungranted()) {
warn("[capability_module] REFUSING '%s': this module was not granted the "
"token_registry host service, so it cannot verify any caller\n",
fromModuleName);
return {};
}
bool callerKnown = false;
for (const std::string& k : known) {
if (k == fromModuleName) { callerKnown = true; break; }
}
if (!callerKnown) {
warn("[capability_module] rejecting request from unknown module identity '%s' "
"- no token registered for it\n", fromModuleName);
return {};
}
// Known-target gate: no token for the target means it is not loaded. Don't
// hand back a token the target would reject anyway.
const std::string moduleToken = logos::host::tokenFor(moduleName);
if (moduleToken.empty()) {
warn("[capability_module] rejecting request for unknown target '%s' "
"- no token registered for it\n", moduleName);
return {};
}
// Access-policy gate.
//
// TODO(access-policy): still fail-OPEN — a target with no registered
// restriction is unrestricted. Intentional for back-compat during rollout;
// the end state is deny-by-default once every deployment ships a policy.
{
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_restrictions.find(moduleName);
if (it != m_restrictions.end() && it->second.count(fromModuleName) == 0) {
warn("[capability_module] access policy denies '%s' -> '%s'\n",
fromModuleName, moduleName);
return {};
}
}
const std::string authToken = mintToken();
ClientHandle client(moduleName, "capability_module");
if (!client) {
warn("[capability_module] could not create a client for target '%s'\n", moduleName);
return {};
}
// Deliver the token for the REQUESTER to the TARGET.
//
// The argument order is the trap here, so spell it out: authenticate with
// the TARGET's own token, `originModule` is the TARGET (the module being
// told), and `moduleName` is the REQUESTER (the module the token is FOR).
// Swapping the last two still compiles and still returns an ok-shaped
// status; it just tells the wrong module about the wrong token.
const logos::host::Status pushed = logos::host::informModuleTokenTo(
client.get(),
/*authToken=*/moduleToken,
/*originModule=*/moduleName,
/*moduleName=*/fromModuleName,
/*token=*/authToken,
kTokenPushTimeoutMs);
if (!pushed) {
if (pushed.ungranted()) {
warn("[capability_module] REFUSING '%s': this module was not granted the "
"token_delivery host service, so it cannot push tokens\n", moduleName);
} else {
warn("[capability_module] failed to inform '%s' about the token for '%s'\n",
moduleName, fromModuleName);
}
return {};
}
return authToken;
}
bool CapabilityModuleImpl::registerRestriction(const std::string& authToken,
const std::string& targetModule,
const std::vector<std::string>& allowedCallers)
{
// Trusted-channel gate: only core (or this module) may rewrite the policy.
// Both hold this module's auth token; a peer knows only its own. The
// generic authorization that fronts this method accepts ANY issued token,
// which would otherwise let any module rewrite the policy.
//
// constantTimeEquals, not ==: comparing a secret with == leaks the length
// of the matching prefix through timing.
const std::string coreToken = logos::host::tokenFor("core");
const std::string capToken = logos::host::tokenFor("capability_module");
const bool callerIsTrusted =
(!coreToken.empty() && logos::host::constantTimeEquals(authToken, coreToken)) ||
(!capToken.empty() && logos::host::constantTimeEquals(authToken, capToken));
if (authToken.empty() || !callerIsTrusted) {
warn("[capability_module] rejecting restriction for '%s' - caller is not the "
"trusted core channel\n", targetModule);
return false;
}
if (targetModule.empty()) {
warn("[capability_module] rejecting empty target module%s\n", std::string());
return false;
}
// Overwrite any previous restriction — core is the single source of truth
// and re-registers the full set each boot.
{
std::lock_guard<std::mutex> lock(m_mutex);
m_restrictions[targetModule] =
std::set<std::string>(allowedCallers.begin(), allowedCallers.end());
}
return true;
}
+61
View File
@@ -0,0 +1,61 @@
#pragma once
// ─────────────────────────────────────────────────────────────────────────────
// capability_module — the trust root, as an ordinary universal module.
//
// It mints per-(caller, target) auth tokens and pushes them to the target, and
// it holds the access policy core registers. That needs two privileges no other
// module has: enumerating the token store, and delivering a token to an
// arbitrary module. It declares them in metadata.json:
//
// "host_services": ["token_registry", "token_delivery"]
//
// and the HOST grants them, bound to this module's verified name. Ungranted,
// every gated call fails closed — and note that is not a partial degradation:
// the known-caller check below reads the token registry, so an ungranted
// capability_module refuses EVERY requestModule.
//
// No Qt: this is a plain C++ class the generator turns into a module. The Qt
// plugin it used to be reached TokenManager directly, which is exactly the
// ambient privilege the host-services grant replaces.
//
// NO trailing `// comments` on declaration lines (the parser needs a `;`).
// ─────────────────────────────────────────────────────────────────────────────
#include <map>
#include <mutex>
#include <set>
#include <string>
#include <vector>
#include <logos_module_context.h>
class CapabilityModuleImpl : public LogosModuleContext {
public:
CapabilityModuleImpl() = default;
~CapabilityModuleImpl() = default;
// Mint a token letting `fromModuleName` call `moduleName`, push it to the
// target, and return it. Empty string on any refusal — an unknown caller,
// an unknown target, a policy denial, or an unreachable target.
std::string requestModule(const std::string& fromModuleName,
const std::string& moduleName);
// Restrict `targetModule` to `allowedCallers`. Only core (or this module)
// may call this; the check is a constant-time comparison against their
// tokens. Re-registering a target replaces its previous set.
bool registerRestriction(const std::string& authToken,
const std::string& targetModule,
const std::vector<std::string>& allowedCallers);
private:
// target -> callers permitted to reach it. A target absent from the map is
// unrestricted; see the fail-open note in the .cpp.
//
// Guarded because a universal module may be dispatched concurrently if it
// ever declares concurrency:"multi", and because this is policy state — the
// Qt original was implicitly serialised by the event loop, which is not a
// property to inherit silently.
std::map<std::string, std::set<std::string>> m_restrictions;
mutable std::mutex m_mutex;
};
-34
View File
@@ -1,34 +0,0 @@
#ifndef CAPABILITY_MODULE_INTERFACE_H
#define CAPABILITY_MODULE_INTERFACE_H
#include <QString>
#include <QStringList>
#include "interface.h"
// Public API of capability_module. It is internal-layer infrastructure: a
// module developer never calls these directly — requestModule is invoked under
// the hood by the SDK when one module first calls another, and
// registerRestriction is invoked by core when it installs the access policy.
class CapabilityModuleInterface : public PluginInterface
{
public:
virtual ~CapabilityModuleInterface() = default;
// Mint an auth token allowing `fromModuleName` to call `moduleName`, inform
// the target of the new token, and return it to the caller.
Q_INVOKABLE virtual QString requestModule(const QString& fromModuleName,
const QString& moduleName) = 0;
// Register an access restriction: only the listed caller modules may obtain
// a token for (and therefore call) `targetModule`. `authToken` must be the
// trusted core/capability_module token.
Q_INVOKABLE virtual bool registerRestriction(const QString& authToken,
const QString& targetModule,
const QStringList& allowedCallers) = 0;
};
#define CapabilityModuleInterface_iid "org.logos.CapabilityModuleInterface"
Q_DECLARE_INTERFACE(CapabilityModuleInterface, CapabilityModuleInterface_iid)
#endif // CAPABILITY_MODULE_INTERFACE_H
-172
View File
@@ -1,172 +0,0 @@
#include "capability_module_plugin.h"
#include <QByteArray>
#include <QDebug>
#include <QUuid>
#include <algorithm>
#include "logos_api_client.h"
#include "token_manager.h"
namespace {
// Constant-time token comparison: compare over the longer of the two lengths
// and fold any length difference into the result, so neither a correct prefix
// nor the secret's length leaks through timing.
bool constantTimeEquals(const QString& a, const QString& b)
{
const QByteArray ba = a.toUtf8();
const QByteArray bb = b.toUtf8();
const int n = std::max(ba.size(), bb.size());
int diff = ba.size() ^ bb.size();
for (int i = 0; i < n; ++i) {
const unsigned char ca = i < ba.size() ? static_cast<unsigned char>(ba[i]) : 0;
const unsigned char cb = i < bb.size() ? static_cast<unsigned char>(bb[i]) : 0;
diff |= (ca ^ cb);
}
return diff == 0;
}
} // namespace
CapabilityModulePlugin::CapabilityModulePlugin(QObject* parent)
: QObject(parent)
{
qDebug() << "CapabilityModulePlugin: created";
}
CapabilityModulePlugin::~CapabilityModulePlugin()
{
qDebug() << "CapabilityModulePlugin: destroyed";
}
void CapabilityModulePlugin::initLogos(LogosAPI* logosAPIInstance)
{
logosAPI = logosAPIInstance;
qDebug() << "CapabilityModulePlugin: LogosAPI initialized";
}
QString CapabilityModulePlugin::requestModule(const QString& fromModuleName, const QString& moduleName)
{
qDebug() << "CapabilityModulePlugin::requestModule called with fromModuleName:" << fromModuleName
<< "moduleName:" << moduleName;
if (!logosAPI) {
qWarning() << "CapabilityModulePlugin::requestModule: LogosAPI not initialized";
return {};
}
if (fromModuleName.isEmpty() || moduleName.isEmpty()) {
qWarning() << "CapabilityModulePlugin::requestModule: rejecting empty module name"
<< "(fromModuleName / moduleName must both be set)";
return {};
}
TokenManager* tokenManager = logosAPI->getTokenManager();
// Known-caller gate: the requesting identity must be a module capability_module
// already knows about. Fail closed on an unknown name rather than mint a token
// for a self-asserted identity that was never loaded.
if (!tokenManager->getTokenKeys().contains(fromModuleName)) {
qWarning() << "CapabilityModulePlugin::requestModule: rejecting request from unknown"
<< "module identity:" << fromModuleName
<< "- no token registered for it (unverified requesting identity)";
return {};
}
// Known-target gate: an empty target token means the target is not loaded /
// unknown. Don't hand back a token the target would reject anyway — fail closed.
const QString moduleToken = tokenManager->getToken(moduleName);
if (moduleToken.isEmpty()) {
qWarning() << "CapabilityModulePlugin::requestModule: rejecting request for unknown"
<< "target module:" << moduleName << "- no token registered for it";
return {};
}
// Access-policy gate: if core registered a restriction for this target,
// only the listed callers may obtain a token. A target with no registered
// restriction is unrestricted (back-compat). Fail closed for a restricted
// target — the denied caller never gets a token, so it can never call it.
//
// TODO(access-policy): this is currently fail-OPEN — when m_restrictions is
// empty (no policy pushed, e.g. running against a core that doesn't push
// restrictions) nothing is enforced and every caller is allowed. This is
// intentional for back-compat during rollout, but the end state should be
// deny-by-default: once every deployment ships a policy, an empty/unknown
// policy should block inter-module calls rather than permit them. Revisit
// and flip the default once the policy is guaranteed to be present.
if (auto it = m_restrictions.constFind(moduleName); it != m_restrictions.constEnd()) {
if (!it->contains(fromModuleName)) {
qWarning() << "CapabilityModulePlugin::requestModule: access policy denies"
<< fromModuleName << "->" << moduleName
<< "- caller not in the allowed set";
return {};
}
}
const QString authTokenString = QUuid::createUuid().toString(QUuid::WithoutBraces);
qDebug() << "CapabilityModulePlugin: Calling informModuleToken on target module:" << moduleName;
// Bounded push. The target must be reachable to be told about the token,
// which means its source has to be published. A module that calls out from
// its own initializer has not published yet (older SDKs publish only after
// the initializer returns), so this can be the one grant that cannot be
// satisfied. Waiting the default 20s there would blow every startup deadline
// downstream — the standalone app gives a ui-host 10s to report ready — and
// turn one unreachable module into a dead UI. Fail fast instead: the caller
// gets no token and a clear reason, and the rest of startup keeps moving.
static constexpr int kTokenPushTimeoutMs = 3000;
const bool success = logosAPI->getClient(moduleName)->informModuleToken_module(
moduleToken, moduleName, fromModuleName, authTokenString, kTokenPushTimeoutMs);
if (!success) {
qWarning() << "CapabilityModulePlugin: Failed to inform" << moduleName
<< "about token for" << fromModuleName;
return {};
}
qDebug() << "CapabilityModulePlugin: Successfully informed" << moduleName
<< "about token for" << fromModuleName;
return authTokenString;
}
bool CapabilityModulePlugin::registerRestriction(const QString& authToken,
const QString& targetModule,
const QStringList& allowedCallers)
{
if (!logosAPI) {
qWarning() << "CapabilityModulePlugin::registerRestriction: LogosAPI not initialized";
return false;
}
// Trusted-channel gate: only core (or capability_module itself) may
// register restrictions. Both hold capability_module's auth token; a
// peer module only knows its own token and so cannot forge this. The
// generic isAuthorized() that fronts this method accepts ANY issued
// token, which would otherwise let a malicious module rewrite the policy.
TokenManager* tokenManager = logosAPI->getTokenManager();
const QString coreToken = tokenManager->getToken(QStringLiteral("core"));
const QString capToken = tokenManager->getToken(QStringLiteral("capability_module"));
const bool callerIsTrusted =
(!coreToken.isEmpty() && constantTimeEquals(authToken, coreToken)) ||
(!capToken.isEmpty() && constantTimeEquals(authToken, capToken));
if (authToken.isEmpty() || !callerIsTrusted) {
qWarning() << "CapabilityModulePlugin::registerRestriction: rejecting restriction for"
<< targetModule << "- caller is not the trusted core channel";
return false;
}
if (targetModule.isEmpty()) {
qWarning() << "CapabilityModulePlugin::registerRestriction: rejecting empty target module";
return false;
}
// Overwrite any previous restriction for this target — core is the
// single source of truth and re-registers the full set each boot.
m_restrictions.insert(targetModule, QSet<QString>(allowedCallers.begin(), allowedCallers.end()));
qDebug() << "CapabilityModulePlugin::registerRestriction: target" << targetModule
<< "restricted to callers" << allowedCallers;
return true;
}
-42
View File
@@ -1,42 +0,0 @@
#ifndef CAPABILITY_MODULE_PLUGIN_H
#define CAPABILITY_MODULE_PLUGIN_H
#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QStringList>
#include "capability_module_interface.h"
#include "logos_api.h"
class CapabilityModulePlugin : public QObject, public CapabilityModuleInterface
{
Q_OBJECT
Q_PLUGIN_METADATA(IID CapabilityModuleInterface_iid FILE "metadata.json")
Q_INTERFACES(CapabilityModuleInterface PluginInterface)
public:
explicit CapabilityModulePlugin(QObject* parent = nullptr);
~CapabilityModulePlugin() override;
QString name() const override { return QStringLiteral("capability_module"); }
QString version() const override { return QStringLiteral("1.0.0"); }
Q_INVOKABLE void initLogos(LogosAPI* logosAPIInstance);
Q_INVOKABLE QString requestModule(const QString& fromModuleName,
const QString& moduleName) override;
Q_INVOKABLE bool registerRestriction(const QString& authToken,
const QString& targetModule,
const QStringList& allowedCallers) override;
private:
// target module -> set of caller modules allowed to reach it. A target
// absent from this map is unrestricted (back-compat default). Populated
// only by registerRestriction; consulted in requestModule.
QHash<QString, QSet<QString>> m_restrictions;
};
#endif // CAPABILITY_MODULE_PLUGIN_H