feat: add server sandbox provider enablement policy (#389)

Operators can now disable individual sandbox providers at the server
level via `[server.sandbox.providers.<provider>]` in `settings.toml`,
without breaking existing deployments that omit the section entirely.

## What changed

**Config layer & resolution** (`fabro-config`, `fabro-types`): new
sparse `ServerSandboxLayer` / `ServerSandboxProvidersLayer` /
`ServerSandboxProviderLayer` structs with `deny_unknown_fields` parse
validation. Resolution defaults every missing level to `enabled = true`.
The resolved `ServerSandboxSettings` / `ServerSandboxProvidersSettings`
/ `ServerSandboxProviderSettings` types live in `fabro-types` and are
shared by all consumers.

**Policy enforcement** (`fabro-server`): three check points enforce the
effective provider (after dry-run Local coercion):
1. `POST /api/v1/runs` — 400 at admission.
2. `POST /api/v1/runs/preflight` — `ok: false` with a `Sandbox Provider
Policy` error check.
3. Launch (`execute_run_in_process` / `execute_run_subprocess`) —
fail-before-execution with a `LaunchFailed` reason.

The dry-run coercion logic was extracted into
`SandboxProvider::effective_for(mode)` on the type itself and reused
across `fabro-server` and `fabro-workflow`.

**Installer** (`fabro-install`): `write_sandbox_settings` now always
writes all three provider policy tables with `enabled = true`, so
generated `settings.toml` files are self-documenting.

**API schema & clients**: `ServerNamespace` gains a required `sandbox`
field in the OpenAPI spec; three new TypeScript model files were
regenerated accordingly.

### Plan Summary
- Task 1: config layer structs → resolved types → resolver helpers →
tests
- Task 2: `effective_sandbox_provider` + `sandbox_provider_policy_error`
helpers; admission, preflight, and launch checks + integration tests
- Task 3: installer writes all three provider entries; install finish
tests updated
- Task 4: OpenAPI schema, `fabro-api` build mappings, TS client
regeneration, docs


### Fabro Details

<details>
<summary>Ran 8 stages in 59m 15s for $45.70</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 5s | – | 0 |
| preflight_lint | 2m 21s | – | 0 |
| implement | 27m 55s | $38.88 | 0 |
| simplify_opus | 14m 20s | $4.93 | 0 |
| simplify_gpt | 3m 14s | $1.88 | 0 |
| verify | 8m 49s | – | 0 |
| **Total** | **59m 15s** | **$45.70** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    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 -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -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. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 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 format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

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

---------

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-24 16:49:05 -04:00 • committed by GitHub
parent 7d655d7c95
commit 2c0416e1c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 593 additions and 26 deletions

View file

@ -7,6 +7,11 @@ Sandboxes isolate agent execution from the host machine. When an agent runs a sh
Fabro supports three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). See [Environments](/execution/environments) for full provider-specific configuration.
Operators can enable or disable which providers the server may launch with
`[server.sandbox.providers.<provider>]` in `settings.toml`. Missing entries default to
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled.
Dry-run Docker/Daytona runs execute locally, so they are governed by the `local` provider policy.
## Network access control
For cloud sandboxes (Daytona), you can control outbound network access with `[environments.<slug>.network]`. Three modes are available: `"allow_all"` (default), `"block"`, and `"cidr_allow_list"` with an `allow = ["..."]` CIDR list.

View file

