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
1 change: 1 addition & 0 deletions Cargo.lock

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

29 changes: 25 additions & 4 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -408,18 +408,39 @@ plugin_mcp_containers:
image: ghcr.io/example/hello-mcp
tag: latest
port: 8420
network: hello-mcp-net
host_directory: /Users/you/hello-mcp-data
container_directory: /data
network_isolation: false
auth:
type: bearer_token
secret_file: /Users/you/.brain3/secrets/hello_mcp.token
secret_mount_path: /run/secrets/mcp_bearer_token
```

`name` must use lowercase letters, digits, and underscores only. Brain3 starts
each configured Plugin MCP Container with one read-write host directory mount,
sends `Authorization: Bearer <token>` when `auth.type` is `bearer_token`, and
exposes its tools with a `{name}__` prefix.
`name` must use lowercase letters, digits, and underscores only. `network` is
required and names the Docker/`container` network the plugin joins. With network
isolation enabled, Brain3 automatically creates it as an internal-only network
with no outbound access if it does not exist. Containers on different plugin
networks cannot reach each other. To let a plugin reach its own sidecar, such as
Postgres, attach the sidecar to the same network name.

`network_isolation` is optional and defaults to `true`. Set it to `false` to
join the configured network without requiring it to be internal. Brain3 reuses
an existing network as-is, including one managed by another stack, or creates a
normal non-internal network when it does not exist. A non-internal network
restores outbound internet access from inside the container. When an existing
Docker network is internal, Brain3 also attaches the container to Docker's
default bridge so its published port and outbound path remain available without
modifying the configured network. This is required for `platform: docker`
plugins on macOS, where Docker Desktop cannot combine only internal networking
with host-reachable published ports. Use `network_isolation: false` there, or
switch to `platform: macos_container` if the plugin does not need outbound
egress.

Brain3 starts each configured Plugin MCP Container with one read-write host
directory mount, sends `Authorization: Bearer <token>` when `auth.type` is
`bearer_token`, and exposes its tools with a `{name}__` prefix.

</details>

Expand Down
2 changes: 1 addition & 1 deletion SECURITY_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Brain3’s highest-risk boundary is the optional public gateway/tunnel that fron
- Whisper model downloads are setup-time, user-initiated egress and must be checksum-verified before model files are treated as usable.
- Prompt injection is generally out of scope for user-controlled vault content, but not for vaults the user does not fully control
- The gateway's SIGUSR1 diagnostics trigger is local-only: a sender must already be able to signal the gateway process, and the dump is written only to that process's stdout plus a log-file marker. The dump includes the MCP container's own logs, so the container must continue to avoid logging secrets; trace-level body logging remains covered by Finding 4.
- Plugin MCP Containers are Experimental and intentionally undocumented outside the README experimental section. They are configured only by hand-editing local files under `<app_home>` and are trusted at the same level as the core vault MCP container once started.
- Plugin MCP Containers are Experimental and intentionally undocumented outside the README experimental section. They are configured only by hand-editing local files under `<app_home>` and are trusted at the same level as the core vault MCP container once started. Individual plugins may opt out of internal-only networking with `network_isolation: false` in `brain3.yaml`, restoring outbound egress without adding remote ingress or changing this local-file-only trust boundary.
- Plugin MCP Container tool metadata and tool results are not sandboxed or semantically vetted by Brain3 before being appended to `tools/list` or returned from `tools/call`. This matches the current trust model for the vault container's tools and must be treated as trusted local-container output, not as untrusted internet content.
- Docker-backed Plugin MCP Containers on macOS are reached through a loopback-published host port because Docker Desktop does not expose published ports from internal-only networks. This remains local-only ingress, but it is not the same internal-network-only posture used by Linux Docker Plugin MCP Containers.

Expand Down
1 change: 1 addition & 0 deletions apps/gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,7 @@ async fn main() -> Result<()> {
let args = Args::parse();
let runtime_overrides = RuntimeOverrides::from_args(&args);
let logging = logging::init_logging(args.log_level.as_str(), args.brain3_home.clone()).await?;
tracing::info!(version = release::APP_VERSION, "🤖🧠 Starting brain3 ...");
let resolved_env = resolve_config_env_file(&args)?;
let interactive_terminal = is_interactive_terminal();
let mode = choose_launch_mode(&args);
Expand Down
3 changes: 3 additions & 0 deletions apps/gateway/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ async fn build_gateway_state(
native_tools,
plugin_clients,
));
mcp_router.log_startup_tool_inventory().await;

let app_state = AppState {
registrar,
Expand Down Expand Up @@ -710,6 +711,8 @@ mod tests {
host_port: None,
host_directory: "/tmp/fluensy-data".into(),
container_directory: "/data".into(),
network_name: "fluensy-learn-net".into(),
network_isolation: true,
auth,
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/gateway/src/tui/screens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ fn ports_and_settings_lines(state: &FirstRunTuiState) -> Vec<Line<'static>> {
"Enabled removes the MCP container's default outbound route for maximum isolation.",
));
lines.push(muted_line(
"Disabled uses the runtime's normal bridge/default network for VPS compatibility.",
"Disabled joins the named network without requiring internal-only isolation.",
));
if state.draft.container_network_isolated {
lines.push(field_line(
Expand Down
54 changes: 50 additions & 4 deletions apps/gateway/tests/e2e_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const OAUTH_USERNAME: &str = "e2e-test-user";
const OAUTH_PASSWORD: &str = "e2e-test-password";
const OAUTH_REDIRECT_URI: &str = "https://claude.ai/api/mcp/auth_callback";
const DIAGNOSTICS_TIMEOUT: Duration = Duration::from_secs(10);
const PROCESS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20);
const NETWORKED_E2E_TIMEOUT: Duration = Duration::from_secs(15);
const HELLO_MCP_CONTAINER_NAME: &str = "hello_mcp";
const HELLO_MCP_BEARER_TOKEN: &str = "e2e-hello-mcp-token";
Expand Down Expand Up @@ -185,8 +186,10 @@ impl TempTestDir {
image: brain3-e2e-hello-mcp
tag: e2e-local
port: 8420
network: brain3-e2e-hello-mcp-net
host_directory: {}
container_directory: /data
network_isolation: false
auth:
type: bearer_token
secret_file: {}
Expand Down Expand Up @@ -393,6 +396,42 @@ impl Brain3Process {
let _ = stdout_reader.join();
}
}

async fn shutdown_and_wait(mut self) -> io::Result<Duration> {
if matches!(self.child.try_wait(), Ok(Some(_))) {
self.join_stdout_reader();
return Ok(Duration::ZERO);
}

let started = Instant::now();
let pid = self.child.id().to_string();
let status = Command::new("kill").arg("-INT").arg(&pid).status()?;
if !status.success() {
return Err(io::Error::other(format!(
"failed to send SIGINT to brain3 pid {pid}: {status}"
)));
}

while started.elapsed() < PROCESS_SHUTDOWN_TIMEOUT {
if matches!(self.child.try_wait(), Ok(Some(_))) {
let elapsed = started.elapsed();
self.join_stdout_reader();
return Ok(elapsed);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}

let _ = self.child.kill();
let _ = self.child.wait();
self.join_stdout_reader();
Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"brain3 did not exit within {}s after SIGINT",
PROCESS_SHUTDOWN_TIMEOUT.as_secs()
),
))
}
}

fn real_cloudflared_on_path() -> bool {
Expand Down Expand Up @@ -482,7 +521,7 @@ impl Drop for Brain3Process {
let pid = self.child.id().to_string();
let _ = Command::new("kill").arg("-INT").arg(&pid).status();

let deadline = Instant::now() + Duration::from_secs(10);
let deadline = Instant::now() + PROCESS_SHUTDOWN_TIMEOUT;
while Instant::now() < deadline {
if matches!(self.child.try_wait(), Ok(Some(_))) {
self.join_stdout_reader();
Expand Down Expand Up @@ -822,9 +861,9 @@ async fn e2e_smoke_5_plugin_mcp_container() -> Result<(), Box<dyn std::error::Er
temp.write_env_file()?;
temp.write_brain3_yaml_with_hello_mcp()?;

{
let shutdown_duration = {
let gateway = Brain3Process::spawn(&temp, TunnelMode::Disabled).await?;
let _diagnostics_guard = DiagnosticsDumpGuard::new(&gateway);
let diagnostics_guard = DiagnosticsDumpGuard::new(&gateway);
assert_container_running_and_vault_visible(&gateway).await?;
let client = connect_local_mcp().await?;

Expand All @@ -847,7 +886,14 @@ async fn e2e_smoke_5_plugin_mcp_container() -> Result<(), Box<dyn std::error::Er
assert_eq!(hello, "hello world");

client.cancel().await?;
}
drop(diagnostics_guard);
gateway.shutdown_and_wait().await?
};

assert!(
shutdown_duration < Duration::from_secs(8),
"managed container shutdown took {shutdown_duration:?}, expected less than 8s"
);

assert_no_container_residue().await?;
Ok(())
Expand Down
Loading
Loading