diff --git a/scripts/punktfunk-scripting.service b/scripts/punktfunk-scripting.service index 12f0103b..58087f7c 100644 --- a/scripts/punktfunk-scripting.service +++ b/scripts/punktfunk-scripting.service @@ -10,8 +10,10 @@ # you add scripts or install plugins. Turn it on once you have automation to run: # systemctl --user enable --now punktfunk-scripting # -# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from -# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing. +# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity +# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env +# editing. The plugin token authorizes the plugin surface but not hook registration or pairing +# administration; a script that needs the admin surface sets PUNKTFUNK_MGMT_TOKEN explicitly. [Unit] Description=punktfunk plugin/script runner Documentation=https://git.unom.io/unom/punktfunk @@ -29,6 +31,19 @@ RestartSec=2 KillMode=mixed KillSignal=SIGTERM TimeoutStopSec=30 +# Sandbox: free hardening for well-behaved plugins. The filesystem is read-only outside the home +# directory (ReadWritePaths keeps plugin state, download dirs, and ~/.config/punktfunk writable); +# /tmp is private; no setuid re-escalation; sockets limited to what automation actually uses +# (loopback mgmt API, LAN/IPv6 webhooks, unix sockets). A plugin that must write OUTSIDE $HOME +# (e.g. a library on another mount) gets a drop-in: +# systemctl --user edit punktfunk-scripting → [Service]\nReadWritePaths=/mnt/games +# NOTE: the mount-namespace options (ProtectSystem/PrivateTmp) need unprivileged user namespaces +# for a *user* unit; on kernels/distros that restrict those, drop them via the same drop-in. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=%h +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 [Install] WantedBy=default.target diff --git a/sdk/src/plugins.ts b/sdk/src/plugins.ts index 124f7a7c..fe066d4f 100644 --- a/sdk/src/plugins.ts +++ b/sdk/src/plugins.ts @@ -13,21 +13,47 @@ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/"; /** Where plugin packages install: `/plugins` (matches runner.ts discovery). */ export const pluginsDirDefault = (): string => path.join(configDir(), "plugins"); +export interface ResolveOptions { + /** + * Allow names that resolve on the PUBLIC npm registry (unscoped `punktfunk-plugin-*`, foreign + * scopes, arbitrary paths). Off by default: only the `@punktfunk` scope — pinned to the Gitea + * registry by [`ensureBunfig`] — installs without it, so a typo or a squatted look-alike + * package can't silently pull operator-privileged code from npmjs.org (the CLI flag is + * `--allow-public-registry`). + */ + allowPublicRegistry?: boolean; +} + /** * Resolve a friendly plugin name to its npm package. A bare first-party name maps into the * `@punktfunk` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` → - * `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention - * (`punktfunk-plugin-…`), or any name with a `/` is used verbatim. + * `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else — + * the unscoped `punktfunk-plugin-…` convention, foreign scopes, registry paths — resolves on + * the public registry and is refused unless [`ResolveOptions.allowPublicRegistry`] is set. */ -export const resolvePackage = (name: string): string => { +export const resolvePackage = ( + name: string, + opts: ResolveOptions = {}, +): string => { const n = name.trim(); if (!n) throw new Error("empty plugin name"); - if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite - if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim - if (n.includes("/")) return n; // some other registry path — trust it - return `@punktfunk/plugin-${n}`; // bare first-party name + if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) { + return `@punktfunk/plugin-${n}`; // bare first-party name + } + if (n.startsWith("@punktfunk/")) return n; // first-party scope, pinned to our registry + if (!opts.allowPublicRegistry) { + throw new Error( + `'${n}' would install from the PUBLIC npm registry, not Punktfunk's. Plugins run ` + + "with operator privileges - install only code you trust. If you mean it, re-run " + + "with --allow-public-registry.", + ); + } + return n; }; +/** Does this resolved package name install from Punktfunk's own (Gitea) registry? */ +const isFirstParty = (pkg: string): boolean => pkg.startsWith("@punktfunk/"); + /** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => { fs.mkdirSync(dir, { recursive: true }); @@ -66,7 +92,7 @@ export const ensureBunfig = (dir = pluginsDirDefault()): void => { } }; -export interface PkgOpts { +export interface PkgOpts extends ResolveOptions { /** Plugins dir. Default `/plugins`. */ dir?: string; /** Line sink for progress. Default stdout. */ @@ -101,12 +127,26 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void = }; /** Install one or more plugins by friendly name or package. */ -export const addPlugins = (names: string[], opts: PkgOpts = {}): void => - runBun("add", names.map(resolvePackage), opts); +export const addPlugins = (names: string[], opts: PkgOpts = {}): void => { + const pkgs = names.map((n) => resolvePackage(n, opts)); + const log = opts.log ?? ((l: string) => console.log(l)); + for (const pkg of pkgs.filter((p) => !isFirstParty(p))) { + log( + `[plugins] WARNING: ${pkg} installs from the public npm registry - it is not ` + + "published by Punktfunk. It will run with operator privileges.", + ); + } + runBun("add", pkgs, opts); +}; -/** Uninstall one or more plugins by friendly name or package. */ +/** Uninstall one or more plugins by friendly name or package. Removal is always safe — a name + * never gates on the registry it once came from. */ export const removePlugins = (names: string[], opts: PkgOpts = {}): void => - runBun("remove", names.map(resolvePackage), opts); + runBun( + "remove", + names.map((n) => resolvePackage(n, { allowPublicRegistry: true })), + opts, + ); export interface InstalledPlugin { /** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */ diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index 3a091a7e..d8762087 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -8,7 +8,9 @@ // // With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …` // here): -// add install first-party (playnite, rom-manager) or punktfunk-plugin-* packages +// add install first-party plugins (playnite, rom-manager); anything resolving on +// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally +// needs --allow-public-registry // remove uninstall // list list installed plugin packages // @@ -44,7 +46,12 @@ const positionals = (): string[] => { return out; }; -const pkgOpts = { dir: options.pluginsDir }; +const pkgOpts = { + dir: options.pluginsDir, + // Opt-in for names that resolve on the public npm registry (supply-chain gate in + // plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own. + allowPublicRegistry: process.argv.includes("--allow-public-registry"), +}; const runPkgOp = ( op: (names: string[], o: typeof pkgOpts) => void, diff --git a/sdk/test/plugins.test.ts b/sdk/test/plugins.test.ts index 0d8f8041..adc2554a 100644 --- a/sdk/test/plugins.test.ts +++ b/sdk/test/plugins.test.ts @@ -37,12 +37,28 @@ describe("resolvePackage", () => { expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager"); }); - test("passes through scoped, unscoped-convention, and pathed names verbatim", () => { + test("passes @punktfunk-scoped names through verbatim (our registry, no gate)", () => { expect(resolvePackage("@punktfunk/plugin-playnite")).toBe( "@punktfunk/plugin-playnite", ); - expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x"); - expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom"); + }); + + test("refuses public-registry names without allowPublicRegistry", () => { + expect(() => resolvePackage("punktfunk-plugin-custom")).toThrow( + /public/i, + ); + expect(() => resolvePackage("@someone/plugin-x")).toThrow(/public/i); + expect(() => resolvePackage("some/registry-path")).toThrow(/public/i); + }); + + test("passes public-registry names through with allowPublicRegistry", () => { + const allow = { allowPublicRegistry: true }; + expect(resolvePackage("punktfunk-plugin-custom", allow)).toBe( + "punktfunk-plugin-custom", + ); + expect(resolvePackage("@someone/plugin-x", allow)).toBe( + "@someone/plugin-x", + ); }); test("trims and rejects empty", () => {