Files
logos-capability-module/tests/test_capability_module.cpp
Dario LipicarandCursor 1cae2932f2 Identify requestModule caller from the RPC token (#26)
* Identify requestModule caller from the RPC token, not fromModuleName.

fromModuleName is leftover ABI: any loaded allowlisted name could be
written there. Use logos::currentCaller() (host → core) for policy and
the token push instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(deps): follow logos-cpp-sdk master for logos_caller.h

module-builder's lock still had cpp-sdk 95d7b3, which does not ship
the caller header requestModule now includes. Follow cpp-sdk master
(937f17, #151).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(identity): fall back when mocks/old hosts omit the caller

Direct impl tests and logoscore doctests do not always run under
logos_module_set_call_caller. Use fromModuleName only in that case;
a named currentCaller still wins, so spoofing the leftover ABI stays
denied.

Also follow qt-sdk, plugin-qt, protocol, and lidl master so
qt-generator sees lidl/identity.hpp and the generated glue actually
pushes the caller document.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert fromModuleName fallback; simulate identity with CallCaller.

requestModule identity is only logos::currentCaller(). Tests (and every
other C++ module) open that stack with logos::CallCaller, the same push
generated RPC glue performs. Pin cpp-sdk feat/call-caller-raii for that
type until #152 lands.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take CallCaller from logos-test-framework, not cpp-sdk.

The production SDK has no CallCaller; unit tests get the RAII stand-in
via logos_test.h. Pin the test-framework branch until that lands on master.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Track logos-test-framework master now that CallCaller landed (#7).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Point the composition doctest at a host that actually injects the caller.

logoscore-cli's lock still predates CallerScope and currentCallerJson, so
requestModule saw Unknown and returned empty. Override protocol, plugin-qt,
and the SDKs onto master for that build — the same dispatch path, not a
fallback in capability.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Rebuild logos_host via liblogos #186; root plugin-qt overrides never reached it.

logoscore-cli does not follows plugin-qt into liblogos, so logos_host kept
shipping without currentCallerJson. Pin liblogos to the protocol-0.8 branch
and keep dumping the daemon log if this still fails.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 08:58:35 -03:00

496 lines
20 KiB
C++

// Unit tests for CapabilityModuleImpl.
//
// capability_module is a universal module: a plain, Qt-free C++ class deriving
// LogosModuleContext, whose public methods ARE its API (the builder generates
// the plugin glue). So these tests construct the impl class directly — there is
// no plugin object and no initLogos step to perform.
//
// requestModule() mints a UUID auth token, pushes it to the target module, and
// returns it to the caller.
//
// ── What is REAL here and what is not ────────────────────────────────────────
//
// Nothing is faked at the lp_* / host-services layer. Every test drives the
// genuine article:
//
// * the host-services grant — lp_grant_host_services(), the same public C
// ABI the host calls; the gates inside
// lp_token_keys / lp_inform_module_token_to
// really fire
// * the token registry — lp_token_save() into this image's real
// TokenManager, read back through the real
// logos::host::tokenKeys()/tokenFor()
// * the client — a real lp_client_create()
// * the token push — a real logos::host::informModuleTokenTo(),
// which really goes LogosAPIClient ->
// LogosAPIConsumer::informModuleToken_module
// -> acquire "<target>__handshake" ->
// LogosObject::informModuleToken
//
// Only the TRANSPORT is substituted, and only by switching the process mode.
// LogosMockSetup puts the SDK in LogosMode::Mock, where
// MockTransportConnection::requestObject vends a MockLogosObject for any name
// and MockLogosObject::informModuleToken returns true. That is what lets the
// success-path tests run all nine steps of requestModule in-process, with no
// seam in the impl and no production call site changed — and it is why the
// tests take ~0 ms rather than timing out on kTokenPushTimeoutMs.
//
// The consequence: the mock accepts every push and records nothing, so no test
// here can assert WHICH module was told or WHICH token it received. Verifying
// the argument order that capability_module_impl.cpp:127-133 warns about would
// need a recording endpoint (local mode + a real ModuleProxy), which drags Qt
// types into this file; the impl is deliberately Qt-free and so is this suite.
// That gap is unchanged from the pre-migration tests.
//
// ── Security contract (F-001, CWE-290) ───────────────────────────────────────
//
// requestModule fails closed. Identity is logos::currentCaller() (the document
// the host pushed for this dispatch), not fromModuleName. Direct unit tests
// wrap calls with logos::CallCaller (from logos_test.h) — production RPC
// glue does that via logos_module_set_call_caller. An unnamed dispatch, an
// empty target, a
// never-loaded TARGET, or a policy miss yields an empty result and no token
// is minted. Spoofing fromModuleName cannot impersonate another loaded module.
//
// Under `universal` there is a second, stronger fail-closed precondition the
// old Qt shape could not express at all: the host-services grant. Reading the
// token registry now requires "token_registry" and pushing a token requires
// "token_delivery"; ungranted, requestModule refuses EVERY request. Both are
// covered below.
//
// ── Test isolation ───────────────────────────────────────────────────────────
//
// The token store and the host-services grant are both PROCESS-GLOBAL, and
// lp_grant_host_services REPLACES the grant rather than adding to it, so state
// leaking between tests would make results order-dependent. CapabilityFixture
// re-establishes BOTH from scratch on every construction — LogosMockSetup's
// constructor clears the token store, and the grant is replaced wholesale — so
// no test can inherit anything from the one before it. Its destructor also
// clears the grant, so a future test that forgets the fixture fails closed
// instead of silently borrowing a neighbour's privileges.
#include <logos_test.h>
#include <logos_mock.h> // LogosMockSetup: LogosMode::Mock + token-store reset
#include <logos_protocol.h> // lp_grant_host_services, lp_token_save, lp_set_mode, LP_OK
#include "capability_module_impl.h"
#include <cstddef>
#include <regex>
#include <set>
#include <string>
#include <vector>
namespace {
// The grant capability_module declares in metadata.json#host_services.
constexpr const char* kAllHostServices = R"(["token_registry","token_delivery"])";
// Registry only: enough to verify a caller, NOT enough to deliver the token.
constexpr const char* kRegistryOnly = R"(["token_registry"])";
constexpr const char* kNoHostServices = "[]";
// One guard per test; construct it FIRST, before any seeding.
//
// Order matters and is enforced by declaration order: m_mock is built before
// the grant, and LogosMockSetup's constructor calls clearAllTokens(). Seeding
// before the fixture would therefore be silently wiped.
class CapabilityFixture {
public:
explicit CapabilityFixture(const char* servicesJson = kAllHostServices)
: m_grantRc(lp_grant_host_services(servicesJson)) {}
~CapabilityFixture() { lp_grant_host_services(kNoHostServices); }
CapabilityFixture(const CapabilityFixture&) = delete;
CapabilityFixture& operator=(const CapabilityFixture&) = delete;
int grantRc() const { return m_grantRc; }
private:
LogosMockSetup m_mock; // must be declared first: it clears the token store
int m_grantRc;
};
// Seed a module's token so capability_module treats it as a known/loaded
// module — the test-side stand-in for the host seeding one entry per module it
// loads. Goes through the real C ABI into the real image token store.
void seedModule(const std::string& name) {
lp_token_save(name.c_str(), ("seed-token-" + name).c_str());
}
// The trusted core/capability_module auth token. registerRestriction requires
// it; only core holds it in production. In tests it is whatever seedModule
// stored for "capability_module".
const std::string kTrustedToken = "seed-token-capability_module";
// Seed the trusted channel so registerRestriction calls authenticate. Call in
// any test that registers a restriction.
void seedTrustedChannel() {
seedModule("capability_module");
}
// UUID without braces: 8-4-4-4-12 lowercase hex digits separated by hyphens.
bool isUuid(const std::string& s) {
static const std::regex re(
"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
return std::regex_match(s, re);
}
} // namespace
// ── Success path: both caller and target are known modules ──────────────────
LOGOS_TEST(requestModule_returns_uuid_format_token) {
CapabilityFixture fixture;
LOGOS_ASSERT_EQ(fixture.grantRc(), LP_OK);
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT_FALSE(token.empty());
LOGOS_ASSERT(isUuid(token));
}
LOGOS_TEST(requestModule_mints_unique_token_per_call) {
CapabilityFixture fixture;
seedModule("requester");
seedModule("target");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester");
std::set<std::string> tokens;
for (int i = 0; i < 10; ++i) {
tokens.insert(impl.requestModule("requester", "target"));
}
// Also the sharpest liveness detector in the suite: if the push had failed,
// all ten would be "" and the set would collapse to size 1.
LOGOS_ASSERT_EQ(tokens.size(), std::size_t(10));
}
LOGOS_TEST(requestModule_works_when_target_token_is_pre_seeded) {
CapabilityFixture fixture;
// Seed both the caller and the target — exercises the tokenFor() path for
// the target. The literal value differs from seedModule's to show nothing
// reads it.
seedModule("requester_module");
lp_token_save("target_module", "pre-seeded-token");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT(isUuid(token));
}
// ── F-001 security regression: fail closed on unverified input ──────────────
// Replaces the old `requestModule_returns_empty_when_not_initialized`. The
// universal shape has no init step and no injected LogosAPI, so there is no
// "not initialized" state to test; the nearest real fail-closed precondition —
// and a stronger one — is the host-services grant this module now depends on.
//
// Note this asserts the CONTRACT, not one code path: with no grant,
// tokenKeys() also comes back empty, so the known-caller gate would refuse
// these inputs even if the explicit ungranted() check were deleted.
LOGOS_TEST(requestModule_returns_empty_when_host_services_ungranted) {
CapabilityFixture fixture(kNoHostServices);
LOGOS_ASSERT_EQ(fixture.grantRc(), LP_OK);
seedModule("requester");
seedModule("target");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester");
// Both names are known and the pair is unrestricted: the ONLY thing
// refusing this request is the missing grant.
const std::string token = impl.requestModule("requester", "target");
LOGOS_ASSERT_TRUE(token.empty());
}
// The second half of the grant, which nothing else covers: an image allowed to
// VERIFY a caller but not to DELIVER a token must still refuse, rather than
// hand back a token the target was never told about.
LOGOS_TEST(requestModule_returns_empty_when_token_delivery_ungranted) {
CapabilityFixture fixture(kRegistryOnly);
LOGOS_ASSERT_EQ(fixture.grantRc(), LP_OK);
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
// Clears gates 1-5 and mints a token; the push then comes back
// LP_ERR_UNSUPPORTED, so the minted token is dropped on the floor.
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT_TRUE(token.empty());
}
// Direct construction has no logos_module_set_call_caller push. An unnamed
// dispatch must refuse even when fromModuleName looks like a loaded module.
LOGOS_TEST(requestModule_rejects_unnamed_caller) {
CapabilityFixture fixture;
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT_TRUE(token.empty());
}
LOGOS_TEST(requestModule_treats_host_as_core) {
CapabilityFixture fixture;
seedModule("target_module");
CapabilityModuleImpl impl;
const auto host = logos::CallCaller::host();
const std::string token = impl.requestModule("ignored_leftover", "target_module");
LOGOS_ASSERT(isUuid(token));
}
LOGOS_TEST(requestModule_ignores_leftover_fromModuleName) {
CapabilityFixture fixture;
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
// Empty leftover ABI still mints for the token-bound caller.
const std::string token = impl.requestModule("", "target_module");
LOGOS_ASSERT(isUuid(token));
}
LOGOS_TEST(requestModule_rejects_empty_targetModuleName) {
CapabilityFixture fixture;
seedModule("requester_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "");
LOGOS_ASSERT_TRUE(token.empty());
}
LOGOS_TEST(requestModule_rejects_unknown_target) {
CapabilityFixture fixture;
seedModule("requester_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "missing_target");
LOGOS_ASSERT_TRUE(token.empty());
}
LOGOS_TEST(requestModule_succeeds_for_known_caller_and_target) {
// The positive control for this section. Without it every assertion above
// would still pass against a requestModule that returned "" unconditionally
// — or against a harness that had quietly stopped working.
CapabilityFixture fixture;
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT_FALSE(token.empty());
LOGOS_ASSERT(isUuid(token));
}
// The remaining refusal in requestModule: the push reached a real transport and
// genuinely failed, as opposed to being refused for want of a grant. Mock mode
// cannot produce it — MockLogosObject accepts everything — so this one test
// runs in LogosMode::Local with nothing published in the PluginRegistry.
// LocalTransportConnection::requestObject then misses on both the handshake
// surface and the business object and returns immediately, so this costs no
// wall-clock time despite exercising the failure arm.
LOGOS_TEST(requestModule_returns_empty_when_target_is_unreachable) {
CapabilityFixture fixture;
LOGOS_ASSERT_EQ(lp_set_mode("local"), LP_OK); // fixture's dtor restores the mode
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT_TRUE(token.empty());
}
// ── Access-policy enforcement (registerRestriction + requestModule) ─────────
//
// Core parses the access policy and calls registerRestriction(target,
// allowedCallers) for each restricted target. requestModule then refuses to
// mint a token when a restricted target's allowed-caller set does not include
// the requester — the denied caller never gets credentials, so it can never
// call the target. A target with NO registered restriction stays unrestricted.
LOGOS_TEST(registerRestriction_rejects_empty_target) {
CapabilityFixture fixture;
seedTrustedChannel();
CapabilityModuleImpl impl;
// Passes the trusted-token gate, then is refused by the empty-target gate —
// which pins the gate ORDER: trust is checked before argument validity.
LOGOS_ASSERT_FALSE(impl.registerRestriction(kTrustedToken, "", {"caller"}));
}
LOGOS_TEST(registerRestriction_rejects_untrusted_caller_token) {
// A loaded module can reach this method (the generic authorization that
// fronts it accepts any issued token), so the explicit trusted-token gate
// is the real defense: a peer presenting its own token must NOT be able to
// register a restriction.
CapabilityFixture fixture;
seedTrustedChannel();
seedModule("malicious_module");
seedModule("package_manager");
CapabilityModuleImpl impl;
// malicious_module tries to grant itself access using its OWN token.
const bool ok = impl.registerRestriction(
"seed-token-malicious_module", "package_manager", {"malicious_module"});
LOGOS_ASSERT_FALSE(ok);
// And an empty token is rejected too.
LOGOS_ASSERT_FALSE(impl.registerRestriction(
"", "package_manager", {"malicious_module"}));
}
LOGOS_TEST(requestModule_allows_listed_caller_for_restricted_target) {
CapabilityFixture fixture;
seedTrustedChannel();
seedModule("package_manager_ui");
seedModule("package_manager");
CapabilityModuleImpl impl;
LOGOS_ASSERT_TRUE(impl.registerRestriction(
kTrustedToken, "package_manager", {"package_manager_ui"}));
const auto caller = logos::CallCaller::module("package_manager_ui");
const std::string token = impl.requestModule("package_manager_ui", "package_manager");
LOGOS_ASSERT_FALSE(token.empty());
LOGOS_ASSERT(isUuid(token));
}
LOGOS_TEST(requestModule_denies_unlisted_caller_for_restricted_target) {
CapabilityFixture fixture;
seedTrustedChannel();
seedModule("some_other_module");
seedModule("package_manager");
CapabilityModuleImpl impl;
impl.registerRestriction(kTrustedToken, "package_manager", {"package_manager_ui"});
// some_other_module is a named, loaded module but is not in
// package_manager's allowed-caller set — must be denied.
const auto caller = logos::CallCaller::module("some_other_module");
const std::string token = impl.requestModule("some_other_module", "package_manager");
LOGOS_ASSERT_TRUE(token.empty());
}
LOGOS_TEST(requestModule_allows_any_caller_for_unrestricted_target) {
// Pins the deliberate fail-OPEN policy decision (see the
// TODO(access-policy) in capability_module_impl.cpp). Nothing else does:
// flipping to deny-by-default must turn THIS test red.
CapabilityFixture fixture;
seedTrustedChannel();
seedModule("some_module");
seedModule("restricted_target");
seedModule("open_target");
CapabilityModuleImpl impl;
// Restrict only restricted_target; open_target has no restriction.
impl.registerRestriction(kTrustedToken, "restricted_target", {"allowed_caller"});
const auto caller = logos::CallCaller::module("some_module");
const std::string token = impl.requestModule("some_module", "open_target");
LOGOS_ASSERT_FALSE(token.empty());
LOGOS_ASSERT(isUuid(token));
}
LOGOS_TEST(requestModule_allows_all_when_no_restriction_registered) {
// Back-compat: with no policy pushed, every known caller/target pair works.
CapabilityFixture fixture;
seedModule("requester_module");
seedModule("target_module");
CapabilityModuleImpl impl;
const auto caller = logos::CallCaller::module("requester_module");
const std::string token = impl.requestModule("requester_module", "target_module");
LOGOS_ASSERT_FALSE(token.empty());
}
LOGOS_TEST(registerRestriction_overwrites_previous_for_same_target) {
CapabilityFixture fixture;
seedTrustedChannel();
seedModule("old_caller");
seedModule("new_caller");
seedModule("target_module");
CapabilityModuleImpl impl;
impl.registerRestriction(kTrustedToken, "target_module", {"old_caller"});
// Re-register (as core does each boot) with a different allowed set.
impl.registerRestriction(kTrustedToken, "target_module", {"new_caller"});
// old_caller is no longer allowed; new_caller is. The second assertion is
// what distinguishes "overwritten" from "registerRestriction broke the
// target for everyone".
{
const auto oldCaller = logos::CallCaller::module("old_caller");
LOGOS_ASSERT_TRUE(impl.requestModule("old_caller", "target_module").empty());
}
{
const auto newCaller = logos::CallCaller::module("new_caller");
LOGOS_ASSERT_FALSE(impl.requestModule("new_caller", "target_module").empty());
}
}
LOGOS_TEST(requestModule_denies_spoofed_fromModuleName) {
CapabilityFixture fixture;
seedTrustedChannel();
seedModule("package_manager_ui");
seedModule("package_manager");
seedModule("malicious_module");
CapabilityModuleImpl impl;
LOGOS_ASSERT_TRUE(impl.registerRestriction(
kTrustedToken, "package_manager", {"package_manager_ui"}));
// Token-bound caller is malicious_module; leftover ABI claims the UI.
const auto caller = logos::CallCaller::module("malicious_module");
const std::string token = impl.requestModule("package_manager_ui", "package_manager");
LOGOS_ASSERT_TRUE(token.empty());
}