feat(security): scope the plugin runner to a capability-limited bearer token

The scripting runner used to hold the console's full-admin mgmt-token — a
plugin defect could rewrite hooks.json (arbitrary command execution) or
administer pairing. The host now mints a second persisted secret,
plugin-token (PUNKTFUNK_PLUGIN_TOKEN), and require_auth grows a third lane
for it: loopback-confined like the admin token, but plugin_may_access
carves out /hooks (read AND write), everything under /pair, /native/pair,
/native/pending, client unpair DELETEs, and other plugins' ui-credential.
Everything a plugin legitimately does (status/library/events/sessions,
its own UI lease) is untouched.

The SDK's zero-config connect() now prefers PUNKTFUNK_PLUGIN_TOKEN /
plugin-token over mgmt-token, so plugins pick the scoped credential up
automatically; a script that genuinely needs the admin surface sets
PUNKTFUNK_MGMT_TOKEN explicitly. Old hosts without a plugin-token fall
back to mgmt-token unchanged. No OpenAPI change: the lane is auth-layer
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:20:09 +02:00
parent ecec7cc062
commit 9b5ca0eff3
6 changed files with 269 additions and 35 deletions
+11
View File
@@ -56,6 +56,10 @@ pub struct Options {
/// Bearer token required on `/api/v1` (except `/health`). `None` ⇒ unauthenticated,
/// which [`run`] only permits on loopback binds.
pub token: Option<String>,
/// The scripting runner's capability-limited bearer token (`plugin-token`): authorizes the
/// plugin surface only, never hook registration or pairing administration
/// (`auth::plugin_may_access`). Optional — `None` simply disables the lane.
pub plugin_token: Option<String>,
}
impl Default for Options {
@@ -63,6 +67,7 @@ impl Default for Options {
Options {
bind: SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT)),
token: None,
plugin_token: None,
}
}
}
@@ -81,6 +86,9 @@ pub(crate) struct MgmtState {
/// (native-only) host, where a Moonlight PIN can never arrive.
gamestream_enabled: bool,
token: Option<String>,
/// The plugin lane's token (see [`Options::plugin_token`]). Checked only after the admin token
/// mismatches, and gated by `auth::plugin_may_access` per route.
plugin_token: Option<String>,
/// The port we serve on, echoed in [`PortMap`] so a client can persist a full endpoint map.
port: u16,
}
@@ -119,6 +127,7 @@ pub async fn run(
let app = app(
state,
Some(token),
opts.plugin_token.filter(|t| !t.trim().is_empty()),
opts.bind.port(),
native,
stats,
@@ -131,6 +140,7 @@ pub async fn run(
fn app(
state: Arc<AppState>,
token: Option<String>,
plugin_token: Option<String>,
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
@@ -142,6 +152,7 @@ fn app(
stats,
gamestream_enabled,
token,
plugin_token,
port,
});
let (api_routes, api) = api_router_parts();