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 <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-22 12:12:17 -04:00 • committed by GitHub
parent 5d188dbe18
commit 199cf0822e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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::<WorkerTokenScopeClaim>(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(),