From 199cf0822eff6f97db21d00d478bd4a8ab2955c4 Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 12:12:17 -0400 Subject: [PATCH] feat: gate Fabro run tools by worker JWT scope (#351) Fabro run tools are now gated behind an explicit per-run opt-in so workflow agents only receive those capabilities when the run requests them. ## What changed - **`fabro_tools` setting** (`run.agent.fabro_tools`, default `false`) is resolved through the existing TOML config layer stack. - **Worker JWT scope** adds `agent:run_tools` only when the resolved setting is `true`; default worker tokens carry only `run:worker`. - **Worker tool registration** derives from the worker JWT scope claim. The CLI worker locally decodes the token payload and registers `FabroRunToolServices` only when the scope includes both `run:worker` and `agent:run_tools`. - **Server-side authorization remains authoritative**. The worker-side decode is only a local tool-registration gate; the server still validates token signature and scopes before accepting run-tool API calls. > **Behavior change:** existing runs that relied on Fabro run tools being always available must add `[run.agent] fabro_tools = true` to their workflow config. ## Verification ```sh cargo +nightly-2026-04-14 fmt --all cargo test -p fabro-cli fabro_run_tools_enabled_token_requires_run_tools_scope cargo test -p fabro-server worker_command_ cargo test -p fabro-static cargo +nightly-2026-04-14 clippy -p fabro-cli -p fabro-server -p fabro-static --all-targets -- -D warnings git diff --check ``` --------- Co-authored-by: Fabro Co-authored-by: Bryan Helmkamp --- .../fabro-cli/src/commands/run/runner.rs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index d3394e3b0..38e6e7437 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -34,6 +34,7 @@ use fabro_workflow::operations::{self, StartServices}; use fabro_workflow::run_control::RunControlState; use fabro_workflow::runtime_store::{RunStoreBackend, RunStoreHandle}; use fabro_workflow::services::FabroRunToolServices; +use jsonwebtoken::dangerous::insecure_decode; #[cfg(unix)] use tokio::signal::unix::{SignalKind, signal}; use tokio::sync::{Mutex, RwLock as AsyncRwLock, mpsc}; @@ -93,7 +94,7 @@ pub(crate) async fn execute( client.clone_for_reuse(), worker_token.to_owned(), ))); - let fabro_run_tools = if run_spec.settings.run.agent.fabro_tools { + let fabro_run_tools = if fabro_run_tools_enabled_from_worker_token(worker_token) { build_fabro_run_tool_services( worker_token, client.clone_for_reuse(), @@ -169,6 +170,34 @@ pub(crate) async fn execute( Ok(()) } +const WORKER_TOKEN_SCOPE: &str = "run:worker"; +const WORKER_RUN_TOOLS_SCOPE: &str = "agent:run_tools"; + +#[derive(serde::Deserialize)] +struct WorkerTokenScopeClaim { + scope: String, +} + +fn fabro_run_tools_enabled_from_worker_token(worker_token: &str) -> bool { + // Local tool registration only. The server validates the token signature and + // scopes. + insecure_decode::(worker_token) + .is_ok_and(|token| worker_scope_has_run_tools(&token.claims.scope)) +} + +fn worker_scope_has_run_tools(scope_claim: &str) -> bool { + let mut has_run_worker = false; + let mut has_agent_run_tools = false; + for scope in scope_claim.split_whitespace() { + match scope { + WORKER_TOKEN_SCOPE => has_run_worker = true, + WORKER_RUN_TOOLS_SCOPE => has_agent_run_tools = true, + _ => return false, + } + } + has_run_worker && has_agent_run_tools +} + fn build_fabro_run_tool_services( worker_token: &str, client: fabro_client::Client, @@ -769,6 +798,41 @@ mod tests { assert!(!super::clone_sandbox_requires_github_credentials("local")); } + #[test] + fn fabro_run_tools_enabled_token_requires_run_tools_scope() { + assert!(!super::fabro_run_tools_enabled_from_worker_token( + "not-a-jwt" + )); + assert!(!super::fabro_run_tools_enabled_from_worker_token( + &worker_token_with_claims(&serde_json::json!({ "scope": "run:worker" })), + )); + assert!(!super::fabro_run_tools_enabled_from_worker_token( + &worker_token_with_claims(&serde_json::json!({ "scope": "agent:run_tools" })), + )); + assert!(!super::fabro_run_tools_enabled_from_worker_token( + &worker_token_with_claims(&serde_json::json!({ "scope": "run:worker agent:wrong" })), + )); + assert!(!super::fabro_run_tools_enabled_from_worker_token( + &worker_token_with_claims( + &serde_json::json!({ "other": "run:worker agent:run_tools" }), + ), + )); + assert!(super::fabro_run_tools_enabled_from_worker_token( + &worker_token_with_claims( + &serde_json::json!({ "scope": "run:worker agent:run_tools" }), + ), + )); + } + + fn worker_token_with_claims(claims: &serde_json::Value) -> String { + jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + claims, + &jsonwebtoken::EncodingKey::from_secret(b"test-worker-token"), + ) + .expect("test worker token should encode") + } + fn test_user_principal(login: &str) -> Principal { Principal::user( IdpIdentity::new("https://github.com", "12345").unwrap(),