Inject GitHub App IAT into Sandbox as GITHUB_TOKEN (#7)

This PR adds GitHub App Installation Access Token (IAT) injection into
sandboxes, allowing `gh` CLI and other GitHub-authenticated tools to
work seamlessly inside workflow sandboxes. Workflow authors can declare
required GitHub permissions in `workflow.toml` under a `[github]`
section (e.g., `permissions = { contents = "write", pull_requests =
"read" }`), with project-wide defaults available in `fabro.toml`.
Workflow-level config fully replaces project-level defaults, consistent
with existing `[pull_request]` behavior.

The implementation introduces a `GitHubConfig` struct wired through
`WorkflowRunConfig`, `RunDefaults`, and `ProjectConfig`, with proper
`apply_defaults` (inherit if unset) and `merge_overlay` (replace if
present) semantics. At runtime, a new `mint_github_token()` helper signs
a JWT, resolves the repo's owner/repo from the origin URL, and requests
a scoped IAT which is injected as `GITHUB_TOKEN` into the sandbox
environment. The previously private
`create_installation_access_token_with_permissions` in `fabro-github` is
made public to support this. A preflight check also mints a token during
validation to surface credential or permission issues early.

Comprehensive tests cover TOML parsing with and without `[github]`,
default inheritance, workflow-over-default precedence, and overlay merge
semantics for `RunDefaults`.

### Fabro Details

<details>
<summary>Ran 7 stages in 27m 15s for $5.88</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $3.79 | 0 |
| simplify | 0s | $2.09 | 0 |
| verify | 0s | – | 0 |
| **Total** | **27m 15s** | **$5.88** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
    simplify          [label="Simplify", prompt="@prompts/simplify.md"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify -> verify
    verify -> exit  [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
arc-1e68f1[bot] 2026-03-15 18:24:30 -04:00 committed by GitHub
parent 527b6252ef
commit 43f5fb0edb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 290 additions and 5 deletions

View file

@ -4306,6 +4306,18 @@ components:
additionalProperties:
$ref: "#/components/schemas/McpServerEntry"
description: Default MCP server configurations.
github:
$ref: "#/components/schemas/GitHubConfiguration"
GitHubConfiguration:
description: GitHub App token injection configuration.
type: object
properties:
permissions:
type: object
additionalProperties:
type: string
description: GitHub API permissions to request (e.g. contents = write).
McpServerEntry:
description: MCP server connection entry.

View file

@ -1350,6 +1350,7 @@ mod runs {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
})
.unwrap()
}
@ -1520,6 +1521,7 @@ mod workflows {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
}),
graph: r#"digraph fix_build {
graph [
@ -1592,6 +1594,7 @@ mod workflows {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
}),
graph: r#"digraph implement {
graph [
@ -1676,6 +1679,7 @@ mod workflows {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
}),
graph: r#"digraph sync {
graph [
@ -1749,6 +1753,7 @@ mod workflows {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
}),
graph: r#"digraph expand {
graph [
@ -3310,6 +3315,7 @@ mod settings {
assets: None,
hooks: vec![],
mcp_servers: Default::default(),
github: None,
},
})
.unwrap()

View file

@ -375,6 +375,7 @@ fn fully_populated_server_config() -> ServerConfig {
},
],
mcp_servers: Default::default(),
github: None,
},
}
}

View file

@ -111,7 +111,7 @@ pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> Result<String, Strin
///
/// Uses the App JWT to find the installation for `owner/repo`, then requests
/// a token scoped to the given `permissions` on that single repository.
async fn create_installation_access_token_with_permissions(
pub async fn create_installation_access_token_with_permissions(
client: &reqwest::Client,
jwt: &str,
owner: &str,

View file

@ -5,8 +5,8 @@ use anyhow::{bail, Context};
use serde::Deserialize;
use super::run_config::{
AssetsConfig, CheckpointConfig, LlmConfig, McpServerEntry, PullRequestConfig, RunDefaults,
SandboxConfig, SetupConfig,
AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, McpServerEntry, PullRequestConfig,
RunDefaults, SandboxConfig, SetupConfig,
};
use crate::hook::HookDefinition;
@ -33,6 +33,7 @@ pub struct ProjectConfig {
pub hooks: Vec<HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
impl ProjectConfig {
@ -49,6 +50,7 @@ impl ProjectConfig {
assets: self.assets,
hooks: self.hooks,
mcp_servers: self.mcp_servers,
github: self.github,
}
}
}
@ -911,4 +913,32 @@ provider = "daytona"
None
);
}
#[test]
fn parse_project_config_with_github() {
let toml = r#"
version = 1
[github]
permissions = { contents = "read" }
"#;
let config = parse_project_config(toml).unwrap();
let github = config.github.unwrap();
assert_eq!(github.permissions["contents"], "read");
}
#[test]
fn into_run_defaults_preserves_github() {
let toml = r#"
version = 1
[github]
permissions = { contents = "read", issues = "write" }
"#;
let config = parse_project_config(toml).unwrap();
let defaults = config.into_run_defaults();
let github = defaults.github.unwrap();
assert_eq!(github.permissions["contents"], "read");
assert_eq!(github.permissions["issues"], "write");
}
}

