Skip to content

fix(integrations): install only what the operations agent loads - #607

Merged
Medformatik merged 6 commits into
mainfrom
fix/installer-custom-integrations-modes
Oct 24, 2026
Merged

Medformatik merged 6 commits into
mainfrom
fix/installer-custom-integrations-modes

Conversation

@Medformatik

@Medformatik Medformatik commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The operations agent loads an installed integration only when:

  • custom_integrations/, the integration's directory and its manifest.json are owned by the agent's account and not writable by group or others;
  • the manifest is a regular file with a single link, at most 128 KiB, valid UTF-8, declaring its own id;
  • its configSchema passes the agent's schema check.

If any installed integration fails these checks, the agent rejects its whole integration registry ("Trusted integration registry rejected"). Every trusted-configuration request and every compose render then fails.

The installer guaranteed none of this:

  • it applied the modes an archive declared;
  • local copies and directories followed the umask;
  • installIntegration created custom_integrations/ under the umask, so 0775 with umask 002;
  • it accepted a hardlinked or symlinked manifest.json, manifests up to 1 MiB, and schemas the agent rejects.

The breakage is real: with main's installer, one archive declaring 0666/0664 modes, installed under umask 002, makes the next render fail with "Trusted integration registry rejected".

What the installer does now

  • Fixed modes. Every install gets 0755 directories and 0644 files, whatever the archive declares or the umask is. That covers artifacts, local sources and git clones, and restores from rollback backups.
    • Archive modes are not just masked: a declared 0000 or 0200 would leave a manifest the agent can't read.
    • Declarative artifacts ship no executables, so nothing needs an execute bit.
  • custom_integrations/ itself. The installer creates it at 0755, or removes group/other write from it when it already exists and the installing account owns it. It refuses with a clear message when another account owns it.
  • Refused before anything is replaced:
    • a manifest.json that is a symlink, hardlinked in either direction, or larger than 128 KiB;
    • a manifest that is not valid UTF-8;
    • a configSchema the agent does not accept;
    • an id longer than 64 characters, which the agent's loader skips.
  • The agent's own check, last. The staged tree then goes through assertTrustedIntegrationDirectory. This is the loader's per-integration check, now exported from core so the installer and the loader share one definition.
  • Packaging. openmapx-ext package refuses a manifest larger than 128 KiB, since no OpenMapX agent would load it. Changeset: @openmapx/extension-cli patch.
    • The bundle diff against main is exactly that limit. The installer imports the check from narrow core subpaths, because importing the services namespace added 233 KB to the CLI bundle.

custom_integrations keeps its owner and mode checks: the loader is unchanged apart from sharing its per-integration read.

Integrations installed before this fix are not tightened

An earlier revision of this PR had the configuration writer remove group and other write from earlier installs, as #605 does for infra/docker. I reverted it: removing write bits afterwards can't prove nobody else wrote the files, so it would turn "possibly written by someone else" into "trusted". Limiting it to the owner's private group isn't reliable either, because inside the agent container /etc/group isn't the host's.

Integrations installed before this fix with group-writable files therefore still stop the agent until they are installed again; the release note says so. The follow-up below removes the dependency on modes altogether.

Assessment: trusting integrations by digest, as #605 does for community services

This PR doesn't do it, because it isn't contained:

  • Who writes the files. feat(ops-agent): trust community services by digest and built-in integrations from the release #605 works because the agent itself clones and writes community repositories, then records the digests it validated. Integrations are downloaded and extracted by app-api, in-process. For the agent to record digests at install time, either the install moves into the agent, or app-api calls a new agent operation after installing.
  • What has to change. Either way that means:
    • a new ops-contract operation and its authorization;
    • a record file in the agent's state directory;
    • the app-api install, rollback-restore and uninstall paths;
    • the host CLI's integrations install, which has no agent client;
    • the fixed CLI's loader;
    • a migration that records integrations installed before.
  • What it would buy.
    • app-api runs as the same uid and chooses what is installed, so a record would not protect against app-api. It would make trust independent of file modes, and detect changes made after install.
    • Moving the install into the agent would also stop app-api writing custom_integrations at all.

Follow-up: move integration installs into the agent, as for community repositories, then drop the owner and mode checks. The agent would record each manifest's SHA-256 in infra/docker/state/ops-agent/, which app-api can't write.

Residual risks

These are limits of the loader that the installer can't fully guard, because they depend on the set of integrations as a whole:

  • Built-in id collision. A later release that adds a built-in integration with the same id as an installed community one makes the registry reject. The installer checks the built-ins of the release it runs in.
  • Registry caps. The loader caps the registry at 256 manifests and 4 MiB in total. Built-ins already count 113.
  • Earlier installs. An integration installed before this fix with group-writable files, for example by pnpm openmapx integrations install under umask 002, still breaks the agent. The release note tells operators to install it again, which replaces its files.