@ -17,7 +17,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom
| Scope | Examples |
|---|---|
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
@ -46,6 +46,15 @@ methods = ["dev-token", "github"]
[server.auth.github]
allowed_usernames = ["alice", "bob"]
[server.sandbox.providers.local]
enabled = true
[server.sandbox.providers.docker]
enabled = true
[server.sandbox.providers.daytona]
enabled = true
[server.integrations.github]
app_id = "123456"
client_id = "Iv1.abc123"
@ -165,6 +174,24 @@ GitHub-specific auth policy.
The GitHub OAuth client ID still lives under `[server.integrations.github].client_id`.
### `[server.sandbox.providers]` section
Controls which sandbox providers the server may launch. Missing provider entries default to
`enabled = true` for backward compatibility. Disabling a provider rejects new runs whose effective
provider is disabled; dry-run Docker/Daytona runs use the local provider and are governed by
`server.sandbox.providers.local.enabled`.
```toml title="settings.toml"
[server.sandbox.providers.local]
enabled = true
[server.sandbox.providers.docker]
enabled = true
[server.sandbox.providers.daytona]
enabled = true
```
### `[server.slatedb]` section
Configure the embedded SlateDB key-value store used for run event storage.

View file

@ -11326,6 +11326,7 @@ components:
- web
- auth
- ip_allowlist
- sandbox
- storage
- artifacts
- slatedb
@ -11343,6 +11344,8 @@ components:
$ref: "#/components/schemas/ServerAuthSettings"
ip_allowlist:
$ref: "#/components/schemas/ServerIpAllowlistSettings"
sandbox:
$ref: "#/components/schemas/ServerSandboxSettings"
storage:
$ref: "#/components/schemas/ServerStorageSettings"
artifacts:
@ -11459,6 +11462,31 @@ components:
type: string
enum: [GitHubMetaHooks]
ServerSandboxSettings:
type: object
required: [providers]
properties:
providers:
$ref: "#/components/schemas/ServerSandboxProvidersSettings"
ServerSandboxProvidersSettings:
type: object
required: [local, docker, daytona]
properties:
local:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
docker:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
daytona:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
ServerSandboxProviderSettings:
type: object
required: [enabled]
properties:
enabled:
type: boolean
ServerStorageSettings:
type: object
required: [root]

View file

