Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Owns every interaction with the `package_manager` LogosAPI module. (Named `Packa
- **Bound consent**: the dispatch id lives on the thing it is answering (`PendingAction::intentRequestId`, or `m_pendingInstallRequestId` for the cascade-less install), never in a single "whatever is pending" slot. A click on one dialog must not be able to answer another request — the shell's own uninstall dialogs carry no id at all.
- **Undelivered answers**: `finishIntent()` reports whether the broker accepted. `false` means the requester is gone or the dispatch already ended — including when the cascade just tore down PMU itself — so the removal falls back to `performLocalRemoval()` rather than being silently dropped.
- **Cascade confirmation**: `confirmUninstallCascade`, `cancelPendingAction` — drives cascade unload via CoreModuleManager + UIPluginManager, then answers.
- **Trust at install** (`utils/InstallTrust.*`): each install dialog (PMU's install gate and upgrade, App Manager) is told who signed every package its plan would bring — the catalogue's `signature` for the planned `rootHash`, or `verifyPackage` of a local file (PMU's `localPath`) — via `installTrustUpdated`, one prompt per install: the package the dialog is about gets its check/warning/cross mark (design-system icons) and the words ("Signed by Logos", `Common/InstallTrustLine.qml`), every other package just the mark beside its name (`Common/InstallTrustMark.qml`, same words on hover) and, when something is not trusted, a `LogosNotice` under the rows (`Common/InstallTrustNotice.qml`), the same in both dialogs. Whether "Trust & install" is offered (`canTrust`) and whether both buttons say "anyway" (`trustCoversAll`) come from `InstallTrust::summary()`, not QML. The confirm calls refuse while that is pending or when anything is invalid; "Trust & install" adds one key to Basecamp's keyring (package_manager's `addTrustedKey`, in `<baseDirectory>/keyring`): the signer of the package the dialog is about (`summary()`'s `trust`), never a dependency's, whose packages install "anyway". It goes on only once the key is in (`utils/PublisherKeyring.*`, which also owns Settings' deletes: one at a time, a row per key file, failures reported). Settings → Trusted Publishers only deletes, Logos included: a new keyring (no folder yet; the first keyring call makes it) gets Logos, retried on each read until a read shows it, and one Basecamp has used before never does, so a deleted Logos stays deleted. A keyring that can't be read trusts nobody: every signer reads as unchecked (`unchecked()`), nothing is offered for trust, and Settings says so. Logos's keys are a list (`logosKeys()`: the current key first, older ones still good, revoked ones); any Logos key in a keyring, old or revoked, stands for Logos and its keys now (`trustedDids()`), and a package signed with a revoked one is Invalid. Check the list before each release (README, "Cutting a release"). A refused or failed gate answers PMU `failed` and shows why (`installRefused`). The gate's answer holds PMU to what was judged: `plan` (`approvedPlan()`: each release pinned to its version, rootHash, repository and signer; PMU installs nothing outside it) or, for a local file, `file` (`fileIdentity()`: rootHash, signer and the whole file's SHA-256, which PMU checks again right before installing the private copy it made of the file). App Manager's own installs are held to the same approval before anything installs: every required package fetched, nothing outside the plan (`notAsApproved()`), each file signed as approved (`signerChanged()`, via `verifyPackage`; the downloader holds each file to the name, version and root hash asked for); an optional one that fails is left out. A confirm request is for a local file only when it names one (`requestsLocalFile()`), not when it has no repository; a local file Basecamp can't verify is blocked, and so is a signer key spelled other than liblgx writes it. What trusting allows is bounded: a package named like one Basecamp ships (its embedded modules and plugins) installs only if a Logos key signed it (`asReplacement()`, `reserved`); an update signed by another publisher than the installed copy asks and says so even when its signer is trusted, and is never offered for trust (`replacesSignedBy`); a name with a word that starts reading as Logos is Logos's (`claimsLogos()`); and the notice says what trusting does. A release installs under its manifest's name, so `fromCatalog()` blocks one whose catalogue entry names another package, or none (the downloader neither lists nor fetches the first, and holds every file to the name asked for). Replacing a copy no checkable key signed asks too, unless Logos signed it (`replacesUnverified`); what an install replaces is judged once the installed packages are known (`withInstalledAndTrustedKeys()`). The reserved names (`reservedNames()`) are the bundled modules and apps, each bundled app's `<app>_backend`, every name that may call package_manager, and `core_service`, which the runtime lets call any module under an enforce policy; they compare case-insensitively. Only Basecamp (`core`) and `package_manager_ui_backend` (`packageModuleCallers()`) may call package_manager and package_downloader: `main.cpp`'s `restrictPackageModules()` registers that with capability_module right after the core starts, before either loads, the way `PluginLoader::restrictBackendToView` restricts an app's backend; one it can't restrict is not started, and an `--access-policy enforce` policy carries the same rule (`withRestrictions()`), since enforce mode would otherwise let any module that declares them as a dependency call them; every keyring call re-sends the keyring folder first. Promises and known limits: `/workspace/package-trust-v1.md`, "What trust promises". The installer's signature policy stays WARN, so this is the user's gate, not enforcement. The same words describe installed apps: the "Open with…" chooser's provider details (`MainUIBackend::providerDetailsFor` → `PackageCoordinator::installedTrust`, judged by `fromInstalled()`), and Settings → Trusted Publishers shows each key's "trusted since" date from the keyring's `addedAt`.
- **Metadata refresh**: `refresh()` triggers the full `getInstalledUiPlugins` + `getInstalledPackages` + per-package `resolveFlatDependencies/Dependents` chain; pushes UI metadata to UIPluginManager via `uiPluginsFetched` signal

### App-to-app intents (`app/IntentRegistry`, `app/IntentBroker`)
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,13 @@ logging; see that tool's `docs/logosctl.md` for the daemon-side view.
#### Inter-module access enforcement (`--access-policy`)

**Default: off.** Without this flag, any loaded module may call any other —
unchanged behaviour.
unchanged behaviour — with one exception: Basecamp always restricts
`package_manager` and `package_downloader`, which hold the trusted keys and
install packages, to itself and the Package Manager's backend
(`package_manager_ui_backend`), before they load. An app that calls them
directly is refused (`access policy denies '<app>' -> 'package_manager'`). If
capability_module won't take that rule, neither module is started. An enforce
policy (below) gets the same rule for each of them it doesn't name itself.

`--access-policy enforce` turns on **deny-by-default**: a module may only call
the modules it declares as dependencies in its `metadata.json`, and any other
Expand Down Expand Up @@ -622,6 +628,13 @@ nix build .#logos-qt-mcp -o result-mcp

The `.mcp.json` in this repo is pre-configured to use the MCP server from `result-mcp/mcp-server/`. Start the app (inspector is enabled by default in debug/dev builds), and the agent can use tools like `qml_find_and_click`, `qml_screenshot`, `qml_list_interactive`, etc. See the [logos-qt-mcp README](https://github.com/logos-co/logos-qt-mcp#readme) for the full list of available tools.

## Cutting a release

- **The Logos signing key.** Basecamp trusts Logos's packages by the keys in `logosKeys()` (`app/utils/InstallTrust.cpp`). Before every release, check that the first one is the key Logos signs with now: the "Official Logos signing key" in [logos-modules-release's README](https://github.com/logos-co/logos-modules-release#official-logos-signing-key). `lgx verify` on a freshly signed official package prints the same DID.
- **A new key:** put it first, and keep the old one while what it signed should still install without asking. The release must be out before Logos signs anything with the new key: logos-modules-release re-signs every current package in the same change as a rotation, and a Basecamp that doesn't know the key shows all of them as impostors. Agree the date with whoever runs that rotation.
- **A leaked key:** move it to `revoked`, and put its replacement first in the same change. Basecamp then installs nothing it signed.
- Never just delete a key from the list, and never leave it without a current key (the unit tests check). Users do nothing either way: a keyring holding any Logos key, old or revoked, stands for Logos and the keys it signs with now.

## Requirements

### Build Tools
Expand Down
7 changes: 7 additions & 0 deletions app/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ find_package(Qt6 COMPONENTS Widgets RemoteObjects Quick Qml QuickWidgets QuickCo
# app/utils/LoggingConfig.*). spdlog supplies the rotating file sink — the same
# one liblogos already logs through, so this adds no second copy to the closure.
find_package(spdlog REQUIRED)
# Unicode's lookalike skeletons (utils/InstallTrust.cpp): the ICU Qt itself links.
find_package(ICU REQUIRED COMPONENTS uc i18n)
find_package(yaml-cpp REQUIRED)

# QML Inspector (Qt DevTools-like inspection over TCP)
Expand Down Expand Up @@ -297,6 +299,9 @@ set(PROJECT_SOURCES
utils/HostProfile.cpp
utils/InstalledIntegrity.cpp
utils/InstalledPlacement.cpp
utils/InstallTrust.cpp
utils/PublisherKeyring.h
utils/PublisherKeyring.cpp
utils/StandaloneMode.h
utils/StandaloneMode.cpp
utils/DependencyBlocker.h
Expand Down Expand Up @@ -430,6 +435,8 @@ endif()
target_link_libraries(LogosBasecamp PRIVATE
spdlog::spdlog
yaml-cpp::yaml-cpp
ICU::i18n
ICU::uc
Qt6::Widgets
Qt6::RemoteObjects
Qt6::Quick
Expand Down
43 changes: 19 additions & 24 deletions app/MainUIBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, ICoreRuntime* core,
// Package repositories — pure re-emits so QML binding to backend.*
connect(m_packageCoordinator, &PackageCoordinator::repositoriesChanged,
this, &MainUIBackend::repositoriesChanged);
connect(m_packageCoordinator, &PackageCoordinator::installTrustUpdated,
this, &MainUIBackend::installTrustUpdated);
connect(m_packageCoordinator, &PackageCoordinator::trustedPublishersChanged,
this, &MainUIBackend::trustedPublishersChanged);
connect(m_packageCoordinator, &PackageCoordinator::trustedPublisherChangeFailed,
this, &MainUIBackend::trustedPublisherChangeFailed);
connect(m_packageCoordinator, &PackageCoordinator::installRefused,
this, &MainUIBackend::installFailureNoticeRequested);
connect(m_packageCoordinator, &PackageCoordinator::repositoriesLoadingChanged,
this, &MainUIBackend::repositoriesLoadingChanged);
connect(m_packageCoordinator, &PackageCoordinator::downloadSourceChanged,
Expand Down Expand Up @@ -727,29 +735,10 @@ QVariantMap MainUIBackend::providerDetailsFor(const QString& packageName) const
out.insert(QStringLiteral("moduleName"), packageName);
out.insert(QStringLiteral("displayName"), displayNameFor(packageName));

// Every package is unsigned today: the catalog carries no signatures and
// `trustedSigners` is empty. Stated outright rather than omitted, because a
// detail panel that lists a version and an origin and says nothing about
// provenance reads as reassurance it has not earned. When signing lands
// this becomes a real answer instead of a constant.
out.insert(QStringLiteral("verified"), false);

if (m_packageCoordinator) {
out.insert(QStringLiteral("trust"), m_packageCoordinator->installedTrust(packageName));
out.insert(QStringLiteral("installType"),
m_packageCoordinator->installType(packageName));

// DELIBERATELY NOT the content hash, though it is available
// (PackageCoordinator::installedRootHash). A hash is only evidence when
// the reader holds an independent, trusted value to compare it against,
// and with nothing signed the catalog that serves the package serves
// the hash too — so an attacker controlling one controls the other. A
// tampered copy does not keep the original digest, but it does not need
// to: it simply presents its own.
//
// Shown here it would be worse than absent. A 64-character digest beside
// the word "Unsigned" reads as rigour, and weakens the one honest
// statement on the panel. When signing lands it stops being decoration,
// because a signature is exactly the trusted reference it lacks today.
}

for (int row = 0; m_appsModel && row < m_appsModel->rowCount(); ++row) {
Expand Down Expand Up @@ -919,20 +908,25 @@ void MainUIBackend::uninstallUiModule(const QString& n) { m_packageCoordin
void MainUIBackend::uninstallApp(const QString& n, const QString& repositoryUrl)
{ m_packageCoordinator->uninstallApp(n, repositoryUrl); }
void MainUIBackend::uninstallCoreModule(const QString& n) { m_packageCoordinator->uninstallCoreModule(n); }
void MainUIBackend::confirmUninstallCascade(const QString& n, const QStringList& optionalNames, const QVariantMap& optionalVersionPins) { m_packageCoordinator->confirmUninstallCascade(n, optionalNames, optionalVersionPins); }
void MainUIBackend::confirmUninstallCascade(const QString& n, const QStringList& optionalNames, const QVariantMap& optionalVersionPins, bool trustPublishers) { m_packageCoordinator->confirmUninstallCascade(n, optionalNames, optionalVersionPins, trustPublishers); }
void MainUIBackend::confirmUninstallMultiCascade(const QStringList& names) { m_packageCoordinator->confirmUninstallMultiCascade(names); }
void MainUIBackend::cancelMultiUninstall(const QStringList& names) { m_packageCoordinator->cancelMultiUninstall(names); }
void MainUIBackend::cancelPendingUninstallApp(const QString& name) { m_packageCoordinator->cancelPendingUninstallApp(name); }
void MainUIBackend::refreshOptionalPreview(const QString& n, const QString& repo,
const QVariantMap& pins, const QVariantMap& selection,
const QVariantMap& optionalPins, bool gate)
{ m_packageCoordinator->refreshOptionalPreview(n, repo, pins, selection, optionalPins, gate); }
void MainUIBackend::confirmInstallGate(const QString& n, const QStringList& optionalNames, const QVariantMap& optionalVersionPins) { m_packageCoordinator->confirmInstallGate(n, optionalNames, optionalVersionPins); }
void MainUIBackend::confirmInstallGate(const QString& n, const QStringList& optionalNames, const QVariantMap& optionalVersionPins, bool trustPublishers) { m_packageCoordinator->confirmInstallGate(n, optionalNames, optionalVersionPins, trustPublishers); }
void MainUIBackend::cancelInstallGate(const QString& n) { m_packageCoordinator->cancelInstallGate(n); }
void MainUIBackend::openApp(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins, bool allowFastLaunch)
{ m_packageCoordinator->openApp(name, repositoryUrl, versionPins, allowFastLaunch); }
void MainUIBackend::confirmCatalogInstall(const QString& name, const QString& repositoryUrl)
{ m_packageCoordinator->confirmCatalogInstall(name, repositoryUrl); }
void MainUIBackend::confirmCatalogInstall(const QString& name, const QString& repositoryUrl,
bool trustPublishers)
{ m_packageCoordinator->confirmCatalogInstall(name, repositoryUrl, trustPublishers); }
void MainUIBackend::refreshTrustedPublishers() { m_packageCoordinator->refreshTrustedPublishers(); }
void MainUIBackend::removeTrustedPublisher(const QString& keyName) { m_packageCoordinator->removeTrustedPublisher(keyName); }
QStringList MainUIBackend::installedFromPublisher(const QString& did) const
{ return m_packageCoordinator->installedFromPublisher(did); }
void MainUIBackend::notifyAddApplicationDialogClosed()
{ m_packageCoordinator->notifyAddApplicationDialogClosed(); }

Expand All @@ -948,6 +942,7 @@ void MainUIBackend::cancelPendingAction(const QString& n) {

// Package repositories — delegations + cache pass-through.
QVariantList MainUIBackend::repositories() const { return m_packageCoordinator->repositories(); }
QVariantList MainUIBackend::trustedPublishers() const { return m_packageCoordinator->trustedPublishers(); }
bool MainUIBackend::repositoriesLoading() const { return m_packageCoordinator->repositoriesLoading(); }
QString MainUIBackend::downloadSource() const { return m_packageCoordinator->downloadSource(); }
bool MainUIBackend::onboardingRequired() const { return m_packageCoordinator->onboardingRequired(); }
Expand Down
Loading
Loading