Group D — three independent security primitives hardening (post-audit 2026-05-02).
kei-runtime — atom invoke RCE allowlist:
- invoke.rs: is_safe_crate_name validator (regex ^kei-[a-z][a-z0-9-]+$);
rejects /, \\, .., :, absolute paths, empty, >128 chars.
InvalidAtom error variant.
stdout/stderr capped at 16 MiB (was unbounded).
- main.rs: InvalidAtom mapped to exit code 2.
- tests/invoke_exit_codes_smoke.rs: invoke_unsafe_crate_name_exits_2 added.
- Closes: any user able to write atoms/*.md with crate_name: "rm" or "sudo"
triggered arbitrary command execution.
kei-graph-stream — WebSocket bearer + Origin:
- auth.rs (new, 142 LOC): token load + bearer extraction + Origin allowlist +
ConstantTimeEq compare; 8 unit tests.
- ws.rs: ws_handler validates Origin + bearer before upgrade (403/401 on failure).
- main.rs: --public-bind-i-accept-the-leak flag required for non-loopback bind;
else bail!() with explicit error.
- tests/smoke.rs: rewritten with Origin + bearer headers via connect_async_with_config.
- Closes: WebSocket /stream had zero auth, zero Origin check; browser CSWSH could
subscribe to agent activity broadcast; KEI_GRAPH_STREAM_BIND env silently
accepted any SocketAddr.
kei-compute-baremetal — SSH option injection (CVE-2023-51385 class):
- ssh.rs: is_safe_user + is_safe_host validators (alphanumeric + -_.; reject leading -;
max 64 chars; no @, :, /, \\, space).
- ssh.rs: -- sentinel before user@host argv (OpenSSH 9.6+ stops flag parsing).
- ssh.rs: StrictHostKeyChecking=yes default; KEI_BAREMETAL_ACCEPT_NEW=1 for TOFU.
- error.rs: InvalidRegion variant.
- provider.rs: validators applied in target_for_spec + target_for_handle.
- Closes: spec.region "-oProxyCommand=evil" triggered local RCE before TCP connect.
Test results: 29 passed; 0 failed across all three crates. cargo check clean.
Findings: RCE allowlist (Wave-A) + WebSocket auth (Wave-B) + SSH injection (Wave-B)
were unique-per-retest discoveries. None present in original wave-1 audit.
Note: kei-compute-baremetal/src/provider.rs at 300 LOC (was 268; +32 from validators).
Pre-existing >200 LOC violation, fix scope was security-additions only. Follow-up:
split provider.rs into provider.rs (<200) + provider_tests.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
83 lines
2.5 KiB
Rust
83 lines
2.5 KiB
Rust
use anyhow::{bail, Result};
|
|
use axum::{Router, routing::get};
|
|
use clap::Parser;
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use tokio::sync::broadcast;
|
|
|
|
use kei_graph_stream::{AliveState, tail, ws};
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "kei-graph-stream", about = "Stream agent events to browser via WebSocket")]
|
|
struct Cli {
|
|
#[arg(long, env = "KEI_GRAPH_STREAM_BIND", default_value = "127.0.0.1:8201")]
|
|
bind: SocketAddr,
|
|
|
|
#[arg(long, env = "KEI_EVENTS_FILE")]
|
|
events_file: Option<PathBuf>,
|
|
|
|
/// Allow binding to a non-loopback address. Without this flag,
|
|
/// kei-graph-stream refuses to start on a non-loopback bind address
|
|
/// to prevent accidental exposure of the WebSocket endpoint.
|
|
#[arg(long)]
|
|
public_bind_i_accept_the_leak: bool,
|
|
}
|
|
|
|
fn default_events_file() -> PathBuf {
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
|
PathBuf::from(home).join(".claude/memory/agent-events.jsonl")
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
if std::env::var("KEI_GRAPH_STREAM_BYPASS").as_deref() == Ok("1") {
|
|
eprintln!("[kei-graph-stream] bypass mode — exiting");
|
|
return Ok(());
|
|
}
|
|
|
|
let cli = Cli::parse();
|
|
|
|
if !cli.bind.ip().is_loopback() && !cli.public_bind_i_accept_the_leak {
|
|
bail!(
|
|
"kei-graph-stream: refusing to bind {}: non-loopback bind requires \
|
|
explicit --public-bind-i-accept-the-leak flag",
|
|
cli.bind
|
|
);
|
|
}
|
|
|
|
let events_file = cli.events_file.unwrap_or_else(default_events_file);
|
|
|
|
if let Some(parent) = events_file.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
if !events_file.exists() {
|
|
tokio::fs::write(&events_file, b"").await?;
|
|
}
|
|
|
|
let (tx, _rx) = broadcast::channel::<String>(256);
|
|
let tx = Arc::new(tx);
|
|
let alive = Arc::new(AliveState::new());
|
|
|
|
tokio::spawn(tail::run(events_file, Arc::clone(&tx), Arc::clone(&alive)));
|
|
|
|
let app = build_router(Arc::clone(&tx), Arc::clone(&alive));
|
|
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
|
eprintln!("[kei-graph-stream] listening on {}", cli.bind);
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn build_router(
|
|
tx: Arc<broadcast::Sender<String>>,
|
|
alive: Arc<AliveState>,
|
|
) -> Router {
|
|
Router::new()
|
|
.route("/stream", get(ws::ws_handler))
|
|
.route("/health", get(health_handler))
|
|
.with_state((tx, alive))
|
|
}
|
|
|
|
async fn health_handler() -> &'static str {
|
|
"kei-graph-stream alive\n"
|
|
}
|