os-deps: a dependency declares who has to be able to run it

The probe asked the owner's login shell where a binary was, and the install reported that as a fact
about the machine. For a confined plugin the work happens as the member's own Linux user, in their own
home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable.

On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an
absolute path resolved. At 750, the more usual default, every member would have lost every binary with
nothing reporting it: the install would still have said satisfied, having only ever asked the owner.
Nobody chose 751, and that is the part worth fixing.

`scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a
manifest error rather than a silent fall back to the permissive one.

Shared changes three things. It is searched for on system directories only — asking the owner's shell
would find the copy in the owner's home and call it satisfied, the exact false pass this exists to
stop. Reachability is checked as world-executable AND every parent traversable to the root, because a
binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a
release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so
the download itself never runs as root.

An unreachable shared dependency gets its own state and refuses the install ahead of every other
check, because no package manager fixes it: the binary is installed and works, and what is wrong is
where it lives — reinstalling would report success and change nothing.

Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is
runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's
data is gated; its executable is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 00:15:38 +00:00
co-authored by Claude Opus 5
parent 87603b800b
commit 4efc2552d0
5 changed files with 331 additions and 19 deletions
+23 -1
View File
@@ -106,6 +106,25 @@ export type OsDependency = {
* to the owner rather than a guess.
*/
packages: Partial<Record<PackageManager, string>>;
/**
* WHO has to be able to run this.
*
* `owner` (the default) — only the server owner runs it. Correct for anything an `execution` plugin
* uses, since those run as the owner in the owner's home by definition.
*
* `shared` — every account with a Linux user must be able to run it, because a `confined` plugin's
* work happens as THEM. That is a different install target and a different check: a binary in the
* owner's `~/.local/bin` is neither on a member's PATH nor guaranteed readable, since reaching it
* means traversing the owner's home.
*
* The distinction was invisible until it was looked for. On alpha the members' binaries "worked"
* only because `/home/<owner>` happened to be mode 751 — world-traversable — so an absolute path
* resolved. Mode 750 would have broken every member silently, and no probe would have noticed,
* because the probe only ever asked the owner.
*
* See `docs/plugin-dependency-scope.md`.
*/
scope?: 'owner' | 'shared';
/**
* A release binary, for a dependency no package manager carries.
*
@@ -466,7 +485,7 @@ function osDependencyProblems(deps: unknown): string[] {
problems.push(`${at} must be an object`);
continue;
}
const { binary, reason, packages } = dep as Partial<OsDependency>;
const { binary, reason, packages, scope } = dep as Partial<OsDependency>;
if (typeof binary !== 'string' || !BINARY_RE.test(binary)) {
problems.push(`${at}.binary must be a plain command name — letters, digits, dot, dash, underscore`);
@@ -476,6 +495,9 @@ function osDependencyProblems(deps: unknown): string[] {
if (typeof reason !== 'string' || !reason.trim()) {
problems.push(`${at}.reason is required — say what stops working without it`);
}
if (scope !== undefined && scope !== 'owner' && scope !== 'shared') {
problems.push(`${at}.scope must be "owner" or "shared" when present`);
}
if (!packages || typeof packages !== 'object') {
problems.push(`${at}.packages must be an object keyed by package manager`);
continue;