@ -259,6 +259,21 @@ fn main() {
"fabro_types::settings::server::IpAllowEntry",
&[],
),
(
"ServerSandboxSettings",
"fabro_types::settings::server::ServerSandboxSettings",
&[],
),
(
"ServerSandboxProvidersSettings",
"fabro_types::settings::server::ServerSandboxProvidersSettings",
&[],
),
(
"ServerSandboxProviderSettings",
"fabro_types::settings::server::ServerSandboxProviderSettings",
&[],
),
(
"ServerStorageSettings",
"fabro_types::settings::server::ServerStorageSettings",

View file

@ -25,7 +25,8 @@ pub mod types {
IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings,
ServerListenSettings, ServerLoggingSettings, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};

View file

@ -2,12 +2,18 @@ use std::any::{TypeId, type_name};
use fabro_api::types::{
LogDestination as ApiLogDestination, ObjectStoreSettings as ApiObjectStoreSettings,
ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings,
ServerNamespace as ApiServerNamespace,
ServerSandboxProviderSettings as ApiServerSandboxProviderSettings,
ServerSandboxProvidersSettings as ApiServerSandboxProvidersSettings,
ServerSandboxSettings as ApiServerSandboxSettings, ServerSettings as ApiServerSettings,
};
use fabro_config::ServerSettingsBuilder;
use fabro_types::ServerSettings;
use fabro_types::settings::ServerNamespace;
use fabro_types::settings::server::{LogDestination, ObjectStoreSettings};
use fabro_types::settings::server::{
LogDestination, ObjectStoreSettings, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings,
};
#[test]
fn server_settings_family_reuses_domain_types() {
@ -15,6 +21,9 @@ fn server_settings_family_reuses_domain_types() {
assert_same_type::<ApiServerNamespace, ServerNamespace>();
assert_same_type::<ApiObjectStoreSettings, ObjectStoreSettings>();
assert_same_type::<ApiLogDestination, LogDestination>();
assert_same_type::<ApiServerSandboxSettings, ServerSandboxSettings>();
assert_same_type::<ApiServerSandboxProvidersSettings, ServerSandboxProvidersSettings>();
assert_same_type::<ApiServerSandboxProviderSettings, ServerSandboxProviderSettings>();
}
#[test]
@ -40,6 +49,9 @@ methods = ["dev-token", "github"]
[server.auth.github]
allowed_usernames = ["alice"]
[server.sandbox.providers.daytona]
enabled = false
[server.storage]
root = "/srv/fabro"
@ -61,6 +73,18 @@ slug = "fabro-dev"
assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276");
assert_eq!(json["server"]["storage"]["root"], "/srv/fabro");
assert_eq!(json["server"]["logging"]["destination"], "stdout");
assert_eq!(
json["server"]["sandbox"]["providers"]["local"]["enabled"],
true
);
assert_eq!(
json["server"]["sandbox"]["providers"]["docker"]["enabled"],
true
);
assert_eq!(
json["server"]["sandbox"]["providers"]["daytona"]["enabled"],
false
);
assert!(json.get("features").is_none());
let round_trip: ApiServerSettings =

View file

@ -42,8 +42,9 @@ pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer,
ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer,
ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer,
ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
ServerWebLayer, SlackIntegrationLayer,
};
pub use settings::SettingsLayer;
pub use workflow::WorkflowLayer;

View file

@ -23,6 +23,8 @@ pub struct ServerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ServerSandboxLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<ServerStorageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ServerArtifactsLayer>,
@ -109,6 +111,32 @@ pub struct ServerIpAllowlistOverrideLayer {
pub trusted_proxy_count: Option<u32>,
}
/// `[server.sandbox]` — server-owned sandbox provider policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<ServerSandboxProvidersLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSandboxProvidersLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ServerSandboxProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docker: Option<ServerSandboxProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona: Option<ServerSandboxProviderLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct ServerSandboxProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
/// `[server.storage]` — single managed local disk root.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]

View file

@ -57,6 +57,7 @@ pub use layers::{
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};

View file

@ -4,7 +4,8 @@ use fabro_types::settings::server::{
IpAllowEntry, ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings,
ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSchedulerSettings,
ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};
@ -15,8 +16,8 @@ use crate::user::default_storage_dir;
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer,
ServerStorageLayer, ServerWebLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSandboxLayer,
ServerSandboxProviderLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
@ -38,6 +39,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
web,
auth,
ip_allowlist,
sandbox: resolve_sandbox(layer.sandbox.as_ref()),
storage: storage.clone(),
artifacts: resolve_artifacts(layer.artifacts.as_ref(), &storage.root, errors),
slatedb: resolve_slatedb(layer.slatedb.as_ref(), &storage.root, errors),
@ -64,6 +66,31 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
}
}
fn resolve_sandbox(layer: Option<&ServerSandboxLayer>) -> ServerSandboxSettings {
let providers = layer.and_then(|sandbox| sandbox.providers.as_ref());
ServerSandboxSettings {
providers: ServerSandboxProvidersSettings {
local: resolve_sandbox_provider(
providers.and_then(|providers| providers.local.as_ref()),
),
docker: resolve_sandbox_provider(
providers.and_then(|providers| providers.docker.as_ref()),
),
daytona: resolve_sandbox_provider(
providers.and_then(|providers| providers.daytona.as_ref()),
),
},
}
}
fn resolve_sandbox_provider(
layer: Option<&ServerSandboxProviderLayer>,
) -> ServerSandboxProviderSettings {
ServerSandboxProviderSettings {
enabled: layer.and_then(|provider| provider.enabled).unwrap_or(true),
}
}
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {
ServerStorageSettings {
root: layer

View file

@ -135,6 +135,66 @@ fn resolved_server_integrations_are_slack_only_for_chat() {
);
}
#[test]
fn server_sandbox_defaults_all_providers_enabled() {
let settings = ServerSettingsBuilder::from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
"#,
)
.expect("server settings should resolve");
let sandbox = settings.server.sandbox;
assert!(sandbox.providers.local.enabled);
assert!(sandbox.providers.docker.enabled);
assert!(sandbox.providers.daytona.enabled);
}
#[test]
fn server_sandbox_allows_partial_provider_overrides() {
let settings = ServerSettingsBuilder::from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.daytona]
enabled = false
"#,
)
.expect("server settings should resolve");
let sandbox = settings.server.sandbox;
assert!(sandbox.providers.local.enabled);
assert!(sandbox.providers.docker.enabled);
assert!(!sandbox.providers.daytona.enabled);
}
#[test]
fn parsing_rejects_unknown_server_sandbox_provider() {
let err = ServerSettingsBuilder::from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.exe]
enabled = true
"#,
)
.expect_err("unknown sandbox provider should be rejected");
assert!(
err.to_string().contains("unknown field `exe`"),
"unexpected error: {err}"
);
}
#[test]
fn parsing_rejects_unknown_server_integrations() {
let source = r"

View file

@ -442,6 +442,21 @@ pub fn write_object_store_settings(
}
}
fn write_sandbox_provider_policy(server: &mut toml::Table) -> Result<()> {
use fabro_types::SandboxProvider;
let sandbox = ensure_table(server, "sandbox")?;
let providers = ensure_table(sandbox, "providers")?;
for provider in [
SandboxProvider::Local,
SandboxProvider::Docker,
SandboxProvider::Daytona,
] {
let entry = ensure_table(providers, &provider.to_string())?;
entry.insert("enabled".to_string(), toml::Value::Boolean(true));
}
Ok(())
}
pub fn write_sandbox_settings(
doc: &mut toml::Value,
selection: InstallSandboxSelection,
@ -461,6 +476,8 @@ pub fn write_sandbox_settings(
"provider".to_string(),
toml::Value::String(provider.to_string()),
);
let server = ensure_table(root, "server")?;
write_sandbox_provider_policy(server)?;
Ok(())
}
@ -1407,6 +1424,9 @@ stale = "remove-me"
.and_then(toml::Value::as_str),
Some("docker")
);
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));
}
#[test]
@ -1433,6 +1453,22 @@ stale = "remove-me"
.and_then(toml::Value::as_str),
Some("daytona")
);
assert_eq!(sandbox_provider_enabled(&doc, "local"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "docker"), Some(true));
assert_eq!(sandbox_provider_enabled(&doc, "daytona"), Some(true));
}
fn sandbox_provider_enabled(doc: &toml::Value, provider: &str) -> Option<bool> {
doc.get("server")
.and_then(toml::Value::as_table)
.and_then(|server| server.get("sandbox"))
.and_then(toml::Value::as_table)
.and_then(|sandbox| sandbox.get("providers"))
.and_then(toml::Value::as_table)
.and_then(|providers| providers.get(provider))
.and_then(toml::Value::as_table)
.and_then(|provider| provider.get("enabled"))
.and_then(toml::Value::as_bool)
}
#[test]

