feat(security): run the Windows plugin runner as LocalService, not SYSTEM

The PunktfunkScripting scheduled task ran operator-installed plugin code
as SYSTEM with -RunLevel Highest — any plugin defect was a full
compromise of the box. The principal is now NT AUTHORITY\LocalService
(minimal privileges, no password to manage, exists at boot, loopback
networking works), registered without RunLevel:

- installer: New-ScheduledTaskPrincipal -UserId 'LocalService'
- plugins enable: converges the principal idempotently (migrating tasks
  an older installer or a dev box registered as SYSTEM) BEFORE starting,
  then grants LocalService read — via icacls, full-System32-path — on
  exactly the two SYSTEM/Admins-DACL'd files the runner's connect()
  needs: the scoped plugin-token and the TLS-pin cert.pem. Never
  mgmt-token. plugins disable revokes the grants; plugins status now
  prints the task principal so the migration is verifiable.
- build-scripting.ps1 mirrors the convergence + grants on dev deploys.

The usage text also mentions the new --allow-public-registry gate that
lands with the supply-chain commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:20:20 +02:00
parent 9b5ca0eff3
commit 84c47cd0a7
5 changed files with 192 additions and 15 deletions
+10 -1
View File
@@ -82,7 +82,16 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
// `process.execPath` is the bun running this file (the vendored one under the package), so a
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
const res = Bun.spawnSync([process.execPath, action, ...pkgs], {
const args = [process.execPath, action, ...pkgs];
// Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical
// path resolves into the installing admin's per-user bun cache
// (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot
// traverse — imports die with EPERM even though the plugins-dir DACL grants read (seen live
// on-glass). copyfile keeps the plugins tree self-contained under %ProgramData%.
if (action === "add" && process.platform === "win32") {
args.push("--backend=copyfile");
}
const res = Bun.spawnSync(args, {
cwd: dir,
stdio: ["inherit", "inherit", "inherit"],
});