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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user