Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ members = [
"bins/aegis-cage-runner",
"bins/aegis-egress-proxy",
"bins/aegis-llm-gateway",
"bins/aegis-tool-broker",
]
# src/fuzz has its own cargo-fuzz-managed build (ASan/sancov instrumentation,
# a separate target dir) and must never be built by `cargo build/test
Expand Down
29 changes: 29 additions & 0 deletions bins/aegis-tool-broker/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "aegis-tool-broker"
version = "0.1.0"
edition = "2021"
description = "AegisAgent standalone tool-broker execution engine (Phase 1 extraction): resolves credentials and runs connectors in its own process, so the gateway never links this crate's connectors or holds a real provider credential."

[[bin]]
name = "aegis-tool-broker"
path = "src/main.rs"

[dependencies]
aegis-tool-broker-core = { path = "../../lib/tool-broker-core" }
aegis-tool-broker-connectors = { path = "../../lib/tool-broker-connectors" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = { workspace = true }
axum = { workspace = true }
clap = { version = "4.6", features = ["derive", "env"] }
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "json"] }
sha2 = { workspace = true }
hex = "=0.4.3"

[dev-dependencies]
tempfile = "3.10"
tower = { version = "0.4", features = ["util"] }
async-trait = "0.1.89"
42 changes: 42 additions & 0 deletions bins/aegis-tool-broker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# aegis-tool-broker: standalone tool-broker execution engine (Phase 1
# extraction). A separate binary/image from the gateway — the gateway no
# longer links aegis-tool-broker-connectors at all, so this is the only
# process that resolves a real provider credential or executes a connector.
FROM rust:1.96-bookworm AS builder

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Rust version 1.96 specified in the builder stage does not exist (the current stable version is in the 1.8x range). This will cause the Docker build to fail immediately. Please use a valid stable Rust version, such as 1.82-bookworm.

FROM rust:1.82-bookworm AS builder

WORKDIR /build

# Network resilience for the crate fetch, mirroring src/Dockerfile.
ENV CARGO_NET_RETRY=10
ENV CARGO_HTTP_MULTIPLEXING=false

# #1160: repo-root .cargo/config.toml sets `--cfg tokio_unstable` — copy it
# so this build (run from /build, mirroring CI's repo-root invocation)
# resolves the same workspace config the gateway's Dockerfile does.
COPY .cargo ./.cargo
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY lib ./lib
COPY bins ./bins
RUN cargo build --release -p aegis-tool-broker --bin aegis-tool-broker
RUN strip /build/target/release/aegis-tool-broker

# distroless/cc-debian12 (no shell, no package manager), matching the
# gateway image's hardening posture. ca-certificates copied explicitly — the
# GithubConnector's real mode and HttpConnector both make outbound HTTPS
# calls and need a real CA store to verify them.
FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
COPY --from=builder /build/target/release/aegis-tool-broker /usr/local/bin/aegis-tool-broker

ENV RUST_LOG=info

# Unlike aegis-egress-proxy (a CONNECT proxy with no HTTP health route),
# this binary is a real HTTP JSON API — `--healthcheck` self-GETs /livez and
# exits 0/1, used here instead of pulling curl/wget into the distroless
# final image.
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
CMD ["/usr/local/bin/aegis-tool-broker", "--healthcheck"]

ENTRYPOINT ["/usr/local/bin/aegis-tool-broker"]
CMD ["--listen", "0.0.0.0:8899"]
69 changes: 69 additions & 0 deletions bins/aegis-tool-broker/src/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Bearer-token auth for the one privileged route (`POST /v1/execute`).
//!
//! Unlike the gateway's optional admin key (which treats a loopback bind as
//! evidence of safe operator intent), this binary's entire surface *is* the
//! privileged action it guards — there is no "safe to leave open" mode, so
//! the token is a required CLI/env field (`Cli::api_token` has no
//! `default_value`) and every `/v1/execute` call is checked unconditionally.

use axum::extract::State;
use axum::http::{header, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;

/// SHA-256 digest equality rather than a raw `==` — mirrors the gateway's
/// `admin_decision` rationale: a timing side-channel on a raw string
/// comparison can leak the configured token one byte at a time.
pub fn tokens_match(configured: &str, provided: &str) -> bool {
fn hash(token: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(token.as_bytes()))
}
hash(configured) == hash(provided)
}
Comment on lines +18 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

As a defense-in-depth measure, tokens_match should explicitly return false if the configured token is empty. This prevents any potential authentication bypass if the server is misconfigured with an empty token.

pub fn tokens_match(configured: &str, provided: &str) -> bool {
    if configured.is_empty() {
        return false;
    }
    fn hash(token: &str) -> String {
        use sha2::{Digest, Sha256};
        hex::encode(Sha256::digest(token.as_bytes()))
    }
    hash(configured) == hash(provided)
}


pub async fn require_bearer_token(
State(configured_token): State<Arc<String>>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
let provided = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));

match provided {
Some(token) if tokens_match(&configured_token, token) => next.run(request).await,
_ => (StatusCode::UNAUTHORIZED, "invalid or missing bearer token").into_response(),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn matching_tokens_are_equal() {
assert!(tokens_match("secret-token", "secret-token"));
}

#[test]
fn mismatched_tokens_are_not_equal() {
assert!(!tokens_match("secret-token", "wrong-token"));
}

#[test]
fn empty_provided_token_never_matches_a_configured_one() {
assert!(!tokens_match("secret-token", ""));
}

#[test]
fn two_empty_tokens_are_equal_but_the_server_never_configures_an_empty_one() {
// Documents the invariant this relies on: Cli::api_token has no
// default_value, so an empty configured token is unreachable in
// production -- clap itself refuses to start the process.
assert!(tokens_match("", ""));
}
Comment on lines +63 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test to assert that an empty configured token never matches, reflecting the new defense-in-depth behavior.

    #[test]
    fn empty_configured_token_never_matches() {
        assert!(!tokens_match("", ""));
    }

}
40 changes: 40 additions & 0 deletions bins/aegis-tool-broker/src/dto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! Wire types for `POST /v1/execute`. Deliberately a separate DTO from
//! `aegis_tool_broker_connectors`' own types (matching this codebase's
//! existing convention for gateway<->satellite contracts, e.g.
//! `routes/egress.rs`'s `EgressCheckRequest` vs. `aegis-egress-proxy`'s own
//! `GatewayCheckRequest` — two independently-defined, structurally-matching
//! types, not a shared crate) so this binary's public HTTP contract can
//! evolve without dragging the gateway's `Cargo.toml` along.

use aegis_tool_broker_core::BrokerAction;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct ExecuteRequest {
pub tool_name: String,
pub connector_type: String,
/// Opaque reference (e.g. `env:GITHUB_TOKEN`), or `None` for a tool
/// registered without a credential. Never a secret value.
pub credential_ref: Option<String>,
/// `active` | `disabled` — from the gateway's `broker_tools` row.
pub tool_status: String,
pub action: BrokerAction,
pub consumed_approval: Option<ConsumedApprovalWire>,
}

#[derive(Debug, Deserialize)]
pub struct ConsumedApprovalWire {
pub approval_id: String,
pub action_hash: String,
}

#[derive(Debug, Serialize)]
pub struct ExecuteResponse {
pub output: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct ErrorBody {
pub error: String,
pub kind: &'static str,
}
31 changes: 31 additions & 0 deletions bins/aegis-tool-broker/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! Maps `aegis_tool_broker_connectors::ExecuteError` to an HTTP response.
//! Status-code mapping is unchanged from the gateway's own (pre-extraction)
//! `broker_execute_error_response` in `routes/broker.rs` — only relocated.

use crate::dto::ErrorBody;
use aegis_tool_broker_connectors::ExecuteError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};

