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 7ac1d086e1..837e347dd5 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", @@ -3565,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" @@ -4152,6 +4171,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/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/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()); + } +} 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