View file

@ -272,6 +272,35 @@ fn resolve_fallback_chain(
}
}
/// Mint a GitHub App Installation Access Token with the given permissions.
///
/// Signs a JWT, resolves `owner/repo` from `origin_url`, and requests a
/// scoped token. Returns the token string on success.
async fn mint_github_token(
creds: &fabro_github::GitHubAppCredentials,
origin_url: &str,
permissions: &HashMap<String, String>,
) -> anyhow::Result<String> {
let https_url = fabro_github::ssh_url_to_https(origin_url);
let (owner, repo) =
fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let client = reqwest::Client::new();
let perms_json = serde_json::to_value(permissions)?;
let token = fabro_github::create_installation_access_token_with_permissions(
&client,
&jwt,
&owner,
&repo,
fabro_github::GITHUB_API_BASE_URL,
perms_json,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(token)
}
/// Accumulates token usage and cost across all workflow stages.
#[derive(Default)]
struct CostAccumulator {
@ -1120,6 +1149,34 @@ pub async fn run_command(
}
env
};
// Mint a GitHub App IAT and inject as GITHUB_TOKEN if [github] permissions are declared
let mut sandbox_env = sandbox_env;
let github_permissions = run_cfg
.as_ref()
.and_then(|c| c.github.as_ref())
.or(run_defaults.github.as_ref());
if let Some(gh_cfg) = github_permissions {
if !gh_cfg.permissions.is_empty() {
if let (Some(ref creds), Some(ref url)) = (&github_app, &origin_url) {
match mint_github_token(creds, url, &gh_cfg.permissions).await {
Ok(token) => {
debug!("Minted GitHub IAT for sandbox GITHUB_TOKEN");
sandbox_env.insert("GITHUB_TOKEN".to_string(), token);
}
Err(e) => {
eprintln!(
"{} Failed to mint GitHub token: {e}",
styles.yellow.apply_to("Warning:"),
);
}
}
} else {
debug!("Skipping GitHub token: no GitHub App credentials or origin URL");
}
}
}
let mcp_servers: Vec<fabro_mcp::config::McpServerConfig> = {
let servers = run_cfg
.as_ref()
@ -2095,7 +2152,7 @@ async fn run_preflight(
let env = crate::daytona_sandbox::DaytonaSandbox::new(
daytona_client,
config,
github_app,
github_app.clone(),
None,
None,
);
@ -2264,7 +2321,57 @@ async fn run_preflight(
}
};
// 5. Render report
// 5. GitHub token preflight
let github_permissions = run_cfg
.as_ref()
.and_then(|c| c.github.as_ref())
.or(run_defaults.github.as_ref());
if let Some(gh_cfg) = github_permissions {
if !gh_cfg.permissions.is_empty() {
let perm_details: Vec<CheckDetail> = gh_cfg
.permissions
.iter()
.map(|(k, v)| CheckDetail::new(format!("{k}: {v}")))
.collect();
match (&github_app, origin_url) {
(Some(creds), Some(url)) => {
match mint_github_token(creds, url, &gh_cfg.permissions).await {
Ok(_) => {
checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Pass,
summary: "minted".into(),
details: perm_details,
remediation: None,
});
}
Err(e) => {
checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Error,
summary: "failed".into(),
details: perm_details,
remediation: Some(format!("Failed to mint GitHub token: {e}")),
});
}
}
}
_ => {
checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Warning,
summary: "skipped".into(),
details: vec![],
remediation: Some(
"No GitHub App credentials or origin URL available".to_string(),
),
});
}
}
}
}
// 6. Render report
spinner.finish_and_clear();
let report = CheckReport {
@ -2607,6 +2714,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let (model, provider) = resolve_model_provider(
Some("gpt-5.2"),
@ -2651,6 +2759,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph);
assert_eq!(model, "toml-model");
@ -2730,6 +2839,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph);
assert_eq!(model, "toml-model");
@ -2762,6 +2872,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let defaults = RunDefaults::default();
assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults));
@ -2793,6 +2904,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let defaults = RunDefaults {
sandbox: Some(run_config::SandboxConfig {
@ -2873,6 +2985,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let defaults = RunDefaults::default();
assert_eq!(
@ -2933,6 +3046,7 @@ mod tests {
pull_request: None,
assets: None,
mcp_servers: Default::default(),
github: None,
};
let defaults = RunDefaults {
sandbox: Some(run_config::SandboxConfig {

View file

@ -40,6 +40,12 @@ pub struct AssetsConfig {
pub include: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct GitHubConfig {
#[serde(default)]
pub permissions: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowRunConfig {
@ -61,6 +67,7 @@ pub struct WorkflowRunConfig {
pub assets: Option<AssetsConfig>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@ -147,6 +154,7 @@ pub struct RunDefaults {
pub hooks: Vec<crate::hook::HookDefinition>,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
pub github: Option<GitHubConfig>,
}
impl WorkflowRunConfig {
@ -285,6 +293,10 @@ impl WorkflowRunConfig {
merged.extend(std::mem::take(&mut self.mcp_servers));
self.mcp_servers = merged;
}
if self.github.is_none() {
self.github = defaults.github.clone();
}
}
}
@ -416,6 +428,10 @@ impl RunDefaults {
merged.extend(overlay.mcp_servers);
self.mcp_servers = merged;
}
if overlay.github.is_some() {
self.github = overlay.github;
}
}
}
@ -2426,4 +2442,110 @@ command = "echo from-workflow"
let config = parse_run_config(toml).unwrap();
assert_eq!(config.graph, "workflow.fabro");
}
#[test]
fn parse_toml_with_github_permissions() {
let toml = r#"
version = 1
goal = "test"
graph = "w.fabro"
[github]
permissions = { contents = "write", pull_requests = "read" }
"#;
let config = parse_run_config(toml).unwrap();
let github = config.github.unwrap();
assert_eq!(github.permissions["contents"], "write");
assert_eq!(github.permissions["pull_requests"], "read");
}
#[test]
fn parse_toml_without_github_defaults_none() {
let toml = r#"
version = 1
goal = "test"
graph = "w.fabro"
"#;
let config = parse_run_config(toml).unwrap();
assert!(config.github.is_none());
}
#[test]
fn apply_defaults_github_inherited() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.fabro"
"#,
)
.unwrap();
let defaults = RunDefaults {
github: Some(GitHubConfig {
permissions: HashMap::from([("contents".into(), "read".into())]),
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
let github = cfg.github.unwrap();
assert_eq!(github.permissions["contents"], "read");
}
#[test]
fn apply_defaults_github_task_wins() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.fabro"
[github]
permissions = { contents = "write" }
"#,
)
.unwrap();
let defaults = RunDefaults {
github: Some(GitHubConfig {
permissions: HashMap::from([("contents".into(), "read".into())]),
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
let github = cfg.github.unwrap();
assert_eq!(github.permissions["contents"], "write");
}
#[test]
fn merge_overlay_github_replaces() {
let mut base = RunDefaults {
github: Some(GitHubConfig {
permissions: HashMap::from([("contents".into(), "read".into())]),
}),
..RunDefaults::default()
};
let overlay = RunDefaults {
github: Some(GitHubConfig {
permissions: HashMap::from([("issues".into(), "write".into())]),
}),
..RunDefaults::default()
};
base.merge_overlay(overlay);
let github = base.github.unwrap();
assert!(!github.permissions.contains_key("contents"));
assert_eq!(github.permissions["issues"], "write");
}
#[test]
fn merge_overlay_github_none_keeps_base() {
let mut base = RunDefaults {
github: Some(GitHubConfig {
permissions: HashMap::from([("contents".into(), "read".into())]),
}),
..RunDefaults::default()
};
let overlay = RunDefaults::default();
base.merge_overlay(overlay);
let github = base.github.unwrap();
assert_eq!(github.permissions["contents"], "read");
}
}