View file

@ -25,8 +25,8 @@ use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxProvider, SandboxSpec}
use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunMode, RunNamespace};
use fabro_types::{ManifestPath, RunId, WorkflowSettings};
use fabro_types::settings::run::{EnvironmentProvider, RunGoal, RunNamespace};
use fabro_types::{ManifestPath, RunId, ServerSettings, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
@ -502,13 +502,26 @@ async fn build_preflight_report(
let resolved_run = materialized.run;
let server_settings = state.server_settings();
let github_integration = &server_settings.server.integrations.github;
let sandbox_provider = resolve_sandbox_provider(&resolved_run);
let sandbox_provider =
if resolved_run.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() {
SandboxProvider::Local
} else {
sandbox_provider
};
let sandbox_provider = effective_sandbox_provider(&resolved_run);
if let Some(error) = sandbox_provider_policy_error(&server_settings, sandbox_provider) {
checks.push(CheckResult {
name: "Sandbox Provider Policy".into(),
status: CheckStatus::Error,
summary: error,
details: Vec::new(),
remediation: None,
});
return Ok((
CheckReport {
title: "Run Preflight".into(),
sections: vec![CheckSection {
title: String::new(),
checks,
}],
},
false,
));
}
run_environment_capability_check(&mut checks, &resolved_run);
let needs_github_credentials =
sandbox_provider.is_clone_based() || resolved_run.integrations.github.is_token_requested();
@ -617,8 +630,25 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec<Chec
]
}
fn resolve_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider)
pub(crate) fn sandbox_provider_policy_error(
server_settings: &ServerSettings,
provider: SandboxProvider,
) -> Option<String> {
let enabled = server_settings
.server
.sandbox
.providers
.for_provider(provider)
.enabled;
(!enabled).then(|| {
format!(
"sandbox provider \"{provider}\" is disabled by server.sandbox.providers.{provider}.enabled"
)
})
}
pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProvider {
SandboxProvider::from(settings.environment.provider).effective_for(settings.execution.mode)
}
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {

View file

@ -2847,6 +2847,23 @@ async fn finish_cancelled_run_before_execution(state: &Arc<AppState>, run_id: Ru
state.scheduler_notify.notify_one();
}
/// Reject the run before execution if its effective sandbox provider is
/// disabled by server policy. Returns `true` when the run was rejected.
async fn reject_run_if_sandbox_provider_disabled(
state: &Arc<AppState>,
server_settings: &ServerSettings,
run_id: RunId,
settings: &RunNamespace,
) -> bool {
let provider = run_manifest::effective_sandbox_provider(settings);
let Some(error) = run_manifest::sandbox_provider_policy_error(server_settings, provider) else {
return false;
};
tracing::warn!(run_id = %run_id, error = %error, "Sandbox provider disabled by server policy");
fail_run_before_execution(state, run_id, FailureReason::LaunchFailed, error).await;
true
}
async fn fail_run_before_execution(
state: &Arc<AppState>,
run_id: RunId,
@ -3596,6 +3613,16 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
finish_cancelled_run_before_execution(&state, run_id).await;
return;
}
if reject_run_if_sandbox_provider_disabled(
&state,
&server_settings,
run_id,
&persisted.run_spec().settings.run,
)
.await
{
return;
}
let github_app_result = {
let run_spec = persisted.run_spec();
let settings = &run_spec.settings.run;
@ -3820,6 +3847,16 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
}
};
let agent_fabro_tools_enabled = run_state.spec.settings.run.agent.fabro_tools;
if reject_run_if_sandbox_provider_disabled(
&state,
&state.server_settings(),
run_id,
&run_state.spec.settings.run,
)
.await
{
return;
}
let state_for_build = Arc::clone(&state);
let run_dir_for_build = run_dir.clone();

