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:
@@ -228,3 +228,79 @@ describe('identity — presence is not the same as being the right program', ()
|
||||
expect(status.imposter).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── scope: who has to be able to run it ──
|
||||
//
|
||||
// The bug this pins is a false PASS, which is the hard kind to notice. The probe asked the owner's login
|
||||
// shell where a binary was; for a `confined` plugin the work happens as somebody else's Linux user, and
|
||||
// nothing checked whether they could reach it. On alpha they could — but only because `/home/<owner>`
|
||||
// happened to be mode 751. At 750, the more usual default, every member would have lost every binary
|
||||
// with no probe reporting anything.
|
||||
|
||||
describe('osDependencies scope validation', () => {
|
||||
test('absent is fine — owner-scoped is the default and most dependencies are', () => {
|
||||
expect(
|
||||
manifestProblems('probe', withDeps([{ binary: 'ffmpeg', reason: 'r', packages: { apt: 'ffmpeg' } }])),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('owner and shared are accepted', () => {
|
||||
for (const scope of ['owner', 'shared']) {
|
||||
expect(
|
||||
manifestProblems('probe', withDeps([{ binary: 'ffmpeg', reason: 'r', packages: { apt: 'ffmpeg' }, scope }])),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('anything else is a manifest error, not a silent default', () => {
|
||||
const problems = manifestProblems(
|
||||
'probe',
|
||||
withDeps([{ binary: 'ffmpeg', reason: 'r', packages: { apt: 'ffmpeg' }, scope: 'everyone' }]),
|
||||
);
|
||||
expect(problems.some((p) => p.includes('scope must be "owner" or "shared"'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a shared dependency is judged on where it lives, not just whether it exists', () => {
|
||||
const scratch = join(tmpdir(), `os-deps-scope-${process.pid}`);
|
||||
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
const pluginWith = (dep: OsDependency): DiscoveredPlugin =>
|
||||
({
|
||||
appName: 'probe',
|
||||
dir: scratch,
|
||||
manifest: { ...base, osDependencies: [dep] },
|
||||
}) as unknown as DiscoveredPlugin;
|
||||
|
||||
test('a shared binary that exists only in the owner’s home reads as missing', () => {
|
||||
// The owner's login shell would find this and call the dependency satisfied. A member's shell would
|
||||
// not: it is not on their PATH, and reaching it means traversing a home that may refuse them.
|
||||
const ownerBin = join(homedir(), '.local', 'bin');
|
||||
mkdirSync(ownerBin, { recursive: true });
|
||||
const name = `os-deps-scope-probe-${process.pid}`;
|
||||
const path = join(ownerBin, name);
|
||||
writeFileSync(path, '#!/bin/sh\necho probe\n', { mode: 0o755 });
|
||||
|
||||
try {
|
||||
const owned = reportDependencies(pluginWith({ binary: name, reason: 'r', packages: {} }));
|
||||
expect(owned.dependencies[0]!.present).toBe(true);
|
||||
|
||||
const shared = reportDependencies(pluginWith({ binary: name, reason: 'r', packages: {}, scope: 'shared' }));
|
||||
expect(shared.dependencies[0]!.present).toBe(false);
|
||||
// Not "unreachable" — from a shared point of view it was never found at all, because the search
|
||||
// never leaves the system directories.
|
||||
expect(shared.dependencies[0]!.path).toBe(null);
|
||||
expect(shared.dependencies[0]!.scope).toBe('shared');
|
||||
} finally {
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a real system binary satisfies a shared dependency', () => {
|
||||
// `sh` is in /bin on every machine this runs on, world-executable, behind nothing.
|
||||
const report = reportDependencies(pluginWith({ binary: 'sh', reason: 'r', packages: {}, scope: 'shared' }));
|
||||
expect(report.dependencies[0]!.present).toBe(true);
|
||||
expect(report.dependencies[0]!.unreachable).toBe(false);
|
||||
expect(report.canInstall).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user