From bf2f51d8454b0ea54d108ab976601173f5b40c37 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:38:18 +0000 Subject: [PATCH 1/2] feat(core): record names-only config diffs and the asserted actor on Updates Every agent and the operator share one Komodo API key, so an Update's `operator` cannot say which session changed a stack, and seeing what changed meant reading prev_toml/current_toml, which hold every env value in plain text (WI-865). Core now adds two logs to Updates, leaving the Update schema, the API types and the UI untouched: - "Config diff (names only)" on every resource config update: env keys added/removed/changed with values shown as sha256 fingerprints, and values for allowlisted non-secret fields only (default-deny). - "Actor (asserted)" from the X-Komodo-Actor / X-Komodo-Reason headers, carried by a task-local through the write/execute handlers. Asserted by the API key holder, not verified: attribution, not authorization. - "File change (names only)" on WriteStackFileContents: fingerprints and line counts against Komodo's cached copy. The diff logic lives in a new fork-only crate, lib/names_diff, which the komodo-mcp binary shares. The Core hooks are one or two lines each and are marked FORK (WI-865). The one changed upstream line wraps the spawned write task in tdd::propagate. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 + bin/core/Cargo.toml | 2 + bin/core/src/api/execute/mod.rs | 2 + bin/core/src/api/write/mod.rs | 5 +- bin/core/src/api/write/stack.rs | 7 + bin/core/src/helpers/update.rs | 6 + bin/core/src/main.rs | 2 + bin/core/src/resource/mod.rs | 4 + bin/core/src/tdd/mod.rs | 296 ++++++++++++ lib/names_diff/Cargo.toml | 16 + lib/names_diff/src/lib.rs | 779 ++++++++++++++++++++++++++++++++ 11 files changed, 1129 insertions(+), 1 deletion(-) create mode 100644 bin/core/src/tdd/mod.rs create mode 100644 lib/names_diff/Cargo.toml create mode 100644 lib/names_diff/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7ac1d086e1..22da376123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3537,6 +3537,7 @@ dependencies = [ "mogh_secret_file", "mogh_server", "mogh_validations", + "names_diff", "partial_derive2", "periphery_client", "regex", @@ -4152,6 +4153,16 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "names_diff" +version = "2.2.0" +dependencies = [ + "serde", + "serde_json", + "sha2 0.11.0", + "toml", +] + [[package]] name = "ndk-context" version = "0.1.1" diff --git a/bin/core/Cargo.toml b/bin/core/Cargo.toml index dbb9983f07..46ad0390d1 100644 --- a/bin/core/Cargo.toml +++ b/bin/core/Cargo.toml @@ -20,6 +20,8 @@ periphery_client.workspace = true mogh_validations.workspace = true interpolate.workspace = true infisical.workspace = true +# FORK: names-only change records (WI-865). Path dep, so no root Cargo.toml line. +names_diff = { path = "../../lib/names_diff" } mogh_secret_file.workspace = true formatting.workspace = true mogh_rate_limit.workspace = true diff --git a/bin/core/src/api/execute/mod.rs b/bin/core/src/api/execute/mod.rs index 502120b092..3bae4d91da 100644 --- a/bin/core/src/api/execute/mod.rs +++ b/bin/core/src/api/execute/mod.rs @@ -173,6 +173,8 @@ pub fn router() -> Router { .layer(middleware::from_fn( authenticate_request::, )) + // FORK: X-Komodo-Actor/-Reason attribution (WI-865). + .layer(middleware::from_fn(crate::tdd::scope_layer)) } async fn variant_handler( diff --git a/bin/core/src/api/write/mod.rs b/bin/core/src/api/write/mod.rs index 7eb880cdd1..8e16115f90 100644 --- a/bin/core/src/api/write/mod.rs +++ b/bin/core/src/api/write/mod.rs @@ -226,6 +226,8 @@ pub fn router() -> Router { .layer(middleware::from_fn( authenticate_request::, )) + // FORK: X-Komodo-Actor/-Reason attribution (WI-865). + .layer(middleware::from_fn(crate::tdd::scope_layer)) } async fn variant_handler( @@ -244,7 +246,8 @@ async fn handler( Extension(user): Extension, Json(request): Json, ) -> mogh_error::Result { - let res = tokio::spawn(task(request, user)) + // FORK: `propagate` carries the asserted actor into the spawned task. + let res = tokio::spawn(crate::tdd::propagate(task(request, user))) .await .context("failure in spawned task"); diff --git a/bin/core/src/api/write/stack.rs b/bin/core/src/api/write/stack.rs index ebfa1e6280..34c032340b 100644 --- a/bin/core/src/api/write/stack.rs +++ b/bin/core/src/api/write/stack.rs @@ -214,6 +214,13 @@ impl Resolve for WriteStackFileContents { make_update(&stack, Operation::WriteStackContents, user); update.push_simple_log("File contents to write", &contents); + // FORK (WI-865): content-free summary of the change. + crate::tdd::push_file_change( + &mut update, + &stack, + &file_path, + &contents, + ); let id = stack.id.clone(); diff --git a/bin/core/src/helpers/update.rs b/bin/core/src/helpers/update.rs index 03b727c783..646d82a4ba 100644 --- a/bin/core/src/helpers/update.rs +++ b/bin/core/src/helpers/update.rs @@ -46,6 +46,7 @@ pub fn make_update( pub async fn add_update( mut update: Update, ) -> anyhow::Result { + crate::tdd::stamp(&mut update); // FORK (WI-865) update.id = db_client() .updates .insert_one(&update) @@ -77,6 +78,10 @@ pub async fn add_update_without_send( } pub async fn update_update(update: Update) -> anyhow::Result<()> { + // FORK (WI-865): re-stamp, idempotently, in case the caller's in-memory + // copy predates the stamp that add_update put on the stored one. + let mut update = update; + crate::tdd::stamp(&mut update); update_one_by_id(&db_client().updates, &update.id, database::mungos::update::Update::Set(to_document(&update)?), None) .await .context("failed to update the update on db. the update build process was deleted")?; @@ -271,6 +276,7 @@ pub async fn init_execution_update( let mut update = make_update(target, operation, user); update.in_progress(); + crate::tdd::stamp(&mut update); // FORK (WI-865) // Hold off on even adding update for DeployStackIfChanged if !matches!(&request, ExecuteRequest::DeployStackIfChanged(_)) { diff --git a/bin/core/src/main.rs b/bin/core/src/main.rs index 6cb8e03deb..fb23f4d4d7 100644 --- a/bin/core/src/main.rs +++ b/bin/core/src/main.rs @@ -26,6 +26,8 @@ mod startup; mod state; mod sync; mod ts_client; +// FORK: attribution + names-only diffs on Updates (WI-865). +mod tdd; async fn app() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/bin/core/src/resource/mod.rs b/bin/core/src/resource/mod.rs index a8c44ee6f0..46d80bd5c6 100644 --- a/bin/core/src/resource/mod.rs +++ b/bin/core/src/resource/mod.rs @@ -637,6 +637,10 @@ pub async fn update( .push_simple_log("Failed export", format_serror(&e.into())), } + // FORK (WI-865): names-only diff, so the change is readable without + // the secret-bearing prev_toml/current_toml. + crate::tdd::push_config_diff(&mut update); + let updated = get::(id_or_name).await?; T::post_update(&updated, &mut update).await?; diff --git a/bin/core/src/tdd/mod.rs b/bin/core/src/tdd/mod.rs new file mode 100644 index 0000000000..65d246262f --- /dev/null +++ b/bin/core/src/tdd/mod.rs @@ -0,0 +1,296 @@ +//! Fork-only (TheDancingDeveloper) additions to Core: attribution and +//! names-only change records for config/env/file updates (Vogt WI-865). +//! +//! Two things are recorded on Update records, each as an extra log so the +//! Update schema, the UI and the API types stay untouched: +//! +//! * **`Actor (asserted)`** — the `X-Komodo-Actor` / `X-Komodo-Reason` +//! request headers. Every agent shares one Komodo API key, so `operator` +//! alone cannot say *which* session made a change. The headers are +//! asserted by the API key holder, not authenticated; they attribute, they +//! do not authorize. Sent by `komodo-mcp` on every mutating call. +//! * **`Config diff (names only)`** / **`File change (names only)`** — what +//! changed, with env values and non-allowlisted fields shown only as +//! fingerprints (see `lib/names_diff`). Reading "what changed on this +//! stack's env" no longer requires dumping `prev_toml`, which holds every +//! secret in plain text. +//! +//! Hook points (all purely additive, one or two lines each): +//! `api/write/mod.rs` and `api/execute/mod.rs` (header scope), +//! `helpers/update.rs` (stamp), `resource/mod.rs::update` (config diff), +//! `api/write/stack.rs::WriteStackFileContents` (file change). + +use std::future::Future; + +use axum::{extract::Request, middleware::Next, response::Response}; +use komodo_client::entities::{ + stack::Stack, + update::{Log, Update}, +}; + +pub const ACTOR_HEADER: &str = "x-komodo-actor"; +pub const REASON_HEADER: &str = "x-komodo-reason"; +pub const ACTOR_LOG_STAGE: &str = "Actor (asserted)"; +pub const DIFF_LOG_STAGE: &str = "Config diff (names only)"; +pub const FILE_LOG_STAGE: &str = "File change (names only)"; + +/// The asserted actor of the request being handled. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Asserted { + pub actor: Option, + pub reason: Option, +} + +tokio::task_local! { + static ASSERTED: Asserted; +} + +fn clean(value: &str) -> Option { + let v: String = value + .chars() + .filter(|c| c.is_ascii_graphic() || *c == ' ') + .take(200) + .collect(); + let v = v.trim(); + (!v.is_empty()).then(|| v.to_string()) +} + +impl Asserted { + pub fn from_headers(headers: &axum::http::HeaderMap) -> Asserted { + let get = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(clean) + }; + Asserted { + actor: get(ACTOR_HEADER), + reason: get(REASON_HEADER), + } + } + + fn log(&self) -> Option { + if self.actor.is_none() && self.reason.is_none() { + return None; + } + let mut body = String::new(); + if let Some(a) = &self.actor { + body.push_str(&format!("actor: {a}\n")); + } + if let Some(r) = &self.reason { + body.push_str(&format!("reason: {r}\n")); + } + body.push_str( + "source: X-Komodo-Actor/X-Komodo-Reason headers (asserted by the API key holder, not verified)", + ); + Some(Log::simple(ACTOR_LOG_STAGE, body)) + } +} + +/// Router middleware: make the request's asserted actor visible to the +/// handler (and to `make_update`/`add_update` beneath it). +pub async fn scope_layer(req: Request, next: Next) -> Response { + let asserted = Asserted::from_headers(req.headers()); + ASSERTED.scope(asserted, next.run(req)).await +} + +/// Carry the current asserted actor into a future that will be spawned. +pub fn propagate( + fut: F, +) -> impl Future { + let asserted = ASSERTED.try_with(Clone::clone).unwrap_or_default(); + ASSERTED.scope(asserted, fut) +} + +/// Add the actor log to an update, once. No-op outside a request scope +/// (background tasks) or when the caller sent no headers. +pub fn stamp(update: &mut Update) { + if update.logs.iter().any(|l| l.stage == ACTOR_LOG_STAGE) { + return; + } + if let Ok(Some(log)) = ASSERTED.try_with(Asserted::log) { + update.logs.push(log); + } +} + +/// Record a names-only diff of `prev_toml` -> `current_toml`. +pub fn push_config_diff(update: &mut Update) { + if update.prev_toml.is_empty() && update.current_toml.is_empty() { + return; + } + let body = match names_diff::diff_resource_toml( + &update.prev_toml, + &update.current_toml, + ) { + Ok(diff) => diff.render(), + Err(e) => format!("names-only diff unavailable: {e}"), + }; + update.push_simple_log(DIFF_LOG_STAGE, body); +} + +/// Record a content-free summary of a stack file write, relative to +/// Komodo's cached copy of that file. +pub fn push_file_change( + update: &mut Update, + stack: &Stack, + path: &str, + contents: &str, +) { + let norm = |p: &str| p.trim_start_matches("./").to_string(); + let before = stack + .info + .remote_contents + .iter() + .flatten() + .find(|f| norm(&f.path) == norm(path)) + .map(|f| f.contents.as_str()); + let c = names_diff::diff_file(before, contents); + let mut body = format!( + "path: {path}\nbefore: {} (Komodo's cached copy)\nafter: {}\nlines: +{} -{}\n", + c.before.as_deref().unwrap_or("(not in cache: new file)"), + c.after, + c.lines_added, + c.lines_removed, + ); + // An env file written through Komodo: also name the keys that changed. + let file = path.rsplit('/').next().unwrap_or(path); + if file.ends_with(".env") || file.starts_with(".env") { + let d = + names_diff::diff_env(before.unwrap_or_default(), contents); + let mut cd = names_diff::ConfigDiff::default(); + cd.env.insert(path.to_string(), d); + body.push_str(&cd.render()); + } + update.push_simple_log(FILE_LOG_STAGE, body); +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue}; + use komodo_client::entities::stack::StackRemoteFileContents; + + use super::*; + + const SECRET: &str = "FAKE-secret-value-0000001"; + + #[test] + fn headers_are_sanitised() { + let mut h = HeaderMap::new(); + h.insert( + ACTOR_HEADER, + HeaderValue::from_static("vogt-session:abc"), + ); + h.insert( + REASON_HEADER, + HeaderValue::from_static(" bump\tdigest "), + ); + let a = Asserted::from_headers(&h); + assert_eq!(a.actor.as_deref(), Some("vogt-session:abc")); + assert_eq!(a.reason.as_deref(), Some("bumpdigest")); + assert_eq!( + Asserted::from_headers(&HeaderMap::new()), + Asserted::default() + ); + } + + #[tokio::test] + async fn stamp_only_inside_scope_and_once() { + let mut u = Update::default(); + stamp(&mut u); + assert!(u.logs.is_empty(), "no scope, no stamp"); + let a = Asserted { + actor: Some("vogt-session:abc".into()), + reason: Some("why".into()), + }; + let u = ASSERTED + .scope(a, async { + let mut u = Update::default(); + stamp(&mut u); + stamp(&mut u); + u + }) + .await; + assert_eq!(u.logs.len(), 1); + assert!(u.logs[0].stdout.contains("actor: vogt-session:abc")); + assert!(u.logs[0].stdout.contains("reason: why")); + } + + #[tokio::test] + async fn propagate_crosses_spawn() { + let a = Asserted { + actor: Some("x".into()), + reason: None, + }; + let u = ASSERTED + .scope(a, async { + tokio::spawn(propagate(async { + let mut u = Update::default(); + stamp(&mut u); + u + })) + .await + .unwrap() + }) + .await; + assert_eq!(u.logs.len(), 1); + // Without propagate a spawned task has no actor. + let u = tokio::spawn(async { + let mut u = Update::default(); + stamp(&mut u); + u + }) + .await + .unwrap(); + assert!(u.logs.is_empty()); + } + + #[test] + fn config_diff_log_has_no_values() { + let mut u = Update { + prev_toml: format!( + "[[stack]]\nname = \"s\"\n[stack.config]\nenvironment = \"\"\"\nA=1\nTOKEN={SECRET}\n\"\"\"\n" + ), + current_toml: "[[stack]]\nname = \"s\"\n[stack.config]\nenvironment = \"\"\"\nA=1\nTOKEN=rotated-FAKE-0000002\nNEW=[[infisical://apps/prod/NEW]]\n\"\"\"\n".into(), + ..Default::default() + }; + push_config_diff(&mut u); + let log = &u.logs[0]; + assert_eq!(log.stage, DIFF_LOG_STAGE); + assert!(log.stdout.contains("~ TOKEN")); + assert!( + log.stdout.contains("+ NEW = [[infisical://apps/prod/NEW]]") + ); + assert!(!log.stdout.contains(SECRET)); + assert!(!log.stdout.contains("rotated-FAKE")); + } + + #[test] + fn file_change_log_has_no_content() { + let stack = Stack { + info: komodo_client::entities::stack::StackInfo { + remote_contents: Some(vec![StackRemoteFileContents { + path: "compose.yml".into(), + contents: format!("a\n# {SECRET}\n"), + ..Default::default() + }]), + ..Default::default() + }, + ..Default::default() + }; + let mut u = Update::default(); + push_file_change(&mut u, &stack, "./compose.yml", "a\nb\n"); + let body = &u.logs[0].stdout; + assert!(body.contains("lines: +1 -1"), "{body}"); + assert!(!body.contains(SECRET)); + let mut u = Update::default(); + push_file_change( + &mut u, + &stack, + ".env", + &format!("K={SECRET}\n"), + ); + let body = &u.logs[0].stdout; + assert!(body.contains("+ K = sha256:"), "{body}"); + assert!(!body.contains(SECRET)); + } +} diff --git a/lib/names_diff/Cargo.toml b/lib/names_diff/Cargo.toml new file mode 100644 index 0000000000..86dea86056 --- /dev/null +++ b/lib/names_diff/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "names_diff" +description = "Names-only diffs of Komodo env and config (fork-only, TheDancingDeveloper)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +serde.workspace = true +sha2.workspace = true +toml.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/lib/names_diff/src/lib.rs b/lib/names_diff/src/lib.rs new file mode 100644 index 0000000000..6f5c2f6aa2 --- /dev/null +++ b/lib/names_diff/src/lib.rs @@ -0,0 +1,779 @@ +//! Names-only diffs of Komodo environment and resource config. +//! +//! Fork-only (TheDancingDeveloper). Shared by Komodo Core, which records a +//! names-only diff on the Update of every resource config change, and by the +//! `komodo-mcp` binary, which presents stack history and env diffs to agents. +//! +//! The contract of this crate is that **no function returns or renders a +//! value** from an environment block or from any config field that is not on +//! an explicit allowlist ([`SAFE_VALUE_KEYS`]). Values are represented only by +//! a [`fingerprint`] (a sha256 prefix plus the value length), which is enough +//! to tell "changed" from "unchanged" and to compare two stacks, but is not +//! the value. Config fields this crate does not know are treated as secret +//! (default-deny). +//! +//! Note: a fingerprint of a *weak* value (a short dictionary password) can be +//! confirmed by brute force like any deterministic hash. Fingerprints are for +//! change detection, not a secrecy boundary for weak secrets. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Config keys whose **values** hold `KEY=VALUE` lists. These are diffed by +/// variable name; their values are never rendered. +pub const ENV_KEYS: &[&str] = + &["environment", "build_args", "secret_args"]; + +/// Config keys whose values are safe to render verbatim in a diff. Anything +/// not listed here (and not in [`ENV_KEYS`]) is rendered as a fingerprint +/// only. Extending this list is a security decision: only add keys that can +/// never carry a credential. +pub const SAFE_VALUE_KEYS: &[&str] = &[ + "name", + "description", + "tags", + "template", + "deploy", + "after", + "server", + "server_id", + "swarm", + "swarm_id", + "builder", + "builder_id", + "linked_repo", + "git_provider", + "git_https", + "git_account", + "repo", + "branch", + "commit", + "clone_path", + "reclone", + "run_directory", + "build_path", + "dockerfile_path", + "file_paths", + "env_file_path", + "additional_env_files", + "config_files", + "files_on_host", + "auto_pull", + "auto_update", + "auto_update_all_services", + "auto_update_skip_services", + "poll_for_updates", + "run_build", + "destroy_before_deploy", + "skip_secret_interp", + "send_alerts", + "webhook_enabled", + "webhook_force_deploy", + "ignore_services", + "project_name", + "registry_provider", + "registry_account", + "image", + "image_registry_account", + "network", + "restart", + "links", + "termination_signal", + "termination_timeout", + "redeploy_on_build", + "version", + "auto_increment_version", + "image_name", + "image_tag", +]; + +/// A short, non-reversible stand-in for a value: `sha256:<12 hex>/`. +/// An empty value is rendered as `empty`. +pub fn fingerprint(value: &str) -> String { + if value.is_empty() { + return String::from("empty"); + } + let digest = Sha256::digest(value.as_bytes()); + let mut hex = String::with_capacity(12); + for byte in digest.iter().take(6) { + hex.push_str(&format!("{byte:02x}")); + } + format!("sha256:{hex}/{}", value.chars().count()) +} + +/// The value of an env entry with one layer of matching wrapping quotes +/// removed, so `A="x"` and `A=x` fingerprint the same. +pub fn normalize_value(value: &str) -> &str { + let v = value.trim(); + for q in ['"', '\''] { + if v.len() >= 2 && v.starts_with(q) && v.ends_with(q) { + return &v[1..v.len() - 1]; + } + } + v +} + +/// If a value is exactly one Komodo interpolation reference such as +/// `[[infisical://apps/prod/X]]` or `[[MY_SECRET]]`, return it. A reference +/// names where a secret lives; it is not the secret, so it is safe to show. +pub fn reference_of(value: &str) -> Option<&str> { + let v = normalize_value(value); + let inner = v.strip_prefix("[[")?.strip_suffix("]]")?; + if inner.is_empty() + || inner.contains("[[") + || inner.contains("]]") + || inner.starts_with('[') + { + return None; + } + if inner.chars().all(|c| { + c.is_ascii_alphanumeric() + || matches!(c, '_' | '-' | '.' | '/' | ':') + }) { + Some(v) + } else { + None + } +} + +/// One parsed environment entry. The value is kept in memory (it is needed +/// to fingerprint and to scrub) but this type deliberately does not +/// implement `Serialize`, so it cannot be emitted by accident. +#[derive(Clone)] +pub struct EnvEntry { + pub name: String, + pub value: String, +} + +impl std::fmt::Debug for EnvEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "EnvEntry({}={})", self.name, fingerprint(&self.value)) + } +} + +/// The result of parsing an env block. +#[derive(Debug, Clone, Default)] +pub struct ParsedEnv { + pub entries: Vec, + /// Lines that could not be parsed as `KEY=VALUE` with a valid key. Only + /// their count is kept: the content of such a line may be a secret (for + /// example a stray line of a multi-line private key). + pub unparsed_lines: usize, +} + +impl ParsedEnv { + /// Name -> value (last assignment wins, matching dotenv semantics). + pub fn map(&self) -> BTreeMap<&str, &str> { + self + .entries + .iter() + .map(|e| (e.name.as_str(), e.value.as_str())) + .collect() + } + + pub fn names(&self) -> Vec { + let set: BTreeSet<&str> = + self.entries.iter().map(|e| e.name.as_str()).collect(); + set.into_iter().map(String::from).collect() + } +} + +fn valid_key(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + key.len() <= 256 + && chars.all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') + }) +} + +/// Parse a Komodo environment block. Follows Komodo's own +/// `parse_key_value_list` rules (comments, ` #` end-of-line comments, yaml +/// `- ` prefixes, `=` or `:` as the assignment, wrapping quotes) and +/// additionally tolerates quoted values that span several lines, and lines +/// it cannot parse (which are counted, never kept). +pub fn parse_env(input: &str) -> ParsedEnv { + let mut out = ParsedEnv::default(); + let mut lines = input.split('\n').peekable(); + while let Some(raw) = lines.next() { + let line = raw.trim(); + if line.is_empty() + || line.starts_with('#') + || line.starts_with("//") + { + continue; + } + let line = line.trim_start_matches('-').trim(); + let line = line.strip_prefix("export ").unwrap_or(line).trim(); + let Some(idx) = line.find(['=', ':']) else { + out.unparsed_lines += 1; + continue; + }; + let mut key = line[..idx].trim(); + let mut value = line[idx + 1..].trim().to_string(); + // Wrapping quotes around key AND value: "KEY=value" + if (key.starts_with('"') || key.starts_with('\'')) + && !(key.ends_with('"') || key.ends_with('\'')) + { + key = key[1..].trim(); + if value.ends_with('"') || value.ends_with('\'') { + value.pop(); + value = value.trim().to_string(); + } + } + // A quoted value that does not close on this line continues until a + // line that ends with the same quote. + if let Some(q) = + value.chars().next().filter(|c| *c == '"' || *c == '\'') + && (value.len() == 1 || !value.ends_with(q)) + { + for next in lines.by_ref() { + value.push('\n'); + value.push_str(next); + if next.trim_end().ends_with(q) { + break; + } + } + value = value.trim_end().to_string(); + } else if let Some((v, _)) = value.split_once(" #") { + value = v.trim().to_string(); + } + if !valid_key(key) { + out.unparsed_lines += 1; + continue; + } + out.entries.push(EnvEntry { + name: key.to_string(), + value, + }); + } + out +} + +/// What is known about one env value without showing it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValueRef { + pub fingerprint: String, + /// Set when the whole value is one interpolation reference, e.g. + /// `[[infisical://apps/prod/X]]`. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference: Option, +} + +impl ValueRef { + pub fn of(value: &str) -> ValueRef { + ValueRef { + fingerprint: fingerprint(normalize_value(value)), + reference: reference_of(value).map(String::from), + } + } + + fn render(&self) -> String { + match &self.reference { + Some(r) => format!("{r} ({})", self.fingerprint), + None => self.fingerprint.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvChange { + pub name: String, + pub before: ValueRef, + pub after: ValueRef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamedValue { + pub name: String, + pub value: ValueRef, +} + +/// Names-only diff of two env blocks. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, +)] +pub struct EnvDiff { + pub added: Vec, + pub removed: Vec, + pub changed: Vec, + pub unchanged: usize, + /// Lines on either side that could not be parsed (content withheld). + #[serde(skip_serializing_if = "is_zero")] + pub unparsed_lines: usize, +} + +fn is_zero(n: &usize) -> bool { + *n == 0 +} + +impl EnvDiff { + pub fn is_empty(&self) -> bool { + self.added.is_empty() + && self.removed.is_empty() + && self.changed.is_empty() + } +} + +/// Diff two env blocks by name. Values are compared after quote +/// normalisation and rendered only as [`ValueRef`]s. +pub fn diff_env(before: &str, after: &str) -> EnvDiff { + let a = parse_env(before); + let b = parse_env(after); + let mut diff = diff_env_maps(&a.map(), &b.map()); + diff.unparsed_lines = a.unparsed_lines + b.unparsed_lines; + diff +} + +/// Diff two name->value maps. +pub fn diff_env_maps( + a: &BTreeMap<&str, &str>, + b: &BTreeMap<&str, &str>, +) -> EnvDiff { + let mut diff = EnvDiff::default(); + for (name, va) in a { + match b.get(name) { + None => diff.removed.push(NamedValue { + name: name.to_string(), + value: ValueRef::of(va), + }), + Some(vb) if normalize_value(va) != normalize_value(vb) => { + diff.changed.push(EnvChange { + name: name.to_string(), + before: ValueRef::of(va), + after: ValueRef::of(vb), + }) + } + Some(_) => diff.unchanged += 1, + } + } + for (name, vb) in b { + if !a.contains_key(name) { + diff.added.push(NamedValue { + name: name.to_string(), + value: ValueRef::of(vb), + }); + } + } + diff +} + +/// One changed non-env config field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FieldChange { + /// Dotted path, e.g. `config.branch`. + pub path: String, + /// The previous value: verbatim for [`SAFE_VALUE_KEYS`], otherwise a + /// fingerprint. `None` = not set (default). + pub before: Option, + pub after: Option, +} + +/// Names-only diff of a resource's config, computed from Komodo's TOML +/// exports (`Update.prev_toml` / `Update.current_toml`). +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, +)] +pub struct ConfigDiff { + pub fields: Vec, + /// Env-like fields (see [`ENV_KEYS`]) keyed by dotted path. + pub env: BTreeMap, +} + +impl ConfigDiff { + pub fn is_empty(&self) -> bool { + self.fields.is_empty() && self.env.values().all(EnvDiff::is_empty) + } + + /// Human-readable rendering for an Update log. Contains no values except + /// for allowlisted fields. + pub fn render(&self) -> String { + let mut out = String::from( + "Names-only diff. Env values and non-allowlisted fields are shown as \ + fingerprints (sha256 prefix/length), never as values.\n", + ); + if self.is_empty() { + out.push_str("(no config change detected)\n"); + return out; + } + for f in &self.fields { + let show = |v: &Option| { + v.clone().unwrap_or_else(|| "(unset)".into()) + }; + out.push_str(&format!( + "~ {}: {} -> {}\n", + f.path, + show(&f.before), + show(&f.after) + )); + } + for (path, d) in &self.env { + if d.is_empty() { + continue; + } + out.push_str(&format!("{path} ({} unchanged):\n", d.unchanged)); + for a in &d.added { + out.push_str(&format!( + " + {} = {}\n", + a.name, + a.value.render() + )); + } + for r in &d.removed { + out.push_str(&format!( + " - {} (was {})\n", + r.name, + r.value.render() + )); + } + for c in &d.changed { + out.push_str(&format!( + " ~ {}: {} -> {}\n", + c.name, + c.before.render(), + c.after.render() + )); + } + if d.unparsed_lines > 0 { + out.push_str(&format!( + " ({} unparseable line(s), content withheld)\n", + d.unparsed_lines + )); + } + } + out + } +} + +fn render_toml_value(key: &str, value: &toml::Value) -> String { + if SAFE_VALUE_KEYS.contains(&key) { + let s = match value { + toml::Value::String(s) => format!("{s:?}"), + other => other.to_string(), + }; + if s.chars().count() > 300 { + let cut: String = s.chars().take(300).collect(); + format!("{cut}… ({})", fingerprint(&s)) + } else { + s + } + } else { + let s = match value { + toml::Value::String(s) => s.clone(), + other => other.to_string(), + }; + fingerprint(&s) + } +} + +fn as_env_text(value: Option<&toml::Value>) -> String { + match value { + None => String::new(), + Some(toml::Value::String(s)) => s.clone(), + // Komodo also accepts `[{variable, value}]` lists. + Some(toml::Value::Array(items)) => items + .iter() + .filter_map(|i| { + let t = i.as_table()?; + let k = t.get("variable")?.as_str()?; + let v = t.get("value")?.as_str()?; + Some(format!("{k}={v}")) + }) + .collect::>() + .join("\n"), + Some(other) => other.to_string(), + } +} + +fn diff_tables( + prefix: &str, + a: &toml::Table, + b: &toml::Table, + out: &mut ConfigDiff, +) { + let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect(); + for key in keys { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + let (va, vb) = (a.get(key), b.get(key)); + if ENV_KEYS.contains(&key.as_str()) { + let d = diff_env(&as_env_text(va), &as_env_text(vb)); + if !d.is_empty() { + out.env.insert(path, d); + } + continue; + } + match (va, vb) { + ( + Some(toml::Value::Table(ta)), + Some(toml::Value::Table(tb)), + ) => diff_tables(&path, ta, tb, out), + (Some(toml::Value::Table(ta)), None) => { + diff_tables(&path, ta, &toml::Table::new(), out) + } + (None, Some(toml::Value::Table(tb))) => { + diff_tables(&path, &toml::Table::new(), tb, out) + } + (Some(x), Some(y)) if x == y => {} + (None, None) => {} + (x, y) => out.fields.push(FieldChange { + path, + before: x.map(|v| render_toml_value(key, v)), + after: y.map(|v| render_toml_value(key, v)), + }), + } + } +} + +/// Extract the single resource table from a Komodo resource TOML export +/// (`[[stack]] ... [stack.config] ...`). +fn resource_table(toml_str: &str) -> Result { + if toml_str.trim().is_empty() { + return Ok(toml::Table::new()); + } + let doc: toml::Table = toml_str + .parse() + // Never echo the parser error: it can quote the offending line. + .map_err(|_| String::from("resource TOML did not parse"))?; + for (_, v) in doc { + if let toml::Value::Array(arr) = v + && let Some(toml::Value::Table(t)) = arr.into_iter().next() + { + return Ok(t); + } + } + Ok(toml::Table::new()) +} + +/// Names-only diff between two Komodo resource TOML exports. +pub fn diff_resource_toml( + prev: &str, + curr: &str, +) -> Result { + let a = resource_table(prev)?; + let b = resource_table(curr)?; + let mut out = ConfigDiff::default(); + diff_tables("", &a, &b, &mut out); + Ok(out) +} + +/// Extract the env block (`config.environment`) from a resource TOML export. +pub fn environment_from_toml( + toml_str: &str, +) -> Result { + let t = resource_table(toml_str)?; + Ok(as_env_text( + t.get("config") + .and_then(|c| c.as_table()) + .and_then(|c| c.get("environment")), + )) +} + +/// Line-level summary of a file change, without content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileChange { + pub before: Option, + pub after: String, + pub lines_added: usize, + pub lines_removed: usize, +} + +/// Summarise a file rewrite as fingerprints and multiset line counts. +pub fn diff_file(before: Option<&str>, after: &str) -> FileChange { + let mut counts: BTreeMap<&str, isize> = BTreeMap::new(); + for l in before.unwrap_or_default().lines() { + *counts.entry(l).or_default() -= 1; + } + for l in after.lines() { + *counts.entry(l).or_default() += 1; + } + let (mut added, mut removed) = (0, 0); + for c in counts.values() { + if *c > 0 { + added += *c as usize; + } else { + removed += c.unsigned_abs(); + } + } + FileChange { + before: before.map(fingerprint), + after: fingerprint(after), + lines_added: added, + lines_removed: removed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const S1: &str = "hunter2-very-secret-value-1"; + const S2: &str = "ghp_FAKEFAKEFAKEFAKEFAKE0123456789"; + const S3: &str = "AAAAprivatekeybodyFAKE"; + + fn assert_no_secret(s: &str) { + for secret in [S1, S2, S3, "hunter2"] { + assert!(!s.contains(secret), "leaked {secret} in: {s}"); + } + } + + #[test] + fn parses_komodo_forms() { + let env = parse_env( + "# c\nA=1\nB = two # eol\n- C: 'three'\n\"D=four\"\nexport E=5\n\nnot a line\n", + ); + let names: Vec<_> = + env.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["A", "B", "C", "D", "E"]); + assert_eq!(env.map()["B"], "two"); + assert_eq!(normalize_value(env.map()["C"]), "three"); + assert_eq!(env.unparsed_lines, 1); + } + + #[test] + fn multiline_quoted_value_is_one_entry() { + let env = parse_env(&format!( + "KEY=\"-----BEGIN PRIVATE KEY-----\n{S3}\n-----END PRIVATE KEY-----\"\nNEXT=1" + )); + assert_eq!(env.names(), ["KEY", "NEXT"]); + assert_eq!(env.unparsed_lines, 0); + } + + #[test] + fn invalid_key_is_not_named() { + // A key that is not an identifier may be secret material. + let env = parse_env(&format!("{S2} abc:def\n")); + assert!(env.entries.is_empty()); + assert_eq!(env.unparsed_lines, 1); + } + + #[test] + fn env_diff_is_names_only() { + let before = format!("KEEP=x\nGONE={S1}\nROT={S1}\nREF={S2}"); + let after = format!( + "KEEP=x\nNEW={S2}\nROT={S2}\nREF=[[infisical://apps/prod/HOMELAB_REF]]" + ); + let d = diff_env(&before, &after); + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].name, "NEW"); + assert_eq!(d.removed[0].name, "GONE"); + assert_eq!(d.changed.len(), 2); + assert_eq!(d.unchanged, 1); + let r = d.changed.iter().find(|c| c.name == "REF").unwrap(); + assert_eq!( + r.after.reference.as_deref(), + Some("[[infisical://apps/prod/HOMELAB_REF]]") + ); + let json = serde_json::to_string(&d).unwrap(); + assert_no_secret(&json); + let mut cd = ConfigDiff::default(); + cd.env.insert("config.environment".into(), d); + assert_no_secret(&cd.render()); + } + + #[test] + fn quote_style_is_not_a_change() { + assert!(diff_env("A=\"x\"", "A=x").is_empty()); + } + + #[test] + fn fingerprint_shape() { + let f = fingerprint(S1); + assert!(f.starts_with("sha256:")); + assert!(f.ends_with(&format!("/{}", S1.len()))); + assert_eq!(fingerprint(""), "empty"); + } + + #[test] + fn reference_detection() { + assert!(reference_of("[[infisical://apps/prod/X]]").is_some()); + assert!(reference_of("\"[[MY_SECRET]]\"").is_some()); + assert!(reference_of("pre[[X]]").is_none()); + assert!(reference_of("[[[X]]]").is_none()); + assert!(reference_of("[[has space]]").is_none()); + } + + #[test] + fn toml_diff_redacts_unknown_and_env() { + let prev = format!( + r#"[[stack]] +name = "vogt-dev" + +[stack.config] +server = "node-b" +branch = "main" +webhook_secret = "{S1}" +environment = """ +A=1 +TOKEN={S2} +""" +"# + ); + let curr = format!( + r#"[[stack]] +name = "vogt-dev" + +[stack.config] +server = "node-b" +branch = "dev" +webhook_secret = "{S3}" +pre_deploy.command = "curl -H 'auth: {S1}'" +environment = """ +A=1 +TOKEN={S3} +NEW_ONE=[[infisical://apps/prod/NEW_ONE]] +""" +"# + ); + let d = diff_resource_toml(&prev, &curr).unwrap(); + let paths: Vec<_> = + d.fields.iter().map(|f| f.path.as_str()).collect(); + assert_eq!( + paths, + [ + "config.branch", + "config.pre_deploy.command", + "config.webhook_secret" + ] + ); + let branch = &d.fields[0]; + assert_eq!(branch.before.as_deref(), Some("\"main\"")); + assert_eq!(branch.after.as_deref(), Some("\"dev\"")); + let env = &d.env["config.environment"]; + assert_eq!(env.added[0].name, "NEW_ONE"); + assert_eq!(env.changed[0].name, "TOKEN"); + assert_no_secret(&serde_json::to_string(&d).unwrap()); + assert_no_secret(&d.render()); + } + + #[test] + fn bad_toml_error_does_not_quote_input() { + let err = + diff_resource_toml(&format!("x = {S1}"), "").unwrap_err(); + assert_no_secret(&err); + } + + #[test] + fn environment_extraction() { + let t = format!( + "[[stack]]\nname=\"s\"\n[stack.config]\nenvironment = \"\"\"\nK={S1}\n\"\"\"\n" + ); + let env = environment_from_toml(&t).unwrap(); + assert_eq!(parse_env(&env).names(), ["K"]); + } + + #[test] + fn file_change_counts() { + let c = diff_file(Some("a\nb\nc\n"), "a\nB\nc\nd\n"); + assert_eq!((c.lines_added, c.lines_removed), (2, 1)); + assert!(c.before.is_some()); + } +} From eae9e4a1c40273d00497d743213cd736c57b54ea Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:38:26 +0000 Subject: [PATCH 2/2] feat(mcp): komodo-mcp, typed stack tools with redaction and an allowlist Agents drove Komodo through 483 raw curl calls. Nine sessions tripped over the body shape (envelope, params.id vs params.stack, method names), GetStack leaked .config.environment into a transcript, and stack file writes that skipped RefreshStackCache deployed stale content (WI-846). bin/mcp is a stdio MCP server built on the fork's typed client: - stack_lookup, get_stack, env_diff, read_stack_file, container_health, container_logs and stack_history are read-only. - write_stack_file runs RefreshStackCache first, skips identical content, refuses .env files and re-reads to verify the write. - deploy_stack polls the Update to completion. It returns pass/fail plus the failing log tail (HTML stripped), or container health on success. - Redaction is default-deny. A stack is projected field by field from Komodo's own types, env is names, fingerprints and [[references]] only, and unclassified fields are withheld. A test fails when upstream adds a field. Every result is also scrubbed of all known env values and common token shapes. - Mutations are read-only by default and allowlisted per stack, matched on the resolved name. Protected stacks (*prod*) need an exact opt-in that a glob never grants. - Mutating calls send X-Komodo-Actor/-Reason, which the previous commit stamps on the Update. stack_history and env_diff (against last_successful_deploy) diff the Update TOMLs client-side using the shared lib/names_diff, so they work against an unpatched Core too. Tests: redaction never emits a fake secret (unit tests plus mock-Core E2E through every tool), allowlist enforcement including id-based bypass, and request bodies checked against Komodo's request types. The binary also ships in the fork's Core image, and CI covers the new crates. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tdd-core-image.yml | 8 +- .github/workflows/tdd-upstream-parity.yml | 4 +- Cargo.lock | 18 + bin/mcp/Cargo.toml | 31 + bin/mcp/README.md | 77 ++ bin/mcp/src/config.rs | 237 ++++++ bin/mcp/src/komodo.rs | 317 +++++++ bin/mcp/src/main.rs | 93 ++ bin/mcp/src/mock_tests.rs | 978 ++++++++++++++++++++++ bin/mcp/src/redact.rs | 594 +++++++++++++ bin/mcp/src/server.rs | 171 ++++ bin/mcp/src/tools.rs | 851 +++++++++++++++++++ docs/tdd/MAINTENANCE.md | 12 + docs/tdd/README.md | 8 + scripts/tdd/core-only.Dockerfile | 6 +- 15 files changed, 3400 insertions(+), 5 deletions(-) create mode 100644 bin/mcp/Cargo.toml create mode 100644 bin/mcp/README.md create mode 100644 bin/mcp/src/config.rs create mode 100644 bin/mcp/src/komodo.rs create mode 100644 bin/mcp/src/main.rs create mode 100644 bin/mcp/src/mock_tests.rs create mode 100644 bin/mcp/src/redact.rs create mode 100644 bin/mcp/src/server.rs create mode 100644 bin/mcp/src/tools.rs diff --git a/.github/workflows/tdd-core-image.yml b/.github/workflows/tdd-core-image.yml index f666a2e56c..c23a0c9f20 100644 --- a/.github/workflows/tdd-core-image.yml +++ b/.github/workflows/tdd-core-image.yml @@ -45,10 +45,10 @@ jobs: # Scoped to the crates this fork owns or changes. `--all` would gate our # CI on upstream's formatting, which we do not control and must not fight. - name: Formatting (fork-owned crates) - run: cargo fmt -p infisical -p interpolate --check + run: cargo fmt -p infisical -p interpolate -p names_diff -p komodo_mcp --check - name: Unit and integration tests - run: cargo test -p infisical -p interpolate --locked + run: cargo test -p infisical -p interpolate -p names_diff -p komodo_mcp --locked # The guard is the safety-critical part: if it stops failing closed, a # failed secret lookup would deploy a literal token as a password. @@ -62,6 +62,10 @@ jobs: - name: Both binaries still build run: cargo check -p komodo_core -p komodo_periphery --locked + # Attribution + names-only change records hooked into Core (WI-865). + - name: Core fork hooks + run: cargo test -p komodo_core --locked tdd:: + image: needs: test runs-on: [self-hosted, docker, publish] diff --git a/.github/workflows/tdd-upstream-parity.yml b/.github/workflows/tdd-upstream-parity.yml index 4aad731106..a61de1a269 100644 --- a/.github/workflows/tdd-upstream-parity.yml +++ b/.github/workflows/tdd-upstream-parity.yml @@ -126,8 +126,8 @@ jobs: continue-on-error: true run: | set -euo pipefail - cargo fmt -p infisical -p interpolate --check - cargo test -p infisical -p interpolate + cargo fmt -p infisical -p interpolate -p names_diff -p komodo_mcp --check + cargo test -p infisical -p interpolate -p names_diff -p komodo_mcp cargo check -p komodo_core -p komodo_periphery - name: Publish the rebased branch and report it is ready diff --git a/Cargo.lock b/Cargo.lock index 22da376123..837e347dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3566,6 +3566,24 @@ dependencies = [ "wildcard", ] +[[package]] +name = "komodo_mcp" +version = "2.2.0" +dependencies = [ + "anyhow", + "axum", + "bson", + "chrono", + "komodo_client", + "mogh_resolver", + "names_diff", + "regex", + "reqwest 0.13.3", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "komodo_periphery" version = "2.2.0" diff --git a/bin/mcp/Cargo.toml b/bin/mcp/Cargo.toml new file mode 100644 index 0000000000..2675d28226 --- /dev/null +++ b/bin/mcp/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "komodo_mcp" +description = "MCP server for Komodo with server-side redaction and a per-stack execute allowlist (fork-only, TheDancingDeveloper)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true + +[[bin]] +name = "komodo-mcp" +path = "src/main.rs" + +[dependencies] +# Fork-local crates are referenced by path rather than through +# `[workspace.dependencies]`, so this crate adds nothing to the root +# Cargo.toml and cannot conflict on an upstream rebase. +names_diff = { path = "../../lib/names_diff" } +komodo_client.workspace = true +anyhow.workspace = true +bson.workspace = true +chrono.workspace = true +regex.workspace = true +reqwest.workspace = true +serde_json.workspace = true +tokio.workspace = true + +[dev-dependencies] +axum.workspace = true +mogh_resolver.workspace = true +serde.workspace = true diff --git a/bin/mcp/README.md b/bin/mcp/README.md new file mode 100644 index 0000000000..6b159a6ff7 --- /dev/null +++ b/bin/mcp/README.md @@ -0,0 +1,77 @@ +# komodo-mcp + +An MCP server (stdio) for Komodo stacks. It is fork-only (TheDancingDeveloper) and built on the fork's typed client (`client/core/rs`). Agents use it in place of raw `curl` against the Komodo API. + +- **Typed requests.** Every body is built from Komodo's own request types, so mistakes like `params.id` vs `params.stack`, the envelope shape or a misspelt method name can't happen. +- **Redaction on the server side, default-deny.** `GetStack` returns the whole `.env` inline as `.config.environment`. This server never returns it: env is shown as names, fingerprints and `[[references]]` only. File contents and `deployed_config` are fingerprinted. Any field not classified in `src/redact.rs` is withheld. A test fails when a Komodo rebase adds an unclassified field. +- **Scrubbing as a second layer.** Before any tool result leaves the process, every env value of every stack the call touched is replaced with `[redacted:NAME]`, along with common token shapes. This includes values inside deploy logs, container logs, health-check output and error text. +- **Read-only by default.** `write_stack_file` and `deploy_stack` work only on allowlisted stacks. A protected stack (default pattern `*prod*`) needs its exact name opted in, and a glob never unlocks one. The allowlist is checked against the resolved stack *name*, so passing an id cannot bypass it. +- **Attribution.** Mutating calls send `X-Komodo-Actor`/`X-Komodo-Reason`. The fork's Core records them on the Update (see `bin/core/src/tdd`). + +## Tools + +| Tool | Kind | What it does | +|---|---|---| +| `stack_lookup(query)` | read | Finds stacks by id or name (exact match first, then substring). For each one it returns server, state, repo/branch, deployed vs latest hash, services, and whether this server may mutate it. | +| `get_stack(stack)` | read | Returns the redacted config and info. | +| `env_diff(stack, against)` | read | Names-only env diff. `against` is one of `{stack}`, `{env_file}`, `{hashes}`, `{update}` or `{last_successful_deploy: true}`. | +| `read_stack_file(stack, path, refresh=true)` | read | Returns a compose/config file from Komodo's cache, refreshing the cache first. Known env values are scrubbed. `.env` files are refused. | +| `write_stack_file(stack, path, content, reason?, dry_run?)` | **mutating** | Runs `RefreshStackCache` first and skips identical content. Then it writes and re-reads to verify. It refuses `.env` files. | +| `deploy_stack(stack, services?, reason?, timeout_secs=900)` | **mutating** | Runs `DeployStack` and polls the Update until it completes. Returns pass/fail and the stages. On failure it adds the failing log tail (HTML stripped, scrubbed); on success it adds container health. | +| `container_health(stack)` | read | Per-service state and docker health. It never returns the inspect dump (env, labels). | +| `container_logs(stack, service, tail=100)` | read | Tails one service's log, scrubbed. Maximum 1000 lines. | +| `stack_history(stack, limit=15, since_last_successful_deploy?)` | read | Shows who changed what: user plus asserted actor, names-only config/env diffs, file-write fingerprints, and deploy results. | + +A fingerprint is `sha256:/` of the value with its wrapping quotes stripped. It is good for change detection and comparison. It is not a secrecy boundary for *weak* values, which can be brute-forced like any deterministic hash. + +## Configuration (environment) + +| Variable | Meaning | +|---|---| +| `KOMODO_URL` (or `KOMODO_ADDRESS`) | Komodo Core base URL, e.g. `http://100.92.54.45:3011` | +| `HOMELAB_KOMODO_API_KEY` / `HOMELAB_KOMODO_API_SECRET` | API credentials. `KOMODO_API_KEY`/`KOMODO_API_SECRET` also work. They are only sent to Komodo and never returned by a tool. | +| `KOMODO_MCP_WRITE_STACKS` | Stacks the mutating tools may touch, as names or `*` globs, comma-separated. Default: none, which means read-only. | +| `KOMODO_MCP_PROTECTED_STACKS` | Protected patterns. Default: `*prod*`. | +| `KOMODO_MCP_ALLOW_PROTECTED` | Exact protected stack names that are explicitly opted in. | +| `KOMODO_MCP_ACTOR` | Actor stamped on mutations. Default: `vogt-session:$VOGT_SESSION_ID`, or `komodo-mcp`. | + +The flags `--allow-write`, `--protected` and `--allow-protected` override the matching variables. `--check` verifies config and connectivity. `--list-tools` prints the tool schemas. + +## Build + +```bash +cargo build --release -p komodo_mcp # -> target/release/komodo-mcp +cargo test -p komodo_mcp -p names_diff # redaction, allowlist, body shapes, mock-Core E2E +``` + +The fork's Core image (`scripts/tdd/core-only.Dockerfile`) also ships it at `/usr/local/bin/komodo-mcp`. + +## Register it + +Claude Code (user scope). The API key and secret are inherited from the session environment, so keep them out of the config file: + +```bash +claude mcp add komodo --scope user \ + -e KOMODO_URL=http://100.92.54.45:3011 \ + -e KOMODO_MCP_WRITE_STACKS=vogt-dev \ + -- /usr/local/bin/komodo-mcp +``` + +Codex (`~/.codex/config.toml`): + +```toml +[mcp_servers.komodo] +command = "/usr/local/bin/komodo-mcp" +env = { KOMODO_URL = "http://100.92.54.45:3011", KOMODO_MCP_WRITE_STACKS = "vogt-dev" } +# Pass the credentials through from the session rather than writing them here. +env_vars = ["HOMELAB_KOMODO_API_KEY", "HOMELAB_KOMODO_API_SECRET", "VOGT_SESSION_ID"] +``` + +Run `komodo-mcp --check` with the same environment to confirm it connects. + +To let a session deploy prod, add `KOMODO_MCP_ALLOW_PROTECTED=vogt-prod` for that session only. A prod deploy recreates the vogt-prod pod and kills any session living in it. + +## Typical flows + +- **Bump a digest and deploy dev:** `read_stack_file` → edit → `write_stack_file` (refresh is automatic) → `deploy_stack` → `container_health`. +- **"What changed on vogt-prod since the last good deploy, and who?":** `stack_history(stack: "vogt-prod", since_last_successful_deploy: true)`, or `env_diff(stack: "vogt-prod", against: {last_successful_deploy: true})`. diff --git a/bin/mcp/src/config.rs b/bin/mcp/src/config.rs new file mode 100644 index 0000000000..1f105e0a66 --- /dev/null +++ b/bin/mcp/src/config.rs @@ -0,0 +1,237 @@ +//! Runtime configuration, read from the environment (and overridable by CLI +//! flags). Credentials are only ever held in memory and sent as request +//! headers to Komodo; no tool returns them. + +use anyhow::{Context, bail}; + +/// Default protected-stack patterns: a stack whose name matches one of these +/// can only be mutated when named exactly in `KOMODO_MCP_ALLOW_PROTECTED`. +pub const DEFAULT_PROTECTED: &str = "*prod*"; + +#[derive(Clone)] +pub struct Config { + pub url: String, + pub key: String, + pub secret: String, + pub policy: Policy, + /// Asserted actor stamped on mutating calls (`X-Komodo-Actor`). + pub actor: String, +} + +impl std::fmt::Debug for Config { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Config") + .field("url", &self.url) + .field("key", &"") + .field("secret", &"") + .field("policy", &self.policy) + .field("actor", &self.actor) + .finish() + } +} + +/// Which stacks the mutating tools (`write_stack_file`, `deploy_stack`) may +/// touch. Read-only by default. +#[derive(Debug, Clone, Default)] +pub struct Policy { + /// Stack name patterns (`*` globs) that mutating tools may target. + pub write: Vec, + /// Stack name patterns that are protected (default `*prod*`). + pub protected: Vec, + /// Exact stack names that are allowed despite matching `protected`. + pub allow_protected: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + Allowed, + /// Not in the write allowlist (the read-only default). + NotAllowlisted, + /// Protected (e.g. prod) and not explicitly opted in by exact name. + Protected, +} + +impl Policy { + pub fn decide(&self, stack_name: &str) -> Decision { + let protected = + self.protected.iter().any(|p| glob_match(p, stack_name)); + if protected { + // Globs never unlock a protected stack: only an exact name does. + if self.allow_protected.iter().any(|n| n == stack_name) { + Decision::Allowed + } else { + Decision::Protected + } + } else if self.write.iter().any(|p| glob_match(p, stack_name)) { + Decision::Allowed + } else { + Decision::NotAllowlisted + } + } + + pub fn read_only(&self) -> bool { + self.write.is_empty() && self.allow_protected.is_empty() + } +} + +/// `*` matches any run of characters; everything else is literal. +pub fn glob_match(pattern: &str, text: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == text; + } + let mut rest = text; + for (i, part) in parts.iter().enumerate() { + if i == 0 { + match rest.strip_prefix(part) { + Some(r) => rest = r, + None => return false, + } + } else if i == parts.len() - 1 { + return rest.ends_with(part); + } else { + match rest.find(part) { + Some(idx) => rest = &rest[idx + part.len()..], + None => return false, + } + } + } + true +} + +pub fn split_list(s: &str) -> Vec { + s.split([',', ' ', '\n']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() +} + +fn env_first(names: &[&str]) -> Option { + names.iter().find_map(|n| { + std::env::var(n).ok().filter(|v| !v.trim().is_empty()) + }) +} + +/// Header-safe actor/reason text: printable ASCII, bounded length. +pub fn sanitize_header(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_graphic() || *c == ' ') + .take(200) + .collect::() + .trim() + .to_string() +} + +pub struct Overrides { + pub write: Option, + pub protected: Option, + pub allow_protected: Option, +} + +impl Config { + pub fn from_env(o: Overrides) -> anyhow::Result { + let url = env_first(&["KOMODO_URL", "KOMODO_ADDRESS"]) + .context("KOMODO_URL (or KOMODO_ADDRESS) is not set")?; + let key = + env_first(&["HOMELAB_KOMODO_API_KEY", "KOMODO_API_KEY"]) + .context( + "HOMELAB_KOMODO_API_KEY (or KOMODO_API_KEY) is not set", + )?; + let secret = env_first(&[ + "HOMELAB_KOMODO_API_SECRET", + "KOMODO_API_SECRET", + ]) + .context( + "HOMELAB_KOMODO_API_SECRET (or KOMODO_API_SECRET) is not set", + )?; + let url = url.trim().trim_end_matches('/').to_string(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + bail!("KOMODO_URL must start with http:// or https://"); + } + let write = o + .write + .or_else(|| env_first(&["KOMODO_MCP_WRITE_STACKS"])) + .unwrap_or_default(); + let protected = o + .protected + .or_else(|| env_first(&["KOMODO_MCP_PROTECTED_STACKS"])) + .unwrap_or_else(|| DEFAULT_PROTECTED.to_string()); + let allow_protected = o + .allow_protected + .or_else(|| env_first(&["KOMODO_MCP_ALLOW_PROTECTED"])) + .unwrap_or_default(); + let actor = env_first(&["KOMODO_MCP_ACTOR"]) + .or_else(|| { + env_first(&["VOGT_SESSION_ID", "VOGT_SESSION"]) + .map(|s| format!("vogt-session:{s}")) + }) + .unwrap_or_else(|| String::from("komodo-mcp")); + Ok(Config { + url, + key, + secret, + policy: Policy { + write: split_list(&write), + protected: split_list(&protected), + allow_protected: split_list(&allow_protected), + }, + actor: sanitize_header(&actor), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy(write: &str, protected: &str, allow: &str) -> Policy { + Policy { + write: split_list(write), + protected: split_list(protected), + allow_protected: split_list(allow), + } + } + + #[test] + fn read_only_by_default() { + let p = policy("", DEFAULT_PROTECTED, ""); + assert!(p.read_only()); + assert_eq!(p.decide("vogt-dev"), Decision::NotAllowlisted); + } + + #[test] + fn globs_allow_but_never_unlock_prod() { + let p = policy("*", DEFAULT_PROTECTED, ""); + assert_eq!(p.decide("vogt-dev"), Decision::Allowed); + assert_eq!(p.decide("vogt-prod"), Decision::Protected); + let p = policy("vogt-*", DEFAULT_PROTECTED, ""); + assert_eq!(p.decide("vogt-prod"), Decision::Protected); + } + + #[test] + fn prod_requires_exact_opt_in() { + let p = policy("vogt-dev", DEFAULT_PROTECTED, "vogt-prod"); + assert_eq!(p.decide("vogt-prod"), Decision::Allowed); + assert_eq!(p.decide("other-prod"), Decision::Protected); + // A glob in the opt-in list is treated literally, not as a pattern. + let p = policy("", DEFAULT_PROTECTED, "*prod*"); + assert_eq!(p.decide("vogt-prod"), Decision::Protected); + } + + #[test] + fn glob_semantics() { + assert!(glob_match("*prod*", "vogt-prod")); + assert!(glob_match("vogt-*", "vogt-dev")); + assert!(!glob_match("vogt-*", "xvogt-dev")); + assert!(glob_match("a*c", "abc")); + assert!(!glob_match("a*c", "abd")); + assert!(glob_match("exact", "exact")); + assert!(!glob_match("exact", "exactly")); + } + + #[test] + fn header_sanitising() { + assert_eq!(sanitize_header("a\nb\u{7f}c é"), "abc"); + } +} diff --git a/bin/mcp/src/komodo.rs b/bin/mcp/src/komodo.rs new file mode 100644 index 0000000000..2c2799f118 --- /dev/null +++ b/bin/mcp/src/komodo.rs @@ -0,0 +1,317 @@ +//! Thin wrapper over the fork's typed client (`komodo_client`). All request +//! bodies are built from Komodo's own request types, so the body shape +//! (`params.stack` vs `params.id`, method names) is checked by the compiler +//! rather than by trial and error. + +use std::time::Duration; + +use komodo_client::{ + KomodoClient, + api::{ + execute::DeployStack, + read::{ + GetStack, GetStackActionState, GetStackLog, GetUpdate, + InspectStackContainer, ListStackServices, ListStacks, + ListUpdates, ListUpdatesResponse, + }, + write::{RefreshStackCache, WriteStackFileContents}, + }, + entities::{ + docker::container::Container, + stack::{Stack, StackActionState, StackListItem, StackService}, + update::{Log, Update, UpdateStatus}, + }, +}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + +use crate::config::{Config, sanitize_header}; + +/// Header carrying the asserted actor of a mutating call. Komodo Core in +/// this fork stamps it on the resulting Update (see `bin/core/src/tdd`). +pub const ACTOR_HEADER: &str = "x-komodo-actor"; +pub const REASON_HEADER: &str = "x-komodo-reason"; + +#[derive(Clone)] +pub struct Komodo { + client: KomodoClient, + actor: String, +} + +fn http(headers: HeaderMap) -> reqwest::Client { + reqwest::Client::builder() + .default_headers(headers) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) + .build() + .unwrap_or_default() +} + +impl Komodo { + pub fn new(config: &Config) -> Komodo { + let client = + KomodoClient::new(&config.url, &config.key, &config.secret) + .set_reqwest(http(HeaderMap::new())); + Komodo { + client, + actor: config.actor.clone(), + } + } + + /// A client whose requests carry the actor stamp and optional reason. + fn stamped(&self, reason: Option<&str>) -> KomodoClient { + let mut headers = HeaderMap::new(); + if let Ok(v) = HeaderValue::from_str(&self.actor) { + headers.insert(HeaderName::from_static(ACTOR_HEADER), v); + } + if let Some(r) = + reason.map(sanitize_header).filter(|r| !r.is_empty()) + && let Ok(v) = HeaderValue::from_str(&r) + { + headers.insert(HeaderName::from_static(REASON_HEADER), v); + } + self.client.clone().set_reqwest(http(headers)) + } + + pub async fn version(&self) -> anyhow::Result { + self.client.core_version().await + } + + pub async fn list_stacks( + &self, + ) -> anyhow::Result> { + self.client.read(ListStacks::default()).await + } + + /// The full stack, including secret-bearing fields. Callers must pass it + /// through `redact::project_stack` and register it with the scrubber. + pub async fn get_stack( + &self, + stack: &str, + ) -> anyhow::Result { + self + .client + .read(GetStack { + stack: stack.to_string(), + }) + .await + } + + pub async fn action_state( + &self, + stack: &str, + ) -> anyhow::Result { + self + .client + .read(GetStackActionState { + stack: stack.to_string(), + }) + .await + } + + pub async fn refresh_cache( + &self, + stack: &str, + reason: Option<&str>, + ) -> anyhow::Result<()> { + self + .stamped(reason) + .write(RefreshStackCache { + stack: stack.to_string(), + }) + .await + .map(|_| ()) + } + + pub async fn write_file( + &self, + stack: &str, + file_path: &str, + contents: &str, + reason: Option<&str>, + ) -> anyhow::Result { + self + .stamped(reason) + .write(WriteStackFileContents { + stack: stack.to_string(), + file_path: file_path.to_string(), + contents: contents.to_string(), + }) + .await + } + + pub async fn deploy( + &self, + stack: &str, + services: Vec, + reason: Option<&str>, + ) -> anyhow::Result { + self + .stamped(reason) + .execute(DeployStack { + stack: stack.to_string(), + services, + stop_time: None, + }) + .await + } + + pub async fn get_update(&self, id: &str) -> anyhow::Result { + self.client.read(GetUpdate { id: id.to_string() }).await + } + + /// Poll an update until it completes, or give up after `timeout`. + pub async fn wait_update( + &self, + id: &str, + timeout: Duration, + interval: Duration, + ) -> anyhow::Result<(Update, bool)> { + let start = tokio::time::Instant::now(); + loop { + let update = self.get_update(id).await?; + if update.status == UpdateStatus::Complete { + return Ok((update, true)); + } + if start.elapsed() >= timeout { + return Ok((update, false)); + } + tokio::time::sleep(interval).await; + } + } + + pub async fn list_updates( + &self, + query: bson::Document, + page: u32, + ) -> anyhow::Result { + self + .client + .read(ListUpdates { + query: Some(query), + page, + }) + .await + } + + pub async fn services( + &self, + stack: &str, + ) -> anyhow::Result> { + self + .client + .read(ListStackServices { + stack: stack.to_string(), + }) + .await + } + + pub async fn inspect( + &self, + stack: &str, + service: &str, + ) -> anyhow::Result { + self + .client + .read(InspectStackContainer { + stack: stack.to_string(), + service: service.to_string(), + }) + .await + } + + pub async fn logs( + &self, + stack: &str, + service: &str, + tail: u64, + timestamps: bool, + ) -> anyhow::Result { + self + .client + .read(GetStackLog { + stack: stack.to_string(), + services: vec![service.to_string()], + tail, + timestamps, + }) + .await + } +} + +/// Mongo filter for the updates of one stack. +pub fn stack_updates_query(stack_id: &str) -> bson::Document { + bson::doc! { "target.type": "Stack", "target.id": stack_id } +} + +#[cfg(test)] +mod tests { + use komodo_client::api::write::UpdateStack; + use mogh_resolver::HasResponse; + use serde::Serialize; + use serde_json::{Value, json}; + + use super::*; + + /// The exact envelope `KomodoClient` posts to `/read|/write|/execute`. + fn envelope(req: T) -> Value { + json!({ "type": T::req_type(), "params": req }) + } + + #[test] + fn body_shapes_match_komodo_types() { + assert_eq!( + envelope(GetStack { + stack: "vogt-dev".into() + }), + json!({"type": "GetStack", "params": {"stack": "vogt-dev"}}) + ); + assert_eq!( + envelope(RefreshStackCache { stack: "s".into() }), + json!({"type": "RefreshStackCache", "params": {"stack": "s"}}) + ); + assert_eq!( + envelope(WriteStackFileContents { + stack: "s".into(), + file_path: "vogt.compose.yml".into(), + contents: "x".into(), + }), + json!({"type": "WriteStackFileContents", "params": { + "stack": "s", "file_path": "vogt.compose.yml", "contents": "x" + }}) + ); + assert_eq!( + envelope(DeployStack { + stack: "s".into(), + services: vec![], + stop_time: None, + }), + json!({"type": "DeployStack", "params": { + "stack": "s", "services": [], "stop_time": null + }}) + ); + assert_eq!( + envelope(GetUpdate { id: "u".into() }), + json!({"type": "GetUpdate", "params": {"id": "u"}}) + ); + // The trap from the incident log: UpdateStack takes `id`, not `stack`. + // This server never sends UpdateStack, but pin the shape so a future + // tool cannot get it wrong. + let v = envelope(UpdateStack { + id: "s".into(), + config: Default::default(), + }); + assert!(v["params"].get("id").is_some()); + assert!(v["params"].get("stack").is_none()); + // GetStack also accepts `id`/`name` aliases on the server side. + let parsed: GetStack = + serde_json::from_value(json!({"name": "vogt-dev"})).unwrap(); + assert_eq!(parsed.stack, "vogt-dev"); + } + + #[test] + fn update_query_shape() { + let q = stack_updates_query("abc"); + assert_eq!(q.get_str("target.type").unwrap(), "Stack"); + assert_eq!(q.get_str("target.id").unwrap(), "abc"); + } +} diff --git a/bin/mcp/src/main.rs b/bin/mcp/src/main.rs new file mode 100644 index 0000000000..6acf5f7e93 --- /dev/null +++ b/bin/mcp/src/main.rs @@ -0,0 +1,93 @@ +//! `komodo-mcp`: an MCP server for Komodo, built on the fork's typed client. +//! +//! Fork-only (TheDancingDeveloper). See `bin/mcp/README.md`. + +mod config; +mod komodo; +mod redact; +mod server; +mod tools; + +use std::time::Duration; + +#[cfg(test)] +mod mock_tests; + +use crate::{ + config::{Config, Overrides}, + komodo::Komodo, + server::{Server, instructions, serve_stdio}, +}; + +const HELP: &str = "komodo-mcp: MCP server (stdio) for Komodo stacks + +USAGE: + komodo-mcp [--allow-write LIST] [--protected LIST] [--allow-protected LIST] + komodo-mcp --check verify config + connectivity, then exit + komodo-mcp --list-tools print the tool descriptors, then exit + +ENVIRONMENT: + KOMODO_URL Komodo Core base URL (or KOMODO_ADDRESS) + HOMELAB_KOMODO_API_KEY API key (or KOMODO_API_KEY) + HOMELAB_KOMODO_API_SECRET API secret (or KOMODO_API_SECRET) + KOMODO_MCP_WRITE_STACKS stacks (names, * globs) mutating tools may touch; default none + KOMODO_MCP_PROTECTED_STACKS protected patterns; default *prod* + KOMODO_MCP_ALLOW_PROTECTED exact protected stack names explicitly opted in + KOMODO_MCP_ACTOR actor stamped on mutations (default vogt-session:$VOGT_SESSION_ID or komodo-mcp) +"; + +fn take(args: &mut Vec, flag: &str) -> Option { + let i = args.iter().position(|a| a == flag)?; + args.remove(i); + (i < args.len()).then(|| args.remove(i)) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "-h" || a == "--help") { + print!("{HELP}"); + return Ok(()); + } + if args.iter().any(|a| a == "--list-tools") { + println!("{}", serde_json::to_string_pretty(&tools::list())?); + return Ok(()); + } + let check = args.iter().any(|a| a == "--check"); + args.retain(|a| a != "--check"); + let overrides = Overrides { + write: take(&mut args, "--allow-write"), + protected: take(&mut args, "--protected"), + allow_protected: take(&mut args, "--allow-protected"), + }; + if let Some(unknown) = args.first() { + anyhow::bail!("unknown argument `{unknown}` (see --help)"); + } + let config = Config::from_env(overrides)?; + let komodo = Komodo::new(&config); + if check { + let version = komodo.version().await?; + eprintln!( + "komodo-mcp: connected to Komodo Core {version} at {}", + config.url + ); + eprintln!("komodo-mcp: actor {:?}", config.actor); + eprintln!("komodo-mcp: {}", instructions(&config.policy)); + return Ok(()); + } + eprintln!( + "komodo-mcp: serving stdio for {} ({})", + config.url, + if config.policy.read_only() { + "read-only" + } else { + "mutations allowlisted" + } + ); + serve_stdio(Server { + komodo, + policy: config.policy, + poll: Duration::from_secs(2), + }) + .await +} diff --git a/bin/mcp/src/mock_tests.rs b/bin/mcp/src/mock_tests.rs new file mode 100644 index 0000000000..19f3212f54 --- /dev/null +++ b/bin/mcp/src/mock_tests.rs @@ -0,0 +1,978 @@ +//! End-to-end tests against a mock Komodo Core. The mock speaks the same +//! envelope as Core (`POST /read|/write|/execute`, `{"type", "params"}`), +//! deserializes every request into Komodo's own request types, and serves +//! fixtures stuffed with fake secrets. Each test drives the real tool code +//! through `Server::call_tool` and asserts on what would reach the agent. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + routing::post, +}; +use komodo_client::{ + api::{ + execute::DeployStack, + read::{GetStack, GetStackLog, GetUpdate, ListUpdates}, + write::{RefreshStackCache, WriteStackFileContents}, + }, + entities::{ + Operation, ResourceTarget, ResourceTargetVariant, Version, + docker::{ + ContainerConfig, + container::{ + Container, ContainerHealth, ContainerListItem, + ContainerState, ContainerStateStatusEnum, HealthStatusEnum, + HealthcheckResult, + }, + }, + stack::{ + Stack, StackActionState, StackConfig, StackInfo, StackListItem, + StackListItemInfo, StackRemoteFileContents, StackService, + StackState, + }, + update::{Log, Update, UpdateListItem, UpdateStatus}, + }, +}; +use serde_json::{Value, json}; + +use crate::{ + config::{Policy, split_list}, + komodo::Komodo, + server::Server, +}; + +const SECRET_DB: &str = "db-pass-FAKE-0000000001"; +const SECRET_GH: &str = "ghp_FAKEFAKEFAKEFAKEFAKEFAKE9999"; +const SECRET_OLD: &str = "old-rotated-FAKE-0000000002"; +const SECRET_HOOK: &str = "hook-FAKE-0000000003"; +const SECRET_LOCAL: &str = "local-only-FAKE-0000000004"; +const DEV_ID: &str = "aaaaaaaaaaaaaaaaaaaa0001"; +const PROD_ID: &str = "aaaaaaaaaaaaaaaaaaaa0002"; +const U_CONFIG: &str = "bbbbbbbbbbbbbbbbbbbb0001"; +const U_DEPLOY_OK: &str = "bbbbbbbbbbbbbbbbbbbb0002"; +const U_CONFIG_2: &str = "bbbbbbbbbbbbbbbbbbbb0003"; +const U_WRITE: &str = "bbbbbbbbbbbbbbbbbbbb0004"; +const U_WRITE_NEW: &str = "bbbbbbbbbbbbbbbbbbbb0005"; +const U_DEPLOY_NEW: &str = "bbbbbbbbbbbbbbbbbbbb0006"; +const ALL_SECRETS: &[&str] = + &[SECRET_DB, SECRET_GH, SECRET_OLD, SECRET_HOOK, SECRET_LOCAL]; + +fn assert_clean(v: &Value) { + let text = v.to_string(); + for s in ALL_SECRETS { + assert!(!text.contains(s), "secret {s} leaked in: {text}"); + } +} + +fn text_of(result: &Value) -> Value { + let t = result["content"][0]["text"].as_str().unwrap(); + serde_json::from_str(t).unwrap() +} + +#[derive(Default)] +struct Mock { + stacks: Vec, + updates: HashMap, + update_list: Vec, + /// (endpoint, type, params, x-komodo-actor) + requests: Vec<(String, String, Value, Option)>, + pending_write: Option<(String, String, String)>, + deploy_fails: bool, + get_update_calls: usize, +} + +type Shared = Arc>; + +fn stack_fixture(id: &str, name: &str, env: &str) -> Stack { + Stack { + id: id.into(), + name: name.into(), + config: StackConfig { + environment: env.into(), + webhook_secret: SECRET_HOOK.into(), + repo: "indexarr/ops".into(), + run_directory: format!("personal/{name}/"), + file_paths: vec!["vogt.compose.yml".into()], + ..Default::default() + }, + info: StackInfo { + deployed_config: Some(format!("DB_PASSWORD: {SECRET_DB}")), + remote_contents: Some(vec![StackRemoteFileContents { + path: "vogt.compose.yml".into(), + contents: format!( + "services:\n vogt:\n image: x@sha256:old\n # {SECRET_DB}\n" + ), + ..Default::default() + }]), + latest_hash: Some("abc123".into()), + ..Default::default() + }, + ..Default::default() + } +} + +fn list_item(s: &Stack) -> StackListItem { + StackListItem { + id: s.id.clone(), + resource_type: ResourceTargetVariant::Stack, + name: s.name.clone(), + template: false, + tags: vec![], + info: StackListItemInfo { + swarm_id: String::new(), + server_id: "srv".into(), + files_on_host: false, + file_contents: false, + linked_repo: String::new(), + git_provider: "repo.indexarr.net".into(), + repo: s.config.repo.clone(), + branch: "main".into(), + repo_link: String::new(), + state: StackState::Running, + status: None, + services: vec![], + project_missing: false, + missing_files: vec![], + deployed_hash: Some("abc123".into()), + latest_hash: Some("abc123".into()), + }, + } +} + +fn update_item(u: &Update) -> UpdateListItem { + UpdateListItem { + id: u.id.clone(), + operation: u.operation, + start_ts: u.start_ts, + success: u.success, + username: "Sprooty".into(), + operator: u.operator.clone(), + target: u.target.clone(), + status: u.status, + version: Version::default(), + other_data: String::new(), + } +} + +fn log( + stage: &str, + stdout: &str, + stderr: &str, + success: bool, +) -> Log { + Log { + stage: stage.into(), + command: String::new(), + stdout: stdout.into(), + stderr: stderr.into(), + success, + start_ts: 0, + end_ts: 0, + } +} + +fn new_mock() -> Shared { + let dev_env = format!( + "PLAIN=1\nDB_PASSWORD={SECRET_DB}\nGH_TOKEN=\"{SECRET_GH}\"\nREF=[[infisical://apps/prod/HOMELAB_REF]]\nNEW_AFTER_DEPLOY=yes" + ); + let prod_env = format!("PLAIN=2\nDB_PASSWORD={SECRET_OLD}"); + let dev = stack_fixture(DEV_ID, "vogt-dev", &dev_env); + let prod = stack_fixture(PROD_ID, "vogt-prod", &prod_env); + let toml_with = |env: &str| { + format!( + "[[stack]]\nname = \"vogt-dev\"\n\n[stack.config]\nbranch = \"main\"\nenvironment = \"\"\"\n{env}\n\"\"\"\n" + ) + }; + let config_update = Update { + id: U_CONFIG.into(), + operation: Operation::UpdateStack, + start_ts: 1_000, + success: true, + status: UpdateStatus::Complete, + operator: "user-1".into(), + target: ResourceTarget::Stack(DEV_ID.into()), + prev_toml: toml_with(&format!( + "PLAIN=1\nDB_PASSWORD={SECRET_OLD}" + )), + current_toml: toml_with(&format!( + "PLAIN=1\nDB_PASSWORD={SECRET_DB}\nGH_TOKEN={SECRET_GH}\nREF=[[infisical://apps/prod/HOMELAB_REF]]" + )), + logs: vec![log( + "Actor (asserted)", + "actor: vogt-session:abc\nreason: rotate db password", + "", + true, + )], + ..Default::default() + }; + let good_deploy = Update { + id: U_DEPLOY_OK.into(), + operation: Operation::DeployStack, + start_ts: 2_000, + end_ts: Some(3_000), + success: true, + status: UpdateStatus::Complete, + operator: "user-1".into(), + target: ResourceTarget::Stack(DEV_ID.into()), + logs: vec![log("Compose Up", "ok", "", true)], + ..Default::default() + }; + let later_config = Update { + id: U_CONFIG_2.into(), + operation: Operation::UpdateStack, + start_ts: 4_000, + success: true, + status: UpdateStatus::Complete, + operator: "user-2".into(), + target: ResourceTarget::Stack(DEV_ID.into()), + prev_toml: config_update.current_toml.clone(), + current_toml: toml_with(&dev_env), + ..Default::default() + }; + let write = Update { + id: U_WRITE.into(), + operation: Operation::WriteStackContents, + start_ts: 5_000, + success: true, + status: UpdateStatus::Complete, + operator: "user-2".into(), + target: ResourceTarget::Stack(DEV_ID.into()), + logs: vec![ + log( + "File contents to write", + &format!("# {SECRET_DB}\n"), + "", + true, + ), + log("Commit", "1 file changed", "", true), + ], + ..Default::default() + }; + let mut updates = HashMap::new(); + // Newest first, like Core. + let mut update_list = Vec::new(); + for u in [&write, &later_config, &good_deploy, &config_update] { + update_list.push(update_item(u)); + updates.insert(u.id.clone(), u.clone()); + } + Arc::new(Mutex::new(Mock { + stacks: vec![dev, prod], + updates, + update_list, + ..Default::default() + })) +} + +fn err(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": msg, "trace": [] })), + ) +} + +fn find(m: &Mock, key: &str) -> Option { + m.stacks + .iter() + .find(|s| s.id == key || s.name == key) + .cloned() +} + +fn matches_query(u: &UpdateListItem, q: &bson::Document) -> bool { + let target_id = match &u.target { + ResourceTarget::Stack(id) => id.as_str(), + _ => "", + }; + if let Ok(id) = q.get_str("target.id") + && id != target_id + { + return false; + } + let op = serde_json::to_value(u.operation).unwrap(); + let op = op.as_str().unwrap(); + match q.get("operation") { + Some(bson::Bson::String(s)) if s != op => return false, + Some(bson::Bson::Document(d)) => { + if let Ok(list) = d.get_array("$in") + && !list.iter().any(|b| b.as_str() == Some(op)) + { + return false; + } + } + _ => {} + } + if let Ok(success) = q.get_bool("success") + && success != u.success + { + return false; + } + if let Ok(d) = q.get_document("start_ts") + // JSON round-trips small numbers as Int32, as it does for Core. + && let Some(lt) = d + .get("$lt") + .and_then(|b| b.as_i64().or(b.as_i32().map(i64::from))) + && u.start_ts >= lt + { + return false; + } + true +} + +async fn handle( + State(state): State, + Path(endpoint): Path, + headers: HeaderMap, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let ty = body["type"].as_str().unwrap_or_default().to_string(); + let params = body["params"].clone(); + let actor = headers + .get("x-komodo-actor") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let mut m = state.lock().unwrap(); + m.requests.push(( + endpoint.clone(), + ty.clone(), + params.clone(), + actor, + )); + // Every request must deserialize into Komodo's own request type: this is + // the body-shape check. + macro_rules! parse { + ($t:ty) => { + serde_json::from_value::<$t>(params.clone()).map_err(|e| { + err(&format!("bad {} body: {e}", stringify!($t))) + })? + }; + } + let out = match (endpoint.as_str(), ty.as_str()) { + ("read", "GetVersion") => json!({ "version": "2.2.0" }), + ("read", "ListStacks") => { + json!(m.stacks.iter().map(list_item).collect::>()) + } + ("read", "GetStack") => { + let p = parse!(GetStack); + json!(find(&m, &p.stack).ok_or_else(|| err("no stack"))?) + } + ("read", "GetStackActionState") => { + json!(StackActionState::default()) + } + ("read", "GetUpdate") => { + let p = parse!(GetUpdate); + if p.id == U_DEPLOY_NEW { + m.get_update_calls += 1; + let done = m.get_update_calls >= 2; + let fails = m.deploy_fails; + let mut logs = vec![log("Compose Pull", "pulled", "", true)]; + if done && fails { + logs.push(log( + "Compose Up", + "", + &format!( + "line 1\nError: env DB_PASSWORD={SECRET_DB} rejected & failed" + ), + false, + )); + } + json!(Update { + id: p.id, + operation: Operation::DeployStack, + start_ts: 10_000, + end_ts: done.then_some(70_000), + status: if done { + UpdateStatus::Complete + } else { + UpdateStatus::InProgress + }, + success: !(done && fails), + logs, + ..Default::default() + }) + } else { + json!(m.updates.get(&p.id).ok_or_else(|| err("no update"))?) + } + } + ("read", "ListUpdates") => { + let p = parse!(ListUpdates); + let q = p.query.unwrap_or_default(); + let list: Vec<_> = m + .update_list + .iter() + .filter(|u| matches_query(u, &q)) + .cloned() + .collect(); + json!({ "updates": list, "next_page": null }) + } + ("read", "ListStackServices") => json!(vec![StackService { + service: "vogt".into(), + image: "x@sha256:new".into(), + container: Some(ContainerListItem { + name: "vogt-dev-vogt-1".into(), + image: Some("x@sha256:new".into()), + state: ContainerStateStatusEnum::Running, + status: Some("Up 2 minutes (healthy)".into()), + labels: [("secret.label".to_string(), SECRET_GH.to_string())] + .into(), + ..Default::default() + }), + ..Default::default() + }]), + ("read", "InspectStackContainer") => json!(Container { + state: Some(ContainerState { + status: ContainerStateStatusEnum::Running, + health: Some(ContainerHealth { + status: HealthStatusEnum::Healthy, + failing_streak: Some(0), + log: vec![HealthcheckResult { + output: Some(format!("ok token={SECRET_GH}")), + ..Default::default() + }], + }), + ..Default::default() + }), + config: Some(ContainerConfig { + env: vec![format!("DB_PASSWORD={SECRET_DB}")], + ..Default::default() + }), + ..Default::default() + }), + ("read", "GetStackLog") => { + let p = parse!(GetStackLog); + assert_eq!(p.services, ["vogt"]); + json!(log( + "logs", + &format!( + "boot\nconnecting with {SECRET_DB}\nready" + ), + "", + true + )) + } + ("write", "RefreshStackCache") => { + let p = parse!(RefreshStackCache); + if let Some((sid, path, contents)) = m.pending_write.take() { + assert_eq!(sid, p.stack); + let s = m.stacks.iter_mut().find(|s| s.id == sid).unwrap(); + let files = s.info.remote_contents.get_or_insert_default(); + match files.iter_mut().find(|f| f.path == path) { + Some(f) => f.contents = contents, + None => files.push(StackRemoteFileContents { + path, + contents, + ..Default::default() + }), + } + } + json!({}) + } + ("write", "WriteStackFileContents") => { + let p = parse!(WriteStackFileContents); + m.pending_write = Some(( + p.stack.clone(), + p.file_path.clone(), + p.contents.clone(), + )); + json!(Update { + id: U_WRITE_NEW.into(), + operation: Operation::WriteStackContents, + status: UpdateStatus::Complete, + success: true, + logs: vec![ + log("File contents to write", &p.contents, "", true), + log("Commit", "[main 1234abc] [Komodo] write", "", true), + ], + ..Default::default() + }) + } + ("execute", "DeployStack") => { + let _ = parse!(DeployStack); + m.get_update_calls = 0; + json!(Update { + id: U_DEPLOY_NEW.into(), + operation: Operation::DeployStack, + status: UpdateStatus::InProgress, + success: true, + ..Default::default() + }) + } + _ => return Err(err(&format!("unhandled {endpoint} {ty}"))), + }; + Ok(Json(out)) +} + +async fn start(policy: Policy) -> (Server, Shared) { + let state = new_mock(); + let app = Router::new() + .route("/{endpoint}", post(handle)) + .with_state(state.clone()); + let listener = + tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn( + async move { axum::serve(listener, app).await.unwrap() }, + ); + let config = crate::config::Config { + url: format!("http://{addr}"), + key: "k".into(), + secret: "s".into(), + policy: policy.clone(), + actor: "vogt-session:test".into(), + }; + let server = Server { + komodo: Komodo::new(&config), + policy, + poll: Duration::from_millis(10), + }; + (server, state) +} + +fn policy(write: &str, allow_protected: &str) -> Policy { + Policy { + write: split_list(write), + protected: split_list("*prod*"), + allow_protected: split_list(allow_protected), + } +} + +fn types(state: &Shared) -> Vec { + state + .lock() + .unwrap() + .requests + .iter() + .map(|r| r.1.clone()) + .collect() +} + +#[tokio::test] +async fn get_stack_redacts_server_side() { + let (server, _) = start(policy("", "")).await; + let res = server + .call_tool("get_stack", &json!({"stack": "vogt-dev"})) + .await; + assert_eq!(res["isError"], false); + let v = text_of(&res); + assert_clean(&v); + let names: Vec<_> = v["config"]["environment"]["variables"] + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + names, + [ + "PLAIN", + "DB_PASSWORD", + "GH_TOKEN", + "REF", + "NEW_AFTER_DEPLOY" + ] + ); + assert_eq!(v["config"]["webhook_secret"], json!({"set": true})); + assert_eq!(v["config"]["branch"], "main"); + assert!(v["mutations"].as_str().unwrap().starts_with("read-only")); +} + +#[tokio::test] +async fn stack_lookup_by_name_and_id() { + let (server, _) = start(policy("vogt-dev", "")).await; + let v = text_of( + &server + .call_tool("stack_lookup", &json!({"query": PROD_ID})) + .await, + ); + assert_eq!(v["count"], 1); + assert_eq!(v["stacks"][0]["name"], "vogt-prod"); + assert!( + v["stacks"][0]["mutations"] + .as_str() + .unwrap() + .starts_with("protected") + ); + let v = text_of( + &server + .call_tool("stack_lookup", &json!({"query": "vogt"})) + .await, + ); + assert_eq!(v["count"], 2); + assert_eq!(v["stacks"][0]["mutations"], "allowed"); +} + +#[tokio::test] +async fn env_diff_modes_never_emit_values() { + let (server, _) = start(policy("", "")).await; + // Against another stack. + let v = text_of( + &server + .call_tool("env_diff", &json!({"stack": "vogt-dev", "against": {"stack": "vogt-prod"}})) + .await, + ); + assert_clean(&v); + let changed: Vec<_> = v["diff"]["changed"] + .as_array() + .unwrap() + .iter() + .map(|c| c["name"].as_str().unwrap()) + .collect(); + assert_eq!(changed, ["DB_PASSWORD", "PLAIN"]); + + // Against the env as of the last successful deploy (from Update TOML). + let v = text_of( + &server + .call_tool( + "env_diff", + &json!({"stack": "vogt-dev", "against": {"last_successful_deploy": true}}), + ) + .await, + ); + assert_clean(&v); + assert_eq!(v["diff"]["added"][0]["name"], "NEW_AFTER_DEPLOY"); + assert!(v["diff"]["changed"].as_array().unwrap().is_empty()); + + // Against supplied fingerprints. + let fp = names_diff::fingerprint(SECRET_DB); + let v = text_of( + &server + .call_tool( + "env_diff", + &json!({"stack": "vogt-dev", "against": {"hashes": {"DB_PASSWORD": fp, "GONE": "sha256:000000000000/1"}}}), + ) + .await, + ); + assert_eq!(v["diff"]["removed"][0]["name"], "GONE"); + assert_eq!(v["diff"]["unchanged"], 1); + + // Against a local env file whose values the server never saw before. + let dir = std::env::temp_dir() + .join(format!("komodo-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("local.env"); + std::fs::write( + &file, + format!("DB_PASSWORD={SECRET_LOCAL}\nPLAIN=1\n"), + ) + .unwrap(); + let v = text_of( + &server + .call_tool( + "env_diff", + &json!({"stack": "vogt-dev", "against": {"env_file": file.to_str().unwrap()}}), + ) + .await, + ); + assert_clean(&v); + assert_eq!(v["diff"]["changed"][0]["name"], "DB_PASSWORD"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test] +async fn mutations_refused_when_read_only() { + let (server, state) = start(policy("", "")).await; + for (tool, args) in [ + ("deploy_stack", json!({"stack": "vogt-dev"})), + ( + "write_stack_file", + json!({"stack": "vogt-dev", "path": "vogt.compose.yml", "content": "x"}), + ), + ] { + let res = server.call_tool(tool, &args).await; + assert_eq!(res["isError"], true, "{tool}"); + assert!( + text_of(&res)["error"] + .as_str() + .unwrap() + .contains("not in the mutation allowlist") + ); + } + let t = types(&state); + assert!(!t.iter().any(|t| t == "DeployStack" + || t == "WriteStackFileContents" + || t == "RefreshStackCache")); +} + +#[tokio::test] +async fn prod_needs_exact_opt_in_even_by_id() { + let (server, state) = start(policy("*", "")).await; + for stack in ["vogt-prod", PROD_ID] { + let res = server + .call_tool("deploy_stack", &json!({"stack": stack})) + .await; + assert_eq!(res["isError"], true); + assert!( + text_of(&res)["error"] + .as_str() + .unwrap() + .contains("protected") + ); + } + assert!(!types(&state).iter().any(|t| t == "DeployStack")); + + let (server, state) = start(policy("", "vogt-prod")).await; + let v = text_of( + &server + .call_tool("deploy_stack", &json!({"stack": PROD_ID})) + .await, + ); + assert_eq!(v["pass"], true); + assert!(types(&state).iter().any(|t| t == "DeployStack")); +} + +#[tokio::test] +async fn write_refreshes_first_and_verifies() { + let (server, state) = start(policy("vogt-dev", "")).await; + let content = "services:\n vogt:\n image: x@sha256:new\n"; + let res = server + .call_tool( + "write_stack_file", + &json!({"stack": "vogt-dev", "path": "vogt.compose.yml", "content": content, "reason": "bump digest"}), + ) + .await; + let v = text_of(&res); + assert_eq!(res["isError"], false, "{v}"); + assert_clean(&v); + assert_eq!(v["verified_after_refresh"], true); + assert_eq!(v["change"]["lines_added"], 1); + assert_eq!(v["change"]["lines_removed"], 2); + assert!(!v.to_string().contains("File contents to write")); + { + let m = state.lock().unwrap(); + let seq: Vec<_> = + m.requests.iter().map(|r| r.1.as_str()).collect(); + assert_eq!( + seq, + [ + "GetStack", + "RefreshStackCache", + "GetStack", + "WriteStackFileContents", + "RefreshStackCache", + "GetStack" + ] + ); + let write = m + .requests + .iter() + .find(|r| r.1 == "WriteStackFileContents") + .unwrap(); + assert_eq!( + write.2, + json!({"stack": DEV_ID, "file_path": "vogt.compose.yml", "contents": content}) + ); + assert_eq!(write.3.as_deref(), Some("vogt-session:test")); + } + + // Identical content: no second write. + let v = text_of( + &server + .call_tool("write_stack_file", &json!({"stack": "vogt-dev", "path": "vogt.compose.yml", "content": content})) + .await, + ); + assert_eq!(v["changed"], false); + let writes = types(&state) + .iter() + .filter(|t| *t == "WriteStackFileContents") + .count(); + assert_eq!(writes, 1); + + // Env files are refused. + let res = server + .call_tool( + "write_stack_file", + &json!({"stack": "vogt-dev", "path": ".env", "content": "A=1"}), + ) + .await; + assert_eq!(res["isError"], true); +} + +#[tokio::test] +async fn deploy_failure_returns_clean_tail() { + let (server, state) = start(policy("vogt-dev", "")).await; + state.lock().unwrap().deploy_fails = true; + let v = text_of( + &server + .call_tool("deploy_stack", &json!({"stack": "vogt-dev"})) + .await, + ); + assert_clean(&v); + assert_eq!(v["pass"], false); + assert_eq!(v["completed"], true); + assert_eq!(v["failure"]["stage"], "Compose Up"); + let tail = v["failure"]["tail"].as_str().unwrap(); + assert!(tail.contains("Error: env DB_PASSWORD=[redacted:DB_PASSWORD] rejected & failed"), "{tail}"); + assert!(!tail.contains(" = v["updates"] + .as_array() + .unwrap() + .iter() + .map(|u| u["operation"].as_str().unwrap()) + .collect(); + assert_eq!( + ops, + ["WriteStackContents", "UpdateStack", "DeployStack"] + ); + let cfg = + &v["updates"][1]["config_diff"]["env"]["config.environment"]; + assert_eq!(cfg["added"][0]["name"], "NEW_AFTER_DEPLOY"); + assert_eq!(v["updates"][0]["written"]["lines"], 1); + + let v = text_of( + &server + .call_tool("stack_history", &json!({"stack": "vogt-dev"})) + .await, + ); + assert_clean(&v); + let first_config = &v["updates"][3]; + assert_eq!(first_config["actor"]["actor"], "vogt-session:abc"); + assert_eq!(first_config["actor"]["reason"], "rotate db password"); + let env = &first_config["config_diff"]["env"]["config.environment"]; + assert_eq!(env["changed"][0]["name"], "DB_PASSWORD"); + let added: Vec<_> = env["added"] + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].as_str().unwrap()) + .collect(); + assert_eq!(added, ["GH_TOKEN", "REF"]); + assert_eq!( + env["added"][1]["value"]["reference"], + "[[infisical://apps/prod/HOMELAB_REF]]" + ); +} + +#[tokio::test] +async fn errors_are_scrubbed_and_protocol_works() { + let (server, _) = start(policy("", "")).await; + let res = server + .call_tool("get_stack", &json!({"stack": "nope"})) + .await; + assert_eq!(res["isError"], true); + let res = server.call_tool("no_such_tool", &json!({})).await; + assert_eq!(res["isError"], true); + + let init = server + .handle(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}})) + .await + .unwrap(); + assert_eq!(init["result"]["protocolVersion"], "2025-03-26"); + assert!( + init["result"]["instructions"] + .as_str() + .unwrap() + .contains("READ-ONLY") + ); + let list = server + .handle( + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + ) + .await + .unwrap(); + let names: Vec<_> = list["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + assert_eq!(names.len(), 9); + assert!(server.handle(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await.is_none()); + let unknown = server + .handle(json!({"jsonrpc": "2.0", "id": 3, "method": "x"})) + .await + .unwrap(); + assert_eq!(unknown["error"]["code"], -32601); + let call = server + .handle(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "get_stack", "arguments": {"stack": "vogt-prod"}}})) + .await + .unwrap(); + assert_clean(&call); +} diff --git a/bin/mcp/src/redact.rs b/bin/mcp/src/redact.rs new file mode 100644 index 0000000000..c4146bfd30 --- /dev/null +++ b/bin/mcp/src/redact.rs @@ -0,0 +1,594 @@ +//! Server-side redaction. Two layers: +//! +//! 1. **Projection (default-deny).** A stack is serialized with Komodo's own +//! types and then projected field by field. Every field must be +//! classified in the tables below; a field that is not (for example one +//! upstream adds in a later release) is withheld, never passed through. +//! `tests::every_stack_field_is_classified` fails on a rebase that adds a +//! field, forcing an explicit decision. +//! 2. **Scrubbing (defence in depth).** Every tool result is walked before it +//! is returned and any known secret value (every env value of every stack +//! the call touched, plus common token shapes) is replaced with +//! `[redacted:NAME]`. This catches values echoed by deploy logs, container +//! logs, health-check output and API error messages. + +use std::sync::{LazyLock, Mutex}; + +use komodo_client::entities::stack::Stack; +use names_diff::{ + fingerprint, normalize_value, parse_env, reference_of, +}; +use regex::Regex; +use serde_json::{Map, Value, json}; + +/// How a field of the serialized stack is presented. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + /// Safe: passed through (still scrubbed). + Pass, + /// A `KEY=VALUE` block: names (and references) only. + Env, + /// A secret: only whether it is set. + SetOnly, + /// Free text that may embed secrets: fingerprint + line count. + Fingerprint, + /// A `{path, command}` system command: path shown, command fingerprinted. + Command, + /// A list of `{path, contents, ...}` files: contents fingerprinted. + Files, + /// A list of `{path, contents}` errors: contents scrubbed and truncated. + Errors, + /// Nested projection (config / info). + Config, + Info, +} + +pub const TOP_FIELDS: &[(&str, Class)] = &[ + ("_id", Class::Pass), + ("name", Class::Pass), + ("description", Class::Pass), + ("template", Class::Pass), + ("tags", Class::Pass), + ("base_permission", Class::Pass), + ("updated_at", Class::Pass), + ("config", Class::Config), + ("info", Class::Info), +]; + +pub const CONFIG_FIELDS: &[(&str, Class)] = &[ + ("swarm_id", Class::Pass), + ("server_id", Class::Pass), + ("links", Class::Pass), + ("project_name", Class::Pass), + ("auto_pull", Class::Pass), + ("run_build", Class::Pass), + ("poll_for_updates", Class::Pass), + ("auto_update", Class::Pass), + ("auto_update_all_services", Class::Pass), + ("auto_update_skip_services", Class::Pass), + ("destroy_before_deploy", Class::Pass), + ("skip_secret_interp", Class::Pass), + ("linked_repo", Class::Pass), + ("git_provider", Class::Pass), + ("git_https", Class::Pass), + ("git_account", Class::Pass), + ("repo", Class::Pass), + ("branch", Class::Pass), + ("commit", Class::Pass), + ("clone_path", Class::Pass), + ("reclone", Class::Pass), + ("webhook_enabled", Class::Pass), + ("webhook_secret", Class::SetOnly), + ("webhook_force_deploy", Class::Pass), + ("files_on_host", Class::Pass), + ("run_directory", Class::Pass), + ("file_paths", Class::Pass), + ("env_file_path", Class::Pass), + ("additional_env_files", Class::Pass), + ("config_files", Class::Pass), + ("send_alerts", Class::Pass), + ("registry_provider", Class::Pass), + ("registry_account", Class::Pass), + ("pre_deploy", Class::Command), + ("post_deploy", Class::Command), + ("extra_args", Class::Pass), + ("build_extra_args", Class::Pass), + ("compose_cmd_wrapper", Class::Fingerprint), + ("compose_cmd_wrapper_include", Class::Pass), + ("ignore_services", Class::Pass), + ("file_contents", Class::Fingerprint), + ("environment", Class::Env), +]; + +pub const INFO_FIELDS: &[(&str, Class)] = &[ + ("missing_files", Class::Pass), + ("deployed_project_name", Class::Pass), + ("deployed_hash", Class::Pass), + ("deployed_message", Class::Pass), + // `docker compose config` output: has interpolated env values. + ("deployed_config", Class::Fingerprint), + ("deployed_contents", Class::Files), + ("deployed_services", Class::Pass), + ("latest_services", Class::Pass), + ("remote_contents", Class::Files), + ("remote_errors", Class::Errors), + ("latest_hash", Class::Pass), + ("latest_message", Class::Pass), +]; + +const WITHHELD: &str = + "[withheld: field not classified for redaction]"; + +fn classify(table: &[(&str, Class)], key: &str) -> Option { + table.iter().find(|(k, _)| *k == key).map(|(_, c)| *c) +} + +fn text_summary(s: &str) -> Value { + json!({ "fingerprint": fingerprint(s), "lines": s.lines().count() }) +} + +/// Names-only view of an env block. +pub fn env_view(env: &str) -> Value { + let parsed = parse_env(env); + let entries: Vec = parsed + .entries + .iter() + .map(|e| { + let mut m = Map::new(); + m.insert("name".into(), json!(e.name)); + m.insert( + "fingerprint".into(), + json!(fingerprint(normalize_value(&e.value))), + ); + if let Some(r) = reference_of(&e.value) { + m.insert("reference".into(), json!(r)); + } + Value::Object(m) + }) + .collect(); + let mut out = json!({ + "count": entries.len(), + "variables": entries, + }); + if parsed.unparsed_lines > 0 { + out["unparsed_lines"] = json!(parsed.unparsed_lines); + } + out +} + +fn project_value(class: Class, v: &Value) -> Value { + match class { + Class::Pass => v.clone(), + Class::Env => env_view(v.as_str().unwrap_or_default()), + Class::SetOnly => json!({ + "set": !(v.is_null() || v.as_str().is_some_and(str::is_empty)) + }), + Class::Fingerprint => match v { + Value::Null => Value::Null, + Value::String(s) if s.is_empty() => json!(""), + Value::String(s) => text_summary(s), + other => text_summary(&other.to_string()), + }, + Class::Command => { + let path = v.get("path").cloned().unwrap_or(Value::Null); + let command = v.get("command").and_then(Value::as_str).unwrap_or(""); + json!({ + "path": path, + "command": if command.is_empty() { json!("") } else { text_summary(command) }, + "shell_mode": v.get("shell_mode").cloned().unwrap_or(Value::Null), + }) + } + Class::Files => match v { + Value::Array(files) => Value::Array( + files + .iter() + .map(|f| { + let contents = + f.get("contents").and_then(Value::as_str).unwrap_or(""); + let mut m = Map::new(); + m.insert("path".into(), f.get("path").cloned().unwrap_or(Value::Null)); + m.insert("fingerprint".into(), json!(fingerprint(contents))); + m.insert("lines".into(), json!(contents.lines().count())); + for k in ["services", "requires"] { + if let Some(x) = f.get(k) { + m.insert(k.into(), x.clone()); + } + } + Value::Object(m) + }) + .collect(), + ), + other => other.clone(), + }, + Class::Errors => match v { + Value::Array(errs) => Value::Array( + errs + .iter() + .map(|e| { + json!({ + "path": e.get("path").cloned().unwrap_or(Value::Null), + "error": truncate( + &strip_html(e.get("contents").and_then(Value::as_str).unwrap_or("")), + 500, + ), + }) + }) + .collect(), + ), + other => other.clone(), + }, + Class::Config => project_object(CONFIG_FIELDS, v), + Class::Info => project_object(INFO_FIELDS, v), + } +} + +fn project_object(table: &[(&str, Class)], v: &Value) -> Value { + let Value::Object(obj) = v else { + return Value::Null; + }; + let mut out = Map::new(); + for (k, val) in obj { + let projected = match classify(table, k) { + Some(class) => project_value(class, val), + None => json!(WITHHELD), + }; + out.insert(k.clone(), projected); + } + Value::Object(out) +} + +/// The redacted view of a stack. Default-deny: unknown fields are withheld. +pub fn project_stack(stack: &Stack) -> Value { + match serde_json::to_value(stack) { + Ok(v) => { + let mut out = project_object(TOP_FIELDS, &v); + // Mongo ids serialize as {"$oid": ".."}; flatten for readability. + if let Some(id) = + out.get("_id").and_then(|i| i.get("$oid")).cloned() + { + out["_id"] = id; + } + out + } + Err(_) => json!({ "error": "stack could not be serialized" }), + } +} + +/// Env names that look like credentials: their values are scrubbed even +/// when short. +fn secretish(name: &str) -> bool { + let n = name.to_ascii_uppercase(); + [ + "PASS", + "SECRET", + "TOKEN", + "KEY", + "PRIVATE", + "CREDENTIAL", + "AUTH", + "PAT", + "DSN", + "COOKIE", + "SESSION", + "SALT", + ] + .iter() + .any(|p| n.contains(p)) +} + +static TOKEN_SHAPES: LazyLock> = LazyLock::new(|| { + [ + r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", + r"\bgh[pousr]_[A-Za-z0-9]{20,}", + r"\bgithub_pat_[A-Za-z0-9_]{20,}", + r"\bsk-[A-Za-z0-9_\-]{20,}", + r"\btskey-[A-Za-z0-9\-]{10,}", + r"\bxox[abpr]-[A-Za-z0-9\-]{10,}", + r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}", + ] + .iter() + .map(|r| Regex::new(r).expect("static regex")) + .collect() +}); + +/// Replaces known secret values in tool output. +#[derive(Default)] +pub struct Scrubber { + /// (value, name), longest value first. + values: Mutex>, + /// Public identifiers (stack names). An env value equal to one of these + /// is not scrubbed unless its variable name looks like a credential, so + /// `TAILSCALE_HOSTNAME=vogt-dev` does not turn every mention of the stack + /// into `[redacted:TAILSCALE_HOSTNAME]`. + public: Mutex>, +} + +impl Scrubber { + pub fn learn(&self, name: &str, value: &str) { + let value = value.trim(); + if value.is_empty() || reference_of(value).is_some() { + return; + } + let long_enough = value.chars().count() >= 8 + || (secretish(name) && value.chars().count() >= 3); + if !long_enough { + return; + } + let mut values = self.values.lock().unwrap(); + for v in [value, normalize_value(value)] { + if v.is_empty() { + continue; + } + match values.iter_mut().find(|(x, _)| x == v) { + // Same value under a credential-like name wins, so the public + // identifier exemption cannot apply to it. + Some(existing) => { + if secretish(name) && !secretish(&existing.1) { + existing.1 = name.to_string(); + } + } + None => values.push((v.to_string(), name.to_string())), + } + } + values.sort_by_key(|v| std::cmp::Reverse(v.0.len())); + } + + /// Learn every secret-bearing value of a stack. + pub fn learn_stack(&self, stack: &Stack) { + self.public.lock().unwrap().insert(stack.name.clone()); + for e in parse_env(&stack.config.environment).entries { + self.learn(&e.name, &e.value); + } + self.learn("webhook_secret", &stack.config.webhook_secret); + } + + pub fn scrub_str(&self, s: &str) -> String { + let mut out = s.to_string(); + let public = self.public.lock().unwrap(); + for (value, name) in self.values.lock().unwrap().iter() { + if public.contains(value) && !secretish(name) { + continue; + } + if out.contains(value.as_str()) { + out = + out.replace(value.as_str(), &format!("[redacted:{name}]")); + } + } + for re in TOKEN_SHAPES.iter() { + if re.is_match(&out) { + out = re.replace_all(&out, "[redacted:token]").into_owned(); + } + } + out + } + + pub fn scrub(&self, v: &mut Value) { + match v { + Value::String(s) => *s = self.scrub_str(s), + Value::Array(a) => a.iter_mut().for_each(|x| self.scrub(x)), + Value::Object(o) => o.values_mut().for_each(|x| self.scrub(x)), + _ => {} + } + } +} + +static TAG: LazyLock = LazyLock::new(|| { + Regex::new(r"]*>").expect("static regex") +}); + +/// Remove the HTML spans Komodo puts in update logs and decode entities. +pub fn strip_html(s: &str) -> String { + TAG + .replace_all(s, "") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +/// The last `n` lines of `s`. +pub fn tail(s: &str, n: usize) -> String { + let lines: Vec<&str> = s.lines().collect(); + let start = lines.len().saturating_sub(n); + lines[start..].join("\n") +} + +pub fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let cut: String = s.chars().take(max).collect(); + format!("{cut}… [truncated]") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use komodo_client::entities::{ + FileContents, SystemCommand, + stack::{StackConfig, StackInfo, StackRemoteFileContents}, + }; + + pub const SECRET_A: &str = "s3cr3t-A-value-FAKE-0001"; + pub const SECRET_B: &str = "ghp_FAKEFAKEFAKEFAKEFAKEFAKE0123"; + pub const SECRET_C: &str = "webhook-FAKE-secret-0003"; + + fn stack() -> Stack { + Stack { + name: "vogt-dev".into(), + config: StackConfig { + environment: format!( + "PLAIN=1\nDB_PASSWORD={SECRET_A}\nGH_TOKEN=\"{SECRET_B}\"\nREF=[[infisical://apps/prod/HOMELAB_X]]" + ), + webhook_secret: SECRET_C.into(), + pre_deploy: SystemCommand { + path: "personal/vogt-dev".into(), + command: format!("echo {SECRET_A}"), + ..Default::default() + }, + file_contents: format!( + "services:\n x:\n environment: [T={SECRET_B}]" + ), + ..Default::default() + }, + info: StackInfo { + deployed_config: Some(format!("DB_PASSWORD: {SECRET_A}")), + remote_contents: Some(vec![StackRemoteFileContents { + path: "vogt.compose.yml".into(), + contents: format!("# {SECRET_A}\n"), + ..Default::default() + }]), + deployed_contents: Some(vec![FileContents { + path: "vogt.compose.yml".into(), + contents: format!("# {SECRET_B}\n"), + }]), + remote_errors: Some(vec![FileContents { + path: "bad.yml".into(), + contents: format!("failed {SECRET_A}"), + }]), + ..Default::default() + }, + ..Default::default() + } + } + + pub fn assert_clean(s: &str) { + for secret in [SECRET_A, SECRET_B, SECRET_C] { + assert!(!s.contains(secret), "secret leaked: {s}"); + } + } + + #[test] + fn projection_never_emits_values() { + let s = stack(); + let mut v = project_stack(&s); + // Projection alone (before scrubbing) must already be clean, except + // for remote_errors which is scrubbed text. + let mut no_errors = v.clone(); + no_errors["info"]["remote_errors"] = Value::Null; + assert_clean(&no_errors.to_string()); + let scrubber = Scrubber::default(); + scrubber.learn_stack(&s); + scrubber.scrub(&mut v); + let text = v.to_string(); + assert_clean(&text); + let names: Vec<&str> = v["config"]["environment"]["variables"] + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["PLAIN", "DB_PASSWORD", "GH_TOKEN", "REF"]); + assert_eq!( + v["config"]["environment"]["variables"][3]["reference"], + "[[infisical://apps/prod/HOMELAB_X]]" + ); + assert_eq!(v["config"]["webhook_secret"]["set"], true); + assert_eq!( + v["config"]["pre_deploy"]["path"], + "personal/vogt-dev" + ); + assert_eq!(v["name"], "vogt-dev"); + assert!(!text.contains("")); + } + + #[test] + fn unknown_fields_are_withheld() { + let mut raw = serde_json::to_value(stack()).unwrap(); + raw["config"]["new_upstream_field"] = json!(SECRET_A); + raw["info"]["another_new_field"] = json!(SECRET_B); + raw["surprise"] = json!(SECRET_C); + // remote_errors is scrubbed text (layer 2), not projected away. + raw["info"]["remote_errors"] = Value::Null; + let v = project_object(TOP_FIELDS, &raw); + assert_clean(&v.to_string()); + assert_eq!(v["config"]["new_upstream_field"], WITHHELD); + assert_eq!(v["info"]["another_new_field"], WITHHELD); + assert_eq!(v["surprise"], WITHHELD); + } + + /// Fails when a Komodo rebase adds a Stack field: classify it above. + #[test] + fn every_stack_field_is_classified() { + let v = serde_json::to_value(stack()).unwrap(); + for (table, obj) in [ + (TOP_FIELDS, &v), + (CONFIG_FIELDS, &v["config"]), + (INFO_FIELDS, &v["info"]), + ] { + for key in obj.as_object().unwrap().keys() { + assert!( + classify(table, key).is_some(), + "Stack field `{key}` is not classified in redact.rs" + ); + } + } + // And the reverse: no stale entries for fields that no longer exist. + let cfg = serde_json::to_value(StackConfig::default()).unwrap(); + for (k, _) in CONFIG_FIELDS { + assert!( + cfg.get(*k).is_some(), + "stale CONFIG_FIELDS entry `{k}`" + ); + } + } + + #[test] + fn scrubber_catches_echoed_values_and_token_shapes() { + let s = Scrubber::default(); + s.learn("DB_PASSWORD", SECRET_A); + s.learn("PORT", "8080"); // short and not secret-ish: kept + s.learn("API_KEY", "abc"); // short but secret-ish: scrubbed + let out = s.scrub_str(&format!( + "pw={SECRET_A} port=8080 key=abc gh={SECRET_B} \ + -----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----" + )); + assert_clean(&out); + assert!(out.contains("[redacted:DB_PASSWORD]")); + assert!(out.contains("port=8080")); + assert!(out.contains("key=[redacted:API_KEY]")); + assert!(!out.contains("AAAA")); + } + + #[test] + fn stack_names_are_public_unless_secret_named() { + let s = Scrubber::default(); + let mut a = stack(); + a.config.environment = + "TAILSCALE_HOSTNAME=vogt-prod\nDB_PASSWORD=vogt-prod".into(); + s.learn_stack(&a); + let mut b = stack(); + b.name = "vogt-prod".into(); + b.config.environment.clear(); + s.learn_stack(&b); + // Both names learned the value; the secret-named one still scrubs. + assert_eq!(s.scrub_str("vogt-prod"), "[redacted:DB_PASSWORD]"); + let s = Scrubber::default(); + a.config.environment = "TAILSCALE_HOSTNAME=vogt-prod".into(); + s.learn_stack(&a); + s.learn_stack(&b); + assert_eq!(s.scrub_str("stack vogt-prod"), "stack vogt-prod"); + } + + #[test] + fn references_are_not_scrubbed() { + let s = Scrubber::default(); + s.learn("X", "[[infisical://apps/prod/HOMELAB_X]]"); + assert_eq!( + s.scrub_str("[[infisical://apps/prod/HOMELAB_X]]"), + "[[infisical://apps/prod/HOMELAB_X]]" + ); + } + + #[test] + fn html_is_stripped() { + assert_eq!( + strip_html( + "error: a <b> & c" + ), + "error: a & c" + ); + } +} diff --git a/bin/mcp/src/server.rs b/bin/mcp/src/server.rs new file mode 100644 index 0000000000..80b95daee6 --- /dev/null +++ b/bin/mcp/src/server.rs @@ -0,0 +1,171 @@ +//! A minimal MCP server over stdio: newline-delimited JSON-RPC 2.0, with +//! `initialize`, `ping`, `tools/list` and `tools/call`. Requests are handled +//! concurrently (a long `deploy_stack` does not block `ping`); responses are +//! written by a single writer task. + +use std::{sync::Arc, time::Duration}; + +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::mpsc, +}; + +use crate::{ + config::Policy, + komodo::Komodo, + redact::Scrubber, + tools::{self, Ctx}, +}; + +pub const SUPPORTED_PROTOCOLS: &[&str] = + &["2025-06-18", "2025-03-26", "2024-11-05"]; + +pub struct Server { + pub komodo: Komodo, + pub policy: Policy, + pub poll: Duration, +} + +impl Server { + /// Run one tool call with its own scrubber and return the MCP result. + /// Every string in the result (including error text) is scrubbed. + pub async fn call_tool(&self, name: &str, args: &Value) -> Value { + let scrubber = Scrubber::default(); + let ctx = Ctx { + komodo: &self.komodo, + policy: &self.policy, + scrubber: &scrubber, + poll: self.poll, + }; + let (mut body, is_error) = + match tools::call(&ctx, name, args).await { + Ok(v) => (v, false), + Err(e) => (json!({ "error": format!("{e:#}") }), true), + }; + scrubber.scrub(&mut body); + let text = serde_json::to_string_pretty(&body) + .unwrap_or_else(|_| String::from("{}")); + json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error, + }) + } + + pub async fn handle(&self, msg: Value) -> Option { + let id = msg.get("id").cloned(); + let method = + msg.get("method").and_then(Value::as_str).unwrap_or(""); + // Notifications (no id) get no response. + let id = id?; + let result = match method { + "initialize" => { + let requested = msg + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .unwrap_or(SUPPORTED_PROTOCOLS[0]); + let version = if SUPPORTED_PROTOCOLS.contains(&requested) { + requested + } else { + SUPPORTED_PROTOCOLS[0] + }; + Ok(json!({ + "protocolVersion": version, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { + "name": "komodo-mcp", + "version": env!("CARGO_PKG_VERSION"), + }, + "instructions": instructions(&self.policy), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({ "tools": tools::list() })), + "tools/call" => { + let name = msg + .pointer("/params/name") + .and_then(Value::as_str) + .unwrap_or(""); + let args = msg + .pointer("/params/arguments") + .cloned() + .unwrap_or_else(|| json!({})); + Ok(self.call_tool(name, &args).await) + } + "resources/list" => Ok(json!({ "resources": [] })), + "prompts/list" => Ok(json!({ "prompts": [] })), + other => Err((-32601, format!("method not found: {other}"))), + }; + Some(match result { + Ok(r) => json!({ "jsonrpc": "2.0", "id": id, "result": r }), + Err((code, message)) => json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": code, "message": message }, + }), + }) + } +} + +pub fn instructions(policy: &Policy) -> String { + let mode = if policy.read_only() { + String::from( + "This server is READ-ONLY: write_stack_file and deploy_stack will refuse every stack.", + ) + } else { + format!( + "Mutations allowed for stacks matching {:?}; protected patterns {:?} need an exact opt-in (opted in: {:?}).", + policy.write, policy.protected, policy.allow_protected + ) + }; + format!( + "Komodo stacks via typed tools; use these instead of curl against the Komodo API. \ + Secrets are redacted server-side: env is names + fingerprints only. {mode} \ + Typical deploy: stack_lookup -> read_stack_file -> write_stack_file -> deploy_stack -> container_health. \ + To answer 'what changed and who': stack_history(since_last_successful_deploy=true) or env_diff(against={{last_successful_deploy:true}})." + ) +} + +/// Serve MCP on stdin/stdout until stdin closes. +pub async fn serve_stdio(server: Server) -> anyhow::Result<()> { + let server = Arc::new(server); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let writer = tokio::spawn(async move { + let mut out = tokio::io::stdout(); + while let Some(msg) = rx.recv().await { + let mut line = serde_json::to_string(&msg).unwrap_or_default(); + line.push('\n'); + if out.write_all(line.as_bytes()).await.is_err() { + break; + } + let _ = out.flush().await; + } + }); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut tasks = tokio::task::JoinSet::new(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let msg: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => { + let _ = tx.send(json!({ + "jsonrpc": "2.0", "id": null, + "error": { "code": -32700, "message": "parse error" }, + })); + continue; + } + }; + let server = server.clone(); + let tx = tx.clone(); + tasks.spawn(async move { + if let Some(resp) = server.handle(msg).await { + let _ = tx.send(resp); + } + }); + } + while tasks.join_next().await.is_some() {} + drop(tx); + let _ = writer.await; + Ok(()) +} diff --git a/bin/mcp/src/tools.rs b/bin/mcp/src/tools.rs new file mode 100644 index 0000000000..c98c1b6801 --- /dev/null +++ b/bin/mcp/src/tools.rs @@ -0,0 +1,851 @@ +//! The MCP tools. Every tool returns a JSON value that the dispatcher scrubs +//! with the per-call [`Scrubber`] before it leaves the process. + +use std::{collections::BTreeMap, time::Duration}; + +use anyhow::{Context, anyhow, bail}; +use komodo_client::entities::{ + Operation, + stack::Stack, + update::{Log, Update, UpdateStatus}, +}; +use names_diff::{ + EnvChange, EnvDiff, NamedValue, ValueRef, diff_env, diff_file, + diff_resource_toml, environment_from_toml, fingerprint, + normalize_value, parse_env, +}; +use serde_json::{Value, json}; + +use crate::{ + config::{Decision, Policy}, + komodo::{Komodo, stack_updates_query}, + redact::{ + Scrubber, env_view, project_stack, strip_html, tail, truncate, + }, +}; + +/// Log stage that carries file content in WriteStackContents updates. +const CONTENT_LOG_STAGE: &str = "File contents to write"; +/// Log stage the fork's Core writes with the asserted actor. +pub const ACTOR_LOG_STAGE: &str = "Actor (asserted)"; +/// Log stage the fork's Core writes with the names-only config diff. +pub const DIFF_LOG_STAGE: &str = "Config diff (names only)"; + +pub struct Ctx<'a> { + pub komodo: &'a Komodo, + pub policy: &'a Policy, + pub scrubber: &'a Scrubber, + /// Poll interval for deploys (shortened in tests). + pub poll: Duration, +} + +/// Tool descriptors for `tools/list`. +pub fn list() -> Value { + let stack = + json!({"type": "string", "description": "Stack name or id"}); + let reason = json!({"type": "string", "description": "Why (stamped on the Komodo Update as X-Komodo-Reason)"}); + json!([ + { + "name": "stack_lookup", + "description": "Find stacks by name or id (exact match first, then substring). Empty query lists all stacks. Returns ids, server, state, repo/branch, deployed vs latest hash, services and whether this MCP may mutate the stack.", + "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}}, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "get_stack", + "description": "Get a stack's config and info with secrets redacted server-side: environment is names (+ fingerprints and [[references]]) only, file contents are fingerprints, unknown fields are withheld (default-deny).", + "inputSchema": {"type": "object", "properties": {"stack": stack}, "required": ["stack"]}, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "env_diff", + "description": "Names-only diff of a stack's environment against something else. `against` is ONE of: {\"stack\": other}, {\"env_file\": local path}, {\"hashes\": {NAME: fingerprint}}, {\"update\": update id (env as of that config update)}, {\"last_successful_deploy\": true} (env as of the most recent successful DeployStack). Output shows added/removed/changed names with sha256 fingerprints, never values. `before` = against, `after` = the stack now.", + "inputSchema": {"type": "object", "properties": {"stack": stack, "against": {"type": "object"}}, "required": ["stack", "against"]}, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "read_stack_file", + "description": "Read one of a stack's compose/config files from Komodo's cache (refreshing the cache first by default). Known env values in the content are scrubbed. Env files (.env) are refused.", + "inputSchema": {"type": "object", "properties": {"stack": stack, "path": {"type": "string"}, "refresh": {"type": "boolean", "default": true}}, "required": ["stack", "path"]}, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "write_stack_file", + "description": "MUTATING (allowlisted stacks only). Write a stack file (for git-backed stacks this commits to the repo). Always runs RefreshStackCache first so the change is computed against fresh content, refuses .env files, skips identical content, and re-reads afterwards to verify. Use dry_run to see the line summary without writing.", + "inputSchema": {"type": "object", "properties": { + "stack": stack, "path": {"type": "string"}, "content": {"type": "string"}, + "reason": reason, "dry_run": {"type": "boolean", "default": false} + }, "required": ["stack", "path", "content"]}, + "annotations": {"destructiveHint": true}, + }, + { + "name": "deploy_stack", + "description": "MUTATING (allowlisted stacks only; prod requires explicit opt-in). Run DeployStack and wait server-side for the Update to complete. Returns pass/fail, the stages, and on failure the failing stage's log tail (HTML stripped, secrets scrubbed), plus container health on success. NOTE: deploying the stack your own session runs in will kill the session.", + "inputSchema": {"type": "object", "properties": { + "stack": stack, "services": {"type": "array", "items": {"type": "string"}}, + "reason": reason, "timeout_secs": {"type": "integer", "default": 900} + }, "required": ["stack"]}, + "annotations": {"destructiveHint": true}, + }, + { + "name": "container_health", + "description": "Per-service container state and docker health check status for a stack (no env, labels or inspect dump).", + "inputSchema": {"type": "object", "properties": {"stack": stack}, "required": ["stack"]}, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "container_logs", + "description": "Tail one service's container log (max 1000 lines), secrets scrubbed.", + "inputSchema": {"type": "object", "properties": { + "stack": stack, "service": {"type": "string"}, + "tail": {"type": "integer", "default": 100}, "timestamps": {"type": "boolean", "default": false} + }, "required": ["stack", "service"]}, + "annotations": {"readOnlyHint": true}, + }, + { + "name": "stack_history", + "description": "Recent Komodo Updates for a stack with who did it (user + asserted actor/reason when stamped) and what changed: names-only config/env diffs for config updates, fingerprints + line counts for file writes, pass/fail for deploys. `since_last_successful_deploy` stops at the most recent successful deploy (answers: what changed since the last good deploy, and who).", + "inputSchema": {"type": "object", "properties": { + "stack": stack, "limit": {"type": "integer", "default": 15}, + "since_last_successful_deploy": {"type": "boolean", "default": false} + }, "required": ["stack"]}, + "annotations": {"readOnlyHint": true}, + }, + ]) +} + +fn arg_str<'a>( + args: &'a Value, + key: &str, +) -> anyhow::Result<&'a str> { + args + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .with_context(|| format!("missing string argument `{key}`")) +} + +fn opt_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + args + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} + +fn opt_bool(args: &Value, key: &str, default: bool) -> bool { + args.get(key).and_then(Value::as_bool).unwrap_or(default) +} + +fn opt_u64(args: &Value, key: &str, default: u64) -> u64 { + args.get(key).and_then(Value::as_u64).unwrap_or(default) +} + +pub async fn call( + ctx: &Ctx<'_>, + name: &str, + args: &Value, +) -> anyhow::Result { + match name { + "stack_lookup" => stack_lookup(ctx, args).await, + "get_stack" => get_stack(ctx, args).await, + "env_diff" => env_diff(ctx, args).await, + "read_stack_file" => read_stack_file(ctx, args).await, + "write_stack_file" => write_stack_file(ctx, args).await, + "deploy_stack" => deploy_stack(ctx, args).await, + "container_health" => container_health(ctx, args).await, + "container_logs" => container_logs(ctx, args).await, + "stack_history" => stack_history(ctx, args).await, + other => bail!("unknown tool `{other}`"), + } +} + +/// Fetch a stack and teach the scrubber its secrets before anything else +/// can be returned. +async fn fetch(ctx: &Ctx<'_>, stack: &str) -> anyhow::Result { + let s = ctx.komodo.get_stack(stack).await?; + ctx.scrubber.learn_stack(&s); + Ok(s) +} + +fn decision_json(policy: &Policy, name: &str) -> Value { + match policy.decide(name) { + Decision::Allowed => json!("allowed"), + Decision::NotAllowlisted => { + json!("read-only (not in KOMODO_MCP_WRITE_STACKS)") + } + Decision::Protected => json!( + "protected (requires exact name in KOMODO_MCP_ALLOW_PROTECTED)" + ), + } +} + +/// Resolve the stack (by id or name) and enforce the mutation allowlist on +/// its *resolved name*, so passing an id cannot bypass a name rule. +async fn fetch_mutable( + ctx: &Ctx<'_>, + stack: &str, +) -> anyhow::Result { + let s = fetch(ctx, stack).await?; + match ctx.policy.decide(&s.name) { + Decision::Allowed => Ok(s), + Decision::NotAllowlisted => Err(anyhow!( + "refused: stack `{}` is not in the mutation allowlist. This server is \ + read-only for it; add it to KOMODO_MCP_WRITE_STACKS to allow writes \ + and deploys.", + s.name + )), + Decision::Protected => Err(anyhow!( + "refused: stack `{}` is protected (matches KOMODO_MCP_PROTECTED_STACKS). \ + Mutating it requires its exact name in KOMODO_MCP_ALLOW_PROTECTED. \ + Remember a prod deploy kills sessions living in the prod pod.", + s.name + )), + } +} + +async fn stack_lookup( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let query = + opt_str(args, "query").unwrap_or("").trim().to_lowercase(); + let all = ctx.komodo.list_stacks().await?; + let exact: Vec<_> = all + .iter() + .filter(|s| s.id == query || s.name.to_lowercase() == query) + .collect(); + let matches: Vec<_> = if !exact.is_empty() { + exact + } else { + all + .iter() + .filter(|s| { + query.is_empty() || s.name.to_lowercase().contains(&query) + }) + .collect() + }; + let items: Vec = matches + .iter() + .map(|s| { + json!({ + "id": s.id, + "name": s.name, + "server_id": s.info.server_id, + "swarm_id": s.info.swarm_id, + "state": s.info.state, + "status": s.info.status, + "repo": s.info.repo, + "branch": s.info.branch, + "linked_repo": s.info.linked_repo, + "files_on_host": s.info.files_on_host, + "deployed_hash": s.info.deployed_hash, + "latest_hash": s.info.latest_hash, + "missing_files": s.info.missing_files, + "services": s.info.services.iter().map(|x| json!({ + "service": x.service, "image": x.image, "update_available": x.update_available + })).collect::>(), + "mutations": decision_json(ctx.policy, &s.name), + }) + }) + .collect(); + Ok(json!({ "count": items.len(), "stacks": items })) +} + +async fn get_stack( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let s = fetch(ctx, arg_str(args, "stack")?).await?; + let mut v = project_stack(&s); + v["mutations"] = decision_json(ctx.policy, &s.name); + Ok(v) +} + +/// The env of a stack as of the newest config update before `before_ts` +/// (or the newest overall). +async fn env_as_of( + ctx: &Ctx<'_>, + stack_id: &str, + before_ts: Option, +) -> anyhow::Result<(String, String)> { + let mut q = stack_updates_query(stack_id); + q.insert( + "operation", + bson::doc! { "$in": ["UpdateStack", "CreateStack"] }, + ); + if let Some(ts) = before_ts { + q.insert("start_ts", bson::doc! { "$lt": ts }); + } + let res = ctx.komodo.list_updates(q, 0).await?; + let item = res.updates.first().context( + "no config update recorded for this stack before that point, so the \ + environment at that time cannot be reconstructed", + )?; + let update = ctx.komodo.get_update(&item.id).await?; + let env = environment_from_toml(&update.current_toml) + .map_err(|e| anyhow!(e))?; + Ok((env, update.id)) +} + +async fn env_diff( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let s = fetch(ctx, arg_str(args, "stack")?).await?; + let against = args + .get("against") + .and_then(Value::as_object) + .context("missing object argument `against`")?; + let current = &s.config.environment; + let (label, diff) = if let Some(other) = + against.get("stack").and_then(Value::as_str) + { + let o = fetch(ctx, other).await?; + ( + format!("stack {}", o.name), + diff_env(&o.config.environment, current), + ) + } else if let Some(path) = + against.get("env_file").and_then(Value::as_str) + { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read env file {path}"))?; + for e in parse_env(&text).entries { + ctx.scrubber.learn(&e.name, &e.value); + } + (format!("file {path}"), diff_env(&text, current)) + } else if let Some(hashes) = + against.get("hashes").and_then(Value::as_object) + { + // Compare fingerprints directly: map current values to fingerprints. + let cur = parse_env(current); + let cur_fp: BTreeMap = cur + .entries + .iter() + .map(|e| { + (e.name.clone(), fingerprint(normalize_value(&e.value))) + }) + .collect(); + let given: BTreeMap = hashes + .iter() + .map(|(k, v)| { + (k.clone(), v.as_str().unwrap_or_default().to_string()) + }) + .collect(); + ( + "supplied fingerprints".to_string(), + diff_fingerprints(&given, &cur_fp), + ) + } else if let Some(update_id) = + against.get("update").and_then(Value::as_str) + { + let u = ctx.komodo.get_update(update_id).await?; + let env = environment_from_toml(&u.current_toml) + .map_err(|e| anyhow!(e))?; + (format!("update {update_id}"), diff_env(&env, current)) + } else if against + .get("last_successful_deploy") + .and_then(Value::as_bool) + .unwrap_or(false) + { + let mut q = stack_updates_query(&s.id); + q.insert("operation", "DeployStack"); + q.insert("success", true); + let deploys = ctx.komodo.list_updates(q, 0).await?; + let deploy = deploys + .updates + .first() + .context("no successful DeployStack recorded for this stack")?; + let (env, update_id) = + env_as_of(ctx, &s.id, Some(deploy.start_ts)).await?; + ( + format!( + "env as of the last successful deploy {} ({}), from config update {update_id}", + deploy.id, + ts(deploy.start_ts) + ), + diff_env(&env, current), + ) + } else { + bail!( + "`against` must be one of {{stack}}, {{env_file}}, {{hashes}}, {{update}}, {{last_successful_deploy: true}}" + ); + }; + Ok(json!({ + "stack": s.name, + "before": label, + "after": "current stack environment", + "identical": diff.is_empty(), + "diff": diff, + "fingerprint_format": "sha256:/", + })) +} + +/// Diff two name -> fingerprint maps (values are already fingerprints). +fn diff_fingerprints( + before: &BTreeMap, + after: &BTreeMap, +) -> EnvDiff { + let r = |f: &String| ValueRef { + fingerprint: f.clone(), + reference: None, + }; + let mut d = EnvDiff::default(); + for (k, a) in before { + match after.get(k) { + None => d.removed.push(NamedValue { + name: k.clone(), + value: r(a), + }), + Some(b) if a != b => d.changed.push(EnvChange { + name: k.clone(), + before: r(a), + after: r(b), + }), + Some(_) => d.unchanged += 1, + } + } + for (k, b) in after { + if !before.contains_key(k) { + d.added.push(NamedValue { + name: k.clone(), + value: r(b), + }); + } + } + d +} + +fn is_env_path(stack: &Stack, path: &str) -> bool { + let file = path.rsplit('/').next().unwrap_or(path); + let env_file = stack + .config + .env_file_path + .rsplit('/') + .next() + .unwrap_or_default(); + file == ".env" + || file.ends_with(".env") + || file.starts_with(".env.") + || (!env_file.is_empty() && file == env_file) + || stack.config.additional_env_files.iter().any(|f| { + f.path.trim_start_matches("./") == path.trim_start_matches("./") + }) +} + +fn cached_file<'a>(stack: &'a Stack, path: &str) -> Option<&'a str> { + let norm = |p: &str| p.trim_start_matches("./").to_string(); + stack + .info + .remote_contents + .as_ref()? + .iter() + .find(|f| norm(&f.path) == norm(path)) + .map(|f| f.contents.as_str()) +} + +fn known_paths(stack: &Stack) -> Vec { + stack + .info + .remote_contents + .iter() + .flatten() + .map(|f| f.path.clone()) + .collect() +} + +async fn read_stack_file( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let name = arg_str(args, "stack")?; + let path = arg_str(args, "path")?; + let s = fetch(ctx, name).await?; + if is_env_path(&s, path) { + bail!( + "refused: `{path}` is an env file; use get_stack/env_diff for names-only views" + ); + } + let s = if opt_bool(args, "refresh", true) { + ctx + .komodo + .refresh_cache(&s.id, Some("komodo-mcp read_stack_file")) + .await?; + fetch(ctx, &s.id).await? + } else { + s + }; + let Some(contents) = cached_file(&s, path) else { + if s.config.file_contents.trim().is_empty() { + bail!( + "`{path}` is not in the stack's cached files: {:?}", + known_paths(&s) + ); + } + // UI-defined stacks keep their compose in config.file_contents. + return Ok(json!({ + "stack": s.name, "path": "(config.file_contents)", + "fingerprint": fingerprint(&s.config.file_contents), + "contents": s.config.file_contents, + })); + }; + Ok(json!({ + "stack": s.name, + "path": path, + "fingerprint": fingerprint(contents), + "lines": contents.lines().count(), + "contents": contents, + })) +} + +fn log_stages(update: &Update) -> Vec { + update + .logs + .iter() + .filter(|l| l.stage != CONTENT_LOG_STAGE) + .map(|l| json!({ "stage": l.stage, "success": l.success })) + .collect() +} + +/// The failing log of an update, as a clean tail. +fn failure(update: &Update, lines: usize) -> Option { + let log: &Log = update.logs.iter().rev().find(|l| !l.success)?; + let body = if log.stderr.trim().is_empty() { + &log.stdout + } else { + &log.stderr + }; + Some(json!({ + "stage": log.stage, + "command": truncate(&strip_html(&log.command), 300), + "tail": tail(&strip_html(body), lines), + })) +} + +async fn write_stack_file( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let name = arg_str(args, "stack")?; + let path = arg_str(args, "path")?; + let content = args + .get("content") + .and_then(Value::as_str) + .context("missing string argument `content`")?; + let reason = opt_str(args, "reason"); + let s = fetch_mutable(ctx, name).await?; + if is_env_path(&s, path) { + bail!( + "refused: `{path}` is an env file. Writing it would commit secret \ + values to the stack's repo; set env through Komodo/Infisical instead." + ); + } + // Refresh first: diffing against or writing over a stale cache is how + // earlier deploys clobbered newer commits. + ctx.komodo.refresh_cache(&s.id, reason).await?; + let s = fetch(ctx, &s.id).await?; + let before = cached_file(&s, path); + let change = diff_file(before, content); + if before == Some(content) { + return Ok(json!({ + "stack": s.name, "path": path, "changed": false, + "note": "content identical to the freshly refreshed file; nothing written", + })); + } + if opt_bool(args, "dry_run", false) { + return Ok(json!({ + "stack": s.name, "path": path, "dry_run": true, + "new_file": before.is_none(), "change": change, + })); + } + let update = + ctx.komodo.write_file(&s.id, path, content, reason).await?; + ctx.komodo.refresh_cache(&s.id, reason).await?; + let after = fetch(ctx, &s.id).await?; + let verified = cached_file(&after, path) == Some(content); + Ok(json!({ + "stack": s.name, + "path": path, + "changed": true, + "new_file": before.is_none(), + "success": update.success, + "update_id": update.id, + "change": change, + "verified_after_refresh": verified, + "latest_hash": after.info.latest_hash, + "stages": log_stages(&update), + "failure": failure(&update, 30), + })) +} + +fn ts(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms) + .map(|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_default() +} + +async fn deploy_stack( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let name = arg_str(args, "stack")?; + let reason = opt_str(args, "reason"); + let services: Vec = args + .get("services") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let timeout = Duration::from_secs( + opt_u64(args, "timeout_secs", 900).clamp(10, 3600), + ); + let s = fetch_mutable(ctx, name).await?; + let state = ctx.komodo.action_state(&s.id).await?; + if state.deploying + || state.pulling + || state.destroying + || state.restarting + { + bail!("refused: stack `{}` is busy ({state:?})", s.name); + } + let started = ctx.komodo.deploy(&s.id, services, reason).await?; + if started.id.is_empty() { + bail!("Komodo did not return an update id for the deploy"); + } + let (update, completed) = ctx + .komodo + .wait_update(&started.id, timeout, ctx.poll) + .await?; + let pass = completed + && update.status == UpdateStatus::Complete + && update.success; + let mut out = json!({ + "stack": s.name, + "pass": pass, + "completed": completed, + "update_id": update.id, + "started": ts(update.start_ts), + "duration_secs": update.end_ts.map(|e| (e - update.start_ts) / 1000), + "stages": log_stages(&update), + }); + if !completed { + out["note"] = json!(format!( + "still running after {}s; poll stack_history or re-check later", + timeout.as_secs() + )); + } + if let Some(f) = failure(&update, 40) { + out["failure"] = f; + } + if pass { + out["health"] = health(ctx, &s.id) + .await + .unwrap_or_else(|e| json!({"error": format!("{e:#}")})); + } + Ok(out) +} + +async fn health( + ctx: &Ctx<'_>, + stack_id: &str, +) -> anyhow::Result { + let services = ctx.komodo.services(stack_id).await?; + let mut items = Vec::new(); + let mut all_ok = true; + for svc in services { + let Some(c) = svc.container.as_ref() else { + all_ok = false; + items.push(json!({"service": svc.service, "image": svc.image, "container": null, "ok": false})); + continue; + }; + let state = serde_json::to_value(c.state).unwrap_or(Value::Null); + let running = state.as_str() == Some("running"); + let mut h = Value::Null; + let mut health_ok = true; + if running + && let Ok(inspect) = + ctx.komodo.inspect(stack_id, &svc.service).await + { + // Only the health block; the inspect dump carries env and labels. + if let Some(hh) = inspect.state.and_then(|s| s.health) { + let status = + serde_json::to_value(hh.status).unwrap_or(Value::Null); + health_ok = !matches!( + status.as_str(), + Some("unhealthy") | Some("starting") + ); + h = json!({ + "status": status, + "failing_streak": hh.failing_streak, + "last_output": hh.log.last().and_then(|l| l.output.clone()).map(|o| truncate(o.trim(), 500)), + }); + } + } + let ok = running && health_ok; + all_ok &= ok; + items.push(json!({ + "service": svc.service, + "container": c.name, + "image": c.image, + "state": state, + "status": c.status, + "health": h, + "ok": ok, + })); + } + Ok(json!({ "all_ok": all_ok, "services": items })) +} + +async fn container_health( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let s = fetch(ctx, arg_str(args, "stack")?).await?; + let mut v = health(ctx, &s.id).await?; + v["stack"] = json!(s.name); + Ok(v) +} + +async fn container_logs( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let s = fetch(ctx, arg_str(args, "stack")?).await?; + let service = arg_str(args, "service")?; + let n = opt_u64(args, "tail", 100).clamp(1, 1000); + let log = ctx + .komodo + .logs(&s.id, service, n, opt_bool(args, "timestamps", false)) + .await?; + Ok(json!({ + "stack": s.name, + "service": service, + "success": log.success, + "stdout": tail(&strip_html(&log.stdout), n as usize), + "stderr": tail(&strip_html(&log.stderr), n as usize), + })) +} + +fn op_name(op: &Operation) -> String { + serde_json::to_value(op) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{op:?}")) +} + +fn actor_of(update: &Update) -> Value { + match update.logs.iter().find(|l| l.stage == ACTOR_LOG_STAGE) { + None => Value::Null, + Some(l) => { + let mut m = serde_json::Map::new(); + for line in l.stdout.lines() { + if let Some((k, v)) = line.split_once(": ") { + m.insert(k.trim().to_string(), json!(v.trim())); + } + } + Value::Object(m) + } + } +} + +async fn stack_history( + ctx: &Ctx<'_>, + args: &Value, +) -> anyhow::Result { + let s = fetch(ctx, arg_str(args, "stack")?).await?; + let limit = opt_u64(args, "limit", 15).clamp(1, 100) as usize; + let since_deploy = + opt_bool(args, "since_last_successful_deploy", false); + let mut entries = Vec::new(); + let mut page = 0; + let mut reached_deploy = false; + 'pages: loop { + let res = ctx + .komodo + .list_updates(stack_updates_query(&s.id), page) + .await?; + for item in res.updates { + if entries.len() >= limit { + break 'pages; + } + let op = op_name(&item.operation); + let mut e = json!({ + "id": item.id, + "operation": op, + "at": ts(item.start_ts), + "status": item.status, + "success": item.success, + "user": item.username, + "operator": item.operator, + }); + let interesting = matches!( + item.operation, + Operation::UpdateStack + | Operation::CreateStack + | Operation::RenameStack + | Operation::WriteStackContents + ) || !item.success + || item.operation == Operation::DeployStack; + if interesting { + let u = ctx.komodo.get_update(&item.id).await?; + e["actor"] = actor_of(&u); + match u.operation { + Operation::UpdateStack | Operation::CreateStack => { + e["config_diff"] = + match diff_resource_toml(&u.prev_toml, &u.current_toml) + { + Ok(d) => { + serde_json::to_value(d).unwrap_or(Value::Null) + } + Err(err) => json!({ "error": err }), + }; + e["core_recorded_diff"] = + json!(u.logs.iter().any(|l| l.stage == DIFF_LOG_STAGE)); + } + Operation::WriteStackContents => { + if let Some(l) = + u.logs.iter().find(|l| l.stage == CONTENT_LOG_STAGE) + { + e["written"] = json!({ + "fingerprint": fingerprint(&l.stdout), + "lines": l.stdout.lines().count(), + }); + } + e["stages"] = json!(log_stages(&u)); + } + _ => { + e["stages"] = json!(log_stages(&u)); + if !u.commit_hash.is_empty() { + e["commit_hash"] = json!(u.commit_hash); + } + } + } + if let Some(f) = failure(&u, 15) { + e["failure"] = f; + } + } + entries.push(e); + if since_deploy + && item.operation == Operation::DeployStack + && item.success + { + reached_deploy = true; + break 'pages; + } + } + match res.next_page { + Some(p) if entries.len() < limit => page = p, + _ => break, + } + } + Ok(json!({ + "stack": s.name, + "count": entries.len(), + "reached_last_successful_deploy": if since_deploy { json!(reached_deploy) } else { Value::Null }, + "updates": entries, + "current_environment": env_view(&s.config.environment), + })) +} diff --git a/docs/tdd/MAINTENANCE.md b/docs/tdd/MAINTENANCE.md index 46eaadc31a..b1fa070f86 100644 --- a/docs/tdd/MAINTENANCE.md +++ b/docs/tdd/MAINTENANCE.md @@ -75,6 +75,18 @@ A conflict means upstream changed something the patch series touches. There are | `Cargo.toml` / `bin/core/Cargo.toml` | upstream bumped a dependency version next to ours | Keep **upstream's** versions, re-add our lines. If ours were not already in the trailing `# FORK` block, move them there so it stops recurring | | `Cargo.lock` | upstream bumped a dependency on the same line our `infisical` entry sits beside (the common case — happened on v2.3.3) | The lockfile is generated, so don't hand-tune it: resolve the marker by keeping **upstream's** version and re-adding our line, then run `cargo fetch` to make the whole file consistent again and commit it. The Upstream parity workflow does this refresh automatically after a clean rebase | +The attribution hooks (WI-865) are one or two lines each, all marked `FORK (WI-865)`: + +| Conflict in | Fix | +|---|---| +| `bin/core/src/api/write/mod.rs` | Keep `crate::tdd::propagate(...)` around the spawned write `task`, and the `tdd::scope_layer` layer on the router | +| `bin/core/src/api/execute/mod.rs` | Keep the `tdd::scope_layer` layer on the router | +| `bin/core/src/helpers/update.rs` | Re-add `crate::tdd::stamp(&mut update)` in `add_update`, `update_update` and after `update.in_progress()` in `init_execution_update` | +| `bin/core/src/resource/mod.rs` | Re-add `crate::tdd::push_config_diff(&mut update)` after `prev_toml`/`current_toml` are set in `update()` | +| `bin/core/src/api/write/stack.rs` | Re-add `crate::tdd::push_file_change(...)` after the "File contents to write" log | + +Then run `cargo test -p komodo_core tdd::` and `cargo test -p komodo_mcp`. The latter includes `every_stack_field_is_classified`, which fails when upstream adds a Stack field: classify the new field in `bin/mcp/src/redact.rs`. An unclassified field is withheld rather than leaked, so the failure is a prompt to decide, not a leak. + After resolving, **always** re-run `cargo test -p interpolate`. The escape-handling tests are the ones that catch a silently broken guard, which is the dangerous failure mode: a broken guard does not error, it lets a literal token through into a deployment. ## Rebase history diff --git a/docs/tdd/README.md b/docs/tdd/README.md index 64a36dc192..6725df1499 100644 --- a/docs/tdd/README.md +++ b/docs/tdd/README.md @@ -33,6 +33,14 @@ Crucially, this does **not** make Komodo depend on Infisical being up. The last | [`CICD.md`](CICD.md) | The build/publish pipeline and the automated upstream-parity watcher | | [`CUTOVER.md`](CUTOVER.md) | Converting the remaining 73 stacks | | [`CADASTRE.md`](CADASTRE.md) | Estate records this work invalidates or adds | +| [`../../bin/mcp/README.md`](../../bin/mcp/README.md) | `komodo-mcp`: typed MCP tools for agents, with server-side redaction and a per-stack mutation allowlist (WI-846) | + +## Second capability: agent tooling and change attribution + +Two more fork-only additions, both self-contained: + +- **`bin/mcp` (`komodo-mcp`)**: an MCP server built on the typed client. It replaces raw `curl` against the API, never returns `.config.environment` values, and gates writes and deploys per stack. Prod requires an exact opt-in. +- **`lib/names_diff` + `bin/core/src/tdd`**: Core now records two extra logs on Updates. One is a **names-only diff** of every resource config change, with env keys added, removed or changed and values shown as fingerprints. The other is the **asserted actor and reason** from `X-Komodo-Actor`/`X-Komodo-Reason`. Seeing what changed in a stack's env therefore no longer means reading `prev_toml`, which holds every secret (WI-865). ## Shape of the change diff --git a/scripts/tdd/core-only.Dockerfile b/scripts/tdd/core-only.Dockerfile index 4a0e911efc..979bfb5a58 100644 --- a/scripts/tdd/core-only.Dockerfile +++ b/scripts/tdd/core-only.Dockerfile @@ -29,12 +29,15 @@ COPY ./client/periphery ./client/periphery COPY ./bin/core ./bin/core COPY ./bin/periphery ./bin/periphery COPY ./bin/cli ./bin/cli +COPY ./bin/mcp ./bin/mcp COPY ./xtask ./xtask # `km` is shipped in the upstream Core image and referenced by the # KOMODO_CLI_CONFIG_* environment below, so it is built to keep this image's # contract identical to upstream's. komodo_periphery is deliberately absent. -RUN cargo build -p komodo_core -p komodo_cli --release && cargo strip +# `komodo-mcp` (fork-only, bin/mcp) ships alongside so agents can run it from +# the image; it is a client of Core's API and is never started by Core. +RUN cargo build -p komodo_core -p komodo_cli -p komodo_mcp --release && cargo strip FROM ${UI_IMAGE} AS ui @@ -48,6 +51,7 @@ COPY ./config/core.config.toml /config/.default.config.toml COPY --from=ui /ui /app/ui COPY --from=builder /builder/target/release/core /usr/local/bin/core COPY --from=builder /builder/target/release/km /usr/local/bin/km +COPY --from=builder /builder/target/release/komodo-mcp /usr/local/bin/komodo-mcp COPY --from=denoland/deno:bin /deno /usr/local/bin/deno ENV DENO_DIR=/action-cache/deno