Validation

  • Unit tests in packages/cli/__tests__/cli-integrations.test.ts, which drive the real installer. Each case ends by asking the agent's loader (loadTrustedIntegrationSchemas) what it loads:

    • an archive declaring 0777, 0666, 0664 and 0000 modes, installed under umask 002, gets 0755/0644 and loads;
    • a local source written under umask 002 (0664 manifest, 0775 directory) loads;
    • an archive with manifest.json hardlinked to another entry, and one with another entry hardlinked to manifest.json, are both refused, and the previously installed integration still loads;
    • a symlinked manifest.json, from an archive and from a local source, is refused;
    • an oversized manifest, invalid UTF-8, a schema keyword the agent rejects (examples) and a 65-character id are each refused;
    • a group-writable custom_integrations/ the installer owns is tightened to 0755;
    • a rollback restore of a backup at 0777/0666 comes back as 0755/0644;
    • packaging refuses a manifest over 128 KiB.

    Against main's installer, 8 of these 9 tests fail. The one that passes on both documents existing behaviour: a local source's hardlinked manifest is copied as its own file.

  • End-to-end in scripts/ops-agent-checkout-trust.test.ts, on a checkout cloned under umask 002:

    • the real installer, under umask 002, installs an archive declaring 0666/0664 and refuses a hardlinked and a symlinked manifest.json;
    • the bundled fixed CLI renders;
    • the bundled agent starts and commits a trusted-configuration request that configures the installed integration (region: "eu" against its schema).

    With main's installer, that render fails with "Trusted integration registry rejected". I moved the agent start and the request into helpers that the existing umask-002 test now uses too.

  • Suites: Vitest for core, integration-framework, cli, extension-cli, apps/api/src/services, ops-agent and scripts: 579 files and 8,971 tests passed, 97 skipped.

    • On Linux (node:24.22.1-bookworm, user node, umask 002), the installer, core services, integration-framework and agent authority suites passed: 81 files, 838 tests.
    • The end-to-end test needs a git checkout and ran on macOS only.
  • Checks:

    • check-types for core, integration-framework, cli, extension-cli, api, ops-agent and data-manager;
    • check-ops-authority and check-dockerfile-sync;
    • Biome, Prettier and the pre-commit hook.
  • Not run: an image build or a deployment, and an install through the admin Store against a running stack.

Release and upgrade impact

Product impact: security

The pending fragment release-notes/release-community-and-integration-trust.md now says what the installer guarantees. Its required action gains a recovery step: reinstall an integration installed earlier that breaks the agent.

Changeset .changeset/integration-manifest-limit.md: @openmapx/extension-cli patch. Packaging refuses a manifest larger than 128 KiB.

@Medformatik Medformatik added documentation Documentation content, tooling or workspace dependencies integration New or changed provider, source or backend integration labels Oct 24, 2026
@Medformatik Medformatik self-assigned this Oct 24, 2026
The agent loads installed integrations only from directories and
manifests its own account owns and nobody else can write, and one it
rejects stops it from authorizing any configuration change. The
installer applied archive-declared modes and the umask, and accepted
links and manifests the agent never reads, so a single install could
break every trusted-configuration request.

Installed files now get fixed modes (0755 directories, 0644 files) for
every source kind and on rollback restore. custom_integrations is
created or tightened in the state the agent trusts, and refused when
another account owns it. A manifest.json that is a symbolic link,
hard-linked, over 128 KiB or not UTF-8, a configuration schema the
agent rejects, or an id longer than 64 characters is refused. Finally
the staged tree goes through the agent's own per-integration check,
now exported from core. Packaging refuses a manifest over 128 KiB.
@Medformatik
Medformatik force-pushed the fix/installer-custom-integrations-modes branch from 8cad0a9 to 88d3698 Compare October 24, 2026 18:34
Integrations installed before the installer fixed its modes, for example
under a umask of 002, kept group-writable directories and manifests,
which the agent refuses to load. As #605 does for infra/docker, the
configuration writer now removes group and other write from
custom_integrations and every directory and regular file in it that its
uid owns, when it takes the writer lock (before any render or request
reads the integration schemas) and where it creates the directory.

Entries are opened without following links and changed through the
descriptor; on Linux the walk goes through the opened directories, so a
swapped link is never followed. Only write bits are removed, and
entries another uid owns are left for the agent to report. The agent
now takes the writer before its first authority load at startup, so an
upgraded agent starts on such a host.
@Medformatik
Medformatik merged commit cad251a into main Oct 24, 2026
27 of 28 checks passed
@Medformatik
Medformatik deleted the fix/installer-custom-integrations-modes branch October 24, 2026 20:06
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

documentation Documentation content, tooling or workspace dependencies integration New or changed provider, source or backend integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants