-
Notifications
You must be signed in to change notification settings - Fork 0
feat(tool-broker): extract connector execution into standalone aegis-tool-broker binary #1856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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" |
| 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 | ||
| 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"] | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| 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, | ||
| } |
| 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() | ||
| } |
| 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"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.