View file

@ -606,6 +606,12 @@ async fn create_run(
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let run_id = prepared.run_id.unwrap_or_else(RunId::new);
let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run);
if let Some(error) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
{
return ApiError::bad_request(error).into_response();
}
if let Some(parent_id) = prepared.parent_id {
if parent_id == run_id {
return ApiError::bad_request("A run cannot be its own parent.").into_response();

View file

@ -959,6 +959,29 @@ id = "missing"
);
}
#[test]
fn sandbox_provider_policy_error_reports_disabled_provider() {
let settings = server_settings_from_toml(
r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.daytona]
enabled = false
"#,
);
assert_eq!(
crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProvider::Daytona)
.as_deref(),
Some(
"sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
)
);
}
#[test]
fn clone_sandbox_credentials_are_available_for_clone_based_providers() {
use fabro_types::settings::run::EnvironmentProvider;

View file

@ -34,6 +34,22 @@ fn spa_fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}
fn assert_sandbox_provider_policy_enabled(settings: &str) {
assert!(settings.contains("[server.sandbox.providers.local]"));
assert!(settings.contains("[server.sandbox.providers.docker]"));
assert!(settings.contains("[server.sandbox.providers.daytona]"));
assert!(settings.contains("enabled = true"));
let resolved = ServerSettingsBuilder::from_toml(settings)
.expect("settings should resolve")
.server
.sandbox
.providers;
assert!(resolved.local.enabled);
assert!(resolved.docker.enabled);
assert!(resolved.daytona.enabled);
}
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
@ -918,6 +934,7 @@ async fn token_install_finish_persists_settings_env_and_vault() {
settings.contains("provider = \"docker\""),
"settings.toml should record explicit docker sandbox provider"
);
assert_sandbox_provider_policy_enabled(&settings);
let resolved = ServerSettingsBuilder::from_toml(&settings)
.expect("settings should resolve")
.server;
@ -2677,6 +2694,7 @@ async fn daytona_install_finish_writes_settings_and_vault_secret() {
settings.contains("provider = \"daytona\""),
"settings.toml should record daytona sandbox provider"
);
assert_sandbox_provider_policy_enabled(&settings);
let vault = Vault::load(Storage::new(temp_dir.path()).secrets_path()).unwrap();
assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));