pub fn execute_error_response(e: ExecuteError) -> Response {
let (status, kind) = match &e {
ExecuteError::ToolNotActive { .. } => (StatusCode::FORBIDDEN, "tool_not_active"),
ExecuteError::UnknownConnectorType { .. } => {
(StatusCode::NOT_IMPLEMENTED, "unknown_connector_type")
}
ExecuteError::ApprovalRequired => (StatusCode::FORBIDDEN, "approval_required"),
ExecuteError::ApprovalActionMismatch { .. } => {
(StatusCode::FORBIDDEN, "approval_action_mismatch")
}
ExecuteError::Credential(_) => (StatusCode::SERVICE_UNAVAILABLE, "credential"),
ExecuteError::Connector(_) => (StatusCode::SERVICE_UNAVAILABLE, "connector"),
};
(
status,
Json(ErrorBody {
error: e.to_string(),
kind,
}),
)
.into_response()
}
143 changes: 143 additions & 0 deletions bins/aegis-tool-broker/src/executor_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Builds the [`BrokerExecutor`] this binary serves. Moved verbatim (Phase 1
//! extraction) from the gateway's `routes/broker.rs::default_broker_executor`.

use std::sync::Arc;

use aegis_tool_broker_connectors::{
BrokerExecutor, ConnectorRegistry, FilesystemConnector, GithubConnector, GithubMode,
HttpConnector, ShellConnector,
};
use aegis_tool_broker_core::EnvCredentialResolver;
use tracing::error;

/// Builds the [`BrokerExecutor`] this process serves. GitHub runs in mock
/// mode unless `AEGIS_GITHUB_API_BASE` is set (real mode); `HttpConnector`
/// is always registered (HTTPS-only). The filesystem and shell connectors
/// are opt-in via `AEGIS_BROKER_WORKSPACE` — with no configured workspace
/// there is nothing safe to scope them to, so they're simply absent from
/// the registry (an execute against `filesystem`/`shell` then fails closed
/// with `UnknownConnectorType`, not a wide-open default).
pub fn build_broker_executor() -> Arc<BrokerExecutor> {
let github_mode = match std::env::var("AEGIS_GITHUB_API_BASE") {
Ok(base_url) if !base_url.trim().is_empty() => GithubMode::Real {
base_url: base_url.trim_end_matches('/').to_string(),
},
_ => GithubMode::Mock,
};
let mut registry = ConnectorRegistry::default()
.register(Arc::new(GithubConnector::new(github_mode)))
.register(Arc::new(HttpConnector::new()));

if let Ok(workspace) = std::env::var("AEGIS_BROKER_WORKSPACE") {
if !workspace.trim().is_empty() {
match FilesystemConnector::new(&workspace) {
Ok(fs) => registry = registry.register(Arc::new(fs)),
Err(e) => error!(
"AEGIS_BROKER_WORKSPACE {:?} unusable for filesystem connector: {}",
workspace, e
),
}
match ShellConnector::new(&workspace) {
Ok(shell) => registry = registry.register(Arc::new(shell)),
Err(e) => error!(
"AEGIS_BROKER_WORKSPACE {:?} unusable for shell connector: {}",
workspace, e
),
}
}
}

Arc::new(BrokerExecutor::new(
registry,
Arc::new(EnvCredentialResolver),
))
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn github_defaults_to_mock_mode_when_api_base_unset() {
std::env::remove_var("AEGIS_GITHUB_API_BASE");
std::env::remove_var("AEGIS_BROKER_WORKSPACE");
let executor = build_broker_executor();
// Mock mode never makes a real network call; a benign read must
// succeed synchronously without any credential configured.
let output = executor
.execute(
&aegis_tool_broker_connectors::BrokerToolBinding {
tool_name: "gh".to_string(),
connector_type: "github".to_string(),
credential_ref: None,
status: "active".to_string(),
},
&aegis_tool_broker_core::BrokerAction {
tool: "gh".to_string(),
action: "read".to_string(),
resource: None,
mutates_state: false,
parameters: serde_json::json!({"path": "/repos/acme/api/issues"}),
},
None,
)
.await;
assert!(output.is_ok());
}

#[tokio::test]
async fn filesystem_and_shell_are_absent_without_a_configured_workspace() {
std::env::remove_var("AEGIS_BROKER_WORKSPACE");
let executor = build_broker_executor();
let err = executor
.execute(
&aegis_tool_broker_connectors::BrokerToolBinding {
tool_name: "fs".to_string(),
connector_type: "filesystem".to_string(),
credential_ref: None,
status: "active".to_string(),
},
&aegis_tool_broker_core::BrokerAction {
tool: "fs".to_string(),
action: "read".to_string(),
resource: None,
mutates_state: false,
parameters: serde_json::json!({}),
},
None,
)
.await
.expect_err("filesystem must be unregistered without AEGIS_BROKER_WORKSPACE");
assert!(matches!(
err,
aegis_tool_broker_connectors::ExecuteError::UnknownConnectorType { .. }
));
}

#[tokio::test]
async fn filesystem_and_shell_are_registered_with_a_configured_workspace() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("AEGIS_BROKER_WORKSPACE", dir.path());
let executor = build_broker_executor();
let output = executor
.execute(
&aegis_tool_broker_connectors::BrokerToolBinding {
tool_name: "shell".to_string(),
connector_type: "shell".to_string(),
credential_ref: None,
status: "active".to_string(),
},
&aegis_tool_broker_core::BrokerAction {
tool: "shell".to_string(),
action: "run".to_string(),
resource: None,
mutates_state: false,
parameters: serde_json::json!({"command": ["/usr/bin/env"]}),
},
None,
)
.await;
assert!(output.is_ok());
std::env::remove_var("AEGIS_BROKER_WORKSPACE");
}
}
Loading
Loading