feat(security): give the de-privileged runner a writable plugin-state dir

The LocalService runner cannot write anywhere under %ProgramData%\punktfunk
(the config dir is Users-read-only), so a state-writing plugin's saveCache /
config-edit / first-run mkdir all fail EPERM — proven on-glass (rom-manager
only looked fine because its state dir was pre-created by an admin run and a
0-title reconcile skipped the write).

Add the one writable grant the model was missing, keeping the split crisp —
code dirs RX+WA, secrets R, and now a dedicated state root RW:

- plugins enable / build-scripting.ps1: create %ProgramData%\punktfunk  plugin-state and grant LocalService (OI)(CI)(M); disable revokes. Users stay
  read-only, so another non-admin still can't tamper with a plugin's launch
  templates.
- SDK: export pluginStateDir(name) -> <config_dir>/plugin-state/<name>. Same
  path on Linux (the systemd --user runner owns the config dir, writable with
  no grant), so plugins use one branch-free helper.

Plugins must persist under pluginStateDir(), not straight under the config dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:23:48 +02:00
parent 5cd6e8f572
commit 73ec6ed9ef
6 changed files with 117 additions and 1 deletions
+20
View File
@@ -52,6 +52,26 @@ export const configDir = (): string => {
return path.join(base, "punktfunk");
};
/**
* The writable state directory a plugin should persist its config/cache into:
* `<config_dir>/plugin-state[/<name>]`.
*
* WHY this and not `<config_dir>/<name>` directly: on Windows the managed runner is de-privileged
* (runs as `NT AUTHORITY\LocalService`), and the config dir is locked to Users-read — so a plugin
* writing straight under it fails with EPERM. `punktfunk-host plugins enable` grants the runner
* **Modify** on exactly `plugin-state` (the config dir and the plugin *code* stay read-only), so
* this is the one place a supervised plugin can write. On Linux the runner is a `systemd --user`
* unit owning the whole config dir, so the path is writable there too — same code, no branch.
*
* `name` is a plugin's own kebab-case id; omit it for the shared root. The directory is NOT created
* here (the caller decides permissions/timing) — `fs.mkdirSync(pluginStateDir(name), {recursive:
* true})` from the runner inherits the granted ACL on Windows.
*/
export const pluginStateDir = (name?: string): string => {
const root = path.join(configDir(), "plugin-state");
return name ? path.join(root, name) : root;
};
const readIfExists = (p: string): string | undefined => {
try {
return fs.readFileSync(p, "utf8");
+2
View File
@@ -30,6 +30,8 @@ import {
export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
// A plugin persists its state here — the one dir the de-privileged Windows runner may write.
export { pluginStateDir } from "./config.js";
export {
type PluginUiHandle,
type PluginUiOptions,