View file

@ -52,6 +52,84 @@ async fn request_json(
.await
}
fn daytona_manifest() -> serde_json::Value {
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["args"] = serde_json::json!({ "environment": "daytona" });
manifest
}
fn daytona_disabled_settings() -> crate::helpers::TestAppSettings {
settings_from_toml(
r"
_version = 1
[server.sandbox.providers.daytona]
enabled = false
",
)
}
#[tokio::test]
async fn create_run_rejects_disabled_sandbox_provider() {
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
daytona_disabled_settings(),
5,
));
let request = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(daytona_manifest().to_string()))
.expect("create run request should build");
let body = response_json(
app.clone().oneshot(request).await.unwrap(),
StatusCode::BAD_REQUEST,
"POST /api/v1/runs",
)
.await;
assert_eq!(
body["errors"][0]["detail"],
"sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
);
}
#[tokio::test]
async fn preflight_reports_disabled_sandbox_provider() {
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
daytona_disabled_settings(),
5,
));
let request = Request::builder()
.method("POST")
.uri(api("/preflight"))
.header("content-type", "application/json")
.body(Body::from(daytona_manifest().to_string()))
.expect("preflight request should build");
let body = response_json(
app.clone().oneshot(request).await.unwrap(),
StatusCode::OK,
"POST /api/v1/preflight",
)
.await;
assert_eq!(body["ok"], false);
let checks = body["checks"]["sections"][0]["checks"]
.as_array()
.expect("preflight checks should be an array");
let policy_check = checks
.iter()
.find(|check| check["name"] == "Sandbox Provider Policy")
.expect("policy check should be present");
assert_eq!(policy_check["status"], "error");
assert_eq!(
policy_check["summary"],
"sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
);
}
#[tokio::test]
async fn run_responses_include_ask_fabro_affordance() {
let settings = settings_from_toml(

View file

@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString};
use crate::settings::run::RunMode;
/// Sandbox provider for agent tool operations.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, Display, EnumString,
@ -31,6 +33,17 @@ impl SandboxProvider {
pub fn is_clone_based(&self) -> bool {
matches!(self, Self::Docker | Self::Daytona)
}
/// Coerce non-local providers to `Local` under dry-run; otherwise
/// unchanged.
#[must_use]
pub fn effective_for(self, mode: RunMode) -> Self {
if mode == RunMode::DryRun && !self.is_local() {
Self::Local
} else {
self
}
}
}
#[cfg(test)]

View file

@ -29,6 +29,7 @@ pub struct ServerNamespace {
pub web: ServerWebSettings,
pub auth: ServerAuthSettings,
pub ip_allowlist: ServerIpAllowlistSettings,
pub sandbox: ServerSandboxSettings,
pub storage: ServerStorageSettings,
pub artifacts: ServerArtifactsSettings,
pub slatedb: ServerSlateDbSettings,
@ -50,6 +51,7 @@ impl ServerNamespace {
web: ServerWebSettings::default(),
auth: ServerAuthSettings::default(),
ip_allowlist: ServerIpAllowlistSettings::default(),
sandbox: ServerSandboxSettings::default(),
storage: ServerStorageSettings::default(),
artifacts: ServerArtifactsSettings::default(),
slatedb: ServerSlateDbSettings::default(),
@ -133,6 +135,43 @@ pub struct ServerIpAllowlistOverrideSettings {
pub trusted_proxy_count: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxSettings {
pub providers: ServerSandboxProvidersSettings,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxProvidersSettings {
pub local: ServerSandboxProviderSettings,
pub docker: ServerSandboxProviderSettings,
pub daytona: ServerSandboxProviderSettings,
}
impl ServerSandboxProvidersSettings {
/// Per-provider policy entry.
#[must_use]
pub fn for_provider(&self, provider: crate::SandboxProvider) -> &ServerSandboxProviderSettings {
match provider {
crate::SandboxProvider::Local => &self.local,
crate::SandboxProvider::Docker => &self.docker,
crate::SandboxProvider::Daytona => &self.daytona,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSandboxProviderSettings {
pub enabled: bool,
}
impl Default for ServerSandboxProviderSettings {
// The resolver defaults each provider to enabled; keep the struct default
// aligned with that so callers that bypass the resolver behave identically.
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IpAllowEntry {
Literal(IpNet),

View file

@ -336,13 +336,8 @@ impl RunSession {
let resolved = &settings.run;
let sandbox_provider = resolve_sandbox_provider(resolved);
let sandbox_provider =
if resolved.execution.mode == RunMode::DryRun && !sandbox_provider.is_local() {
SandboxProvider::Local
} else {
sandbox_provider
};
resolve_sandbox_provider(resolved).effective_for(resolved.execution.mode);
let catalog = Arc::clone(&services.catalog);
let configured =
configured_providers_for_start(services.vault.as_ref(), Arc::clone(&catalog)).await;

View file

@ -397,6 +397,9 @@ models/server-listen-tcp-settings.ts
models/server-listen-unix-settings.ts
models/server-logging-settings.ts
models/server-namespace.ts
models/server-sandbox-provider-settings.ts
models/server-sandbox-providers-settings.ts
models/server-sandbox-settings.ts
models/server-scheduler-settings.ts
models/server-settings.ts
models/server-slate-db-settings.ts

View file

@ -373,6 +373,9 @@ export * from './server-listen-tcp-settings';
export * from './server-listen-unix-settings';
export * from './server-logging-settings';
export * from './server-namespace';
export * from './server-sandbox-provider-settings';
export * from './server-sandbox-providers-settings';
export * from './server-sandbox-settings';
export * from './server-scheduler-settings';
export * from './server-settings';
export * from './server-slate-db-settings';

View file

@ -36,6 +36,9 @@ import type { ServerListenSettings } from './server-listen-settings';
import type { ServerLoggingSettings } from './server-logging-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { ServerSandboxSettings } from './server-sandbox-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { ServerSchedulerSettings } from './server-scheduler-settings';
// May contain unused imports in some cases
// @ts-ignore
@ -53,6 +56,7 @@ export interface ServerNamespace {
'web': ServerWebSettings;
'auth': ServerAuthSettings;
'ip_allowlist': ServerIpAllowlistSettings;
'sandbox': ServerSandboxSettings;
'storage': ServerStorageSettings;
'artifacts': ServerArtifactsSettings;
'slatedb': ServerSlateDbSettings;

View file

@ -0,0 +1,19 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ServerSandboxProviderSettings {
'enabled': boolean;
}

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ServerSandboxProviderSettings } from './server-sandbox-provider-settings';
export interface ServerSandboxProvidersSettings {
'local': ServerSandboxProviderSettings;
'docker': ServerSandboxProviderSettings;
'daytona': ServerSandboxProviderSettings;
}

View file

@ -0,0 +1,22 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ServerSandboxProvidersSettings } from './server-sandbox-providers-settings';
export interface ServerSandboxSettings {
'providers': ServerSandboxProvidersSettings;
}